"""app/harness/factory.py 的单元测试。 验证 build_inference_deps 的返回类型、字段连接、以及错误路径。 """ from __future__ import annotations import json from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock if TYPE_CHECKING: from pathlib import Path import numpy as np import pytest from app.harness.factory import InferenceDeps, build_inference_deps from core.types import GeneratedQuestion class TestBuildInferenceDeps: """build_inference_deps 工厂函数测试。""" def test_returns_inference_deps(self, tmp_path: Path) -> None: """用 fake adapters 验证返回类型和字段非 None。""" # 准备一棵最小树 vid_dir = tmp_path / "videos" / "test_vid" vid_dir.mkdir(parents=True) (vid_dir / "frames").mkdir() minimal_tree = { "metadata": {"source_path": "test", "modality": "video"}, "roots": [ { "id": "L1_000", "card": { "scene_summary": "s", "main_setting": "s", "key_entities": [], "main_actions": [], "topic_keywords": [], "visible_text": [], "temporal_flow": "s", }, "time_range": [0, 10], "children": [], } ], } (vid_dir / "tree.json").write_text(json.dumps(minimal_tree)) # prompts prompts_dir = tmp_path / "prompts" prompts_dir.mkdir() (prompts_dir / "system.md").write_text("You are a search agent.") fake_llm = AsyncMock() fake_vlm = AsyncMock() fake_embed = MagicMock() fake_embed.dim = 4 fake_embed.embed = lambda t: np.zeros((1, 4), dtype=np.float32) deps = build_inference_deps( store_dir=tmp_path, video_id="test_vid", prompts_dir=prompts_dir, skills_dir=None, skill_mode="none", embed_provider=fake_embed, llm=fake_llm, vlm=fake_vlm, ocr=None, verify_vision=False, anchor=False, assemble_mode="ids", ) assert isinstance(deps, InferenceDeps) assert deps.llm is fake_llm assert callable(deps.tool_dispatch_fn) assert callable(deps.prompt_builder) # 验证 prompt_builder 实际可用(连接正确) fake_q = GeneratedQuestion( question_id="q1", video_id="test_vid", task_type="Object Recognition", question="What?", options=("A. X", "B. Y", "C. Z", "D. W"), answer="A", source_nodes=(), difficulty="medium", ) system, user = deps.prompt_builder(fake_q) assert isinstance(system, str) and len(system) > 0 assert isinstance(user, str) and "What?" in user def test_missing_tree_raises(self, tmp_path: Path) -> None: """tree.json 不存在时应抛出 FileNotFoundError。""" prompts_dir = tmp_path / "prompts" prompts_dir.mkdir() (prompts_dir / "system.md").write_text("x") vid_dir = tmp_path / "videos" / "nonexist" vid_dir.mkdir(parents=True) with pytest.raises(FileNotFoundError): build_inference_deps( store_dir=tmp_path, video_id="nonexist", prompts_dir=prompts_dir, skills_dir=None, skill_mode="none", embed_provider=MagicMock(), llm=AsyncMock(), vlm=AsyncMock(), ocr=None, verify_vision=False, anchor=False, assemble_mode="ids", ) def test_with_skills_dir(self, tmp_path: Path) -> None: """提供 skills_dir 时 skill 信息应正确注入到 prompt_builder 输出。""" # 准备树 vid_dir = tmp_path / "videos" / "vid1" vid_dir.mkdir(parents=True) (vid_dir / "frames").mkdir() minimal_tree = { "metadata": {"source_path": "test", "modality": "video"}, "roots": [ { "id": "L1_000", "card": { "scene_summary": "test scene", "main_setting": "indoor", "key_entities": [], "main_actions": [], "topic_keywords": [], "visible_text": [], "temporal_flow": "linear", }, "time_range": [0, 5], "children": [], } ], } (vid_dir / "tree.json").write_text(json.dumps(minimal_tree)) # prompts prompts_dir = tmp_path / "prompts" prompts_dir.mkdir() (prompts_dir / "system.md").write_text("Base system prompt.") # skills skills_dir = tmp_path / "skills" skills_dir.mkdir() (skills_dir / "always_nav.md").write_text( "---\nname: always_nav\nalways: true\n---\nAlways navigate broadly." ) (skills_dir / "action_skill.md").write_text( "---\nname: action_skill\ntask_type: Action Reasoning\n---\nFocus on actions." ) fake_llm = AsyncMock() fake_vlm = AsyncMock() fake_embed = MagicMock() fake_embed.dim = 4 fake_embed.embed = lambda t: np.zeros((1, 4), dtype=np.float32) deps = build_inference_deps( store_dir=tmp_path, video_id="vid1", prompts_dir=prompts_dir, skills_dir=skills_dir, skill_mode="auto", embed_provider=fake_embed, llm=fake_llm, vlm=fake_vlm, ocr=None, verify_vision=False, anchor=False, assemble_mode="ids", ) fake_q = GeneratedQuestion( question_id="q2", video_id="vid1", task_type="Action Reasoning", question="What happened?", options=("A. X", "B. Y", "C. Z", "D. W"), answer="B", source_nodes=(), difficulty="easy", ) system, user = deps.prompt_builder(fake_q) # always skill 文本和 task_type skill 文本应出现在 system prompt 中 assert "Always navigate broadly" in system assert "Focus on actions" in system assert "What happened?" in user def test_frozen_dataclass(self) -> None: """InferenceDeps 是 frozen dataclass,不可修改属性。""" deps = InferenceDeps( llm=AsyncMock(), tool_dispatch_fn=lambda: None, prompt_builder=lambda q: ("", ""), ) with pytest.raises(AttributeError): deps.llm = AsyncMock() # type: ignore[misc]