Ctrl K
ComunidadGuías…

tests/test_seed_corrections.py

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

Archivo original ↗Preguntar a KiBot
"""种子纠错只撤销自己的精确断言,包历史阻止回放。"""
import copy
import sqlite3
from unittest import mock

from peach import seed_pack
from peach.performer_profiles import read_profile, write_profile
from test_seed_pack import SeedPackCase, entity, alias, ref, link, membership, label_maker, STAMP


class CorrectionTests(SeedPackCase):
    def setUp(self):
        super().setUp()
        with self.source:
            self.person = entity(self.source, "performer", "天川そら")
            ref(self.source, self.person, "minnano-av", "123")
        with self.target:
            self.recipient = entity(self.target, "performer", "天川そら")

    def pack(self, revision, withdrawals=()):
        return seed_pack.export_pack(self.source, version=f"2026-10-{revision:02}",
                                     revision=revision, withdrawals=list(withdrawals))

    def withdrawal(self, field, value, *, name="天川そら", key="correction-1"):
        return {"id": key, "identity": {"kind": "performer", "name": name,
                                           "refs": [{"provider": "minnano-av", "kind": "performer", "id": "123"}]},
                "field": field, "value": value, "reason": "来源核对确认指向错误", "evidence": "review:verified.csv"}

    def land(self, pack):
        with self.target:
            return seed_pack.land(self.target, pack)

    def test_explicit_alias_ref_and_link_withdrawals_allow_corrected_ref(self):
        with self.source:
            alias(self.source, self.person, "错误名字", "review:source")
            ref(self.source, self.person, "javdb", "wrong")
            link(self.source, self.person, "social", "https://x.com/wrong")
        self.land(self.pack(1))
        withdrawals = [self.withdrawal("alias", "错误名字", key="bad-alias"),
                       self.withdrawal("ref", {"provider": "javdb", "kind": "performer", "id": "wrong"}, key="bad-ref"),
                       self.withdrawal("link", {"kind": "social", "url": "https://x.com/wrong"}, key="bad-link")]
        with self.source:
            self.source.execute("DELETE FROM entity_alias")
            self.source.execute("DELETE FROM entity_external_ref WHERE provider='javdb'")
            self.source.execute("DELETE FROM entity_link")
            ref(self.source, self.person, "javdb", "correct")
        fixed = self.pack(2, withdrawals)
        report = self.land(fixed)
        self.assertEqual(report["withdrawn"], 3)
        self.assertIsNone(self.target.execute("SELECT 1 FROM entity_alias WHERE alias='错误名字'").fetchone())
        self.assertEqual(self.target.execute("SELECT external_id FROM entity_external_ref WHERE provider='javdb'").fetchone()[0], "correct")
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_link").fetchone()[0], 0)
        self.assertEqual(seed_pack.written(self.land(fixed)), 0)

    def test_withdrawal_preserves_each_nonseed_owner(self):
        for owner in ("user:manual", "review:verified", "auto:performer-profile@4"):
            with self.subTest(owner=owner), self.target:
                self.target.execute("DELETE FROM entity_alias")
                alias(self.target, self.recipient, "错误名字", owner)
                report = seed_pack.land(self.target, self.pack(1, [self.withdrawal("alias", "错误名字")]))
                self.assertEqual(report["withdrawn"], 0)
                self.assertEqual(self.target.execute("SELECT source FROM entity_alias").fetchone()[0], owner)
                self.assertEqual(len(report["withdrawal_conflicts"]), 1)

    def test_profile_correction_uses_revision_even_with_same_fetched_at(self):
        with self.source:
            write_profile(self.source, self.person, {"height_cm": 164}, source="review:profile", source_url="https://example.com/profile", fetched_at=STAMP)
        self.land(self.pack(1))
        with self.source:
            self.source.execute("UPDATE performer_profile SET height_cm=165")
        report = self.land(self.pack(2))
        self.assertEqual(read_profile(self.target, self.recipient)["height_cm"], 165)
        self.assertEqual(report["refreshed"], 1)
        self.assertEqual(seed_pack.written(self.land(self.pack(2))), 0)

    def test_cumulative_withdrawal_supports_skipped_revision_and_rejects_replay(self):
        with self.source:
            alias(self.source, self.person, "错误名字", "review:source")
        initial = self.pack(1)
        self.land(initial)
        with self.source:
            self.source.execute("DELETE FROM entity_alias")
        withdrawal = self.withdrawal("alias", "错误名字")
        self.land(self.pack(3, [withdrawal]))
        with self.assertRaises(ValueError):
            self.land(initial)
        with self.assertRaises(ValueError):
            self.land(seed_pack.export_pack(self.source, version="2099-01-01"))
        self.assertIsNone(self.target.execute("SELECT 1 FROM entity_alias WHERE alias='错误名字'").fetchone())
        with self.assertRaises(ValueError):
            self.land(self.pack(4))

    def test_ambiguous_withdrawal_leaves_rows_and_records_review(self):
        with self.target:
            other = entity(self.target, "performer", "另一位艺人")
            ref(self.target, other, "minnano-av", "123")
            alias(self.target, self.recipient, "错误名字", "auto:seed@old")
        report = self.land(self.pack(1, [self.withdrawal("alias", "错误名字")]))
        self.assertEqual(report["withdrawn"], 0)
        self.assertEqual(len(report["withdrawal_conflicts"]), 1)
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_alias").fetchone()[0], 1)

    def test_absence_is_not_a_withdrawal(self):
        with self.source:
            alias(self.source, self.person, "已登记别名", "review:source")
        self.land(self.pack(1))
        with self.source:
            self.source.execute("DELETE FROM entity_alias")
        self.land(self.pack(2))
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_alias WHERE alias='已登记别名'").fetchone()[0], 1)

    def test_modified_content_and_invalid_pack_are_rejected_atomically(self):
        pack = self.pack(1)
        broken = copy.deepcopy(pack)
        broken["entities"][0]["aliases"].append({"alias": "无效包的名字", "source": "review:source"})
        before = list(self.target.iterdump())
        with self.assertRaises(ValueError):
            self.land(broken)
        self.assertEqual(list(self.target.iterdump()), before)
        self.land(pack)
        with self.source:
            alias(self.source, self.person, "同版本新内容", "review:source")
        with self.assertRaises(ValueError):
            self.land(self.pack(1))

    def test_schema_rejects_bool_revision_unknown_fields_and_duplicate_ref_identity(self):
        for mutate in (
                lambda p: p.update(revision=True),
                lambda p: p.update(unexpected="value"),
                lambda p: p["entities"].append({**copy.deepcopy(p["entities"][0]), "name": "另一位艺人"})):
            with self.subTest(mutate=mutate):
                pack = self.pack(1)
                mutate(pack)
                before = list(self.target.iterdump())
                with self.assertRaises(ValueError):
                    self.land(pack)
                self.assertEqual(list(self.target.iterdump()), before)

    def test_same_package_cannot_assert_its_withdrawn_value(self):
        with self.source:
            alias(self.source, self.person, "错误名字", "review:source")
        with self.assertRaises(ValueError):
            self.pack(1, [self.withdrawal("alias", "错误名字")])

    def test_export_inherits_withdrawals_from_explicit_previous_package(self):
        previous = self.pack(1, [self.withdrawal("alias", "错误名字")])
        later = seed_pack.export_pack(self.source, version="2026-10-04", revision=4, previous=previous)
        self.assertEqual(later["withdrawals"], previous["withdrawals"])

    def test_profile_and_both_pointer_types_are_precisely_withdrawn(self):
        with self.source:
            agency = entity(self.source, "agency", "事务所甲")
            maker = entity(self.source, "studio", "片商甲")
            label = entity(self.source, "studio", "厂牌甲")
            membership(self.source, self.person, agency, "review:source")
            label_maker(self.source, label, maker, "review:source")
            write_profile(self.source, self.person, {"height_cm": 164}, source="review:source", source_url="https://example.com/profile", fetched_at=STAMP)
        with self.target:
            entity(self.target, "agency", "事务所甲")
            entity(self.target, "studio", "片商甲")
            entity(self.target, "studio", "厂牌甲")
        first = self.pack(1)
        self.land(first)
        person = next(item for item in first["entities"] if item["kind"] == "performer")
        studio = next(item for item in first["entities"] if item["name"] == "厂牌甲")
        withdrawals = [self.withdrawal("profile", person["profile"], key="bad-profile"),
                       self.withdrawal("agency", person["agency"], key="bad-agency"),
                       {"id": "bad-maker", "identity": studio["identity"], "field": "maker", "value": studio["maker"],
                        "reason": "已核对归属", "evidence": "review:verified.csv"}]
        with self.source:
            self.source.execute("DELETE FROM performer_profile")
            self.source.execute("DELETE FROM entity_membership")
            self.source.execute("DELETE FROM label_maker")
        self.assertEqual(self.land(self.pack(2, withdrawals))["withdrawn"], 3)
        self.assertIsNone(read_profile(self.target, self.recipient))
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_membership").fetchone()[0], 0)
        self.assertEqual(self.target.execute("SELECT count(*) FROM label_maker").fetchone()[0], 0)

    def test_profile_withdrawal_requires_all_nonempty_columns(self):
        with self.target:
            write_profile(self.target, self.recipient, {"height_cm": 164, "birthplace": "東京都"}, source="auto:seed@old", source_url="https://example.com/profile", fetched_at=STAMP)
        partial = {"fields": {"height_cm": 164}, "source": "review:source", "source_url": "https://example.com/profile", "fetched_at": STAMP}
        report = self.land(self.pack(1, [self.withdrawal("profile", partial)]))
        self.assertEqual(report["withdrawn"], 0)
        self.assertEqual(len(report["withdrawal_conflicts"]), 1)
        self.assertEqual(read_profile(self.target, self.recipient)["birthplace"], "東京都")

    def test_runtime_error_rolls_back_withdrawals_facts_and_history(self):
        with self.source:
            alias(self.source, self.person, "错误名字", "review:source")
        self.land(self.pack(1))
        with self.source:
            self.source.execute("DELETE FROM entity_alias")
        fixed = self.pack(2, [self.withdrawal("alias", "错误名字")])
        before = list(self.target.iterdump())
        with mock.patch.object(seed_pack.seed_corrections, "remember", side_effect=sqlite3.OperationalError("simulated failure")):
            with self.assertRaises(sqlite3.OperationalError):
                seed_pack.land(self.target, fixed)
        self.assertEqual(list(self.target.iterdump()), before)

    def test_nonseed_profile_is_not_corrected_by_new_revision(self):
        with self.source:
            write_profile(self.source, self.person, {"height_cm": 164}, source="review:source", source_url="https://example.com/profile", fetched_at=STAMP)
        with self.target:
            write_profile(self.target, self.recipient, {"height_cm": 180}, source="auto:performer-profile@5", source_url="https://example.com/local", fetched_at=STAMP)
        self.land(self.pack(1))
        self.assertEqual(read_profile(self.target, self.recipient)["height_cm"], 180)

    def test_schema_failure_after_valid_entity_cannot_land_any_rows(self):
        pack = seed_pack.export_pack(self.source, version="2026-10-01")
        pack["entities"].append({"kind": "performer", "name": "另一位", "aliases": False, "refs": [], "links": []})
        before = list(self.target.iterdump())
        with self.assertRaises(ValueError):
            seed_pack.land(self.target, pack)
        self.assertEqual(list(self.target.iterdump()), before)

    def test_conflicting_reference_identities_are_rejected_without_hash_gate(self):
        pack = seed_pack.export_pack(self.source, version="2026-10-01")
        pack["entities"].append({"kind": "performer", "name": "另一位艺人", "aliases": [],
                                 "refs": copy.deepcopy(pack["entities"][0]["refs"]), "links": []})
        before = list(self.target.iterdump())
        with self.assertRaisesRegex(ValueError, "编号身份冲突"):
            seed_pack.land(self.target, pack)
        self.assertEqual(list(self.target.iterdump()), before)

    def test_alias_confidence_rejects_boolean_and_out_of_range_values(self):
        with self.source:
            alias(self.source, self.person, "来源别名", "review:source")
        for confidence in (True, None, -0.1, 1.1):
            with self.subTest(confidence=confidence):
                pack = self.pack(1)
                pack["entities"][0]["aliases"][0]["confidence"] = confidence
                pack["content_sha256"] = seed_pack.seed_validation.digest(pack)
                before = list(self.target.iterdump())
                with self.assertRaises(ValueError):
                    seed_pack.land(self.target, pack)
                self.assertEqual(list(self.target.iterdump()), before)

    def test_caller_rollback_restores_facts_and_version_history(self):
        with self.assertRaises(RuntimeError):
            with self.target:
                seed_pack.land(self.target, self.pack(1))
                raise RuntimeError("caller abort")
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_external_ref").fetchone()[0], 0)
        self.assertEqual(self.target.execute("SELECT count(*) FROM seed_pack_history").fetchone()[0], 0)

    def test_source_confidence_round_trip_preserves_manual_values(self):
        with self.source:
            alias(self.source, self.person, "来源别名", "r18:performer")
            self.source.execute("UPDATE entity_alias SET confidence=0.42")
        pack = self.pack(1)
        self.assertEqual(pack["entities"][0]["aliases"][0]["confidence"], 0.42)
        self.land(pack)
        held = self.target.execute("SELECT confidence,source FROM entity_alias WHERE alias='来源别名'").fetchone()
        self.assertEqual(tuple(held), (0.42, "auto:seed@2026-10-01"))
        with self.target:
            self.target.execute("UPDATE entity_alias SET confidence=0.61,source='user:manual' WHERE alias='来源别名'")
        with self.source:
            self.source.execute("UPDATE entity_alias SET confidence=0.3")
        self.land(self.pack(2))
        self.assertEqual(tuple(self.target.execute("SELECT confidence,source FROM entity_alias WHERE alias='来源别名'").fetchone()), (0.61, "user:manual"))

    def test_source_confidence_corrects_seed_owned_legacy_confidence(self):
        with self.source:
            alias(self.source, self.person, "来源别名", "r18:performer")
            self.source.execute("UPDATE entity_alias SET confidence=0.42")
        legacy = seed_pack.export_pack(self.source, version="2026-09-25")
        self.land(legacy)
        self.assertEqual(self.target.execute("SELECT confidence FROM entity_alias WHERE alias='来源别名'").fetchone()[0], 1)
        report = self.land(self.pack(1))
        self.assertEqual(report["refreshed"], 1)
        self.assertEqual(self.target.execute("SELECT confidence FROM entity_alias WHERE alias='来源别名'").fetchone()[0], 0.42)
        self.assertEqual(seed_pack.written(self.land(self.pack(1))), 0)

    def test_stash_aliases_are_not_exported_as_public_v2_facts(self):
        with self.source:
            alias(self.source, self.person, "本机导入别名", "stash:performer")
        self.assertTrue(seed_pack.export_pack(self.source, version="2026-09-25")["entities"][0]["aliases"])
        pack = self.pack(1)
        self.assertEqual(pack["entities"][0]["aliases"], [])
        self.assertEqual(pack["withdrawals"], [])

    def test_local_import_only_people_remain_local(self):
        local_people = (("古河君", "古河X沐沐", "61"),
                        ("小芽芽不乖", "91社区21442644", "63"))
        with self.source:
            for name, other_name, external_id in local_people:
                person = entity(self.source, "performer", name)
                alias(self.source, person, other_name, "stash:performer")
                ref(self.source, person, "stash", external_id)
        snapshot = {
            table: [tuple(row) for row in self.source.execute(f"SELECT * FROM {table} ORDER BY 1")]
            for table in ("entity", "entity_alias", "entity_external_ref")}
        pack = self.pack(1)
        self.assertEqual([item["name"] for item in pack["entities"]], ["天川そら"])
        self.assertEqual(pack["counts"]["entities"], 1)
        self.assertEqual(pack["withdrawals"], [])
        self.assertEqual(snapshot, {
            table: [tuple(row) for row in self.source.execute(f"SELECT * FROM {table} ORDER BY 1")]
            for table in snapshot})