Files
Video-Tree-TRM5/tests/unit/test_tree_index.py
T
iomgaa d3be9b1322 refactor(tree): subtitle 迁入 L3Card/L2Card + 建树管线修正
- L3Card/L2Card 新增 subtitle: str 字段(L1Card 不加)
- L3Node 移除 subtitle 字段(数据迁入 Card)
- assign_subtitles_voronoi 改写 Card.subtitle + L2 聚合
- _collect_card_strings 增加 skip_fields 排除 subtitle
- _node_full_text/_node_anchored_text 保持 字幕:/[sN] 语义
- get_subtitle 读 Card.subtitle(L2/L3)
- verify.py/synthesizer.py: l3.subtitle → l3.card.subtitle
- 迁移脚本 tools/migrate_subtitle_to_card.py(幂等,300 棵树已迁移)
- 9→6 个测试文件适配(3 个无需改动)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 11:57:41 -04:00

228 lines
6.6 KiB
Python

"""TreeIndex 数据结构单元测试。"""
from __future__ import annotations
import json
import numpy as np
import pytest
from app.tree.index import (
IndexMeta,
L1Card,
L1Node,
L2Card,
L2Node,
L3Card,
L3Node,
TreeIndex,
)
def _make_l3(idx: int = 0) -> L3Node:
return L3Node(
id=f"l1_0_l2_0_l3_{idx}",
card=L3Card(
frame_summary=f"帧{idx}描述",
visible_entities=["实体A"],
ongoing_actions=["动作A"],
visible_text=["文字A"],
spatial_layout="居中构图",
visual_attributes={"lighting": "明亮"},
),
timestamp=idx * 2.0,
frame_path=f"frames/l1_0_l2_0_l3_{idx}.jpg",
)
def _make_l2(n_l3: int = 2) -> L2Node:
return L2Node(
id="l1_0_l2_0",
card=L2Card(
event_description="事件描述",
entities=["实体B"],
actions=["动作B"],
action_subjects=["主体B"],
visible_text=["文字B"],
spatial_relations="左右排列",
state_changes=None,
),
time_range=(0.0, 60.0),
children=[_make_l3(i) for i in range(n_l3)],
)
def _make_l1(n_l2: int = 1, n_l3: int = 2) -> L1Node:
return L1Node(
id="l1_0",
card=L1Card(
scene_summary="场景摘要",
main_setting="室内",
key_entities=["实体C"],
main_actions=["动作C"],
topic_keywords=["关键词"],
visible_text=["文字C"],
temporal_flow="从左到右",
),
time_range=(0.0, 600.0),
children=[_make_l2(n_l3) for _ in range(n_l2)],
)
def _make_index(n_l1: int = 1) -> TreeIndex:
meta = IndexMeta(source_path="/test/video.mp4", modality="video")
return TreeIndex(metadata=meta, roots=[_make_l1() for _ in range(n_l1)])
class TestCards:
def test_l3_card_frozen(self):
card = L3Card(
frame_summary="desc",
visible_entities=[],
ongoing_actions=[],
visible_text=[],
spatial_layout="",
visual_attributes={},
)
with pytest.raises(AttributeError):
card.frame_summary = "changed"
def test_l2_card_fields(self):
card = L2Card(
event_description="evt",
entities=[],
actions=[],
action_subjects=[],
visible_text=[],
spatial_relations="",
state_changes=None,
)
assert card.event_description == "evt"
assert card.state_changes is None
def test_l1_card_fields(self):
card = L1Card(
scene_summary="scene",
main_setting="outdoor",
key_entities=["e"],
main_actions=["a"],
topic_keywords=["k"],
visible_text=["t"],
temporal_flow="flow",
)
assert card.scene_summary == "scene"
class TestNodes:
def test_l3_description_property(self):
node = _make_l3()
assert node.description == node.card.frame_summary
def test_l2_description_property(self):
node = _make_l2()
assert node.description == node.card.event_description
def test_l1_summary_property(self):
node = _make_l1()
assert node.summary == node.card.scene_summary
def test_l3_default_embedding_none(self):
node = _make_l3()
assert node.embedding is None
def test_l3_card_subtitle_default_empty(self):
node = _make_l3()
assert node.card.subtitle == ""
class TestTreeIndex:
def test_is_embedded_false_by_default(self):
index = _make_index()
assert not index.is_embedded
def test_embed_all(self):
index = _make_index()
def fake_embed(texts):
if isinstance(texts, str):
texts = [texts]
return np.random.randn(len(texts), 4).astype(np.float32)
index.embed_all(fake_embed, "test-model", 4)
assert index.is_embedded
assert index.metadata.embed_model == "test-model"
assert index.metadata.embed_dim == 4
def test_l1_embeddings_shape(self):
index = _make_index(n_l1=2)
def fake_embed(texts):
if isinstance(texts, str):
texts = [texts]
return np.random.randn(len(texts), 4).astype(np.float32)
index.embed_all(fake_embed, "test-model", 4)
m = index.l1_embeddings()
assert m.shape == (2, 4)
def test_get_node(self):
index = _make_index()
node = index.get_node(0, 0, 1)
assert node.id == "l1_0_l2_0_l3_1"
def test_get_node_out_of_bounds(self):
index = _make_index()
with pytest.raises(IndexError):
index.get_node(99, 0, 0)
class TestSerialization:
def test_json_roundtrip(self, tmp_path):
index = _make_index()
path = tmp_path / "tree.json"
index.save_json(str(path))
loaded = TreeIndex.load_json(str(path))
assert len(loaded.roots) == 1
assert loaded.roots[0].id == "l1_0"
assert loaded.roots[0].card.scene_summary == "场景摘要"
assert loaded.roots[0].children[0].children[0].card.frame_summary == "帧0描述"
def test_json_roundtrip_with_embedding(self, tmp_path):
index = _make_index()
def fake_embed(texts):
if isinstance(texts, str):
texts = [texts]
return np.random.randn(len(texts), 4).astype(np.float32)
index.embed_all(fake_embed, "test-model", 4)
path = tmp_path / "tree_emb.json"
index.save_json(str(path), include_embedding=True)
loaded = TreeIndex.load_json(str(path))
assert loaded.is_embedded
np.testing.assert_array_almost_equal(
loaded.roots[0].embedding, index.roots[0].embedding, decimal=5
)
def test_l1_json_roundtrip(self, tmp_path):
from app.tree.index import load_l1_json, save_l1_json
l1 = _make_l1()
path = tmp_path / "l1_0.json"
save_l1_json(str(path), l1)
loaded = load_l1_json(str(path))
assert loaded.id == "l1_0"
assert len(loaded.children) == 1
assert len(loaded.children[0].children) == 2
def test_id_uniqueness_validation(self, tmp_path):
"""重复 ID 在反序列化时应报错。"""
index = _make_index()
d = index.to_dict()
d["roots"].append(d["roots"][0])
path = tmp_path / "dup.json"
with open(path, "w") as f:
json.dump(d, f)
with pytest.raises(ValueError, match="重复"):
TreeIndex.load_json(str(path))