Ctrl K
ComunidadGuías…

tests/test_xkb_review_governance.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import json
import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parents[1] / "scripts"))
import xkb_review

FIXTURE = """## Candidate 1
- **Topic:** topic-a
- **Confidence:** high
- **Source date:** 2026-08-26
- **Status:** [ ] approve  [ ] skip

Reusable claim with https://example.test/evidence.
"""


class GovernanceTests(unittest.TestCase):
    def setUp(self):
        temp = tempfile.TemporaryDirectory()
        self.addCleanup(temp.cleanup)
        for name, value in (("TOPICS_DIR", Path(temp.name) / "topics"),
                            ("GOVERNANCE_DIR", Path(temp.name) / "governance")):
            patch = mock.patch.object(xkb_review, name, value)
            patch.start()
            self.addCleanup(patch.stop)

    def with_staging(self, text=FIXTURE):
        temp = tempfile.TemporaryDirectory()
        staging = Path(temp.name) / "staging"
        staging.mkdir()
        (staging / "batch-candidates.md").write_text(text, encoding="utf-8")
        old = xkb_review.STAGING_DIR
        xkb_review.STAGING_DIR = staging
        return temp, staging, old

    def test_normalize_and_stable_identity_are_deterministic(self):
        self.assertEqual(xkb_review.normalize("  Hello   WORLD\n"), "hello world")
        self.assertEqual(xkb_review.stable_candidate_id("a.md", 3), xkb_review.stable_candidate_id("a.md", 3))
        self.assertEqual(xkb_review.stable_fingerprint("  Hello WORLD"), xkb_review.stable_fingerprint("hello world"))

    def test_provenance_and_relative_source_path(self):
        temp, staging, old = self.with_staging()
        try:
            candidate = xkb_review.load_candidates()[0]
            self.assertEqual(candidate.source_file, "batch-candidates.md")
            self.assertTrue(candidate.evidence_present)
            self.assertTrue(candidate.provenance_complete)
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()

    def _proposals(self, times: int) -> str:
        """同一個新主題被提議 times 次的 staging 內容。"""
        return "\n".join(
            FIXTURE.replace("Candidate 1", f"Candidate {i + 1}")
                   .replace("topic-a", "[NEW: proposed-topic]")
                   .replace("Reusable claim", f"Reusable claim number {i + 1}")
            for i in range(times)
        )

    def test_a_one_off_new_topic_goes_to_general_instead_of_waiting(self):
        """提一次的新主題不該無限期停在提案區。

        原本它會,而且唯一的出路是「主題已經存在」——對 [NEW: x] 而言那個
        條件永遠不成立。155 條就是這樣卡住的。現在它進 general,並且帶著
        當初提議的名字,日後撈得回來。
        """
        temp, staging, old = self.with_staging(self._proposals(1))
        try:
            result = xkb_review.governance_batch(limit=10, dry_run=True, ttl_days=9999)
            self.assertEqual(result["stats"]["routed_to_general"], 1)
            self.assertEqual(result["stats"]["proposal_queue"], 0)
            self.assertEqual(result["topic_suggestions"], [])
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()

    def test_a_repeated_new_topic_becomes_a_proposal(self):
        """反覆出現才值得開一頁,而開不開仍然是領域判斷。"""
        temp, staging, old = self.with_staging(self._proposals(xkb_review.PROMOTE_AFTER))
        try:
            result = xkb_review.governance_batch(limit=20, dry_run=True, ttl_days=9999)
            self.assertEqual(result["stats"]["routed_to_general"], 0)
            proposals = [t for t in result["topic_suggestions"] if t["action"] == "proposal"]
            self.assertEqual(len(proposals), 1)
            self.assertEqual(proposals[0]["proposed_count"], xkb_review.PROMOTE_AFTER)
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()

    def test_low_confidence_never_promotes(self):
        """信心不足擋放行,這跟主題存不存在是兩件獨立的事。"""
        text = FIXTURE.replace("high", "low").replace("https://example.test/evidence", "")
        temp, staging, old = self.with_staging(text)
        try:
            result = xkb_review.governance_batch(limit=10, dry_run=True, ttl_days=9999)
            self.assertEqual(result["stats"]["promoted"], 0)
            self.assertEqual(result["stats"]["review_queue"], 1)
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()

    def test_registry_rerun_is_idempotent(self):
        temp, staging, old = self.with_staging()
        try:
            registry = staging / "registry.jsonl"
            self.assertEqual(xkb_review.write_registry(xkb_review.load_candidates(), registry)["added"], 1)
            self.assertEqual(xkb_review.write_registry(xkb_review.load_candidates(), registry)["added"], 0)
            self.assertEqual(len(registry.read_text(encoding="utf-8").splitlines()), 1)
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()

    def test_bounded_batch_advances_past_registered_candidates(self):
        temp, staging, old = self.with_staging()
        old_governance = xkb_review.GOVERNANCE_DIR
        try:
            source = staging / "batch-candidates.md"
            source.write_text(FIXTURE + FIXTURE.replace("Candidate 1", "Candidate 2").replace("topic-a", "topic-b").replace("Reusable claim with", "A different reusable claim with"), encoding="utf-8")
            governance = staging / "governance"
            governance.mkdir()
            first = xkb_review.load_candidates()[0]
            (governance / "candidate-registry.jsonl").write_text(
                '{"candidate_id": "%s", "lifecycle": "retained"}\n' % first.candidate_id,
                encoding="utf-8",
            )
            xkb_review.GOVERNANCE_DIR = governance
            result = xkb_review.governance_batch(limit=1, dry_run=True, ttl_days=9999)
            self.assertEqual(result["stats"]["discovered"], 1)
            self.assertEqual(result["queues"]["review_queue"][0]["candidate_id"],
                             xkb_review.load_candidates()[1].candidate_id)
        finally:
            xkb_review.STAGING_DIR = old
            xkb_review.GOVERNANCE_DIR = old_governance
            temp.cleanup()

    def test_missing_evidence_cannot_promote(self):
        with tempfile.TemporaryDirectory() as d:
            tmp_path = Path(d)
            staging = tmp_path / "staging"
            staging.mkdir()
            (staging / "batch-candidates.md").write_text("## Candidate 1\n- **Topic:** topic-a\n- **Confidence:** low\n- **Source date:** 2020-01-01\n- **Status:** [ ] approve  [ ] skip\n\nOld claim.\n", encoding="utf-8")
            old = xkb_review.STAGING_DIR
            xkb_review.STAGING_DIR = staging
            try:
                result = xkb_review.governance_batch(limit=10, dry_run=True, ttl_days=30)
                self.assertEqual(result["stats"]["quarantine"], 1)
            finally:
                xkb_review.STAGING_DIR = old

    def test_non_prefix_near_duplicate_is_explainably_related(self):
        text = FIXTURE + FIXTURE.replace("Candidate 1", "Candidate 2").replace("Reusable claim with", "A reusable claim with")
        temp, staging, old = self.with_staging(text)
        try:
            candidates = xkb_review.load_candidates()
            self.assertTrue(candidates[1].relation in ("exact_duplicate", "near_duplicate"))
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()


    def test_apply_promotes_only_gated_candidates_and_is_idempotent(self):
        temp, staging, old = self.with_staging()
        old_governance = xkb_review.GOVERNANCE_DIR
        old_topics = xkb_review.TOPICS_DIR
        topic_dir = staging / "topics"
        topic_dir.mkdir()
        (topic_dir / "topic-a.md").write_text("# Topic A\n\n## Claims\n", encoding="utf-8")
        xkb_review.GOVERNANCE_DIR = staging / "governance"
        xkb_review.TOPICS_DIR = topic_dir
        try:
            first = xkb_review.governance_batch(limit=10, dry_run=False, ttl_days=9999)
            second = xkb_review.governance_batch(limit=10, dry_run=False, ttl_days=9999)
            self.assertEqual(first["stats"]["promoted"], 1)
            self.assertEqual(second["stats"]["promoted"], 0)
            self.assertTrue(first["batch_id"])
            topic = (topic_dir / "topic-a.md").read_text(encoding="utf-8")
            self.assertEqual(topic.count("xkb-candidate:"), 1)
            self.assertGreaterEqual(len((xkb_review.GOVERNANCE_DIR / "audit.jsonl").read_text(encoding="utf-8").splitlines()), 1)
        finally:
            xkb_review.STAGING_DIR = old
            xkb_review.GOVERNANCE_DIR = old_governance
            xkb_review.TOPICS_DIR = old_topics
            temp.cleanup()

    def test_missing_topic_is_retained_then_promoted_after_topic_creation(self):
        temp, staging, old = self.with_staging()
        old_governance = xkb_review.GOVERNANCE_DIR
        old_topics = xkb_review.TOPICS_DIR
        topic_dir = staging / "topics"
        topic_dir.mkdir()
        xkb_review.GOVERNANCE_DIR = staging / "governance"
        xkb_review.TOPICS_DIR = topic_dir
        try:
            deferred = xkb_review.governance_batch(limit=1, dry_run=False, ttl_days=9999)
            candidate_id = xkb_review.load_candidates()[0].candidate_id
            self.assertEqual(deferred["stats"]["promoted"], 0)
            self.assertEqual(deferred["topic_changes"], [])
            rows = [json.loads(line) for line in
                    (xkb_review.GOVERNANCE_DIR / "candidate-registry.jsonl").read_text(encoding="utf-8").splitlines()]
            self.assertEqual(rows[0]["candidate_id"], candidate_id)
            self.assertEqual(rows[0]["lifecycle"], "retained")

            (topic_dir / "topic-a.md").write_text("# Topic A\n", encoding="utf-8")
            promoted = xkb_review.governance_batch(limit=1, dry_run=False, ttl_days=9999)
            self.assertEqual(promoted["stats"]["promoted"], 1)
            self.assertEqual(len(promoted["topic_changes"]), 1)
            self.assertEqual(promoted["topic_changes"][0]["candidate_id"], candidate_id)
            topic = (topic_dir / "topic-a.md").read_text(encoding="utf-8")
            self.assertEqual(topic.count("xkb-candidate:"), 1)
            registry = (xkb_review.GOVERNANCE_DIR / "candidate-registry.jsonl").read_text(encoding="utf-8")
            self.assertEqual(registry.count('"lifecycle": "retained"'), 1)
            self.assertEqual(registry.count('"lifecycle": "promoted"'), 1)
            self.assertEqual(xkb_review.rollback_batch(promoted["batch_id"])["restored"], 3)
            self.assertEqual((topic_dir / "topic-a.md").read_text(encoding="utf-8"), "# Topic A\n")
        finally:
            xkb_review.STAGING_DIR = old
            xkb_review.GOVERNANCE_DIR = old_governance
            xkb_review.TOPICS_DIR = old_topics
            temp.cleanup()

    def test_rollback_restores_only_batch_outputs_and_preserves_staging(self):
        temp, staging, old = self.with_staging()
        old_governance = xkb_review.GOVERNANCE_DIR
        old_topics = xkb_review.TOPICS_DIR
        topic_dir = staging / "topics"
        topic_dir.mkdir()
        topic_path = topic_dir / "topic-a.md"
        original = "# Topic A\n\n## Claims\n"
        topic_path.write_text(original, encoding="utf-8")
        xkb_review.GOVERNANCE_DIR = staging / "governance"
        xkb_review.TOPICS_DIR = topic_dir
        source_before = (staging / "batch-candidates.md").read_bytes()
        try:
            result = xkb_review.governance_batch(limit=1, dry_run=False, ttl_days=9999)
            self.assertEqual(xkb_review.rollback_batch(result["batch_id"])["restored"], 3)
            self.assertEqual(topic_path.read_text(encoding="utf-8"), original)
            self.assertEqual((staging / "batch-candidates.md").read_bytes(), source_before)
            self.assertIn("rollback", (xkb_review.GOVERNANCE_DIR / "audit.jsonl").read_text(encoding="utf-8").splitlines()[-1])
        finally:
            xkb_review.STAGING_DIR = old
            xkb_review.GOVERNANCE_DIR = old_governance
            xkb_review.TOPICS_DIR = old_topics
            temp.cleanup()


    def test_health_counts_include_actionable_queues_without_writes(self):
        xkb_review.TOPICS_DIR.mkdir()
        (xkb_review.TOPICS_DIR / "topic-a.md").write_text("# Topic A\n", encoding="utf-8")
        text = FIXTURE + FIXTURE.replace("Candidate 1", "Candidate 2").replace("topic-a", "[NEW: proposed]").replace("high", "low")
        temp, staging, old = self.with_staging(text)
        try:
            counts = xkb_review.governance_health_counts(ttl_days=9999)
            self.assertEqual(counts["pending"], 2)
            self.assertEqual(counts["low"], 1)
            # 提一次的新主題會先被導向 general,所以它不是提案。這裡數的必須
            # 跟治理真的會做的一致——報 1 而治理看到 0,就是「77 待處理」
            # 與「實際 3 條」並存的那個病。
            self.assertEqual(counts["proposal"], 0)
            self.assertEqual(counts["safe_promotion"], 1)
            self.assertEqual(counts["quarantine"], 0)
        finally:
            xkb_review.STAGING_DIR = old
            temp.cleanup()


if __name__ == "__main__":
    unittest.main()