virtual-class / backend /tests /test_api.py
tourmii's picture
Deploy HF Spaces
905b7fd
Raw
History Blame Contribute Delete
16.9 kB
import pathlib
from types import SimpleNamespace
import pytest
from openai import OpenAIError as OpenAIStub
from fastapi.testclient import TestClient
from app.main import app
from app.schemas import Lesson
from app.services.llm import LessonService, extract_json, post_process
from app.services.sanitize import InvalidFigure, sanitize_svg
client = TestClient(app)
def test_health():
body = client.get("/api/health").json()
assert body["status"] == "ok"
def test_sanitize_strips_script_and_handlers():
dirty = (
'<svg viewBox="0 0 100 100">'
'<script>alert(1)</script>'
'<circle cx="10" cy="10" r="5" onclick="steal()" fill="red"/>'
"</svg>"
)
clean = sanitize_svg(dirty)
assert "script" not in clean
assert "onclick" not in clean
assert "red" not in clean # màu bị ép về currentColor
assert "currentColor" in clean
assert "circle" in clean
def test_sanitize_rejects_non_svg():
with pytest.raises(InvalidFigure):
sanitize_svg("<div>không phải svg</div>")
def test_latex_is_stripped_from_spoken_line():
step = Lesson.model_validate(
{"title": "Thử", "steps": [{"say": r"Ta có $a^2$ \sqrt{4}"}]}
).steps[0]
assert "$" not in step.say
assert "\\" not in step.say
def test_bad_figure_is_dropped_not_fatal():
lesson = post_process(
{
"title": "Hình hỏng",
"steps": [{"say": "Xem hình nhé", "board": {"kind": "figure", "content": "<b>hỏng"}}],
}
)
assert lesson.steps[0].board is None
def test_figure_survives_sanitising():
lesson = post_process(
{
"title": "Hình tốt",
"steps": [
{
"say": "Đây là đường tròn",
"board": {
"kind": "figure",
"content": '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="20" stroke="currentColor" fill="none"/></svg>',
},
}
],
}
)
assert lesson.steps[0].board.kind == "figure"
assert "circle" in lesson.steps[0].board.content
def test_lesson_rejects_too_many_steps():
# Pack soạn tay được tới 12 bước (5 câu trắc nghiệm), quá thì chặn.
Lesson.model_validate({"title": "Vừa đủ", "steps": [{"say": "x"}] * 12})
with pytest.raises(Exception):
Lesson.model_validate({"title": "Dài quá", "steps": [{"say": "x"}] * 13})
def test_model_is_still_capped_at_six_steps():
# Giới hạn của model tách riêng khỏi giới hạn của pack.
from app.services.llm import LESSON_SCHEMA
assert LESSON_SCHEMA["properties"]["steps"]["maxItems"] == 6
# --- Bóc JSON từ output của model reasoning ---
def test_extract_json_ignores_think_block():
raw = '<think>Để xem nào, học sinh hỏi về...</think>\n{"title": "A", "steps": []}'
assert extract_json(raw)["title"] == "A"
def test_extract_json_ignores_unclosed_think():
raw = '<think>suy nghĩ dở dang {"title": "sai"}'
with pytest.raises(Exception):
extract_json(raw)
def test_extract_json_strips_code_fence_and_preamble():
raw = 'Đây là giáo án:\n```json\n{"title": "B", "steps": []}\n```\nChúc em học tốt!'
assert extract_json(raw)["title"] == "B"
def test_extract_json_survives_braces_inside_strings():
raw = '{"title": "C", "note": "dấu } trong chuỗi", "steps": []}'
assert extract_json(raw)["note"] == "dấu } trong chuỗi"
def test_extract_json_reports_truncation():
with pytest.raises(Exception, match="cắt giữa chừng"):
extract_json('{"title": "D", "steps": [{"say": "chưa xong"')
# --- Tụt hạng khi endpoint không hỗ trợ tool call ---
class _FakeCompletions:
def __init__(self, content):
self.content = content
self.seen = []
def create(self, **kwargs):
if "tools" in kwargs:
self.seen.append("tool_call")
raise OpenAIStub("endpoint không hỗ trợ tools")
if "response_format" in kwargs:
self.seen.append("json_schema")
raise OpenAIStub("endpoint không hỗ trợ json_schema")
self.seen.append("prompt")
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=self.content, tool_calls=None))]
)
def _fake_client(content):
comp = _FakeCompletions(content)
return SimpleNamespace(chat=SimpleNamespace(completions=comp)), comp
def test_falls_back_to_prompt_mode_and_remembers_it():
body = '<think>ừm</think>{"title": "Pytago", "steps": [{"say": "Chào em"}]}'
client, comp = _fake_client(body)
service = LessonService(client=client)
service._mode = None
lesson = service.generate("Pytago là gì ạ?")
assert lesson.title == "Pytago"
assert comp.seen == ["tool_call", "json_schema", "prompt"]
service.generate("Hỏi tiếp ạ") # lần hai không dò lại
assert comp.seen == ["tool_call", "json_schema", "prompt", "prompt"]
# ============ Giáo án soạn sẵn ============
from app.services import packs as packs_mod # noqa: E402
from app.services.matcher import tokenize # noqa: E402
PACK_ID = "bai-10-tu-giac"
def test_pack_loads_and_validates():
pack = packs_mod.PACKS[PACK_ID]
assert len(pack.scenarios) >= 15
assert len(pack.lessons) == len(pack.scenarios)
def test_every_figure_ref_resolved_to_real_svg():
pack = packs_mod.PACKS[PACK_ID]
for lesson in pack.lessons.values():
for step in lesson.steps:
if step.board and step.board.kind == "figure":
assert step.board.content.startswith("<svg")
assert "currentColor" in step.board.content
def test_spoken_lines_carry_no_latex():
pack = packs_mod.PACKS[PACK_ID]
for line in pack.spoken_lines():
assert "$" not in line and "\\" not in line and "^" not in line
def test_accents_disambiguate_ve_and_ve():
# "vẽ" (draw) và "về" (about) bỏ dấu đều thành "ve" — dạng có dấu phải khác nhau.
assert "vẽ" in tokenize("thầy vẽ hình giúp em")
assert "vẽ" not in tokenize("em muốn học về hình bình hành")
@pytest.mark.parametrize(
"question,expected",
[
# Khái niệm
("Tứ giác là gì ạ?", "dinh-nghia-tu-giac"),
("tu giac loi la gi", "tu-giac-loi"),
("Tổng các góc của tứ giác bằng bao nhiêu?", "tong-cac-goc"),
("đường chéo là gì ạ", "dinh-doi-canh-doi-duong-cheo"),
("cặp cạnh đối là gì", "dinh-doi-canh-doi-duong-cheo"),
("cách gọi tên tứ giác", "cach-goi-ten"),
("thầy tóm tắt bài", "tom-tat-bai"),
("thầy vẽ hình tứ giác cho em xem", "ve-tu-giac"),
# SGK
("Bài 3.1 giải thế nào ạ", "bai-3-1"),
("thầy chữa bài 3.2 với", "bai-3-2"),
("bài 3.3 hình cái diều", "bai-3-3"),
("chứng minh AC là trung trực của BD", "bai-3-3"),
("tính góc D hình 3.6", "vi-du-hinh-3-6"),
("hình 3.7 tính góc F", "luyen-tap-hinh-3-7"),
("đáp án trắc nghiệm", "trac-nghiem"),
("bài toán mở đầu", "bai-toan-mo-dau"),
("bài tập về nhà là gì ạ", "huong-dan-ve-nha"),
# Phiếu — ví dụ
("ví dụ 1 hình nào là tứ giác lồi", "phieu-vd-1"),
("ví dụ 2 tìm x", "phieu-vd-2"),
("tìm x trong hình 1.3", "phieu-vd-2"),
("hình 1.4 tính x", "phieu-vd-2"),
("tính diện tích thân diều", "phieu-vd-3"),
("tứ giác Long Xuyên", "phieu-vd-4"),
("ví dụ 4", "phieu-vd-4"),
("ví dụ 5 chứng minh AC + BD > AB + CD", "phieu-vd-5"),
# Phiếu — bài tập
("thầy chữa bài 1 giúp em", "phieu-bai-1"),
("chứng minh BD là đường trung trực của AC", "phieu-bai-1"),
("bài 2 phiếu bài tập", "phieu-bai-2"),
("bài 3 ạ", "phieu-bai-3"),
("tứ giác MNPQ", "phieu-bai-3"),
("bài 4", "phieu-bai-4"),
("góc A trừ góc B bằng 10 độ", "phieu-bai-4"),
("bài 5 pytago", "phieu-bai-5"),
("hai đường chéo vuông góc tính CD", "phieu-bai-5"),
("bài 6 tìm x hình 1.8", "phieu-bai-6"),
("bài 7 tính các góc ngoài", "phieu-bai-7"),
("bài 8 nửa chu vi", "phieu-bai-8"),
],
)
def test_matcher_hits(question, expected):
scenario, score = packs_mod.PACKS[PACK_ID].find(question)
got = scenario.id if scenario else None
assert got == expected, f"{question!r}{got} (điểm {score:.2f})"
@pytest.mark.parametrize(
"question",
[
"Thầy ơi hình thang cân là gì ạ?",
"định lý Pytago phát biểu thế nào",
"em muốn học về hình bình hành",
"hôm nay trời đẹp quá thầy nhỉ",
],
)
def test_matcher_falls_through_to_ai(question):
scenario, _ = packs_mod.PACKS[PACK_ID].find(question)
assert scenario is None
def test_exercise_numbers_do_not_bleed_into_each_other():
pack = packs_mod.PACKS[PACK_ID]
for number, expected in [("3.1", "bai-3-1"), ("3.2", "bai-3-2"), ("3.3", "bai-3-3")]:
scenario, _ = pack.find(f"thầy giải bài {number} giúp em")
assert scenario.id == expected
def test_scenario_endpoint_returns_prebuilt_lesson():
res = client.post(
"/api/lessons",
json={"question": "Bài 3.1", "pack_id": PACK_ID, "scenario_id": "bai-3-1"},
)
assert res.status_code == 200
body = res.json()
assert body["source"] == "scenario"
assert body["scenario_id"] == "bai-3-1"
assert any("80" in (s["board"] or {}).get("content", "") for s in body["steps"])
def test_free_question_matches_scenario_without_calling_model():
res = client.post("/api/lessons", json={"question": "tứ giác lồi là gì ạ", "pack_id": PACK_ID})
assert res.status_code == 200
assert res.json()["source"] == "scenario"
def test_default_pack_used_when_none_given():
# App phục vụ một bài, nên client không cần biết pack_id.
res = client.post("/api/lessons", json={"question": "tính góc D hình 3.6"})
assert res.status_code == 200
assert res.json()["scenario_id"] == "vi-du-hinh-3-6"
def test_pack_carries_slide_context_for_the_model():
pack = packs_mod.default()
assert pack.id == PACK_ID
assert "Tổng các góc của một tứ giác" in pack.context
def test_upload_endpoint_is_gone():
# Không còn route nào; phần mount tĩnh ở "/" trả 405 cho POST.
assert client.post("/api/documents").status_code in (404, 405)
def test_unknown_scenario_id_is_404():
res = client.post(
"/api/lessons", json={"question": "x", "pack_id": PACK_ID, "scenario_id": "khong-co"}
)
assert res.status_code == 404
def test_packs_endpoint_lists_grouped_suggestions():
body = client.get(f"/api/packs/{PACK_ID}").json()
labels = [g["label"] for g in body["groups"]]
assert labels[:2] == ["Tóm tắt khái niệm", "Vẽ hình minh họa"]
assert body["scenario_count"] == len(body["groups"][0]["items"]) + sum(
len(g["items"]) for g in body["groups"][1:]
)
# ============ Bảng cộng dồn ============
def test_lessons_are_detailed_enough():
pack = packs_mod.PACKS[PACK_ID]
for sid, lesson in pack.lessons.items():
assert len(lesson.steps) >= 5, f"{sid} quá ngắn"
def test_every_lesson_ends_on_a_highlighted_conclusion():
# Bước cuối phải là kết luận đóng khung, để mắt biết dừng ở đâu.
pack = packs_mod.PACKS[PACK_ID]
for sid, lesson in pack.lessons.items():
boxed = [s for s in lesson.steps if s.board and s.board.highlight]
assert boxed, f"{sid} không có kết luận nào được đóng khung"
def test_most_working_sits_beside_the_figure():
# Hình không bắt buộc phải ở bước đầu — "tong-cac-goc" phát biểu định lí
# trước rồi mới vẽ hình chứng minh, và như thế là đúng sư phạm. Yêu cầu
# thật là: phần lớn lập luận phải nằm cạnh hình chứ không phải trước nó.
pack = packs_mod.PACKS[PACK_ID]
for sid, lesson in pack.lessons.items():
kinds = [s.board.kind for s in lesson.steps if s.board]
if "figure" not in kinds:
continue
first = kinds.index("figure")
lines = [k for k in kinds if k != "figure"]
beside = len([k for k in kinds[first:] if k != "figure"])
assert beside >= len(lines) / 2, f"{sid}: hình lên bảng quá muộn"
def test_sections_do_not_leave_a_long_orphan_block():
# Danh sách cứng các bài "có câu a, câu b" sẽ lỗi thời mỗi lần thêm nội
# dung. Bất biến thật nằm ở chỗ khác: phần dẫn nhập chung trước cái vạch
# đầu tiên phải ngắn, nếu không cột lập luận có cả khối dòng mồ côi không
# thuộc câu nào.
pack = packs_mod.PACKS[PACK_ID]
for sid, lesson in pack.lessons.items():
sections = [i for i, s in enumerate(lesson.steps) if s.section]
if not sections:
continue
assert sections[0] <= 2, f"{sid}: {sections[0]} bước mồ côi trước phân đoạn đầu tiên"
def test_worksheet_and_textbook_numbering_do_not_collide():
# "bài 1" là của phiếu, "bài 3.1" là của SGK — hai bài khác hẳn nhau.
pack = packs_mod.PACKS[PACK_ID]
for question, expected in [
("thầy chữa bài 1 giúp em", "phieu-bai-1"),
("bài 3.1 làm thế nào ạ", "bai-3-1"),
("bài 2 phiếu bài tập", "phieu-bai-2"),
("thầy giải bài 3.2 với", "bai-3-2"),
("bài 3 ạ", "phieu-bai-3"),
("bài 3.3 hình cái diều", "bai-3-3"),
]:
scenario, score = pack.find(question)
assert scenario is not None and scenario.id == expected, (
f"{question!r}{scenario.id if scenario else None} (điểm {score:.2f})"
)
def test_multi_part_problems_swap_figures_not_lessons():
# Bài 3.1 có hai hình: câu a dùng 3.8a, câu b đổi sang 3.8b.
steps = packs_mod.PACKS[PACK_ID].lessons["bai-3-1"].steps
figures = [s.board.content for s in steps if s.board and s.board.kind == "figure"]
assert len(figures) == 2 and figures[0] != figures[1]
# ============ Đóng gói ============
#
# Ba lỗi Docker rất dễ tái phát vì không lộ ra khi chạy local: image kéo theo
# cache tuỳ máy, thiếu scripts/ nên không prebuild được, và thư mục cache không
# ghi được nên hỏng trong im lặng.
ROOT = pathlib.Path(__file__).resolve().parents[2]
def test_dockerignore_excludes_machine_specific_files():
ignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
for pattern in ["content/.audio-cache/", ".venv/", ".env"]:
assert pattern in ignore, f"thiếu {pattern} trong .dockerignore"
def test_image_can_run_the_prebuild_script():
dockerfile = (ROOT / "backend" / "Dockerfile").read_text(encoding="utf-8")
assert "COPY scripts ./scripts" in dockerfile
def test_audio_cache_dir_is_writable_by_the_runtime_user():
dockerfile = (ROOT / "backend" / "Dockerfile").read_text(encoding="utf-8")
assert "/srv/content/.audio-cache" in dockerfile
assert "chown -R 10001:10001 /home/lophoc /srv/content" in dockerfile
def test_audio_cache_survives_a_rebuild():
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "audio-cache:/srv/content/.audio-cache" in compose
def test_keywords_longer_than_a_trigram_are_rejected():
# Bẫy im lặng: từ khoá 4 âm tiết không bao giờ nằm trong tập gram, nên nó
# ngồi trong pack trông có vẻ có tác dụng mà đóng góp đúng số không.
from app.services.matcher import Scenario, TermTooLong
with pytest.raises(TermTooLong):
Scenario(
{
"id": "thu",
"title": "Thử",
"keywords": {"giao diem hai duong cheo": 2.0},
"lesson": {"title": "x", "steps": [{"say": "x"}]},
},
"pack",
)
def test_worksheet_scenarios_are_all_present():
pack = packs_mod.PACKS[PACK_ID]
expected = {f"phieu-vd-{i}" for i in range(1, 6)} | {f"phieu-bai-{i}" for i in range(1, 9)}
assert expected <= set(pack.lessons)
assert len(pack.scenarios) == 31
def test_suggestion_tabs_cover_both_sources():
groups = {g["category"]: len(g["items"]) for g in packs_mod.PACKS[PACK_ID].suggestions()}
assert groups["phieu-vi-du"] == 5
assert groups["phieu-bai-tap"] == 8