Files
Video-Tree-TRM5/tests/unit/test_pair_atomic_write.py
T
iomgaa 1ecb6ba25c fix: fail-loud on disk-corrupt pairs and preserve buffer on reject
read_accepted now distinguishes size==1 dangling orphans (warn+drop) from
structural corruption / role duplication (raise), and runs explicit binding
checks (video_id/task_type/flip_axis) that survive python -O. add() validates
before evicting the buffered partner so pending_orphans can recover it.
2026-07-15 09:31:42 -04:00

245 lines
8.5 KiB
Python
Raw 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 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, caplog) -> 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")
loaded = read_accepted(out)
assert len(loaded) == 1
assert loaded[0].kind == "single"
assert loaded[0].questions[0].question_id == single.question_id
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])