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 = (
'"
)
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("
không phải svg
")
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": "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": '',
},
}
],
}
)
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 = 'Để xem nào, học sinh hỏi về...\n{"title": "A", "steps": []}'
assert extract_json(raw)["title"] == "A"
def test_extract_json_ignores_unclosed_think():
raw = '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 = 'ừm{"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("