346 lines
18 KiB
Python
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()
|