"""修复重生成器单元测试。""" from __future__ import annotations import asyncio import json from pathlib import Path from app.tree.index import ( IndexMeta, L1Card, L1Node, L2Card, L2Node, L3Card, L3Node, TreeIndex, ) from app.tree.repair.detector import NodeIssue from app.tree.repair.regenerator import RepairStats, repair_tree from core.types import LLMResponse def _mock_response(content: str) -> LLMResponse: """构造模拟 LLMResponse。""" return LLMResponse( content=content, thinking="", model="mock", provider="mock", prompt_tokens=0, completion_tokens=0, latency_ms=0, ttft_ms=None, max_inter_token_ms=None, cache_hit=False, call_id="mock", ) class MockVLM: """模拟 VLM 端口,返回固定的 L3Card JSON。""" def __init__(self) -> None: self.call_count = 0 async def chat_with_images( self, messages: list[dict], images: list, **kw: object, ) -> LLMResponse: """模拟 VLM 图文调用。""" self.call_count += 1 return _mock_response( json.dumps( { "frame_summary": "修复后的帧描述", "visible_entities": ["修复实体"], "ongoing_actions": ["修复动作"], "visible_text": [], "spatial_layout": "居中", "visual_attributes": {"lighting": "明亮"}, } ) ) class MockLLM: """模拟 LLM 端口,根据 prompt 内容返回 L2Card 或 L1Card JSON。""" def __init__(self) -> None: self.call_count = 0 async def chat( self, messages: list[dict], **kw: object, ) -> LLMResponse: """模拟 LLM 文本调用,按 prompt 内容区分 L2/L1 响应。""" self.call_count += 1 content = messages[-1].get("content", "") if "段落" in content or "scene" in content.lower(): return _mock_response( json.dumps( { "scene_summary": "修复后的场景", "main_setting": "室内", "key_entities": [], "main_actions": [], "topic_keywords": [], "visible_text": [], "temporal_flow": "", } ) ) return _mock_response( json.dumps( { "event_description": "修复后的事件", "entities": [], "actions": [], "action_subjects": [], "visible_text": [], "spatial_relations": "", "state_changes": None, } ) ) class TestRepairTree: """repair_tree 核心测试。""" def _make_broken_tree( self, tmp_path: Path, ) -> tuple[TreeIndex, list[NodeIssue]]: """构建含一个空 frame_summary 的 L3 节点的测试树。""" frame_path = tmp_path / "frames" / "L1_000_L2_000_L3_000.jpg" frame_path.parent.mkdir(parents=True) frame_path.write_bytes(b"\xff\xd8\xff\xe0fake") l3 = L3Node( id="vid_L1_000_L2_000_L3_000", card=L3Card("", [], [], [], "", {}), timestamp=1.0, frame_path="frames/L1_000_L2_000_L3_000.jpg", ) l2 = L2Node( id="vid_L1_000_L2_000", card=L2Card("原始事件", [], [], [], [], "", None), time_range=(0.0, 10.0), children=[l3], ) l1 = L1Node( id="vid_L1_000", card=L1Card("原始场景", "", [], [], [], [], ""), time_range=(0.0, 10.0), children=[l2], ) index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1]) issues = [ NodeIssue( "vid_L1_000_L2_000_L3_000", 3, "empty_field", "frame_summary 为空", ) ] return index, issues def test_repairs_l3_and_cascades(self, tmp_path: Path) -> None: """修复 L3 后应级联重生成 L2 和 L1。""" index, issues = self._make_broken_tree(tmp_path) stats = asyncio.run(repair_tree(index, issues, MockVLM(), MockLLM(), tmp_path)) assert stats.l3_repaired == 1 assert stats.l2_regenerated == 1 assert stats.l1_regenerated == 1 assert index.roots[0].children[0].children[0].card.frame_summary == "修复后的帧描述" assert index.roots[0].children[0].card.event_description == "修复后的事件" assert index.roots[0].card.scene_summary == "修复后的场景" def test_no_issues_no_changes(self) -> None: """无问题时不进行任何修复。""" l3 = L3Node( id="l1_0_l2_0_l3_0", card=L3Card("正常", [], [], [], "", {}), timestamp=1.0, ) l2 = L2Node( id="l1_0_l2_0", card=L2Card("正常事件", [], [], [], [], "", None), time_range=(0.0, 10.0), children=[l3], ) l1 = L1Node( id="l1_0", card=L1Card("正常场景", "", [], [], [], [], ""), time_range=(0.0, 10.0), children=[l2], ) index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1]) stats = asyncio.run(repair_tree(index, [], MockVLM(), MockLLM(), Path("/tmp"))) assert stats.l3_repaired == 0 assert stats.l2_regenerated == 0 assert stats.l1_regenerated == 0 def test_stats_dataclass(self) -> None: """RepairStats 数据类字段验证。""" stats = RepairStats(l3_repaired=2, l2_regenerated=1, l1_regenerated=1) assert stats.l3_repaired == 2 assert stats.l2_regenerated == 1 assert stats.l1_regenerated == 1 def test_skips_non_empty_field_issues(self, tmp_path: Path) -> None: """非 empty_field 类型的 issue 不触发 L3 修复。""" l3 = L3Node( id="l1_0_l2_0_l3_0", card=L3Card("正常描述", [], [], [], "", {}), timestamp=1.0, ) l2 = L2Node( id="l1_0_l2_0", card=L2Card("原始事件", [], [], [], [], "", None), time_range=(0.0, 10.0), children=[l3], ) l1 = L1Node( id="l1_0", card=L1Card("原始场景", "", [], [], [], [], ""), time_range=(0.0, 10.0), children=[l2], ) index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1]) issues = [NodeIssue("l1_0_l2_0_l3_0", 3, "missing_frame", "帧文件不存在")] stats = asyncio.run(repair_tree(index, issues, MockVLM(), MockLLM(), tmp_path)) assert stats.l3_repaired == 0 assert stats.l2_regenerated == 0 def test_multiple_l3_under_same_l2(self, tmp_path: Path) -> None: """同一 L2 下多个 L3 修复后,L2 只重生成一次。""" frame_dir = tmp_path / "frames" frame_dir.mkdir(parents=True) for i in range(2): (frame_dir / f"f{i}.jpg").write_bytes(b"\xff\xd8\xff\xe0fake") l3_a = L3Node( id="l1_0_l2_0_l3_0", card=L3Card("", [], [], [], "", {}), timestamp=1.0, frame_path="frames/f0.jpg", ) l3_b = L3Node( id="l1_0_l2_0_l3_1", card=L3Card("", [], [], [], "", {}), timestamp=2.0, frame_path="frames/f1.jpg", ) l2 = L2Node( id="l1_0_l2_0", card=L2Card("原始事件", [], [], [], [], "", None), time_range=(0.0, 10.0), children=[l3_a, l3_b], ) l1 = L1Node( id="l1_0", card=L1Card("原始场景", "", [], [], [], [], ""), time_range=(0.0, 10.0), children=[l2], ) index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1]) issues = [ NodeIssue("l1_0_l2_0_l3_0", 3, "empty_field", "frame_summary 为空"), NodeIssue("l1_0_l2_0_l3_1", 3, "empty_field", "frame_summary 为空"), ] vlm = MockVLM() llm = MockLLM() stats = asyncio.run(repair_tree(index, issues, vlm, llm, tmp_path)) assert stats.l3_repaired == 2 assert stats.l2_regenerated == 1 assert stats.l1_regenerated == 1 assert vlm.call_count == 2 # LLM 应被调用 2 次:一次 L2 + 一次 L1 assert llm.call_count == 2 def test_missing_frame_file_skips_l3(self, tmp_path: Path) -> None: """帧文件不存在时跳过该 L3 节点的修复。""" l3 = L3Node( id="l1_0_l2_0_l3_0", card=L3Card("", [], [], [], "", {}), timestamp=1.0, frame_path="frames/nonexistent.jpg", ) l2 = L2Node( id="l1_0_l2_0", card=L2Card("原始事件", [], [], [], [], "", None), time_range=(0.0, 10.0), children=[l3], ) l1 = L1Node( id="l1_0", card=L1Card("原始场景", "", [], [], [], [], ""), time_range=(0.0, 10.0), children=[l2], ) index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1]) issues = [NodeIssue("l1_0_l2_0_l3_0", 3, "empty_field", "frame_summary 为空")] stats = asyncio.run(repair_tree(index, issues, MockVLM(), MockLLM(), tmp_path)) # 帧文件不存在 → 跳过 L3 修复 → 无级联 assert stats.l3_repaired == 0 assert stats.l2_regenerated == 0 assert stats.l1_regenerated == 0