"""作答记录与按需判分。 笔记本的核心行为:提交答案只是记录,判分是另一个动作。 """ import json from unittest.mock import patch import pytest from app.services.llm.client import LLMResult SINGLE = { "type": "single_choice", "stem_markdown": "2+2=?", "options": [ {"key": "A", "text_markdown": "3"}, {"key": "B", "text_markdown": "4"}, ], "correct_answer": ["B"], "tags": ["算术"], } MULTI = { "type": "multiple_choice", "stem_markdown": "哪些是偶数?", "options": [ {"key": "A", "text_markdown": "2"}, {"key": "B", "text_markdown": "3"}, {"key": "C", "text_markdown": "4"}, ], "correct_answer": ["A", "C"], } # 拍照记下来的样子:没题型、没答案 CAPTURED = {"type": "unclassified", "stem_markdown": "随手记下的一道题"} async def _create(client, headers, payload): r = await client.post("/api/questions", json=payload, headers=headers) assert r.status_code == 201 return r.json()["id"] async def _attempt(client, headers, qid, answer): r = await client.post( f"/api/questions/{qid}/attempts", json={"user_answer": answer}, headers=headers ) assert r.status_code == 201 return r.json() class TestAttemptRecording: """提交答案 = 只记录,不判分、不碰 AI。""" async def test_attempt_is_recorded_unjudged(self, client, auth_headers): h = await auth_headers() qid = await _create(client, h, SINGLE) out = await _attempt(client, h, qid, ["B"]) assert out["is_correct"] is None, "提交时不该自动判分" assert out["judged_by"] == "self" assert out["user_answer"] == ["B"] async def test_attempt_works_without_ai_configured(self, client, auth_headers): """回归:主观题在未配 AI 时也必须能记录,答案不能丢。 改造前这里会返回 400 且不写任何记录,用户输入直接丢失。 """ h = await auth_headers() qid = await _create(client, h, {"type": "short_answer", "stem_markdown": "简述惯性"}) out = await _attempt(client, h, qid, ["物体保持原有运动状态的性质"]) assert out["is_correct"] is None history = (await client.get(f"/api/questions/{qid}/attempts", headers=h)).json() assert len(history) == 1 assert history[0]["user_answer"] == ["物体保持原有运动状态的性质"] async def test_attempt_on_captured_note(self, client, auth_headers): """未分类、无答案的笔记也能作答记录。""" h = await auth_headers() qid = await _create(client, h, CAPTURED) out = await _attempt(client, h, qid, ["我的解法"]) assert out["is_correct"] is None async def test_history_newest_first(self, client, auth_headers): h = await auth_headers() qid = await _create(client, h, SINGLE) await _attempt(client, h, qid, ["A"]) await _attempt(client, h, qid, ["B"]) history = (await client.get(f"/api/questions/{qid}/attempts", headers=h)).json() assert [x["user_answer"] for x in history] == [["B"], ["A"]] async def test_cannot_attempt_others_note(self, client, auth_headers): ha = await auth_headers("alice") hb = await auth_headers("bob") qid = await _create(client, ha, SINGLE) r = await client.post( f"/api/questions/{qid}/attempts", json={"user_answer": ["B"]}, headers=hb ) assert r.status_code == 404 class TestLocalJudging: """有标准答案 → 本地精确判,不花 AI 额度。""" @pytest.mark.parametrize( "answer,expected", [(["B"], True), (["A"], False), ([], False)], ) async def test_single_choice(self, client, auth_headers, answer, expected): h = await auth_headers() qid = await _create(client, h, SINGLE) a = await _attempt(client, h, qid, answer) r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h) assert r.status_code == 200 assert r.json()["is_correct"] is expected assert r.json()["judged_by"] == "auto" @pytest.mark.parametrize( "answer,expected", [ (["A", "C"], True), (["C", "A"], True), # 顺序无关 (["a", "c"], True), # 大小写无关 (["A"], False), # 少选 (["A", "B", "C"], False), # 多选 ], ) async def test_multiple_choice(self, client, auth_headers, answer, expected): h = await auth_headers() qid = await _create(client, h, MULTI) a = await _attempt(client, h, qid, answer) r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h) assert r.json()["is_correct"] is expected async def test_judging_reveals_correct_answer(self, client, auth_headers): h = await auth_headers() qid = await _create(client, h, SINGLE) a = await _attempt(client, h, qid, ["A"]) r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h) assert r.json()["correct_answer"] == ["B"] async def test_objective_without_answer_needs_ai(self, client, auth_headers): """客观题但没录标准答案 → 判不了,落到 AI 分支。 关键:不该像改造前那样静默算错。 """ h = await auth_headers() qid = await _create( client, h, { "type": "single_choice", "stem_markdown": "没录答案的选择题", "options": [{"key": "A", "text_markdown": "x"}], }, ) a = await _attempt(client, h, qid, ["A"]) r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h) # 未配 AI → 400,但作答记录仍在,且未被误判为错 assert r.status_code == 400 history = (await client.get(f"/api/questions/{qid}/attempts", headers=h)).json() assert history[0]["is_correct"] is None async def test_cannot_judge_others_attempt(self, client, auth_headers): ha = await auth_headers("alice") hb = await auth_headers("bob") qid = await _create(client, ha, SINGLE) a = await _attempt(client, ha, qid, ["B"]) r = await client.post(f"/api/attempts/{a['id']}/judge", headers=hb) assert r.status_code == 404 class TestSelfJudging: """自评:我自己知道对错,不用花 AI 额度。""" async def test_self_judge(self, client, auth_headers): h = await auth_headers() qid = await _create(client, h, CAPTURED) a = await _attempt(client, h, qid, ["我的解法"]) r = await client.post( f"/api/attempts/{a['id']}/self-judge", json={"is_correct": False}, headers=h ) assert r.status_code == 200 assert r.json()["is_correct"] is False assert r.json()["judged_by"] == "self" async def test_self_judged_wrong_enters_review(self, client, auth_headers): h = await auth_headers() qid = await _create(client, h, CAPTURED) a = await _attempt(client, h, qid, ["错的解法"]) await client.post( f"/api/attempts/{a['id']}/self-judge", json={"is_correct": False}, headers=h ) review = (await client.get("/api/notebook/review", headers=h)).json() assert len(review) == 1 assert review[0]["question"]["id"] == qid assert review[0]["last_wrong"] is True class TestAiJudging: """没有标准答案 → 交给 AI 判(用户主动触发)。""" async def test_ai_judge(self, client, auth_headers, configure_ai): h = await auth_headers() await configure_ai(h) qid = await _create(client, h, {"type": "short_answer", "stem_markdown": "简述惯性"}) a = await _attempt(client, h, qid, ["物体保持原有状态"]) payload = json.dumps( { "is_correct": True, "feedback_markdown": "答得不错", "key_points_missed": [], } ) async def fake(self, system, user, json_mode=False, model=None): return LLMResult(payload, "text-model", 10, 20) with patch("app.services.llm.client.LLMClient.chat_text", fake): r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h) assert r.status_code == 200 assert r.json()["is_correct"] is True assert r.json()["judged_by"] == "ai" assert r.json()["feedback_markdown"] == "答得不错" async def test_ai_judge_wrong_enters_review(self, client, auth_headers, configure_ai): h = await auth_headers() await configure_ai(h) qid = await _create(client, h, {"type": "short_answer", "stem_markdown": "简述惯性"}) a = await _attempt(client, h, qid, ["不知道"]) payload = json.dumps( { "is_correct": False, "feedback_markdown": "遗漏关键点", "key_points_missed": ["惯性定义"], } ) async def fake(self, system, user, json_mode=False, model=None): return LLMResult(payload, "text-model", 10, 20) with patch("app.services.llm.client.LLMClient.chat_text", fake): r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h) assert r.json()["key_points_missed"] == ["惯性定义"] review = (await client.get("/api/notebook/review", headers=h)).json() assert len(review) == 1