Files
Video-Tree-TRM5/tests/unit/test_pair_atomic_write.py
iomgaa 3cc8dc9105 refactor: assert warn sink, rename disk-pair sifter, trim WHAT docstrings
Capture loguru warning via project sink pattern and assert the dangling-orphan
warning is emitted; rename _sift_disk_pairs to _keep_complete_disk_pairs; drop
pure-WHAT docstrings on __init__/pending_orphans while keeping WHY notes.
2026-07-15 09:42:27 -04:00

254 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""pair_atomic_writer 纯 helper 单元测试。
覆盖三件核心行为:
- PairPendingBuffersingle 直通成单元、pair 按 pair_id 收齐才 emit、
未配对孤儿可查询、绑定不一致(unit_hash 语义代偿)拒绝。
- write_accepted:全量 tmp + os.replace 原子写,落盘无残留 .tmp。
- read_acceptedbuild_units + validate_units 剔除磁盘悬挂孤儿(warn+drop)。
测试用真实 GeneratedQuestion 构造(single + 合法 AR pair + 故意缺 mirror 的孤儿),
跑完整 add → write → read 往返。
"""
from __future__ import annotations
import json
import pytest
from loguru import logger
from app.harness.pools import _q_to_dict
from app.question_gen.pair_atomic_writer import (
PairPendingBuffer,
read_accepted,
write_accepted,
)
from core.types import GeneratedQuestion
def _make_single(qid: str = "s1") -> GeneratedQuestion:
"""构造一条真实 single 题(pair_id=Nonequestion_role='single')。"""
return GeneratedQuestion(
question_id=qid,
video_id="vid_A",
task_type="Action Reasoning",
question=f"{qid} 问题文本?",
options=("A. 甲", "B. 乙", "C. 丙", "D. 丁"),
answer="B",
source_nodes=("L2_0", "L3_1"),
difficulty="medium",
)
def _make_pair(
pair_id: str = "p1",
*,
video_id: str = "vid_B",
flip_axis: str = "before_after",
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
"""构造一对合法 AR 孪生对(original + mirror,共享 pair_id/flip_axis)。"""
original = GeneratedQuestion(
question_id=f"{pair_id}_o",
video_id=video_id,
task_type="Action Reasoning",
question="事件 X 发生在事件 Y 之前吗?",
options=("A. 是", "B. 否", "C. 无关", "D. 无法判断"),
answer="A",
source_nodes=("L2_2",),
difficulty="hard",
pair_id=pair_id,
question_role="pair_original",
flip_axis=flip_axis,
)
mirror = GeneratedQuestion(
question_id=f"{pair_id}_m",
video_id=video_id,
task_type="Action Reasoning",
question="事件 Y 发生在事件 X 之前吗?",
options=("A. 是", "B. 否", "C. 无关", "D. 无法判断"),
answer="B",
source_nodes=("L2_2",),
difficulty="hard",
pair_id=pair_id,
question_role="pair_mirror",
flip_axis=flip_axis,
)
return original, mirror
def test_single_add_emits_unit_immediately() -> None:
"""single 题 add 后立即返回 kind='single' 单元,不进 pending。"""
buf = PairPendingBuffer()
unit = buf.add(_make_single())
assert unit is not None
assert unit.kind == "single"
assert unit.size == 1
assert buf.pending_orphans() == []
def test_pair_emits_only_when_both_arrived() -> None:
"""pair 首个成员进 pending 返回 None,第二个到齐才 emit 一个 pair 单元。"""
buf = PairPendingBuffer()
original, mirror = _make_pair()
assert buf.add(original) is None
assert len(buf.pending_orphans()) == 1
unit = buf.add(mirror)
assert unit is not None
assert unit.kind == "pair"
assert unit.size == 2
assert buf.pending_orphans() == []
def test_pair_completes_regardless_of_arrival_order() -> None:
"""mirror 先到、original 后到也能正确成对。"""
buf = PairPendingBuffer()
original, mirror = _make_pair()
assert buf.add(mirror) is None
unit = buf.add(original)
assert unit is not None and unit.kind == "pair" and unit.size == 2
def test_dangling_original_stays_in_pending() -> None:
"""只落 original 未落 mirror → 悬挂在 pending,供调用方批次末尾检测。"""
buf = PairPendingBuffer()
original, _ = _make_pair()
assert buf.add(original) is None
orphans = buf.pending_orphans()
assert len(orphans) == 1
assert orphans[0].question_id == original.question_id
def test_inconsistent_pair_binding_rejected_and_partner_recoverable() -> None:
"""两成员 flip_axis 不一致 → 拒绝;且校验失败后首成员仍留 pending 可取回。"""
buf = PairPendingBuffer()
original, _ = _make_pair(flip_axis="before_after")
_, bad_mirror = _make_pair(flip_axis="left_right")
buf.add(original)
with pytest.raises(ValueError, match="绑定不一致"):
buf.add(bad_mirror)
# Important: 先校验后删除——raise 后首成员未被驱逐,调用方仍能检测到悬挂项
orphans = buf.pending_orphans()
assert len(orphans) == 1
assert orphans[0].question_id == original.question_id
def test_same_role_pair_rejected() -> None:
"""同 pair_id 两成员角色相同(两个 original)→ fail-fast 拒绝。"""
buf = PairPendingBuffer()
o1, _ = _make_pair()
o2, _ = _make_pair()
buf.add(o1)
with pytest.raises(ValueError):
buf.add(o2)
def test_write_read_roundtrip_keeps_pair_and_single(tmp_path) -> None:
"""add → write_accepted → read_accepted 往返:single 直通、pair 不拆、内容等价。"""
buf = PairPendingBuffer()
single = _make_single()
original, mirror = _make_pair()
units = []
for q in (single, original, mirror):
emitted = buf.add(q)
if emitted is not None:
units.append(emitted)
assert buf.pending_orphans() == []
assert len(units) == 2 # 1 single + 1 pair
out = tmp_path / "accepted" / "bank.json"
write_accepted(out, units)
assert out.exists()
assert not out.with_suffix(".tmp").exists() # 原子写无残留
loaded = read_accepted(out)
kinds = sorted(u.kind for u in loaded)
assert kinds == ["pair", "single"]
loaded_pair = next(u for u in loaded if u.kind == "pair")
assert loaded_pair.size == 2
assert {q.question_id for q in loaded_pair.questions} == {
original.question_id,
mirror.question_id,
}
assert {q.flip_axis for q in loaded_pair.questions} == {"before_after"}
def test_read_drops_disk_dangling_orphan(tmp_path) -> None:
"""磁盘上某 pair 只有 original(缺 mirror)→ read_accepted warn+drop,不 raise。"""
single = _make_single()
original, _ = _make_pair()
# 手工写入"只落 P 未落 Q"的记录:single + 孤零 original
records = [_q_to_dict(single), _q_to_dict(original)]
out = tmp_path / "bank.json"
out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
# loguru 不走标准 logging,用项目既定 sink 捕获模式(见 test_inference_pair_aggregate
captured: list[str] = []
sink_id = logger.add(captured.append, level="WARNING", format="{message}")
try:
loaded = read_accepted(out)
finally:
logger.remove(sink_id)
assert len(loaded) == 1
assert loaded[0].kind == "single"
assert loaded[0].questions[0].question_id == single.question_id
# warn+drop 契约的 "warn" 半:悬挂孤儿必须告警,不静默丢弃
assert any("悬挂孤儿" in msg for msg in captured), "悬挂孤儿未告警(静默丢弃)"
def test_read_raises_on_disk_corrupt_duplicate_role(tmp_path) -> None:
"""磁盘上同 pair_id 出现两个 pair_original(角色重复)→ 数据损坏,fail-fast raise。"""
o1, _ = _make_pair()
o2, _ = _make_pair()
# 同 pair_id、两条都是 pair_originalsize==2 但角色非法)→ 结构损坏
dup = GeneratedQuestion(
question_id="p1_o2",
video_id=o2.video_id,
task_type=o2.task_type,
question="重复 original",
options=o2.options,
answer=o2.answer,
source_nodes=o2.source_nodes,
difficulty=o2.difficulty,
pair_id=o1.pair_id,
question_role="pair_original",
flip_axis=o1.flip_axis,
)
records = [_q_to_dict(o1), _q_to_dict(dup)]
out = tmp_path / "corrupt.json"
out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
with pytest.raises(ValueError, match="结构损坏"):
read_accepted(out)
def test_read_raises_on_disk_binding_mismatch(tmp_path) -> None:
"""磁盘上合法成对但 flip_axis 绑定不一致 → 显式 raise(不依赖会被 -O 剥除的 assert)。"""
original, _ = _make_pair(pair_id="p1", flip_axis="before_after")
_, mirror = _make_pair(pair_id="p1", flip_axis="left_right")
# 直接写盘绕过 write_accepted 的 from_pair 组装,模拟外部篡改的不一致成对
records = [_q_to_dict(original), _q_to_dict(mirror)]
out = tmp_path / "mismatch.json"
out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
with pytest.raises(ValueError, match="绑定不一致"):
read_accepted(out)
def test_write_accepted_rejects_malformed_unit(tmp_path) -> None:
"""validate_units 闸门:结构非法的 pair 单元(size≠2)落盘前 fail-fast。"""
from core.types import QuestionUnit
original, _ = _make_pair()
bad_unit = QuestionUnit("pair", "p1", "Action Reasoning", (original,))
with pytest.raises(ValueError):
write_accepted(tmp_path / "bad.json", [bad_unit])