Files
Video-Tree-TRM5/tests/unit/test_run_store.py
T

314 lines
11 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.
"""QuestionGenStore 单元测试 — SQLite 出题管线日志记录器。
覆盖 schema 幂等性、run 生命周期、item 写入与门判定更新、难度更新。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from app.question_gen.run_store import QuestionGenStore, RunStats
# ---------------------------------------------------------------------------
# GateReport 测试替身(Task 6 尚未实现,此处构造兼容 duck-type)
# ---------------------------------------------------------------------------
class _Verdict(Enum):
"""门判定枚举替身。"""
PASS = "pass"
FAIL = "fail"
SKIP = "skip"
@dataclass(frozen=True)
class _GateResult:
"""单门判定结果替身。"""
verdict: _Verdict
reason: str
@dataclass
class _MockGateReport:
"""GateReport 测试替身,duck-type 兼容。"""
key_verify: _GateResult
blind_answer: _GateResult
multi_true: _GateResult
leak_test: _GateResult
@property
def passed(self) -> bool:
"""所有门均 PASS 或 SKIP 时视为通过。"""
results = [self.key_verify, self.blind_answer, self.multi_true, self.leak_test]
return all(r.verdict != _Verdict.FAIL for r in results)
@property
def reject_reason(self) -> str | None:
"""返回首个 FAIL 门的 reason,若全部通过则 None。"""
for r in [self.key_verify, self.blind_answer, self.multi_true, self.leak_test]:
if r.verdict == _Verdict.FAIL:
return r.reason
return None
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def store(tmp_path: Path) -> QuestionGenStore:
"""创建临时 SQLite 上的 QuestionGenStore 实例。"""
db_path = tmp_path / "question_gen.db"
s = QuestionGenStore(db_path=db_path)
yield s
s.close()
# ---------------------------------------------------------------------------
# TestQuestionGenStore
# ---------------------------------------------------------------------------
class TestQuestionGenStore:
"""QuestionGenStore 全功能验证。"""
def test_schema_idempotent(self, tmp_path: Path) -> None:
"""连续两次初始化同一 db 不报错,表结构不变。"""
db_path = tmp_path / "idem.db"
s1 = QuestionGenStore(db_path=db_path)
s2 = QuestionGenStore(db_path=db_path)
# 第二次初始化不应抛异常
s2.record_run_start(run_id="r-idem", git_sha="abc123", config_snapshot="{}")
s1.close()
s2.close()
def test_record_run_lifecycle(self, store: QuestionGenStore) -> None:
"""start -> record items -> end -> get_stats 完整生命周期。"""
run_id = "run-001"
store.record_run_start(
run_id=run_id,
git_sha="deadbeef",
config_snapshot='{"n_slots": 240}',
)
# 写入几个 item 来模拟统计
for i in range(5):
store.record_item(
item_id=f"item-{i}",
run_id=run_id,
slot_id=f"slot-{i}",
video_id="v001",
family="retrieval",
task_type="Object Recognition",
skill_target="M1",
attempt=1,
question_text=f"问题{i}",
)
# 结束 run
stats = RunStats(total_slots=10, accepted=5, rejected=3, heavy_sampled=2)
store.record_run_end(run_id=run_id, status="completed", stats=stats)
# 查询 stats
retrieved = store.get_run_stats(run_id)
assert retrieved.total_slots == 10
assert retrieved.accepted == 5
assert retrieved.rejected == 3
assert retrieved.heavy_sampled == 2
def test_record_item_and_gates(self, store: QuestionGenStore) -> None:
"""record_item 后 update_gates 正确更新门判定列与 final_status。"""
run_id = "run-gates"
store.record_run_start(run_id=run_id, git_sha="aaa111", config_snapshot="{}")
item_id = "item-gate-1"
store.record_item(
item_id=item_id,
run_id=run_id,
slot_id="slot-0",
video_id="v002",
family="reasoning",
task_type="Action Reasoning",
skill_target="M2",
attempt=1,
question_text="为什么这样做?",
)
# 所有门通过
report_pass = _MockGateReport(
key_verify=_GateResult(_Verdict.PASS, "ok"),
blind_answer=_GateResult(_Verdict.PASS, "ok"),
multi_true=_GateResult(_Verdict.SKIP, "n/a"),
leak_test=_GateResult(_Verdict.PASS, "ok"),
)
store.update_gates(item_id=item_id, report=report_pass)
# 验证数据库中的值
import sqlite3
conn = sqlite3.connect(str(store._db_path))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM question_gen_items WHERE item_id=?", (item_id,)
).fetchone()
conn.close()
assert row["gate_key_verify"] == "pass"
assert row["gate_blind_answer"] == "pass"
assert row["gate_multi_true"] == "skip"
assert row["gate_leak_test"] == "pass"
assert row["gate_reject_reason"] is None
assert row["final_status"] == "accepted"
def test_update_gates_rejected(self, store: QuestionGenStore) -> None:
"""门判定失败时 final_status 为 rejected 且记录 reject_reason。"""
run_id = "run-rej"
store.record_run_start(run_id=run_id, git_sha="bbb222", config_snapshot="{}")
item_id = "item-rej-1"
store.record_item(
item_id=item_id,
run_id=run_id,
slot_id="slot-1",
video_id="v003",
family="visual",
task_type="Scene Transition",
skill_target="M3",
attempt=2,
question_text="画面中有什么?",
)
report_fail = _MockGateReport(
key_verify=_GateResult(_Verdict.PASS, "ok"),
blind_answer=_GateResult(_Verdict.FAIL, "答案可由常识推断"),
multi_true=_GateResult(_Verdict.SKIP, "n/a"),
leak_test=_GateResult(_Verdict.SKIP, "n/a"),
)
store.update_gates(item_id=item_id, report=report_fail)
import sqlite3
conn = sqlite3.connect(str(store._db_path))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM question_gen_items WHERE item_id=?", (item_id,)
).fetchone()
conn.close()
assert row["final_status"] == "rejected"
assert row["gate_reject_reason"] == "答案可由常识推断"
def test_update_difficulty(self, store: QuestionGenStore) -> None:
"""update_difficulty 正确写入 difficulty_steps。"""
run_id = "run-diff"
store.record_run_start(run_id=run_id, git_sha="ccc333", config_snapshot="{}")
item_id = "item-diff-1"
store.record_item(
item_id=item_id,
run_id=run_id,
slot_id="slot-2",
video_id="v004",
family="enumeration",
task_type="Counting",
skill_target="M4",
attempt=1,
question_text="有多少个?",
)
store.update_difficulty(item_id=item_id, difficulty_steps=7)
import sqlite3
conn = sqlite3.connect(str(store._db_path))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT difficulty_steps FROM question_gen_items WHERE item_id=?",
(item_id,),
).fetchone()
conn.close()
assert row["difficulty_steps"] == 7
def test_get_run_stats_not_found(self, store: QuestionGenStore) -> None:
"""查询不存在的 run_id 应报错。"""
with pytest.raises(ValueError, match="run_id"):
store.get_run_stats("nonexistent-run")
def test_record_run_end_missing_run_raises(self, store: QuestionGenStore) -> None:
"""对不存在的 run_id 调用 record_run_end 应报错。"""
stats = RunStats(total_slots=10, accepted=5, rejected=3, heavy_sampled=2)
with pytest.raises(ValueError, match="run_id"):
store.record_run_end(run_id="ghost-run", status="completed", stats=stats)
def test_update_gates_missing_item_raises(self, store: QuestionGenStore) -> None:
"""对不存在的 item_id 调用 update_gates 应报错。"""
report = _MockGateReport(
key_verify=_GateResult(_Verdict.PASS, "ok"),
blind_answer=_GateResult(_Verdict.PASS, "ok"),
multi_true=_GateResult(_Verdict.PASS, "ok"),
leak_test=_GateResult(_Verdict.PASS, "ok"),
)
with pytest.raises(ValueError, match="item_id"):
store.update_gates(item_id="ghost-item", report=report)
def test_mark_item_rejected(self, store: QuestionGenStore) -> None:
"""mark_item_rejected 将 final_status 设为 rejected 并记录原因。"""
run_id = "run-mark-rej"
store.record_run_start(run_id=run_id, git_sha="ddd444", config_snapshot="{}")
item_id = "item-mark-rej-1"
store.record_item(
item_id=item_id,
run_id=run_id,
slot_id="slot-mark",
video_id="v005",
family="retrieval",
task_type="Action Recognition",
skill_target="M1",
attempt=1,
question_text="这是什么?",
)
# 先模拟门控通过(将 final_status 设为 accepted
report_pass = _MockGateReport(
key_verify=_GateResult(_Verdict.PASS, "ok"),
blind_answer=_GateResult(_Verdict.PASS, "ok"),
multi_true=_GateResult(_Verdict.PASS, "ok"),
leak_test=_GateResult(_Verdict.PASS, "ok"),
)
store.update_gates(item_id=item_id, report=report_pass)
# 然后因去重被拒绝
store.mark_item_rejected(item_id, "duplicate detected by embedding similarity")
import sqlite3
conn = sqlite3.connect(str(store._db_path))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT final_status, gate_reject_reason FROM question_gen_items WHERE item_id=?",
(item_id,),
).fetchone()
conn.close()
assert row["final_status"] == "rejected"
assert row["gate_reject_reason"] == "duplicate detected by embedding similarity"
def test_mark_item_rejected_missing_item_raises(self, store: QuestionGenStore) -> None:
"""对不存在的 item_id 调用 mark_item_rejected 应报错。"""
with pytest.raises(ValueError, match="item_id"):
store.mark_item_rejected(item_id="ghost-item", reason="duplicate")
def test_update_difficulty_missing_item_raises(self, store: QuestionGenStore) -> None:
"""对不存在的 item_id 调用 update_difficulty 应报错。"""
with pytest.raises(ValueError, match="item_id"):
store.update_difficulty(item_id="ghost-item", difficulty_steps=5)