c49d0ff12f
Add ValueError guard at the top of sample_material_v2 for level not in
{1, 2, 3}, preventing silent fallthrough to L1 sampling. Add unit test
for the new validation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
381 lines
13 KiB
Python
381 lines
13 KiB
Python
"""v2 素材采样器单元测试。
|
||
|
||
测试 sample_material_v2 及其辅助函数的核心行为:
|
||
- 正常采样返回 MaterialContext
|
||
- 已用节点排除
|
||
- 约束违反时重试直至 RuntimeError
|
||
- 跨 L2 上下文收集
|
||
- 字幕收集
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
|
||
import pytest
|
||
|
||
from app.question_gen.families import (
|
||
REASONING_FAMILY,
|
||
RETRIEVAL_FAMILY,
|
||
VISUAL_FAMILY,
|
||
SamplingConstraint,
|
||
)
|
||
from app.tree.index import (
|
||
IndexMeta,
|
||
L1Card,
|
||
L1Node,
|
||
L2Card,
|
||
L2Node,
|
||
L3Card,
|
||
L3Node,
|
||
TreeIndex,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixture: 构建含丰富数据的真实树结构
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_l3(
|
||
l1_idx: int,
|
||
l2_idx: int,
|
||
l3_idx: int,
|
||
*,
|
||
subtitle: str = "",
|
||
frame_path: str | None = None,
|
||
) -> L3Node:
|
||
"""构建 L3 节点,带可控 subtitle/frame_path。"""
|
||
node_id = f"l1_{l1_idx}_l2_{l2_idx}_l3_{l3_idx}"
|
||
return L3Node(
|
||
id=node_id,
|
||
card=L3Card(
|
||
frame_summary=f"帧{l3_idx}描述:L1={l1_idx},L2={l2_idx}",
|
||
visible_entities=[f"实体_{l3_idx}"],
|
||
ongoing_actions=[f"动作_{l3_idx}"],
|
||
visible_text=[],
|
||
spatial_layout="居中",
|
||
visual_attributes={"lighting": "明亮"},
|
||
subtitle=subtitle,
|
||
),
|
||
timestamp=float(l3_idx * 2),
|
||
frame_path=frame_path,
|
||
)
|
||
|
||
|
||
def _make_l2(
|
||
l1_idx: int,
|
||
l2_idx: int,
|
||
n_l3: int = 3,
|
||
*,
|
||
subtitle: str = "",
|
||
with_frames: bool = True,
|
||
with_subtitles: bool = True,
|
||
) -> L2Node:
|
||
"""构建 L2 节点,可控子节点数量和属性。"""
|
||
children: list[L3Node] = []
|
||
for i in range(n_l3):
|
||
sub = f"字幕L1={l1_idx}_L2={l2_idx}_L3={i}" if with_subtitles else ""
|
||
fp = f"frames/l1_{l1_idx}_l2_{l2_idx}_l3_{i}.jpg" if with_frames else None
|
||
children.append(_make_l3(l1_idx, l2_idx, i, subtitle=sub, frame_path=fp))
|
||
|
||
l2_subtitle = subtitle or (f"L2事件字幕:L1={l1_idx}_L2={l2_idx}" if with_subtitles else "")
|
||
return L2Node(
|
||
id=f"l1_{l1_idx}_l2_{l2_idx}",
|
||
card=L2Card(
|
||
event_description=f"事件:L1={l1_idx},L2={l2_idx}",
|
||
entities=[f"角色_{l2_idx}"],
|
||
actions=[f"行为_{l2_idx}"],
|
||
action_subjects=[f"主体_{l2_idx}"],
|
||
visible_text=[],
|
||
spatial_relations="左右排列",
|
||
state_changes=None,
|
||
subtitle=l2_subtitle,
|
||
),
|
||
time_range=(l2_idx * 30.0, (l2_idx + 1) * 30.0),
|
||
children=children,
|
||
)
|
||
|
||
|
||
def _make_l1(l1_idx: int, n_l2: int = 3, n_l3: int = 3) -> L1Node:
|
||
"""构建 L1 节点,含多个 L2 子节点。"""
|
||
return L1Node(
|
||
id=f"l1_{l1_idx}",
|
||
card=L1Card(
|
||
scene_summary=f"场景{l1_idx}摘要",
|
||
main_setting="室内" if l1_idx % 2 == 0 else "户外",
|
||
key_entities=[f"主角_{l1_idx}"],
|
||
main_actions=[f"主行为_{l1_idx}"],
|
||
topic_keywords=[f"关键词_{l1_idx}"],
|
||
visible_text=[],
|
||
temporal_flow="从左到右",
|
||
),
|
||
time_range=(l1_idx * 600.0, (l1_idx + 1) * 600.0),
|
||
children=[_make_l2(l1_idx, j, n_l3) for j in range(n_l2)],
|
||
)
|
||
|
||
|
||
@pytest.fixture()
|
||
def real_tree() -> TreeIndex:
|
||
"""构建包含 2 个 L1、每个 L1 含 3 个 L2、每个 L2 含 5 个 L3 的真实树。
|
||
|
||
共 2*3*5 = 30 个 L3 节点,6 个 L2 节点,2 个 L1 节点。
|
||
所有节点有帧路径和字幕。满足 REASONING_FAMILY 的 min_l3_nodes=4 要求。
|
||
"""
|
||
meta = IndexMeta(source_path="/test/video.mp4", modality="video")
|
||
roots = [_make_l1(i, n_l2=3, n_l3=5) for i in range(2)]
|
||
return TreeIndex(metadata=meta, roots=roots)
|
||
|
||
|
||
@pytest.fixture()
|
||
def sparse_tree() -> TreeIndex:
|
||
"""构建一棵稀疏树——无帧、少字幕,用于测试约束违反。
|
||
|
||
只有 1 个 L1, 1 个 L2, 1 个 L3。L3 无帧无字幕。
|
||
"""
|
||
meta = IndexMeta(source_path="/test/sparse.mp4", modality="video")
|
||
l3 = _make_l3(0, 0, 0, subtitle="", frame_path=None)
|
||
l2 = L2Node(
|
||
id="sparse_l2_0",
|
||
card=L2Card(
|
||
event_description="稀疏事件",
|
||
entities=[],
|
||
actions=[],
|
||
action_subjects=[],
|
||
visible_text=[],
|
||
spatial_relations="",
|
||
state_changes=None,
|
||
subtitle="",
|
||
),
|
||
time_range=(0.0, 30.0),
|
||
children=[l3],
|
||
)
|
||
l1 = L1Node(
|
||
id="sparse_l1_0",
|
||
card=L1Card(
|
||
scene_summary="稀疏场景",
|
||
main_setting="未知",
|
||
key_entities=[],
|
||
main_actions=[],
|
||
topic_keywords=[],
|
||
visible_text=[],
|
||
temporal_flow="",
|
||
),
|
||
time_range=(0.0, 600.0),
|
||
children=[l2],
|
||
)
|
||
return TreeIndex(metadata=meta, roots=[l1])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 测试类
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSampleMaterialV2:
|
||
"""sample_material_v2 核心行为测试。"""
|
||
|
||
def test_returns_material_context(self, real_tree: TreeIndex) -> None:
|
||
"""正常采样返回 MaterialContext,字段类型正确。"""
|
||
from app.question_gen.sampler_v2 import MaterialContext, sample_material_v2
|
||
|
||
rng = random.Random(42)
|
||
result = sample_material_v2(
|
||
tree=real_tree,
|
||
task_type="Action Reasoning",
|
||
used_node_ids=set(),
|
||
rng=rng,
|
||
level=2,
|
||
constraint=RETRIEVAL_FAMILY.sampling,
|
||
)
|
||
|
||
assert isinstance(result, MaterialContext)
|
||
assert result.anchor.node_id # 非空
|
||
assert result.anchor.level in (1, 2, 3)
|
||
assert len(result.source_nodes) > 0
|
||
assert isinstance(result.subtitle_sentences, list)
|
||
assert isinstance(result.frame_paths, list)
|
||
assert isinstance(result.cross_l2_texts, list)
|
||
|
||
def test_respects_used_nodes(self, real_tree: TreeIndex) -> None:
|
||
"""已用节点被正确排除,不会重复采样。"""
|
||
from app.question_gen.sampler_v2 import sample_material_v2
|
||
|
||
rng = random.Random(42)
|
||
|
||
# 把所有 L2 节点标记为已用(除了最后一个)
|
||
all_l2_ids: set[str] = set()
|
||
for l1 in real_tree.roots:
|
||
for l2 in l1.children:
|
||
all_l2_ids.add(l2.id)
|
||
|
||
# 留下恰好一个 L2 未用
|
||
last_l2_id = real_tree.roots[-1].children[-1].id
|
||
used = all_l2_ids - {last_l2_id}
|
||
|
||
result = sample_material_v2(
|
||
tree=real_tree,
|
||
task_type="Action Reasoning",
|
||
used_node_ids=used,
|
||
rng=rng,
|
||
level=2,
|
||
constraint=RETRIEVAL_FAMILY.sampling,
|
||
)
|
||
|
||
# 锚节点应该是那个未被排除的 L2
|
||
assert result.anchor.node_id == last_l2_id
|
||
|
||
def test_constraint_violation_retries(self, sparse_tree: TreeIndex) -> None:
|
||
"""稀疏树上,严格约束满足不了,耗尽重试后抛 RuntimeError。"""
|
||
from app.question_gen.sampler_v2 import sample_material_v2
|
||
|
||
rng = random.Random(42)
|
||
|
||
# VISUAL_FAMILY 要求 require_frames=True, min_l3_nodes=3
|
||
# sparse_tree 只有 1 个 L3 且无帧 → 约束必然违反
|
||
with pytest.raises(RuntimeError, match="max_attempts"):
|
||
sample_material_v2(
|
||
tree=sparse_tree,
|
||
task_type="Object Recognition",
|
||
used_node_ids=set(),
|
||
rng=rng,
|
||
level=3,
|
||
constraint=VISUAL_FAMILY.sampling,
|
||
max_attempts=3,
|
||
)
|
||
|
||
def test_cross_l2_populated_for_reasoning(self, real_tree: TreeIndex) -> None:
|
||
"""REASONING 家族要求 cross_l2_span=True,cross_l2_texts 应被填充。"""
|
||
from app.question_gen.sampler_v2 import sample_material_v2
|
||
|
||
rng = random.Random(42)
|
||
|
||
result = sample_material_v2(
|
||
tree=real_tree,
|
||
task_type="Action Reasoning",
|
||
used_node_ids=set(),
|
||
rng=rng,
|
||
level=2,
|
||
constraint=REASONING_FAMILY.sampling,
|
||
)
|
||
|
||
# cross_l2_span=True 时必须有跨 L2 文本
|
||
assert len(result.cross_l2_texts) > 0
|
||
|
||
def test_subtitle_sentences_from_anchor(self, real_tree: TreeIndex) -> None:
|
||
"""采样结果的 subtitle_sentences 来自锚节点所属子树。"""
|
||
from app.question_gen.sampler_v2 import sample_material_v2
|
||
|
||
rng = random.Random(42)
|
||
|
||
result = sample_material_v2(
|
||
tree=real_tree,
|
||
task_type="Action Reasoning",
|
||
used_node_ids=set(),
|
||
rng=rng,
|
||
level=2,
|
||
constraint=RETRIEVAL_FAMILY.sampling,
|
||
)
|
||
|
||
# real_tree 所有节点都有字幕,所以 subtitle_sentences 非空
|
||
assert len(result.subtitle_sentences) > 0
|
||
# 字幕应来自锚节点所属的子树(L2 自身字幕 + 子 L3 字幕)
|
||
# fixture 中 L2 字幕格式: "L2事件字幕:L1={l1_idx}_L2={l2_idx}"
|
||
# fixture 中 L3 字幕格式: "字幕L1={l1_idx}_L2={l2_idx}_L3={l3_idx}"
|
||
# 解析锚 L2 的索引信息来验证
|
||
anchor_l2_id = result.anchor.l2_id # 如 "l1_1_l2_2"
|
||
# 从 ID 提取 L1/L2 索引
|
||
parts = anchor_l2_id.split("_") # ["l1", "1", "l2", "2"]
|
||
l1_idx, l2_idx = parts[1], parts[3]
|
||
# 字幕中应包含 "L1={l1_idx}_L2={l2_idx}" 格式
|
||
pattern = f"L1={l1_idx}_L2={l2_idx}"
|
||
has_related = any(pattern in s for s in result.subtitle_sentences)
|
||
assert has_related
|
||
|
||
def test_invalid_level_raises(self, real_tree: TreeIndex) -> None:
|
||
"""无效的 level 参数应抛出 ValueError。"""
|
||
from app.question_gen.sampler_v2 import sample_material_v2
|
||
|
||
rng = random.Random(42)
|
||
with pytest.raises(ValueError, match="level 必须为"):
|
||
sample_material_v2(
|
||
tree=real_tree,
|
||
task_type="Object Recognition",
|
||
used_node_ids=set(),
|
||
rng=rng,
|
||
level=4,
|
||
constraint=RETRIEVAL_FAMILY.sampling,
|
||
)
|
||
|
||
|
||
class TestValidateSamplingConstraints:
|
||
"""_validate_sampling_constraints 辅助函数测试。"""
|
||
|
||
def test_passes_relaxed_constraint(self, real_tree: TreeIndex) -> None:
|
||
"""宽松约束在丰富树上应通过。"""
|
||
from app.question_gen.sampler_v2 import _validate_sampling_constraints
|
||
|
||
relaxed = SamplingConstraint(
|
||
min_subtitles=1,
|
||
min_l3_nodes=1,
|
||
require_frames=False,
|
||
cross_l2_span=False,
|
||
)
|
||
# 取第一个 L2 节点
|
||
node_id = real_tree.roots[0].children[0].id
|
||
assert _validate_sampling_constraints(real_tree, node_id, relaxed) is True
|
||
|
||
def test_fails_strict_frame_constraint(self, sparse_tree: TreeIndex) -> None:
|
||
"""require_frames=True 但无帧时应返回 False。"""
|
||
from app.question_gen.sampler_v2 import _validate_sampling_constraints
|
||
|
||
strict = SamplingConstraint(
|
||
min_subtitles=0,
|
||
min_l3_nodes=1,
|
||
require_frames=True,
|
||
cross_l2_span=False,
|
||
)
|
||
node_id = sparse_tree.roots[0].children[0].id
|
||
assert _validate_sampling_constraints(sparse_tree, node_id, strict) is False
|
||
|
||
|
||
class TestCollectSubtitleSentences:
|
||
"""_collect_subtitle_sentences 辅助函数测试。"""
|
||
|
||
def test_collects_from_l2_and_l3(self, real_tree: TreeIndex) -> None:
|
||
"""收集指定节点的 L2 字幕和子 L3 字幕。"""
|
||
from app.question_gen.sampler_v2 import _collect_subtitle_sentences
|
||
|
||
l2_id = real_tree.roots[0].children[0].id
|
||
sentences = _collect_subtitle_sentences(real_tree, (l2_id,))
|
||
# 应包含 L2 自身字幕 + 3 个 L3 子节点字幕
|
||
assert len(sentences) >= 1
|
||
|
||
def test_empty_for_no_subtitles(self, sparse_tree: TreeIndex) -> None:
|
||
"""无字幕节点返回空列表。"""
|
||
from app.question_gen.sampler_v2 import _collect_subtitle_sentences
|
||
|
||
l2_id = sparse_tree.roots[0].children[0].id
|
||
sentences = _collect_subtitle_sentences(sparse_tree, (l2_id,))
|
||
assert sentences == []
|
||
|
||
|
||
class TestCollectCrossL2Context:
|
||
"""_collect_cross_l2_context 辅助函数测试。"""
|
||
|
||
def test_returns_peer_l2_descriptions(self, real_tree: TreeIndex) -> None:
|
||
"""跨 L2 上下文应返回同 L1 下其他 L2 的描述。"""
|
||
from app.question_gen.sampler_v2 import _collect_cross_l2_context
|
||
|
||
anchor_l2_id = real_tree.roots[0].children[0].id
|
||
texts = _collect_cross_l2_context(real_tree, anchor_l2_id, max_peers=3)
|
||
# L1_0 有 3 个 L2,排除 anchor 后剩 2 个
|
||
assert len(texts) == 2
|
||
|
||
def test_max_peers_limits_output(self, real_tree: TreeIndex) -> None:
|
||
"""max_peers 参数限制返回数量。"""
|
||
from app.question_gen.sampler_v2 import _collect_cross_l2_context
|
||
|
||
anchor_l2_id = real_tree.roots[0].children[0].id
|
||
texts = _collect_cross_l2_context(real_tree, anchor_l2_id, max_peers=1)
|
||
assert len(texts) <= 1
|