346 lines
18 KiB
Python

#!/usr/bin/env python3
"""AI 味案例卡离线合同测试;不连接数据库、不调用模型。"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import yaml
from capture_cases import (
CaseCardError,
PATTERNS,
annotate_card,
capture_feedback,
capture_file,
build_inventory,
build_case_card,
confirm_card,
project_sample,
propose_rule,
build_revalidation_report,
load_bundle,
revalidate_card,
text_sha256,
validate_card,
main,
)
class CaptureCasesTest(unittest.TestCase):
def test_source_hash_is_derived_from_supplied_text(self):
with self.assertRaises(CaseCardError):
build_case_card(
text="值得注意的是。", source_sha256="0" * 64,
source_kind="existing_work", source_license="owned", source_ref="x.txt",
work_ref="work-a", start=0, end=6, pattern=PATTERNS[0],
)
def test_inventory_is_deterministic_and_keeps_only_hash_for_research_sources(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "b.txt").write_text("值得注意的是。\n", encoding="utf-8")
(root / "a.txt").write_text("嘴角微微上扬。\n", encoding="utf-8")
report = build_inventory(root, source_root_ref="fixture", generated_on="2026-08-13")
self.assertEqual("ai-flavor-inventory-v1", report["schema_version"])
self.assertEqual({"books": 2, "cards": 2},
{key: report["totals"][key] for key in ("books", "cards")})
self.assertEqual(2, len({card["id"] for card in report["cards"]}))
self.assertTrue(all(card["state"] == "shadow" and not card["excerpt"] for card in report["cards"]))
self.assertTrue(all(not card["source"]["source_ref"].startswith("/") for card in report["cards"]))
def test_backfill_research_only_is_hash_only_and_replayable(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("第一行。值得注意的是,门外下雨了。\n", encoding="utf-8")
cards = capture_file(path, work_ref="work-a")
self.assertEqual(1, len(cards))
card = cards[0]
self.assertEqual("shadow", card["state"])
self.assertEqual("", card["excerpt"])
self.assertEqual("", card["context"])
self.assertEqual(text_sha256("值得注意的是,门外下雨了。"), card["source"]["excerpt_sha256"])
self.assertEqual(1, card["source"]["location"]["line_start"])
validate_card(card)
def test_owned_backfill_keeps_context_and_location(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("她抬头。嘴角微微上扬。\n", encoding="utf-8")
cards = capture_file(path, work_ref="work-owned", source_license="owned")
self.assertEqual(1, len(cards))
self.assertEqual("嘴角微微上扬。", cards[0]["excerpt"])
self.assertIn("她抬头", cards[0]["context"])
self.assertEqual(1, cards[0]["source"]["location"]["line_start"])
def test_duplicate_scan_pattern_is_deduplicated(self):
pattern = PATTERNS[0]
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "same.txt"
path.write_text("值得注意的是。", encoding="utf-8")
cards = capture_file(path, patterns=[pattern, pattern], source_license="owned", work_ref="work-same")
self.assertEqual(1, len(cards))
def test_feedback_is_bound_to_creation_run(self):
card = capture_feedback("候选正文", work_ref="work-12", run_ref="run-7", issue="段尾空泛")
self.assertEqual("live_feedback", card["capture_mode"])
self.assertEqual("creation_feedback", card["source"]["kind"])
self.assertEqual("run-7", card["feedback"]["run_ref"])
self.assertEqual("shadow", card["state"])
def test_unlicensed_card_cannot_be_confirmed_or_store_text(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "third-party.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(path, work_ref="work-third")[0]
with self.assertRaises(CaseCardError):
confirm_card(card, reviewer="u", note="不能确认")
broken = dict(card)
broken["excerpt"] = "偷偷保存的原文"
with self.assertRaises(CaseCardError):
validate_card(broken)
def test_revalidate_unchanged_source_is_verified(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("第一行。值得注意的是,门外下雨了。\n", encoding="utf-8")
card = capture_file(path, work_ref="work-a", source_license="owned")[0]
result = revalidate_card(card, source_path=path, checked_on="2026-08-14")
self.assertEqual("verified", result["status"])
self.assertEqual(card["source"]["source_sha256"], result["actual_source_sha256"])
self.assertEqual("2026-08-14", result["checked_on"])
def test_revalidate_modified_source_is_stale_and_does_not_mutate_card(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。\n", encoding="utf-8")
card = capture_file(path, work_ref="work-a", source_license="owned")[0]
original_state = card["state"]
path.write_text("已经改成另一版正文。\n", encoding="utf-8")
result = revalidate_card(card, source_path=path)
self.assertEqual("stale", result["status"])
self.assertEqual("source_hash_mismatch", result["reason"])
self.assertEqual("shadow", original_state)
self.assertEqual("shadow", card["state"])
def test_revalidate_same_source_but_tampered_anchor_is_card_mismatch(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(path, work_ref="work-a", source_license="owned")[0]
tampered = dict(card)
tampered["source"] = dict(card["source"])
tampered["source"]["surface_location"] = dict(card["source"]["surface_location"])
tampered["source"]["surface_location"]["char_start"] = 1
result = revalidate_card(tampered, source_path=path)
self.assertEqual("card_mismatch", result["status"])
self.assertEqual("card_surface_mismatch", result["reason"])
def test_revalidate_unavailable_source_is_fail_closed(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(path, work_ref="work-a")[0]
result = revalidate_card(card, source_path=Path(tmp) / "missing" / "work.txt")
self.assertEqual("unavailable", result["status"])
self.assertEqual("source_not_found", result["reason"])
def test_revalidate_report_deduplicates_source_reads_and_exposes_counts(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。嘴角微微上扬。", encoding="utf-8")
cards = capture_file(path, work_ref="work-a", source_license="owned")
report = build_revalidation_report(cards, source_root=Path(tmp), checked_on="2026-08-14")
self.assertEqual(2, report["totals"]["cards"])
self.assertEqual(2, report["totals"]["verified"])
self.assertTrue(report["usable"])
def test_lifecycle_receipt_wrapper_can_feed_next_step(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = annotate_card(capture_file(path, work_ref="work-a", source_license="owned")[0], label="sf")
wrapper = Path(tmp) / "annotated.json"
wrapper.write_text(yaml.safe_dump({"card": card, "persistence": {"status": "offline"}}, allow_unicode=True), encoding="utf-8")
loaded = load_bundle([wrapper])
self.assertEqual([card["id"]], [item["id"] for item in loaded])
def test_confirmation_requires_verified_receipt(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
shadow = capture_file(path, work_ref="work-a", source_license="owned")[0]
annotated = annotate_card(shadow, label="sf", carrier="narration")
with self.assertRaises(CaseCardError):
confirm_card(annotated, reviewer="human", note="无功能")
verification = {"cards": [revalidate_card(annotated, source_path=path)]}
canonical = confirm_card(annotated, reviewer="human", note="无功能", verification=verification)
self.assertEqual("canonical", canonical["state"])
def test_shadow_needs_explicit_annotation_before_rule_candidate(self):
with tempfile.TemporaryDirectory() as tmp:
a = Path(tmp) / "a.txt"
b = Path(tmp) / "b.txt"
a.write_text("值得注意的是。", encoding="utf-8")
b.write_text("值得注意的是。", encoding="utf-8")
sf = annotate_card(capture_file(a, work_ref="work-a", source_license="owned")[0], label="sf")
snf = annotate_card(capture_file(b, work_ref="work-b", source_license="owned")[0], label="snf")
verification = {"cards": [
revalidate_card(sf, source_path=a), revalidate_card(snf, source_path=b)
]}
rule = propose_rule([sf, snf], rule_id="candidate-1", name="元话语", fix_hint="先核功能",
verification=verification)
self.assertEqual("candidate", rule["status"])
self.assertEqual({"work-a", "work-b"}, set(rule["source_work_refs"]))
def test_rule_candidate_rejects_single_work_or_one_sided_evidence(self):
with tempfile.TemporaryDirectory() as tmp:
a = Path(tmp) / "a.txt"
a.write_text("值得注意的是。\n值得注意的是。", encoding="utf-8")
cards = capture_file(a, work_ref="work-a", source_license="owned")
sf = annotate_card(cards[0], label="sf")
sf2 = annotate_card(cards[1], label="sf")
with self.assertRaises(CaseCardError):
propose_rule([sf, sf2], rule_id="candidate-one-sided", name="单样本禁令", fix_hint="删除")
def test_canonical_unknown_carrier_cannot_project_to_sample(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
shadow = capture_file(path, work_ref="work-a", source_license="owned")[0]
annotated = annotate_card(shadow, label="sf", carrier="unknown")
verification = {"cards": [revalidate_card(annotated, source_path=path)]}
canonical = confirm_card(annotated, reviewer="human", note="功能已确认", verification=verification)
with self.assertRaises(CaseCardError):
project_sample(canonical, verification=verification)
def test_canonical_card_projects_to_sample_only_after_review(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
shadow = capture_file(path, work_ref="work-a", source_license="owned")[0]
annotated = annotate_card(shadow, label="sf", carrier="narration")
with self.assertRaises(CaseCardError):
project_sample(annotated, verification={})
verification = {"cards": [revalidate_card(annotated, source_path=path)]}
canonical = confirm_card(annotated, reviewer="human", note="上下文无功能", verification=verification)
sample = project_sample(canonical, verification=verification)
self.assertEqual("sample-" + canonical["id"], sample["id"])
self.assertEqual(canonical["id"], sample["case_card_id"])
self.assertEqual("hand_written", sample["source"])
self.assertEqual("narration", sample["carrier"])
def test_shipped_fixtures_pass_the_same_validator(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures"
for name in ("backfill-hash-only.yaml", "canonical-samples.yaml"):
data = yaml.safe_load((root / name).read_text(encoding="utf-8"))
for card in data["cards"]:
validate_card(card)
def test_shipped_rule_seed_is_candidate_only(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures"
data = yaml.safe_load((root / "rule-candidates.yaml").read_text(encoding="utf-8"))
self.assertTrue(data["rules"])
self.assertTrue(all(rule["status"] == "candidate" for rule in data["rules"]))
def test_shipped_revalidation_report_is_structurally_usable(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures"
data = yaml.safe_load((root / "revalidation-2026-08-14.json").read_text(encoding="utf-8"))
self.assertEqual("ai-flavor-revalidation-v1", data["schema_version"])
self.assertTrue(data["usable"])
self.assertEqual({"cards": 788, "verified": 788, "stale": 0, "unavailable": 0, "card_mismatch": 0}, data["totals"])
def test_revalidate_cli_is_persist_by_default_contract(self):
import capture_cases
parser = capture_cases._parser()
args = parser.parse_args(["revalidate", "cards.yaml", "--output", "receipt.json"])
self.assertFalse(args.offline)
def test_inventory_cli_automatically_calls_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "works"
root.mkdir()
(root / "work.txt").write_text("值得注意的是。", encoding="utf-8")
output = Path(tmp) / "inventory.json"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main(["inventory", str(root), "--output", str(output)])
self.assertEqual(0, rc)
persist.assert_called_once()
def test_inventory_cli_offline_is_explicit_escape_hatch(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "works"
root.mkdir()
(root / "work.txt").write_text("值得注意的是。", encoding="utf-8")
output = Path(tmp) / "inventory.json"
with patch("capture_cases._persist_detection") as persist:
rc = main(["inventory", str(root), "--output", str(output), "--offline"])
self.assertEqual(0, rc)
persist.assert_not_called()
def test_scan_cli_automatically_calls_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "work.txt"
source.write_text("值得注意的是。", encoding="utf-8")
output = root / "cards.yaml"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main(["scan", str(source), "--work-ref", "work-a", "--output", str(output)])
self.assertEqual(0, rc)
persist.assert_called_once()
def test_feedback_cli_automatically_calls_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "candidate.txt"
source.write_text("候选正文。", encoding="utf-8")
output = root / "feedback.yaml"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main([
"feedback", "--text-file", str(source), "--work-ref", "work-a",
"--run-ref", "run-a", "--issue", "段尾空泛", "--output", str(output),
])
self.assertEqual(0, rc)
persist.assert_called_once()
def test_revalidate_single_card_writes_inventory_sidecar_before_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "work.txt"
source.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(source, work_ref="work-a", source_license="owned")[0]
cards_path = root / "card.yaml"
cards_path.write_text(yaml.safe_dump({"schema_version": "ai-flavor-case-v1", "cards": [card]}, allow_unicode=True), encoding="utf-8")
receipt_path = root / "receipt.json"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main([
"revalidate", str(cards_path), "--source-root", str(root),
"--output", str(receipt_path), "--checked-on", "2026-08-14",
])
self.assertEqual(0, rc)
persist.assert_called_once()
self.assertTrue((root / "card.inventory.json").is_file())
def test_revalidate_single_card_offline_does_not_write_database(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "work.txt"
source.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(source, work_ref="work-a", source_license="owned")[0]
cards_path = root / "card.yaml"
cards_path.write_text(yaml.safe_dump({"schema_version": "ai-flavor-case-v1", "cards": [card]}, allow_unicode=True), encoding="utf-8")
receipt_path = root / "receipt.json"
with patch("capture_cases._persist_detection") as persist:
rc = main([
"revalidate", str(cards_path), "--source-root", str(root),
"--output", str(receipt_path), "--offline",
])
self.assertEqual(0, rc)
persist.assert_not_called()
if __name__ == "__main__":
unittest.main()