Spaces:
Sleeping
Sleeping
| 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") | |
| 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})" | |
| 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 | |