"""笔记本行为:轻量记录、我的笔记、待复习、知识点导航、多维检索。""" 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, r.text return r.json() class TestCaptureFirst: """主路径要轻:拍完就能存,不强制补题型/答案。""" async def test_save_without_type_or_answer(self, client, auth_headers): h = await auth_headers() # 连 type 都不传 note = await _create(client, h, stem_markdown="只有题干") assert note["type"] == "unclassified" assert note["correct_answer"] is None assert note["options"] is None async def test_unclassified_note_is_searchable(self, client, auth_headers): h = await auth_headers() await _create(client, h, **CAPTURED) r = await client.get("/api/questions?type=unclassified", headers=h) assert r.json()["total"] == 1 async def test_can_upgrade_note_later(self, client, auth_headers): """先随手记,之后想练习时再补题型和答案。""" h = await auth_headers() note = await _create(client, h, **CAPTURED) r = await client.put( f"/api/questions/{note['id']}", json={ "type": "single_choice", "options": [ {"key": "A", "text_markdown": "1"}, {"key": "B", "text_markdown": "2"}, ], "correct_answer": ["B"], }, headers=h, ) assert r.status_code == 200 assert r.json()["type"] == "single_choice" assert r.json()["correct_answer"] == ["B"] class TestMyNote: """"我的笔记"是一等公民,且与 AI 产出分离。""" async def test_write_and_read_my_note(self, client, auth_headers): h = await auth_headers() note = await _create( client, h, stem_markdown="题目", my_note_markdown="我错在没看清定义域" ) assert note["my_note_markdown"] == "我错在没看清定义域" async def test_my_note_is_searchable(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="题目甲", my_note_markdown="关键是换元法") await _create(client, h, stem_markdown="题目乙") r = await client.get("/api/questions?q=换元法", headers=h) assert r.json()["total"] == 1 assert r.json()["items"][0]["stem_markdown"] == "题目甲" async def test_my_note_and_ai_explanation_coexist(self, client, auth_headers): h = await auth_headers() note = await _create( client, h, stem_markdown="题目", my_note_markdown="我的反思", explanation_markdown="AI 的讲解", ) assert note["my_note_markdown"] == "我的反思" assert note["explanation_markdown"] == "AI 的讲解" class TestNeedsReview: async def test_mark_and_unmark(self, client, auth_headers): h = await auth_headers() note = await _create(client, h, stem_markdown="要复习的题") assert note["needs_review"] is False r = await client.put( f"/api/questions/{note['id']}", json={"needs_review": True}, headers=h ) assert r.json()["needs_review"] is True review = (await client.get("/api/notebook/review", headers=h)).json() assert len(review) == 1 assert review[0]["marked"] is True assert review[0]["last_wrong"] is False await client.put( f"/api/questions/{note['id']}", json={"needs_review": False}, headers=h ) assert (await client.get("/api/notebook/review", headers=h)).json() == [] async def test_marked_without_ever_answering(self, client, auth_headers): """笔记本的逻辑:我抄进来的就是错题,不必先答一遍来证明。""" h = await auth_headers() await _create(client, h, stem_markdown="没答过但要复习", needs_review=True) review = (await client.get("/api/notebook/review", headers=h)).json() assert len(review) == 1 assert review[0]["wrong_count"] == 0 async def test_filter_by_needs_review(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="要复习", needs_review=True) await _create(client, h, stem_markdown="不用复习") r = await client.get("/api/questions?needs_review=true", headers=h) assert r.json()["total"] == 1 async def test_review_isolated_per_user(self, client, auth_headers): ha = await auth_headers("alice") hb = await auth_headers("bob") await _create(client, ha, stem_markdown="alice 的题", needs_review=True) assert len((await client.get("/api/notebook/review", headers=ha)).json()) == 1 assert (await client.get("/api/notebook/review", headers=hb)).json() == [] class TestOverview: async def test_overview_shape(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="导数题", tags=["导数", "微积分"]) await _create(client, h, stem_markdown="待复习的导数题", tags=["导数"], needs_review=True) await _create(client, h, stem_markdown="没归类的题") ov = (await client.get("/api/notebook/overview", headers=h)).json() assert ov["total_notes"] == 3 assert ov["review_count"] == 1 assert ov["untagged_count"] == 1 tags = {t["name"]: (t["count"], t["needs_review_count"]) for t in ov["tags"]} assert tags["导数"] == (2, 1) assert tags["微积分"] == (1, 0) # 最近记录倒序 assert ov["recent"][0]["stem_markdown"] == "没归类的题" async def test_empty_overview(self, client, auth_headers): h = await auth_headers() ov = (await client.get("/api/notebook/overview", headers=h)).json() assert ov["total_notes"] == 0 assert ov["tags"] == [] assert ov["recent"] == [] async def test_overview_isolated_per_user(self, client, auth_headers): ha = await auth_headers("alice") hb = await auth_headers("bob") await _create(client, ha, stem_markdown="alice 的题", tags=["私有标签"]) ov = (await client.get("/api/notebook/overview", headers=hb)).json() assert ov["total_notes"] == 0 assert ov["tags"] == [] async def test_no_accuracy_stats_endpoint(self, client, auth_headers): """这是笔记本,不做正确率统计。""" h = await auth_headers() assert (await client.get("/api/practice/stats", headers=h)).status_code == 404 class TestSearchDimensions: """按知识点 + 时间 + 难度 多维检索。""" async def test_difficulty_filters(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="易", difficulty=1) await _create(client, h, stem_markdown="中", difficulty=3) await _create(client, h, stem_markdown="难", difficulty=5) async def total(qs): return (await client.get(f"/api/questions{qs}", headers=h)).json()["total"] assert await total("?difficulty=3") == 1 assert await total("?difficulty_min=3") == 2 assert await total("?difficulty_max=3") == 2 assert await total("?difficulty_min=2&difficulty_max=4") == 1 async def test_multi_tag_is_intersection(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="两个标签", tags=["导数", "极限"]) await _create(client, h, stem_markdown="一个标签", tags=["导数"]) async def total(qs): return (await client.get(f"/api/questions{qs}", headers=h)).json()["total"] assert await total("?tag=导数") == 2 assert await total("?tag=导数&tag=极限") == 1 async def test_time_range(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="现在记的") async def total(qs): return (await client.get(f"/api/questions{qs}", headers=h)).json()["total"] assert await total("?created_after=2020-01-01T00:00:00") == 1 assert await total("?created_after=2099-01-01T00:00:00") == 0 assert await total("?created_before=2099-01-01T00:00:00") == 1 assert await total("?created_before=2020-01-01T00:00:00") == 0 async def test_untagged_filter(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="有标签", tags=["导数"]) await _create(client, h, stem_markdown="没标签") r = await client.get("/api/questions?untagged=true", headers=h) assert r.json()["total"] == 1 assert r.json()["items"][0]["stem_markdown"] == "没标签" async def test_combined_filters(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="命中", tags=["导数"], difficulty=5, needs_review=True) await _create(client, h, stem_markdown="难度不符", tags=["导数"], difficulty=1, needs_review=True) await _create(client, h, stem_markdown="标签不符", tags=["极限"], difficulty=5, needs_review=True) r = await client.get( "/api/questions?tag=导数&difficulty_min=4&needs_review=true", headers=h ) assert r.json()["total"] == 1 assert r.json()["items"][0]["stem_markdown"] == "命中" async def test_sort_orders(self, client, auth_headers): h = await auth_headers() await _create(client, h, stem_markdown="先记的") await _create(client, h, stem_markdown="后记的") desc = (await client.get("/api/questions?sort=created_desc", headers=h)).json() assert desc["items"][0]["stem_markdown"] == "后记的" asc = (await client.get("/api/questions?sort=created_asc", headers=h)).json() assert asc["items"][0]["stem_markdown"] == "先记的"