"""ActionRecognitionStrategy 单元测试。 验证策略属性、SubPattern 定义、加权随机选择行为。 """ from __future__ import annotations import random from collections import Counter import pytest from app.question_gen.families import SamplingConstraint from app.question_gen.strategy import SubPattern, TaskTypeStrategy from app.question_gen.strategy_action_recognition import ( AR_SUB_PATTERNS, ActionRecognitionStrategy, ) # --------------------------------------------------------------------------- # SubPattern 分组 # --------------------------------------------------------------------------- _L1_PATTERN_NAMES = frozenset( { "premature_evidence_anchoring", "temporal_reasoning_failure", "cross_segment_entity_tracking", } ) _L2_PATTERN_NAMES = frozenset( { "semantic_rigidity", "fine_grained_visual_action", "evidence_gap_confabulation", } ) _ALL_PATTERN_NAMES = _L1_PATTERN_NAMES | _L2_PATTERN_NAMES class TestStrategyProperties: """策略静态属性与 Protocol 符合性。""" @pytest.fixture() def strategy(self) -> ActionRecognitionStrategy: """创建策略实例。""" return ActionRecognitionStrategy() def test_implements_protocol(self, strategy: ActionRecognitionStrategy) -> None: """ActionRecognitionStrategy 满足 TaskTypeStrategy Protocol。""" assert isinstance(strategy, TaskTypeStrategy) def test_task_type(self, strategy: ActionRecognitionStrategy) -> None: """task_type 为 'Action Recognition'。""" assert strategy.task_type == "Action Recognition" def test_strategy_name(self, strategy: ActionRecognitionStrategy) -> None: """strategy_name 为 'ACTION_RECOGNITION'。""" assert strategy.strategy_name == "ACTION_RECOGNITION" def test_skill_target(self, strategy: ActionRecognitionStrategy) -> None: """skill_target 为 'M1_AR'。""" assert strategy.skill_target == "M1_AR" def test_sampling_level(self, strategy: ActionRecognitionStrategy) -> None: """sampling_level 为 2(L2)。""" assert strategy.sampling_level == 2 def test_sampling_constraint(self, strategy: ActionRecognitionStrategy) -> None: """采样约束匹配设计值。""" expected = SamplingConstraint( min_subtitles=3, min_l3_nodes=5, require_frames=True, cross_l2_span=True, ) assert strategy.sampling_constraint == expected def test_prompt_template(self, strategy: ActionRecognitionStrategy) -> None: """prompt_template 为 'action_recognition.md'。""" assert strategy.prompt_template == "action_recognition.md" def test_leak_probe_template(self, strategy: ActionRecognitionStrategy) -> None: """leak_probe_template 为 'gate_leak_retrieval.md'。""" assert strategy.leak_probe_template == "gate_leak_retrieval.md" def test_extra_gates_empty(self, strategy: ActionRecognitionStrategy) -> None: """extra_gates 返回空列表。""" assert strategy.extra_gates(None) == [] class TestSubPatternDefinitions: """SubPattern 定义的完整性与正确性。""" def test_ar_sub_patterns_count(self) -> None: """AR_SUB_PATTERNS 包含 6 个子模式。""" assert len(AR_SUB_PATTERNS) == 6 def test_ar_sub_patterns_is_tuple(self) -> None: """AR_SUB_PATTERNS 为 tuple 类型。""" assert isinstance(AR_SUB_PATTERNS, tuple) def test_all_are_sub_pattern_instances(self) -> None: """所有元素均为 SubPattern 实例。""" for sp in AR_SUB_PATTERNS: assert isinstance(sp, SubPattern) def test_all_names_present(self) -> None: """6 个子模式名称完整覆盖。""" names = {sp.name for sp in AR_SUB_PATTERNS} assert names == _ALL_PATTERN_NAMES def test_l1_patterns_have_level_override_1(self) -> None: """L1 子模式的 sampling_level_override 为 1。""" for sp in AR_SUB_PATTERNS: if sp.name in _L1_PATTERN_NAMES: assert sp.sampling_level_override == 1, ( f"{sp.name} 应为 L1 (override=1),实际 {sp.sampling_level_override}" ) def test_l2_patterns_have_no_level_override(self) -> None: """L2 子模式的 sampling_level_override 为 None。""" for sp in AR_SUB_PATTERNS: if sp.name in _L2_PATTERN_NAMES: assert sp.sampling_level_override is None, ( f"{sp.name} 应为 L2 (override=None),实际 {sp.sampling_level_override}" ) def test_weights_sum_to_one(self) -> None: """所有子模式权重之和为 1.0。""" total = sum(sp.weight for sp in AR_SUB_PATTERNS) assert abs(total - 1.0) < 1e-9 def test_individual_weights(self) -> None: """各子模式权重匹配设计值。""" weight_map = {sp.name: sp.weight for sp in AR_SUB_PATTERNS} assert abs(weight_map["premature_evidence_anchoring"] - 0.20) < 1e-9 assert abs(weight_map["temporal_reasoning_failure"] - 0.20) < 1e-9 assert abs(weight_map["semantic_rigidity"] - 0.15) < 1e-9 assert abs(weight_map["fine_grained_visual_action"] - 0.15) < 1e-9 assert abs(weight_map["cross_segment_entity_tracking"] - 0.15) < 1e-9 assert abs(weight_map["evidence_gap_confabulation"] - 0.15) < 1e-9 def test_all_have_nonempty_instruction(self) -> None: """每个子模式有非空 instruction。""" for sp in AR_SUB_PATTERNS: assert sp.instruction.strip(), f"{sp.name} instruction 为空" def test_all_have_nonempty_distractor_rules(self) -> None: """每个子模式有非空 distractor_rules。""" for sp in AR_SUB_PATTERNS: assert sp.distractor_rules.strip(), f"{sp.name} distractor_rules 为空" def test_all_have_positive_examples(self) -> None: """每个子模式有至少 1 个正面示例。""" for sp in AR_SUB_PATTERNS: assert len(sp.positive_examples) >= 1, f"{sp.name} 缺少 positive_examples" def test_all_have_negative_examples(self) -> None: """每个子模式有至少 1 个反面示例。""" for sp in AR_SUB_PATTERNS: assert len(sp.negative_examples) >= 1, f"{sp.name} 缺少 negative_examples" def test_positive_examples_have_required_keys(self) -> None: """正面示例包含 question, answer, why 字段。""" required = {"question", "answer", "why"} for sp in AR_SUB_PATTERNS: for ex in sp.positive_examples: missing = required - set(ex.keys()) assert not missing, f"{sp.name} 正面示例缺少字段: {missing}" def test_negative_examples_have_required_keys(self) -> None: """反面示例包含 question, why 字段。""" required = {"question", "why"} for sp in AR_SUB_PATTERNS: for ex in sp.negative_examples: missing = required - set(ex.keys()) assert not missing, f"{sp.name} 反面示例缺少字段: {missing}" def test_all_constraint_overrides_none(self) -> None: """所有子模式的 constraint_override 为 None。""" for sp in AR_SUB_PATTERNS: assert sp.constraint_override is None, f"{sp.name} constraint_override 应为 None" class TestSelectSubPattern: """select_sub_pattern 加权随机选择行为。""" @pytest.fixture() def strategy(self) -> ActionRecognitionStrategy: """创建策略实例。""" return ActionRecognitionStrategy() def test_returns_sub_pattern(self, strategy: ActionRecognitionStrategy) -> None: """select_sub_pattern 返回 SubPattern(不是 None)。""" rng = random.Random(42) result = strategy.select_sub_pattern(rng) assert isinstance(result, SubPattern) def test_deterministic_with_same_seed(self, strategy: ActionRecognitionStrategy) -> None: """相同种子产生相同结果。""" results_a = [strategy.select_sub_pattern(random.Random(99)).name for _ in range(20)] results_b = [strategy.select_sub_pattern(random.Random(99)).name for _ in range(20)] assert results_a == results_b def test_covers_all_patterns(self, strategy: ActionRecognitionStrategy) -> None: """足够多次采样覆盖全部 6 个子模式。""" rng = random.Random(12345) seen = {strategy.select_sub_pattern(rng).name for _ in range(500)} assert seen == _ALL_PATTERN_NAMES def test_distribution_roughly_matches_weights( self, strategy: ActionRecognitionStrategy ) -> None: """采样分布大致匹配权重(容差 +-0.05)。""" rng = random.Random(42) n = 5000 counter: Counter[str] = Counter() for _ in range(n): counter[strategy.select_sub_pattern(rng).name] += 1 weight_map = {sp.name: sp.weight for sp in AR_SUB_PATTERNS} for name, expected_w in weight_map.items(): observed_ratio = counter[name] / n assert abs(observed_ratio - expected_w) < 0.05, ( f"{name}: expected ~{expected_w:.2f}, got {observed_ratio:.3f}" ) class TestBuildPromptContext: """build_prompt_context 输出验证。""" @pytest.fixture() def strategy(self) -> ActionRecognitionStrategy: """创建策略实例。""" return ActionRecognitionStrategy() def test_returns_dict_with_required_keys(self, strategy: ActionRecognitionStrategy) -> None: """返回包含 family_name, prompt_template, sub_pattern 的字典。""" sp = AR_SUB_PATTERNS[0] ctx = strategy.build_prompt_context(material=None, sub_pattern=sp) assert "family_name" in ctx assert "prompt_template" in ctx assert "sub_pattern" in ctx def test_family_name_value(self, strategy: ActionRecognitionStrategy) -> None: """family_name 为 ACTION_RECOGNITION。""" sp = AR_SUB_PATTERNS[0] ctx = strategy.build_prompt_context(material=None, sub_pattern=sp) assert ctx["family_name"] == "ACTION_RECOGNITION" def test_prompt_template_value(self, strategy: ActionRecognitionStrategy) -> None: """prompt_template 为 action_recognition.md。""" sp = AR_SUB_PATTERNS[0] ctx = strategy.build_prompt_context(material=None, sub_pattern=sp) assert ctx["prompt_template"] == "action_recognition.md" def test_sub_pattern_name(self, strategy: ActionRecognitionStrategy) -> None: """sub_pattern 字段等于子模式名称。""" for sp in AR_SUB_PATTERNS: ctx = strategy.build_prompt_context(material=None, sub_pattern=sp) assert ctx["sub_pattern"] == sp.name