"""postprocess 模块单元测试 — 选项洗牌、指代黑名单、逐字重复率、时间锚点、素材禁区、run_postprocess 编排。""" from __future__ import annotations import random from app.question_gen.postprocess import ( PostprocessResult, check_forbidden_material, check_referent_blacklist, check_verbatim, has_time_anchor, run_postprocess, shuffle_options, ) # --------------------------------------------------------------------------- # TestShuffleOptions # --------------------------------------------------------------------------- class TestShuffleOptions: """选项洗牌确定性与答案重映射测试。""" def test_deterministic_with_seed(self) -> None: """相同种子产生相同置换结果。""" options = ("A. 苹果", "B. 香蕉", "C. 橙子", "D. 葡萄") answer = "B" rng1 = random.Random(42) rng2 = random.Random(42) result1 = shuffle_options(options, answer, rng1) result2 = shuffle_options(options, answer, rng2) assert result1 == result2 def test_answer_remapped_correctly(self) -> None: """洗牌后 answer 字母始终指向原正确文本。""" options = ("A. 苹果", "B. 香蕉", "C. 橙子", "D. 葡萄") answer = "B" # 正确文本是 "香蕉" # 用多个不同种子确保覆盖不同排列 for seed in range(100): rng = random.Random(seed) new_options, new_answer = shuffle_options(options, answer, rng) # 答案字母对应的选项文本必须包含 "香蕉" answer_idx = ord(new_answer) - ord("A") assert "香蕉" in new_options[answer_idx], ( f"seed={seed}: 新答案 {new_answer} 对应 '{new_options[answer_idx]}',不含 '香蕉'" ) def test_all_options_preserved(self) -> None: """洗牌后选项内容集合不变(仅字母前缀重映射)。""" options = ("A. 苹果", "B. 香蕉", "C. 橙子", "D. 葡萄") answer = "A" rng = random.Random(7) new_options, _ = shuffle_options(options, answer, rng) # 提取纯文本(去除 "X. " 前缀) original_texts = {opt[3:] for opt in options} shuffled_texts = {opt[3:] for opt in new_options} assert original_texts == shuffled_texts def test_output_letters_sequential(self) -> None: """洗牌后选项前缀始终为 A/B/C/D 顺序。""" options = ("A. one", "B. two", "C. three", "D. four") answer = "C" rng = random.Random(99) new_options, _ = shuffle_options(options, answer, rng) for i, opt in enumerate(new_options): expected_prefix = f"{chr(ord('A') + i)}. " assert opt.startswith(expected_prefix), ( f"选项 {i} 应以 '{expected_prefix}' 开头,实际为 '{opt[:3]}'" ) # --------------------------------------------------------------------------- # TestReferentBlacklist # --------------------------------------------------------------------------- class TestReferentBlacklist: """指代黑名单检测测试。""" def test_clean_passes(self) -> None: """无自指代语言的正常题目返回空列表。""" q = "What is the main character doing in the kitchen?" violations = check_referent_blacklist(q) assert violations == [] def test_this_clip_caught(self) -> None: """'this clip' 被检测为违规。""" q = "What happens in this clip after the man enters?" violations = check_referent_blacklist(q) assert len(violations) >= 1 assert any("this clip" in v.lower() for v in violations) def test_the_video_caught(self) -> None: """'the video' 被检测为违规。""" q = "In the video, what color is the car?" violations = check_referent_blacklist(q) assert len(violations) >= 1 def test_chinese_referent_caught(self) -> None: """中文自指代 '这段视频' 被检测。""" q = "这段视频中男人在做什么?" violations = check_referent_blacklist(q) assert len(violations) >= 1 def test_this_scene_caught(self) -> None: """'this scene' 被检测为违规。""" q = "What is shown in this scene before the explosion?" violations = check_referent_blacklist(q) assert len(violations) >= 1 def test_case_insensitive(self) -> None: """大小写不影响检测。""" q = "What does THIS CLIP show at the end?" violations = check_referent_blacklist(q) assert len(violations) >= 1 # --------------------------------------------------------------------------- # TestVerbatim # --------------------------------------------------------------------------- class TestVerbatim: """逐字重复率检测测试。""" def test_zero_overlap(self) -> None: """正确选项与来源文本完全无重叠时返回 0.0。""" ratio = check_verbatim( question_text="What color is the sky?", correct_option="A. The sky is painted in bright orange", source_texts=["A man walks into a store and buys groceries for dinner."], window=6, ) assert ratio == 0.0 def test_full_copy_returns_one(self) -> None: """正确选项完全来自来源文本时返回 1.0。""" source = "The cat sat on the mat near the window looking outside" option = "A. The cat sat on the mat near the window looking outside" ratio = check_verbatim( question_text="What did the animal do?", correct_option=option, source_texts=[source], window=6, ) assert ratio == 1.0 def test_partial(self) -> None: """部分重叠返回 0-1 之间的值。""" source = "The large brown dog jumped over the lazy red fox quickly" # 选项中 6 词窗口 "large brown dog jumped over the" 出现在 source 中 option = "A. A large brown dog jumped over the small fence" ratio = check_verbatim( question_text="What happened?", correct_option=option, source_texts=[source], window=6, ) assert 0.0 < ratio < 1.0 def test_empty_source_returns_zero(self) -> None: """来源文本为空列表时返回 0.0。""" ratio = check_verbatim( question_text="What is this?", correct_option="A. Something interesting", source_texts=[], window=6, ) assert ratio == 0.0 def test_short_option_below_window(self) -> None: """选项词数不足窗口大小时仍正常工作(返回 0.0)。""" ratio = check_verbatim( question_text="What?", correct_option="A. Hello world", source_texts=["Some very long source text that goes on and on"], window=6, ) assert ratio == 0.0 # --------------------------------------------------------------------------- # TestTimeAnchor # --------------------------------------------------------------------------- class TestTimeAnchor: """时间锚点检测测试。""" def test_timestamp(self) -> None: """包含 '01:30' 格式时间戳 → True。""" assert has_time_anchor("What happens at 01:30 in the recording?") is True def test_no_anchor(self) -> None: """无任何时间锚点 → False。""" assert has_time_anchor("What is the man doing in the park?") is False def test_beginning_phrase(self) -> None: """'at the beginning' → True。""" assert has_time_anchor("At the beginning, what does the woman say?") is True def test_chinese_beginning(self) -> None: """'开头' → True。""" assert has_time_anchor("视频开头出现了什么?") is True def test_chinese_ending(self) -> None: """'结尾' → True。""" assert has_time_anchor("在视频结尾发生了什么?") is True def test_minute_second_format(self) -> None: """'12:45' 格式 → True。""" assert has_time_anchor("At 12:45, who appears on screen?") is True # --------------------------------------------------------------------------- # TestCheckForbiddenMaterial # --------------------------------------------------------------------------- class TestCheckForbiddenMaterial: """素材禁区检测测试。""" def test_clean_material_passes(self) -> None: """正常素材无违规。""" source = "一个男人走进厨房,开始准备晚餐,切菜并烧水。" violations = check_forbidden_material(source, "Action Recognition") assert violations == [] def test_t1_instant_action_detected(self) -> None: """T1 瞬时动作模式被检测。""" source = "球员瞬间射门得分。一闪而过的画面。" violations = check_forbidden_material(source, "Action Recognition") assert len(violations) >= 1 def test_t1_scoreboard_temporal_detected(self) -> None: """T1 记分牌时序模式被检测。""" source = "比分从2:1变为3:1,记分牌显示更新。" violations = check_forbidden_material(source, "Counting Problem") assert len(violations) >= 1 def test_t7_option_repetition_detected(self) -> None: """T7 选项重复模式被检测。""" source = "选项A和选项B完全相同。重复选项无法区分。" violations = check_forbidden_material(source, "Object Recognition") assert len(violations) >= 1 def test_t7_counting_ambiguity_detected(self) -> None: """T7 计数边界口径含糊被检测。""" source = "大约有三到五个人,数量不确定,难以精确计数。" violations = check_forbidden_material(source, "Counting Problem") assert len(violations) >= 1 # --------------------------------------------------------------------------- # TestRunPostprocess # --------------------------------------------------------------------------- class TestRunPostprocess: """run_postprocess 编排测试。""" def test_returns_postprocess_result(self) -> None: """返回类型正确。""" result = run_postprocess( question_text="What is the main character doing?", options=("A. Running", "B. Swimming", "C. Reading", "D. Sleeping"), answer="A", source_texts=["The protagonist goes for a morning jog in the park."], rng=random.Random(42), ) assert isinstance(result, PostprocessResult) def test_shuffle_applied(self) -> None: """选项顺序被打乱(至少在多次运行中出现过不同排列)。""" options = ("A. Alpha", "B. Beta", "C. Gamma", "D. Delta") answer = "A" seen_orders: set[tuple[str, ...]] = set() for seed in range(50): result = run_postprocess( question_text="Pick one", options=options, answer=answer, source_texts=["Unrelated source text about something else entirely."], rng=random.Random(seed), ) seen_orders.add(result.options) # 50 个不同种子应产生多于 1 种排列 assert len(seen_orders) > 1 def test_referent_violations_populated(self) -> None: """有指代违规时正确填入结果。""" result = run_postprocess( question_text="What happens in this clip?", options=("A. One", "B. Two", "C. Three", "D. Four"), answer="B", source_texts=["Some text here."], rng=random.Random(0), ) assert len(result.referent_violations) >= 1 def test_verbatim_ratio_range(self) -> None: """verbatim_ratio 始终在 [0.0, 1.0] 范围内。""" result = run_postprocess( question_text="What is shown?", options=( "A. A cat sits on the mat", "B. A dog runs", "C. A bird flies", "D. A fish swims", ), answer="A", source_texts=["A cat sits on the mat by the window."], rng=random.Random(1), ) assert 0.0 <= result.verbatim_ratio <= 1.0 def test_has_time_anchor_field(self) -> None: """时间锚点字段正确填写。""" result = run_postprocess( question_text="What happens at 02:30?", options=("A. X", "B. Y", "C. Z", "D. W"), answer="C", source_texts=["Some source text."], rng=random.Random(5), ) assert result.has_time_anchor is True