Ctrl K
ComunidadGuías…

tests/test_seed_pack.py

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

Archivo original ↗Preguntar a KiBot
"""实体事实种子包:导出只带公开事实且逐字节稳定,导入只给已有实体填空、只换种子自己写过的行,
整批可撤(ADR-0073、ADR-0075)。

全程两本临时账本:一本当导出方,一本当另一台机器。
"""
import contextlib
import importlib.util
import io
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest import mock

from peach import seed_pack
from peach.entities import normalize_entity_name
from peach.performer_profiles import read_profile, write_profile
from support.ledger import fresh_ledger

ROOT = Path(__file__).resolve().parents[1]
STAMP = "2026-09-25T00:00:00Z"
VERSION = "2026-09-25"
BATCH = f"auto:seed@{VERSION}"


def load_script(name: str):
    spec = importlib.util.spec_from_file_location(f"{name}_under_test", ROOT / "scripts" / f"{name}.py")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def connect(path: Path) -> sqlite3.Connection:
    connection = sqlite3.connect(path)
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys=ON")
    return connection


def entity(connection, kind: str, name: str) -> int:
    connection.execute(
        "INSERT INTO entity(kind,canonical_name,normalized_name,metadata_json,created_at,updated_at)"
        " VALUES(?,?,?,?,?,?)",
        (kind, name, normalize_entity_name(name), json.dumps({"raw_snapshot": r"C:\somewhere\x.json"}),
         STAMP, STAMP))
    return int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])


def alias(connection, entity_id: int, name: str, source: str) -> None:
    connection.execute("INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence)"
                       " VALUES(?,?,?,?,1.0)", (entity_id, name, normalize_entity_name(name), source))


def ref(connection, entity_id: int, provider: str, external_id: str, metadata: dict | None = None) -> None:
    connection.execute("INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id,"
                       "metadata_json,last_synced_at) VALUES(?,?,'performer',?,?,?)",
                       (entity_id, provider, external_id, json.dumps(metadata or {}), STAMP))


def link(connection, entity_id: int, kind: str, url: str, *, sensitive: int = 0, metadata=None) -> None:
    connection.execute("INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,is_sensitive,"
                       "metadata_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
                       (entity_id, kind, "label", url, "x.com", sensitive, json.dumps(metadata or {}),
                        STAMP, STAMP))


def membership(connection, member_id: int, agency_id: int, source: str) -> None:
    connection.execute("INSERT INTO entity_membership(member_id,agency_id,source,confidence,checked_at)"
                       " VALUES(?,?,?,1.0,?)", (member_id, agency_id, source, STAMP))


def label_maker(connection, label_id: int, maker_id: int, source: str) -> None:
    connection.execute("INSERT INTO label_maker(label_id,maker_id,source,confidence,checked_at)"
                       " VALUES(?,?,?,1.0,?)", (label_id, maker_id, source, STAMP))


PROFILE = {"kana": "あまかわそら", "birth_date": "1998-10-10", "height_cm": 164, "cup": "G",
           "tags": ["巨乳", "美肌"], "raw": {"名前": "天川そら", "サイズ": "T164 / B87"}}


class SeedPackCase(unittest.TestCase):
    def setUp(self):
        # 目录先登记回收:清理按后进先出执行,两个连接关掉之后才删目录。
        self.root = Path(self.enterContext(
            tempfile.TemporaryDirectory(prefix="peach-seed-", ignore_cleanup_errors=True)))
        self.source = connect(fresh_ledger(self.root, "source.db"))
        self.target = connect(fresh_ledger(self.root, "target.db"))
        self.addCleanup(self.source.close)
        self.addCleanup(self.target.close)

    def seeded_source(self) -> dict:
        """导出方账本:一位有全套事实的女优、她的事务所、一家带别名的厂牌、一个归属片商的 label、
        一位只有名字的女优。"""
        con = self.source
        with con:
            sora = entity(con, "performer", "天川そら")
            agency = entity(con, "agency", "STARTUP")
            studio = entity(con, "studio", "S1 NO.1 STYLE")
            maker = entity(con, "studio", "Prestige")
            label = entity(con, "studio", "PRESTIGE PREMIUM")
            entity(con, "performer", "只有名字")
            label_maker(con, label, maker, "review:label-maker-20260923.csv")
            alias(con, sora, "春山心愛", "r18:performer")
            alias(con, sora, "Amakawa Sora", "r18:performer")
            alias(con, sora, "そら", "javdb-actor-page@javdb-20260912")
            alias(con, sora, "二宮そら", "javdb-actor-page@javdb-20260912")
            alias(con, sora, "二宮そら", "kanji-simplification")
            alias(con, sora, "种子写的", BATCH)
            ref(con, sora, "minnano-av", "295275")
            ref(con, sora, "stash", "26")
            ref(con, sora, "javdb", "seedref", {"source": "auto:seed", "batch": "auto:seed@old"})
            link(con, sora, "social", "https://x.com/amakawa_sora_")
            link(con, sora, "official", "https://www.Startup0701.net/")
            link(con, sora, "social", "https://onlyfans.example/sora", sensitive=1)
            link(con, sora, "catalog", "https://www.minnano-av.com/actress295275.html")
            link(con, sora, "social", "https://x.com/seeded", metadata={"source": "auto:seed", "batch": "x"})
            write_profile(con, sora, dict(PROFILE), source="auto:performer-profile@7",
                          source_url="https://www.minnano-av.com/actress295275.html", fetched_at=STAMP)
            membership(con, sora, agency, "minnano-av:所属事務所")
            alias(con, studio, "S1", "javbus:studio-localization")
            link(con, studio, "official", "https://s1s1s1.com/")
        return seed_pack.export_pack(con, version=VERSION)


class SeedPerformerNameTests(SeedPackCase):
    def name_pack(self, *, revision=None):
        with self.source:
            person = entity(self.source, "performer", "天使萌")
            alias(self.source, person, "天使もえ", "avdb-actor-mapping@test")
        return seed_pack.export_pack(self.source, version=VERSION, revision=revision)

    def test_verified_chinese_name_updates_the_entity_and_work_projection(self):
        pack = self.name_pack()
        with self.target:
            person = entity(self.target, "performer", "天使もえ")
            self.target.execute("INSERT INTO asset(id,location,path,name,medium) VALUES(1,'local','R:\\a.mp4','a','video')")
            self.target.execute("INSERT INTO asset_entity(asset_id,entity_id,role,source) VALUES(1,?,'performer','test')", (person,))
            self.target.execute("INSERT INTO asset_tag(asset_id,tag,source) VALUES(1,'演员:天使もえ','test')")
            report = seed_pack.land(self.target, pack)
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (person,)).fetchone()[0], "天使萌")
        self.assertEqual(self.target.execute("SELECT tag FROM asset_tag WHERE asset_id=1").fetchone()[0], "演员:天使萌")
        self.assertEqual(report["names"], 1)
        self.assertIn("天使もえ", {r[0] for r in self.target.execute("SELECT alias FROM entity_alias WHERE entity_id=?", (person,))})
        with self.target:
            self.assertEqual(seed_pack.written(seed_pack.land(self.target, pack)), 0)
        target_path = self.target.execute("PRAGMA database_list").fetchone()[2]
        revert = load_script("revert_auto_landing")
        with contextlib.redirect_stdout(io.StringIO()):
            self.assertEqual(revert.main(["--db", target_path, "--source", "auto:seed", "--apply",
                "--backup", str(self.root / "pre-revert.db"), "--logo-root", str(self.root / "logos")]), 0)
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (person,)).fetchone()[0], "天使もえ")
        self.assertEqual(self.target.execute("SELECT tag FROM asset_tag WHERE asset_id=1").fetchone()[0], "演员:天使もえ")

    def test_a_user_preferred_name_is_preserved(self):
        from peach.entities import prefer_entity_name
        pack = self.name_pack()
        with self.target:
            person = entity(self.target, "performer", "天使萌")
            alias(self.target, person, "天使もえ", "user:manual")
            prefer_entity_name(self.target, person, "天使もえ")
            seed_pack.land(self.target, pack)
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (person,)).fetchone()[0], "天使もえ")
        self.assertEqual(seed_pack.planned_names(self.target, "auto:seed"), [])

    def test_confirming_the_current_name_protects_it_from_localization(self):
        from peach.entities import prefer_entity_name
        pack = self.name_pack()
        with self.target:
            person = entity(self.target, "performer", "天使もえ")
            prefer_entity_name(self.target, person, "天使もえ")
            seed_pack.land(self.target, pack)
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (person,)).fetchone()[0], "天使もえ")

    def test_revision_identity_and_aliases_must_resolve_to_one_person(self):
        pack = self.name_pack(revision=1)
        with self.target:
            person = entity(self.target, "performer", "天使もえ")
            report = seed_pack.land(self.target, pack)
        self.assertEqual(report["names"], 1)
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (person,)).fetchone()[0], "天使萌")

    def test_custom_names_and_ambiguous_identities_are_preserved(self):
        pack = self.name_pack()
        with self.target:
            person = entity(self.target, "performer", "我选的名字")
            alias(self.target, person, "天使もえ", "user:manual")
            seed_pack.land(self.target, pack)
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (person,)).fetchone()[0], "我选的名字")
        with self.target:
            entity(self.target, "performer", "天使萌")
            report = seed_pack.land(self.target, pack)
        self.assertEqual(len(report["duplicates"]), 1)
        self.assertEqual(report.get("names", 0), 0)


class ExportTests(SeedPackCase):
    def test_pack_carries_public_facts_and_nothing_local(self):
        pack = self.seeded_source()
        self.assertEqual(pack["version"], VERSION)
        self.assertEqual(pack["counts"], {"entities": 3, "aliases": 3, "refs": 1, "links": 3,
                                          "profiles": 1, "memberships": 1, "makers": 1})
        by_name = {item["name"]: item for item in pack["entities"]}
        self.assertEqual(list(by_name), ["天川そら", "PRESTIGE PREMIUM", "S1 NO.1 STYLE"], "按种类与归一名排序")
        self.assertNotIn("STARTUP", by_name, "事务所自己没有可补的事实,只作为她的归属出现")
        self.assertNotIn("Prestige", by_name, "片商自己没有可补的事实,只作为 label 的归属出现")
        self.assertEqual(by_name["PRESTIGE PREMIUM"]["maker"],
                         {"name": "Prestige", "source": "review:label-maker-20260923.csv"})
        sora = by_name["天川そら"]
        self.assertEqual([(a["alias"], a["source"]) for a in sora["aliases"]],
                         [("二宮そら", "javdb-actor-page@javdb-20260912"), ("春山心愛", "r18:performer")],
                         "按归一写法排序,同一写法只留一个来源;罗马字、短单名与种子自己写的不导")
        self.assertEqual(sora["refs"], [{"provider": "minnano-av", "kind": "performer", "id": "295275"}],
                         "Stash 的行号与种子写的编号都不导")
        self.assertEqual([l["url"] for l in sora["links"]],
                         ["https://www.startup0701.net/", "https://x.com/amakawa_sora_"],
                         "敏感链接、目录页与种子写的链接都不导;主机按导入同一套规则归一,大小写不进包")
        self.assertEqual(sora["profile"]["fields"],
                         {"kana": "あまかわそら", "birth_date": "1998-10-10", "height_cm": 164, "cup": "G",
                          "tags": ["巨乳", "美肌"]}, "资料按列导,raw 残片不导")
        self.assertEqual(sora["profile"]["source"], "auto:performer-profile@7")
        self.assertEqual(sora["agency"], {"name": "STARTUP", "source": "minnano-av:所属事務所"})
        self.assertNotIn("只有名字", by_name, "光一个名字对别的数据库没有用处")
        text = seed_pack.dump(pack)
        self.assertNotIn("raw_snapshot", text)
        self.assertNotIn("somewhere", text)

    def test_two_exports_of_the_same_ledger_are_byte_identical(self):
        first = seed_pack.dump(self.seeded_source())
        second = seed_pack.dump(seed_pack.export_pack(self.source, version=VERSION))
        self.assertEqual(first, second)


class ImportTests(SeedPackCase):
    def seeded_target(self) -> dict[str, int]:
        """另一台机器:账本里她叫「春山心愛」,事务所已登记,厂牌、label 与片商都只有名字;
        包里的另一位 `未登记` 本机没有。"""
        con = self.target
        with con:
            ids = {"her": entity(con, "performer", "春山心愛"), "agency": entity(con, "agency", "STARTUP"),
                   "studio": entity(con, "studio", "S1 NO.1 STYLE"), "other": entity(con, "performer", "别人"),
                   "label": entity(con, "studio", "PRESTIGE PREMIUM"), "maker": entity(con, "studio", "Prestige")}
        return ids

    def test_only_existing_entities_get_their_blanks_filled(self):
        pack = self.seeded_source()
        pack["entities"].append({"kind": "performer", "name": "未登记", "aliases": [], "refs": [],
                                 "links": [{"kind": "social", "label": "X", "url": "https://x.com/nobody"}]})
        ids = self.seeded_target()
        with self.target:
            report = seed_pack.land(self.target, pack)
        self.assertEqual(report, {"version": VERSION, "batch": BATCH, "matched": 3, "unmatched": 1,
                                  "aliases": 3, "refs": 1, "links": 3, "profiles": 1, "memberships": 1,
                                  "makers": 1, "refreshed": 0, "conflicts": [], "duplicates": []})
        her = ids["her"]
        rows = self.target.execute("SELECT alias,source FROM entity_alias WHERE entity_id=? ORDER BY alias",
                                   (her,)).fetchall()
        self.assertEqual([tuple(row) for row in rows], [("二宮そら", BATCH), ("天川そら", BATCH)],
                         "包里的规范名与别名都补成别名,本机规范名不动,来源是批次号")
        self.assertEqual(self.target.execute("SELECT canonical_name FROM entity WHERE id=?", (her,)).fetchone()[0],
                         "春山心愛")
        provider, external_id, metadata = self.target.execute(
            "SELECT provider,external_id,metadata_json FROM entity_external_ref WHERE entity_id=?", (her,)).fetchone()
        self.assertEqual((provider, external_id, json.loads(metadata)),
                         ("minnano-av", "295275", {"source": "auto:seed", "batch": BATCH}))
        links = self.target.execute("SELECT url,metadata_json FROM entity_link WHERE entity_id=? ORDER BY url",
                                    (her,)).fetchall()
        self.assertEqual([row[0] for row in links], ["https://www.startup0701.net/", "https://x.com/amakawa_sora_"])
        self.assertEqual(json.loads(links[0][1]), {"source": "auto:seed", "batch": BATCH})
        profile = read_profile(self.target, her)
        self.assertEqual((profile["kana"], profile["height_cm"], profile["tags"], profile["raw"], profile["source"]),
                         ("あまかわそら", 164, ["巨乳", "美肌"], {}, BATCH))
        self.assertEqual(profile["fetched_at"], STAMP, "取回时间照包里的记,30 天算旧的规则接着管它")
        member = self.target.execute("SELECT agency_id,source FROM entity_membership WHERE member_id=?",
                                     (her,)).fetchone()
        self.assertEqual(tuple(member), (ids["agency"], BATCH))
        studio_alias = self.target.execute("SELECT alias,source FROM entity_alias WHERE entity_id=?",
                                           (ids["studio"],)).fetchone()
        self.assertEqual(tuple(studio_alias), ("S1", BATCH))
        maker = self.target.execute("SELECT maker_id,source FROM label_maker WHERE label_id=?",
                                    (ids["label"],)).fetchone()
        self.assertEqual(tuple(maker), (ids["maker"], BATCH), "label 归哪家片商也补上,来源是批次号")
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity").fetchone()[0], 6, "不造实体")

    def test_seed_never_overwrites_what_the_ledger_already_holds(self):
        pack = self.seeded_source()
        ids = self.seeded_target()
        with self.target:
            write_profile(self.target, ids["her"], {"kana": "本机刮的", "raw": {}},
                          source="auto:performer-profile@99", source_url="https://example/", fetched_at=STAMP)
            membership(self.target, ids["her"], ids["agency"], "review:user")
            link(self.target, ids["her"], "social", "https://X.com/amakawa_sora_")
            alias(self.target, ids["her"], "二宮そら", "user:manual")
            kmp = entity(self.target, "studio", "K M Produce")
            label_maker(self.target, ids["label"], kmp, "review:user")
        with self.target:
            report = seed_pack.land(self.target, pack)
        self.assertEqual((report["profiles"], report["refs"], report["memberships"], report["links"],
                          report["aliases"], report["makers"], report["refreshed"]), (0, 1, 0, 2, 2, 0, 0))
        self.assertEqual(report["conflicts"],
                         [{"kind": "studio", "entity": "PRESTIGE PREMIUM", "field": "maker", "ours": "K M Produce",
                           "source": "review:user", "theirs": "Prestige"}],
                         "人复核过的片商与包里不一致:不动,记进明细等人看")
        self.assertEqual(self.target.execute("SELECT maker_id FROM label_maker WHERE label_id=?",
                                             (ids["label"],)).fetchone()[0], kmp)
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_link WHERE entity_id=?",
                                             (ids["her"],)).fetchone()[0], 2,
                         "本机那条主机大写的同一地址不再补一条只差大小写的")
        self.assertEqual(read_profile(self.target, ids["her"])["source"], "auto:performer-profile@99",
                         "本机后继刚刮的资料比种子新,不被盖掉")
        self.assertEqual(self.target.execute("SELECT source FROM entity_membership WHERE member_id=?",
                                             (ids["her"],)).fetchone()[0], "review:user")
        rows = self.target.execute("SELECT alias,source FROM entity_alias WHERE entity_id=? ORDER BY alias",
                                   (ids["her"],)).fetchall()
        self.assertEqual([tuple(row) for row in rows], [("二宮そら", "user:manual"), ("天川そら", BATCH)],
                         "已有的写法保留原来源,只补缺的那个")

    def test_a_newer_seed_replaces_what_an_older_seed_wrote(self):
        pack = self.seeded_source()
        ids = self.seeded_target()
        with self.target:
            old_agency = entity(self.target, "agency", "旧事务所")
            old_maker = entity(self.target, "studio", "旧片商")
            membership(self.target, ids["her"], old_agency, "auto:seed@2026-01-01")
            label_maker(self.target, ids["label"], old_maker, "auto:seed@2026-01-01")
            write_profile(self.target, ids["her"], {"kana": "旧种子写的", "raw": {}}, source="auto:seed@2026-01-01",
                          source_url="https://example/", fetched_at="2026-01-01T00:00:00Z")
        with self.target:
            report = seed_pack.land(self.target, pack)
        self.assertEqual((report["memberships"], report["makers"], report["profiles"], report["refreshed"],
                          report["conflicts"]), (0, 0, 0, 3, []), "旧种子写的三行都换成新批次,不算新补也不算冲突")
        self.assertEqual(tuple(self.target.execute("SELECT agency_id,source FROM entity_membership WHERE member_id=?",
                                                   (ids["her"],)).fetchone()), (ids["agency"], BATCH))
        self.assertEqual(tuple(self.target.execute("SELECT maker_id,source FROM label_maker WHERE label_id=?",
                                                   (ids["label"],)).fetchone()), (ids["maker"], BATCH))
        profile = read_profile(self.target, ids["her"])
        self.assertEqual((profile["kana"], profile["source"]), ("あまかわそら", BATCH))
        with self.target:
            again = seed_pack.land(self.target, pack)
        self.assertEqual(seed_pack.written(again), 0, "同一包再跑一遍不再动任何一行")

    def test_a_name_on_one_entity_and_a_number_on_another_matches_nobody(self):
        pack = self.seeded_source()
        ids = self.seeded_target()
        with self.target:
            ref(self.target, ids["other"], "minnano-av", "295275")
        with self.target:
            report = seed_pack.land(self.target, pack)
        self.assertEqual((report["matched"], report["unmatched"], report["duplicates"]),
                         (2, 0, [{"kind": "performer", "name": "天川そら", "entities": ["别人", "春山心愛"]}]),
                         "两家厂牌对上了;她那条认出两位,记进重复身份明细")
        for entity_id in (ids["her"], ids["other"]):
            self.assertEqual(self.target.execute("SELECT count(*) FROM entity_alias WHERE entity_id=?",
                                                 (entity_id,)).fetchone()[0], 0, "名字与编号各指一位,谁都不动")
        owner = self.target.execute("SELECT entity_id FROM entity_external_ref WHERE external_id='295275'").fetchone()
        self.assertEqual(owner[0], ids["other"], "别人已登记的编号不改指")

    def test_two_local_matches_are_left_alone(self):
        pack = self.seeded_source()
        with self.target:
            first = entity(self.target, "performer", "春山心愛")
            second = entity(self.target, "performer", "二宮そら")
        with self.target:
            report = seed_pack.land(self.target, pack)
        self.assertEqual((report["matched"], report["unmatched"], report["duplicates"]),
                         (0, 2, [{"kind": "performer", "name": "天川そら", "entities": ["二宮そら", "春山心愛"]}]))
        for entity_id in (first, second):
            self.assertEqual(self.target.execute("SELECT count(*) FROM entity_alias WHERE entity_id=?",
                                                 (entity_id,)).fetchone()[0], 0, "两位都对得上就谁都不动")

    def test_plan_reports_the_same_counts_without_touching_the_ledger(self):
        pack = self.seeded_source()
        self.seeded_target()
        before = self.target.execute("SELECT count(*) FROM entity_alias").fetchone()[0]
        report = seed_pack.plan(self.target, pack)
        self.assertEqual((report["matched"], report["aliases"], report["profiles"]), (3, 3, 1))
        self.assertEqual(self.target.execute("SELECT count(*) FROM entity_alias").fetchone()[0], before)

    def test_a_seed_batch_is_reverted_across_all_six_tables(self):
        pack = self.seeded_source()
        ids = self.seeded_target()
        with self.target:
            alias(self.target, ids["her"], "人写的", "user:manual")
            seed_pack.land(self.target, pack)
        self.target.close()
        revert = load_script("revert_auto_landing")
        db = self.root / "target.db"
        base = ["--db", str(db), "--source", "auto:seed", "--logo-root", str(self.root / "no-logos")]
        with contextlib.redirect_stdout(io.StringIO()) as out:
            self.assertEqual(revert.main(base), 0)
        self.assertIn("'归属': 1", out.getvalue())
        self.assertIn("'片商': 1", out.getvalue())
        with contextlib.redirect_stdout(io.StringIO()):
            self.assertEqual(revert.main([*base, "--apply", "--backup", str(self.root / "backup.db")]), 0)
        con = connect(db)
        self.addCleanup(con.close)
        counts = [con.execute(sql).fetchone()[0] for sql in (
            "SELECT count(*) FROM entity_alias", "SELECT count(*) FROM entity_external_ref",
            "SELECT count(*) FROM entity_link", "SELECT count(*) FROM performer_profile",
            "SELECT count(*) FROM entity_membership", "SELECT count(*) FROM label_maker")]
        self.assertEqual(counts, [1, 0, 0, 0, 0, 0], "只剩人写的那条别名")


class ScriptTests(SeedPackCase):
    def test_default_pack_is_a_valid_bundled_public_resource(self):
        script = load_script("seed_pack")
        export = script.build_parser().parse_args(["export"])
        self.assertEqual(export.out, seed_pack.DEFAULT_PACK)
        self.assertEqual(export.out, ROOT / "resources/seed/entities.json")
        pack = seed_pack.load(export.out)
        self.assertEqual(pack["format"], 2)
        self.assertGreater(pack["counts"]["entities"], 0)
        self.assertGreater(pack["revision"], 0)

    def test_pack_is_required_in_each_packaging_layout(self):
        from peach.bundle_resources import data_files
        with self.assertRaises(FileNotFoundError):
            data_files(self.root)
        for name in ("web", "migrations", "resources", "tools/amane-bridge"):
            (self.root / name).mkdir(parents=True, exist_ok=True)
        for name in ("bridge.py", "pyproject.toml", "uv.lock"):
            (self.root / "tools/amane-bridge" / name).touch()
        seed = self.root / "resources/seed/entities.json"
        seed.parent.mkdir()
        seed.write_text(seed_pack.dump(self.seeded_source()), encoding="utf-8")
        entries = data_files(self.root)
        # data_files 给的是解析过链接的真实路径;macOS 的临时目录 /var 指向 /private/var。
        self.assertIn((self.root.resolve() / "resources", "resources"), entries)
        seed.unlink()
        with self.assertRaises(FileNotFoundError):
            data_files(self.root)

    def test_validate_checks_package_without_opening_a_database(self):
        pack = self.root / "package.json"
        pack.write_text(seed_pack.dump(self.seeded_source()), encoding="utf-8")
        script = load_script("seed_pack")
        with mock.patch.object(script, "open_readonly", side_effect=AssertionError("database must remain closed")):
            with contextlib.redirect_stdout(io.StringIO()) as output:
                self.assertEqual(script.main(["validate", "--pack", str(pack)]), 0)
            self.assertTrue(json.loads(output.getvalue())["valid"])
            pack.write_text('{"format":true}', encoding="utf-8")
            with contextlib.redirect_stdout(io.StringIO()) as rejected:
                self.assertEqual(script.main(["validate", "--pack", str(pack)]), 2)
            self.assertFalse(json.loads(rejected.getvalue())["written"])

    def test_export_is_idempotent_and_import_is_dry_run_by_default(self):
        self.seeded_source()
        self.source.close()
        script = load_script("seed_pack")
        out = self.root / "seed" / "entities.json"
        with contextlib.redirect_stdout(io.StringIO()) as first:
            self.assertEqual(script.main(["export", "--db", str(self.root / "source.db"), "--version", VERSION,
                                          "--out", str(out)]), 0)
        with contextlib.redirect_stdout(io.StringIO()) as second:
            script.main(["export", "--db", str(self.root / "source.db"), "--version", VERSION, "--out", str(out)])
        self.assertEqual((json.loads(first.getvalue())["changed"], json.loads(second.getvalue())["changed"]),
                         (True, False))
        self.assertEqual(seed_pack.load(out)["counts"]["entities"], 3)
        with self.target:
            entity(self.target, "performer", "天川そら")
        self.target.close()
        target = self.root / "target.db"
        with contextlib.redirect_stdout(io.StringIO()) as dry:
            self.assertEqual(script.main(["import", "--db", str(target), "--pack", str(out)]), 0)
        self.assertIn("dry-run", dry.getvalue())
        con = connect(target)
        self.addCleanup(con.close)
        self.assertEqual(con.execute("SELECT count(*) FROM entity_alias").fetchone()[0], 0)
        with contextlib.redirect_stdout(io.StringIO()) as applied:
            self.assertEqual(script.main(["import", "--db", str(target), "--pack", str(out), "--apply",
                                          "--backup", str(self.root / "pre-seed.db")]), 0)
        report = json.loads(applied.getvalue())
        self.assertEqual((report["matched"], report["aliases"], report["integrity_check"]), (1, 2, "ok"))
        self.assertTrue((self.root / "pre-seed.db").exists())

    def test_a_changed_export_that_keeps_the_version_is_refused(self):
        """版本与内容绑定,导出内容变化须推进版本。"""
        self.seeded_source()
        script = load_script("seed_pack")
        out, db = self.root / "seed" / "entities.json", str(self.root / "source.db")
        with contextlib.redirect_stdout(io.StringIO()):
            self.assertEqual(script.main(["export", "--db", db, "--version", VERSION, "--out", str(out)]), 0)
        before = out.read_text(encoding="utf-8")
        sora = self.source.execute("SELECT id FROM entity WHERE canonical_name='天川そら'").fetchone()[0]
        with self.source:
            alias(self.source, sora, "改名后的艺名", "r18:performer")
        self.source.close()
        with contextlib.redirect_stdout(io.StringIO()) as refused:
            self.assertEqual(script.main(["export", "--db", db, "--version", VERSION, "--out", str(out)]), 2)
        report = json.loads(refused.getvalue())
        self.assertEqual((report["version"], report["changed"], report["written"]), (VERSION, True, False))
        self.assertIn("换一个新版本串", report["error"])
        self.assertEqual(out.read_text(encoding="utf-8"), before, "被拒的导出不动文件")
        with contextlib.redirect_stdout(io.StringIO()) as renewed:
            self.assertEqual(script.main(["export", "--db", db, "--version", f"{VERSION}.1", "--out", str(out)]), 0)
        self.assertTrue(json.loads(renewed.getvalue())["changed"])
        self.assertEqual((seed_pack.load(out)["version"], seed_pack.load(out)["counts"]["aliases"]),
                         (f"{VERSION}.1", 4))
        # 文件已是 `.1`,再改内容后用缺省那样的当天日期导出:比现有版本串旧,同样拒绝。
        source = connect(self.root / "source.db")
        with source:
            alias(source, sora, "再改一次", "r18:performer")
        source.close()
        with contextlib.redirect_stdout(io.StringIO()) as older:
            self.assertEqual(script.main(["export", "--db", db, "--version", VERSION, "--out", str(out)]), 2)
        self.assertIn("需要更新的 version 与 revision", json.loads(older.getvalue())["error"])
        self.assertEqual(seed_pack.load(out)["version"], f"{VERSION}.1")


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