"""v3 出题领域观测表落库测试 — facts/unit_verdict/collapse_metrics/quarantine/resume_state。 验证 5 张 v3 表的 insert 方法落库+读回、幂等 upsert(同指纹/同 unit_id 不产生重复行)、 以及 ts 由外部传入(禁进程内 now,保幂等可复现)。同时确认 v2 旧表未受本 Task 影响。 """ from __future__ import annotations import sqlite3 from typing import TYPE_CHECKING import pytest from app.question_gen.run_store import QuestionGenStore, RunStats if TYPE_CHECKING: from pathlib import Path @pytest.fixture def store(tmp_path: Path) -> QuestionGenStore: """基于真临时 SQLite 文件构造 QuestionGenStore。""" db_path = tmp_path / "qgen_v3.db" s = QuestionGenStore(db_path) yield s s.close() # --------------------------------------------------------------------------- # 表 1:facts # --------------------------------------------------------------------------- def test_insert_fact_roundtrip(store: QuestionGenStore) -> None: """insert_fact 落库后可读回全部列,ts 为外部传入值。""" ts = "2026-07-15T10:00:00+00:00" store.insert_fact( fact_id="fact-1", video_id="vid-1", segment_id="seg-1", subject="man", action="pour", object="water", frame_ids="[10, 20, 30]", polarity="真", fact_type="binding", difficulty_tier=2, verifier_refs='{"qwen": "pass", "minimax": "pass"}', cross_agree=1, negative_at_target="not_false", session_id="sess-1", ts=ts, ) row = store._conn.execute( "SELECT fact_id, video_id, segment_id, subject, action, object, frame_ids, " "polarity, fact_type, difficulty_tier, verifier_refs, cross_agree, " "negative_at_target, session_id, ts FROM facts WHERE fact_id='fact-1'" ).fetchone() assert row == ( "fact-1", "vid-1", "seg-1", "man", "pour", "water", "[10, 20, 30]", "真", "binding", 2, '{"qwen": "pass", "minimax": "pass"}', 1, "not_false", "sess-1", ts, ) def test_insert_fact_ts_is_external_not_now(store: QuestionGenStore) -> None: """ts 如实存储外部传入的历史时间戳,不被进程内 now 覆盖。""" old_ts = "2020-01-01T00:00:00+00:00" store.insert_fact( fact_id="fact-old", video_id="v", segment_id="s", subject="a", action="b", object="c", frame_ids="[]", polarity="真", fact_type="state", difficulty_tier=1, verifier_refs="{}", cross_agree=0, negative_at_target="x", session_id="sess", ts=old_ts, ) stored = store._conn.execute("SELECT ts FROM facts WHERE fact_id='fact-old'").fetchone()[0] assert stored == old_ts # --------------------------------------------------------------------------- # 表 2:unit_verdict # --------------------------------------------------------------------------- def test_insert_unit_verdict_roundtrip(store: QuestionGenStore) -> None: """insert_unit_verdict 落库后可读回,含可空 pair_id/metric_value/model。""" ts = "2026-07-15T11:00:00+00:00" store.insert_unit_verdict( unit_id="unit-1", pair_id="pair-1", sub_pattern="binding_swap", stage=3, verdict="pass", reason="ok", metric_value=0.87, model="qwen-vl", session_id="sess-1", ts=ts, ) row = store._conn.execute( "SELECT unit_id, pair_id, sub_pattern, stage, verdict, reason, metric_value, " "model, session_id, ts FROM unit_verdict WHERE unit_id='unit-1' AND stage=3" ).fetchone() assert row == ( "unit-1", "pair-1", "binding_swap", 3, "pass", "ok", 0.87, "qwen-vl", "sess-1", ts, ) def test_insert_unit_verdict_nullable_fields(store: QuestionGenStore) -> None: """可空列(pair_id/metric_value/model)支持 None 落库。""" store.insert_unit_verdict( unit_id="unit-2", pair_id=None, sub_pattern="order", stage=1, verdict="abstain", reason="no judge", metric_value=None, model=None, session_id="sess-1", ts="2026-07-15T11:05:00+00:00", ) row = store._conn.execute( "SELECT pair_id, metric_value, model FROM unit_verdict WHERE unit_id='unit-2'" ).fetchone() assert row == (None, None, None) def test_insert_unit_verdict_rerun_same_stage_overwrites(store: QuestionGenStore) -> None: """同 (unit_id, stage) 重跑覆盖更新,行数保持 1。""" common = { "unit_id": "unit-3", "pair_id": "pair-3", "sub_pattern": "manner", "stage": 2, "session_id": "sess-1", } store.insert_unit_verdict( verdict="fail", reason="first", metric_value=0.1, model="m1", ts="2026-07-15T11:10:00+00:00", **common, ) new_ts = "2026-07-15T11:20:00+00:00" store.insert_unit_verdict( verdict="pass", reason="second", metric_value=0.9, model="m2", ts=new_ts, **common, ) rows = store._conn.execute( "SELECT verdict, reason, metric_value, ts " "FROM unit_verdict WHERE unit_id='unit-3' AND stage=2" ).fetchall() assert len(rows) == 1 # ts 随整行覆盖为第二次(新)ts,锁死 DO UPDATE SET ts=excluded.ts 语义。 assert rows[0] == ("pass", "second", 0.9, new_ts) # --------------------------------------------------------------------------- # 表 3:collapse_metrics # --------------------------------------------------------------------------- def test_insert_collapse_metrics_roundtrip(store: QuestionGenStore) -> None: """insert_collapse_metrics 落库后可读回全部度量列。""" ts = "2026-07-15T12:00:00+00:00" store.insert_collapse_metrics( pair_id="pair-1", text_only_acc=0.3, single_frame_acc=0.4, placebo_drop=0.1, majority_vote_hit=0.25, slot_chi2=1.5, distractor_min_dist=0.6, multiformat_consistency=0.8, subtitle_answerability=0.2, ts=ts, ) row = store._conn.execute( "SELECT pair_id, text_only_acc, single_frame_acc, placebo_drop, majority_vote_hit, " "slot_chi2, distractor_min_dist, multiformat_consistency, subtitle_answerability, ts " "FROM collapse_metrics WHERE pair_id='pair-1'" ).fetchone() assert row == ( "pair-1", 0.3, 0.4, 0.1, 0.25, 1.5, 0.6, 0.8, 0.2, ts, ) def test_insert_collapse_metrics_recompute_upserts(store: QuestionGenStore) -> None: """同 pair_id 重算覆盖更新,行数保持 1。""" store.insert_collapse_metrics( pair_id="pair-2", text_only_acc=0.3, single_frame_acc=0.4, placebo_drop=0.1, majority_vote_hit=0.25, slot_chi2=1.0, distractor_min_dist=0.5, multiformat_consistency=0.7, subtitle_answerability=0.1, ts="2026-07-15T12:00:00+00:00", ) new_ts = "2026-07-15T12:30:00+00:00" store.insert_collapse_metrics( pair_id="pair-2", text_only_acc=0.05, single_frame_acc=0.05, placebo_drop=0.5, majority_vote_hit=0.25, slot_chi2=9.0, distractor_min_dist=0.9, multiformat_consistency=0.95, subtitle_answerability=0.0, ts=new_ts, ) rows = store._conn.execute( "SELECT text_only_acc, slot_chi2, ts FROM collapse_metrics WHERE pair_id='pair-2'" ).fetchall() assert len(rows) == 1 # ts 随整行覆盖为第二次(新)ts,锁死 DO UPDATE SET ts=excluded.ts 语义。 assert rows[0] == (0.05, 9.0, new_ts) # --------------------------------------------------------------------------- # 表 4:quarantine # --------------------------------------------------------------------------- def test_quarantine_roundtrip(store: QuestionGenStore) -> None: """quarantine 落库后可读回,ts 为外部传入值。""" ts = "2026-07-15T13:00:00+00:00" store.quarantine( content_fingerprint="fp-1", sub_pattern="binding_swap", quarantine_reason="double_true", round_no=1, ts=ts, ) row = store._conn.execute( "SELECT content_fingerprint, sub_pattern, quarantine_reason, round_no, ts " "FROM quarantine WHERE content_fingerprint='fp-1'" ).fetchone() assert row == ("fp-1", "binding_swap", "double_true", 1, ts) def test_quarantine_same_fingerprint_dedup(store: QuestionGenStore) -> None: """同 content_fingerprint 重复调用不产生重复行(去重幂等),且内容被更新。""" store.quarantine( content_fingerprint="fp-dup", sub_pattern="order", quarantine_reason="reason_a", round_no=1, ts="2026-07-15T13:00:00+00:00", ) store.quarantine( content_fingerprint="fp-dup", sub_pattern="order", quarantine_reason="reason_b", round_no=2, ts="2026-07-15T13:30:00+00:00", ) rows = store._conn.execute( "SELECT quarantine_reason, round_no FROM quarantine WHERE content_fingerprint='fp-dup'" ).fetchall() assert len(rows) == 1 assert rows[0] == ("reason_b", 2) # --------------------------------------------------------------------------- # 表 5:resume_state # --------------------------------------------------------------------------- def test_upsert_resume_state_roundtrip(store: QuestionGenStore) -> None: """upsert_resume_state 落库后可读回全部列。""" store.upsert_resume_state( unit_id="unit-1", status="pending", config_fingerprint="cfg-abc", seq_offset=0, ) row = store._conn.execute( "SELECT unit_id, status, config_fingerprint, seq_offset " "FROM resume_state WHERE unit_id='unit-1'" ).fetchone() assert row == ("unit-1", "pending", "cfg-abc", 0) def test_upsert_resume_state_same_unit_overwrites(store: QuestionGenStore) -> None: """同 unit_id 覆盖更新 status/config/seq_offset,行数保持 1。""" store.upsert_resume_state( unit_id="unit-2", status="pending", config_fingerprint="cfg-1", seq_offset=0, ) store.upsert_resume_state( unit_id="unit-2", status="accepted", config_fingerprint="cfg-2", seq_offset=5, ) rows = store._conn.execute( "SELECT status, config_fingerprint, seq_offset FROM resume_state WHERE unit_id='unit-2'" ).fetchall() assert len(rows) == 1 assert rows[0] == ("accepted", "cfg-2", 5) # --------------------------------------------------------------------------- # CHECK 约束 fail-loud(非法枚举写入直接报错,符合 §4.2/P5) # --------------------------------------------------------------------------- def test_unit_verdict_invalid_stage_raises(store: QuestionGenStore) -> None: """非法 stage(超出 1-6)触发 CHECK 约束,写入直接 IntegrityError。""" with pytest.raises(sqlite3.IntegrityError): store.insert_unit_verdict( unit_id="unit-bad", pair_id=None, sub_pattern="order", stage=7, verdict="pass", reason="r", metric_value=None, model=None, session_id="sess-1", ts="2026-07-15T11:00:00+00:00", ) def test_unit_verdict_invalid_verdict_raises(store: QuestionGenStore) -> None: """非法 verdict(不在 pass/fail/abstain)触发 CHECK 约束,写入直接 IntegrityError。""" with pytest.raises(sqlite3.IntegrityError): store.insert_unit_verdict( unit_id="unit-bad2", pair_id=None, sub_pattern="order", stage=1, verdict="maybe", reason="r", metric_value=None, model=None, session_id="sess-1", ts="2026-07-15T11:00:00+00:00", ) def test_facts_invalid_cross_agree_raises(store: QuestionGenStore) -> None: """非法 cross_agree(非 0/1)触发 CHECK 约束,写入直接 IntegrityError。""" with pytest.raises(sqlite3.IntegrityError): store.insert_fact( fact_id="fact-bad", video_id="v", segment_id="s", subject="a", action="b", object="c", frame_ids="[]", polarity="真", fact_type="state", difficulty_tier=1, verifier_refs="{}", cross_agree=2, negative_at_target="x", session_id="sess", ts="2026-07-15T11:00:00+00:00", ) def test_resume_state_invalid_status_raises(store: QuestionGenStore) -> None: """非法 status(不在 pending/accepted/rejected)触发 CHECK 约束,写入直接 IntegrityError。""" with pytest.raises(sqlite3.IntegrityError): store.upsert_resume_state( unit_id="unit-bad", status="unknown", config_fingerprint="cfg", seq_offset=0, ) # --------------------------------------------------------------------------- # v2 旧表未受影响 # --------------------------------------------------------------------------- def test_v2_tables_unaffected(store: QuestionGenStore) -> None: """v3 建表后 v2 旧表仍存在且旧方法仍可用。""" store.record_run_start(run_id="run-1", git_sha="deadbeef", config_snapshot="{}") store.record_run_end( run_id="run-1", status="completed", stats=RunStats(total_slots=10, accepted=8, rejected=2, heavy_sampled=1), ) stats = store.get_run_stats("run-1") assert stats.accepted == 8 tables = { r[0] for r in store._conn.execute("SELECT name FROM sqlite_master WHERE type='table'") } assert {"question_gen_runs", "question_gen_items", "adversarial_verdicts"} <= tables