bdcc93d7de
PairPendingBuffer collects pair members by pair_id and emits units; write_accepted does tmp+os.replace atomic write; read_accepted rebuilds units and drops disk-dangling orphans. Pure helper; on_accept wiring is Phase 2 (real write point: adversarial_filter.write_final_bank).
201 lines
6.6 KiB
Python
201 lines
6.6 KiB
Python
"""pair_atomic_writer 纯 helper 单元测试。
|
||
|
||
覆盖三件核心行为:
|
||
- PairPendingBuffer:single 直通成单元、pair 按 pair_id 收齐才 emit、
|
||
未配对孤儿可查询、绑定不一致(unit_hash 语义代偿)拒绝。
|
||
- write_accepted:全量 tmp + os.replace 原子写,落盘无残留 .tmp。
|
||
- read_accepted:build_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=None,question_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() -> None:
|
||
"""同 pair_id 两成员 flip_axis 不一致 → 拒绝(unit_hash 语义代偿)。"""
|
||
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)
|
||
|
||
|
||
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_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])
|