"""tools/build_trees.py 单元测试。 覆盖纯函数(完整性校验、待建清单发现、SRT 查找)与编排集成(Task 3 追加)。 """ from __future__ import annotations import argparse import asyncio import json import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from app.tree.index import IndexMeta, L1Card, L1Node, TreeIndex # noqa: E402 from tools.build_trees import ( # noqa: E402 _discover_pending, _find_srt_entries, _tree_is_complete, load_progress, main_async, ) def _write_valid_tree(tree_path: Path) -> None: """写入一棵最小合法树。""" l1 = L1Node( id="vid_L1_000", card=L1Card("场景", "室内", ["实体"], ["动作"], ["关键词"], [], "线性"), time_range=(0.0, 10.0), children=[], ) index = TreeIndex(metadata=IndexMeta("/v.mp4", "video"), roots=[l1]) tree_path.parent.mkdir(parents=True, exist_ok=True) index.save_json(str(tree_path)) class TestTreeIsComplete: """_tree_is_complete 测试。""" def test_valid_tree(self, tmp_path: Path) -> None: """合法 tree.json 判定完整。""" tree_path = tmp_path / "vid" / "tree.json" _write_valid_tree(tree_path) assert _tree_is_complete(tree_path) is True def test_missing_file(self, tmp_path: Path) -> None: """文件不存在判定不完整。""" assert _tree_is_complete(tmp_path / "nope" / "tree.json") is False def test_corrupt_json(self, tmp_path: Path) -> None: """损坏 JSON 判定不完整(不抛异常)。""" p = tmp_path / "vid" / "tree.json" p.parent.mkdir(parents=True) p.write_text("{broken", encoding="utf-8") assert _tree_is_complete(p) is False class TestDiscoverPending: """_discover_pending 测试。""" def _touch_videos(self, videos_dir: Path, names: list[str]) -> None: videos_dir.mkdir(parents=True, exist_ok=True) for n in names: (videos_dir / n).write_bytes(b"") def test_all_pending_when_fresh(self, tmp_path: Path) -> None: """无进度无产物时全部待建,按名排序。""" videos = tmp_path / "videos" self._touch_videos(videos, ["b.mp4", "a.mkv", "c.txt"]) pending = _discover_pending(videos, tmp_path / "out", set()) assert [p.name for p in pending] == ["a.mkv", "b.mp4"] # 非视频扩展名被忽略 def test_skips_finished_and_complete(self, tmp_path: Path) -> None: """progress 已记录或 tree.json 完整的视频被跳过。""" videos = tmp_path / "videos" out = tmp_path / "out" self._touch_videos(videos, ["a.mp4", "b.mp4", "c.mp4"]) _write_valid_tree(out / "b" / "tree.json") # b 已有完整树 pending = _discover_pending(videos, out, {"a"}) # a 在 progress 中 assert [p.name for p in pending] == ["c.mp4"] def test_incomplete_tree_not_skipped(self, tmp_path: Path) -> None: """tree.json 损坏的视频仍待建(重建覆盖)。""" videos = tmp_path / "videos" out = tmp_path / "out" self._touch_videos(videos, ["a.mp4"]) (out / "a").mkdir(parents=True) (out / "a" / "tree.json").write_text("{broken", encoding="utf-8") pending = _discover_pending(videos, out, set()) assert [p.name for p in pending] == ["a.mp4"] class TestFindSrtEntries: """_find_srt_entries 测试。""" def test_found(self, tmp_path: Path) -> None: """同名 .srt 存在时解析返回条目。""" srt = tmp_path / "vid.srt" srt.write_text( "1\n00:00:01,000 --> 00:00:03,000\nhello world\n\n", encoding="utf-8", ) entries = _find_srt_entries(tmp_path / "vid.mp4", tmp_path) assert entries is not None assert len(entries) == 1 def test_missing_returns_none(self, tmp_path: Path) -> None: """无同名 .srt 返回 None。""" assert _find_srt_entries(tmp_path / "vid.mp4", tmp_path) is None # ── 编排集成(桩 builder,无真实视频/LLM)────────────────────── class _StubBuilder: """记录并发与注入信号量的桩 builder。""" instances: list[_StubBuilder] = [] inflight = 0 max_inflight = 0 def __init__(self, vlm, llm, config, *, api_semaphore=None) -> None: """记录注入的 api_semaphore 并登记实例。""" self.api_semaphore = api_semaphore _StubBuilder.instances.append(self) async def build_async(self, video_path: str, srt_entries=None) -> TreeIndex: """模拟建树:短暂 sleep 并统计并发峰值,返回最小合法树。""" _StubBuilder.inflight += 1 _StubBuilder.max_inflight = max(_StubBuilder.max_inflight, _StubBuilder.inflight) await asyncio.sleep(0.02) _StubBuilder.inflight -= 1 l1 = L1Node( id="x_L1_000", card=L1Card("s", "室内", [], [], [], [], "线性"), time_range=(0.0, 1.0), children=[], ) return TreeIndex(metadata=IndexMeta(video_path, "video"), roots=[l1]) @pytest.fixture() def batch_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: """5 个假视频 + 桩 builder + 隔离的 progress 路径。""" import tools.build_trees as bt _StubBuilder.instances = [] _StubBuilder.inflight = 0 _StubBuilder.max_inflight = 0 monkeypatch.setattr(bt, "VideoTreeBuilder", _StubBuilder) monkeypatch.setattr(bt, "_build_clients", lambda api_concurrency: (None, None)) videos = tmp_path / "videos" videos.mkdir() for i in range(5): (videos / f"v{i}.mp4").write_bytes(b"") return { "videos": videos, "out": tmp_path / "out", "progress": tmp_path / "build_progress.json", } def _make_args(env: dict, video_concurrency: int = 2, limit: int = 0) -> argparse.Namespace: """构造 main_async 所需的 CLI 参数命名空间。""" return argparse.Namespace( videos_dir=str(env["videos"]), out_dir=str(env["out"]), srt_dir=str(env["videos"]), video_concurrency=video_concurrency, limit=limit, progress_path=str(env["progress"]), ) class TestOrchestration: """main_async 编排行为(桩 builder)。""" @pytest.mark.asyncio async def test_video_concurrency_capped(self, batch_env: dict) -> None: """同时在建视频数不得超过 video_concurrency。""" await main_async(_make_args(batch_env, video_concurrency=2)) assert _StubBuilder.max_inflight <= 2 assert len(_StubBuilder.instances) == 5 @pytest.mark.asyncio async def test_shared_api_semaphore(self, batch_env: dict) -> None: """全部 builder 实例共享同一个 API Semaphore 对象。""" await main_async(_make_args(batch_env)) sems = {id(b.api_semaphore) for b in _StubBuilder.instances} assert len(sems) == 1 assert _StubBuilder.instances[0].api_semaphore is not None @pytest.mark.asyncio async def test_trees_saved_and_progress_recorded(self, batch_env: dict) -> None: """每个视频产出 tree.json 且 progress 记录全部完成。""" await main_async(_make_args(batch_env)) for i in range(5): assert (batch_env["out"] / f"v{i}" / "tree.json").exists() assert load_progress(batch_env["progress"]) == {f"v{i}" for i in range(5)} @pytest.mark.asyncio async def test_resume_skips_finished(self, batch_env: dict) -> None: """第二次运行跳过全部已完成视频。""" await main_async(_make_args(batch_env)) n_first = len(_StubBuilder.instances) await main_async(_make_args(batch_env)) assert len(_StubBuilder.instances) == n_first # 无新建 @pytest.mark.asyncio async def test_partial_completion_resume(self, batch_env: dict) -> None: """部分完成后重跑只建剩余视频(模拟中断后恢复)。""" batch_env["progress"].write_text( json.dumps({"finished_video_ids": ["v0", "v1"]}), encoding="utf-8", ) await main_async(_make_args(batch_env)) assert len(_StubBuilder.instances) == 3 # 仅 v2/v3/v4 @pytest.mark.asyncio async def test_limit(self, batch_env: dict) -> None: """--limit 2 只建前两个(烟测入口)。""" await main_async(_make_args(batch_env, limit=2)) assert len(_StubBuilder.instances) == 2