Ctrl K
ComunidadGuías…

tests/test_duplicate_identity_merge.py

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

Archivo original ↗Preguntar a KiBot
"""creator / performer 跨类重复身份审计与兼容投影回归。"""
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path

from support.ledger import fresh_ledger
from peach.review_csv import read_rows
from scripts.merge_duplicate_identities import (
    apply_repeated_projections,
    apply_rows,
    build_parser,
    collect,
    collect_repeated_projections,
    named_pairs,
    run,
)


class DuplicateIdentityMergeTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.db = fresh_ledger(self.tmp.name)
        self.con = sqlite3.connect(self.db)
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,creator) "
            "VALUES(?,'local',?,?,'video',?)",
            [(1, "/x/1.mp4", "1.mp4", "小千 Qian0791"),
             (2, "/x/2.mp4", "2.mp4", "小千 Qian0791"),
             (3, "/x/3.mp4", "3.mp4", "NOZOMI NOZOMI"),
             (4, "/x/4.mp4", "4.mp4", "NOZOMI NOZOMI")])
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) "
            "VALUES(?,?,?,?, 't','t')",
            [(10, "creator", "小千 Qian0791", "小千 qian0791"),
             (11, "performer", "小千", "小千"),
             (12, "creator", "NOZOMI NOZOMI", "nozomi nozomi"),
             (13, "performer", "NOZOMI", "nozomi"),
             (14, "creator", "无关目录", "无关目录")])
        self.con.executemany(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence) "
            "VALUES(?,?,?,?,1.0)",
            [(11, "Qian0791", "qian0791", "stash:performer"),
             (13, "NOZOMI NOZOMI", "nozomi nozomi", "javbus:performer")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) "
            "VALUES(?,?,?,?,1.0)",
            [(1, 10, "creator", "legacy:asset"), (2, 10, "creator", "legacy:asset"),
             (1, 11, "performer", "performer"), (2, 11, "performer", "performer"),
             (3, 12, "creator", "legacy:asset"),
             (3, 13, "performer", "javbus:performer"),
             (4, 13, "performer", "javbus:performer"),
             (1, 14, "creator", "legacy:asset"), (2, 14, "creator", "legacy:asset")])
        self.con.executemany(
            "INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(?,?,1.0,?)",
            [(1, "演员:小千", "performer"), (2, "演员:小千", "performer"),
             (3, "演员:NOZOMI NOZOMI", "javbus:performer")])
        self.con.commit()

    def tearDown(self):
        self.con.close()
        self.tmp.cleanup()

    def test_collect_uses_same_assets_aliases_and_release_provenance(self):
        rows = collect(self.con)
        self.assertEqual(len(rows), 2)
        by_drop = {row["drop_name"]: row for row in rows}
        self.assertEqual(by_drop["小千"]["keep_kind"], "creator")
        self.assertIn("账号别名", by_drop["小千"]["match_evidence"])
        self.assertEqual(by_drop["NOZOMI NOZOMI"]["keep_kind"], "performer")
        self.assertEqual(by_drop["NOZOMI NOZOMI"]["evidence"], "发行元数据")
        self.assertNotIn("无关目录", {row["keep_name"] for row in rows})

    def test_apply_syncs_flat_creator_and_actor_tag_projections(self):
        counts = apply_rows(self.con, collect(self.con))
        self.con.commit()

        self.assertIsNone(self.con.execute(
            "SELECT 1 FROM entity WHERE id=11").fetchone())
        self.assertIsNone(self.con.execute(
            "SELECT 1 FROM entity WHERE id=12").fetchone())
        self.assertEqual(self.con.execute(
            "SELECT creator FROM asset WHERE id=3").fetchone()[0], None)
        self.assertEqual(self.con.execute(
            "SELECT creator FROM asset WHERE id=4").fetchone()[0], None,
            "兼容字段要按坏名字全量清,不能只清旧 creator 关系覆盖的资产")
        tags = dict(self.con.execute(
            "SELECT asset_id,tag FROM asset_tag ORDER BY asset_id"))
        self.assertNotIn(1, tags)
        self.assertNotIn(2, tags)
        self.assertEqual(tags[3], "演员:NOZOMI")
        self.assertEqual(counts["actor_tags_removed"], 2)
        self.assertEqual(counts["actor_tags_rewritten"], 1)
        self.assertEqual(self.con.execute(
            "PRAGMA foreign_key_check").fetchall(), [])

    def _seed_same_kind_projection(self):
        """`小桃` / `小桃 shixiaotaone` 这类同 kind 目录名投影。

        20 号是规范实体:带两个账号别名,作品集合是 {20,21,22}。21 号是
        `R:\\Media\\<本名 + 账号名>` 投影出来的第二条 creator,作品 {20,21} 全落在 20 号里。
        22 号名字同样由 20 号的本名和别名拼成,但作品 {22,23} 越出了 20 号的集合。
        """
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,creator) "
            "VALUES(?,'local',?,?,'video',?)",
            [(20, "/x/20.mp4", "20.mp4", "小桃 shixiaotaone"),
             (21, "/x/21.mp4", "21.mp4", "小桃 shixiaotaone"),
             (22, "/x/22.mp4", "22.mp4", "小桃 taomi"),
             (23, "/x/23.mp4", "23.mp4", "小桃 taomi")])
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) "
            "VALUES(?,?,?,?, 't','t')",
            [(20, "creator", "小桃", "小桃"),
             (21, "creator", "小桃 shixiaotaone", "小桃 shixiaotaone"),
             (22, "creator", "小桃 taomi", "小桃 taomi")])
        self.con.executemany(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence) "
            "VALUES(?,?,?,'stash:performer',0.9)",
            [(20, "shixiaotaone", "shixiaotaone"), (20, "taomi", "taomi")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) "
            "VALUES(?,?,?,?,1.0)",
            [(20, 20, "performer", "performer"), (21, 20, "performer", "performer"),
             (22, 20, "performer", "performer"),
             (20, 21, "creator", "legacy:asset"), (21, 21, "creator", "legacy:asset"),
             (22, 22, "creator", "legacy:asset"), (23, 22, "creator", "legacy:asset")])
        self.con.commit()

    def test_collect_merges_same_kind_directory_projection(self):
        self._seed_same_kind_projection()
        rows = [row for row in collect(self.con) if row["drop_id"] == 21]
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0]["keep_id"], 20)
        self.assertEqual(rows[0]["keep_kind"], "creator")
        self.assertEqual(rows[0]["drop_kind"], "creator")
        self.assertIn("同 kind", rows[0]["match_evidence"])

    def test_collect_skips_same_kind_without_asset_or_provenance_evidence(self):
        self._seed_same_kind_projection()
        # 21 号一旦有了自己的外部引用就不再是纯目录投影,留给人工。
        self.con.execute(
            "INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) "
            "VALUES(21,'stash','performer','777')")
        self.con.commit()
        drops = {row["drop_id"] for row in collect(self.con)}
        self.assertNotIn(21, drops, "带外部引用的实体不是目录投影")
        self.assertNotIn(22, drops, "作品集合越出保留方时,拼名不足以判同人")

    def test_apply_rewrites_flat_creator_for_same_kind_merge(self):
        self._seed_same_kind_projection()
        rows = [row for row in collect(self.con) if row["drop_id"] == 21]
        counts = apply_rows(self.con, rows)
        self.con.commit()

        self.assertIsNone(self.con.execute("SELECT 1 FROM entity WHERE id=21").fetchone())
        self.assertEqual([row[0] for row in self.con.execute(
            "SELECT creator FROM asset WHERE id IN (20,21) ORDER BY id")], ["小桃", "小桃"])
        self.assertEqual(counts["flat_rewritten"], 2)
        self.assertEqual(self.con.execute(
            "SELECT creator FROM asset WHERE id=22").fetchone()[0], "小桃 taomi",
            "只改写被丢弃的那个名字,不动别的目录名")
        self.assertIn("小桃 shixiaotaone", [row[0] for row in self.con.execute(
            "SELECT alias FROM entity_alias WHERE entity_id=20")])
        self.assertEqual(self.con.execute("PRAGMA foreign_key_check").fetchall(), [])

    def test_projection_audit_repairs_orphan_flat_fields_tags_and_aliases(self):
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,creator) "
            "VALUES(?,'local',?,?,'video',?)",
            [(5, "/x/5.mp4", "5.mp4", "瀬奈まお 瀬奈まお"),
             (6, "/x/6.mp4", "6.mp4", "画像を拡大する 画像を拡大する")],
        )
        self.con.execute(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) "
            "VALUES(15,'performer','瀬奈まお','瀬奈まお','t','t')"
        )
        self.con.execute(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence) "
            "VALUES(15,'瀬奈まお 瀬奈まお','瀬奈まお 瀬奈まお','user:merge',1.0)"
        )
        self.con.execute(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) "
            "VALUES(5,15,'performer','javbus:performer',1.0)"
        )
        self.con.executemany(
            "INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(?,?,1.0,'javbus:performer')",
            [(5, "演员:瀬奈まお 瀬奈まお"),
             (6, "演员:画像を拡大する 画像を拡大する")],
        )
        rows = collect_repeated_projections(self.con)
        by_name = {row["bad_name"]: row for row in rows}
        self.assertEqual(by_name["瀬奈まお 瀬奈まお"]["action"], "use-performer")
        self.assertEqual(by_name["画像を拡大する 画像を拡大する"]["action"], "remove-invalid")
        counts = apply_repeated_projections(self.con, rows)
        self.con.commit()
        self.assertEqual(self.con.execute(
            "SELECT creator FROM asset WHERE id=5").fetchone()[0], None)
        self.assertEqual(self.con.execute(
            "SELECT creator FROM asset WHERE id=6").fetchone()[0], None)
        self.assertEqual([row[0] for row in self.con.execute(
            "SELECT tag FROM asset_tag WHERE asset_id=5")], ["演员:瀬奈まお"])
        self.assertEqual([row[0] for row in self.con.execute(
            "SELECT tag FROM asset_tag WHERE asset_id=6")], [])
        self.assertEqual([row[0] for row in self.con.execute(
            "SELECT alias FROM entity_alias WHERE entity_id=15")], [])
        self.assertGreaterEqual(counts["flat_cleared"], 2)


class ContainedAssetSetMergeTests(unittest.TestCase):
    """跨 kind 判据放宽到「真子集」后的回归。

    只认作品集合完全相同的话,`哆米`(7 部) 与目录投影 `哆米 Dolmi24`(6 部) 这种
    一侧多出一两部的就永远落在判据外——多出的那几部只被 Stash 认成 performer、
    没有对应本地目录,是常态而不是反证。放宽的部分由名字那一重证据兜底。
    """

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.db = fresh_ledger(self.tmp.name)
        self.con = sqlite3.connect(self.db)
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,creator) "
            "VALUES(?,'local',?,?,'video',?)",
            [(1, "/x/1.mp4", "1.mp4", "哆米 Dolmi24"),
             (2, "/x/2.mp4", "2.mp4", "哆米 Dolmi24"),
             (3, "/x/3.mp4", "3.mp4", None),
             (4, "/x/4.mp4", "4.mp4", "桉X合集")])
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) "
            "VALUES(?,?,?,?, 't','t')",
            [(20, "creator", "哆米 Dolmi24", "哆米 dolmi24"),
             (21, "performer", "哆米", "哆米"),
             (22, "creator", "桉X合集", "桉x合集"),
             (23, "performer", "桉X", "桉x")])
        self.con.executemany(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence) "
            "VALUES(?,?,?,?,1.0)",
            [(21, "Dolmi24", "dolmi24", "stash:performer"),
             (23, "lananh9496", "lananh9496", "stash:performer")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) "
            "VALUES(?,?,?,?,1.0)",
            # creator 只挂 1、2;performer 多出第 3 部——真子集,不是相等。
            [(1, 20, "creator", "legacy:asset"), (2, 20, "creator", "legacy:asset"),
             (1, 21, "performer", "performer"), (2, 21, "performer", "performer"),
             (3, 21, "performer", "performer"),
             (4, 22, "creator", "legacy:asset"),
             (4, 23, "performer", "performer")])
        self.con.commit()

    def tearDown(self):
        self.con.close()
        self.tmp.cleanup()

    def test_collect_merges_when_creator_assets_are_a_strict_subset(self):
        rows = collect(self.con)
        by_drop = {row["drop_name"]: row for row in rows}
        self.assertIn("哆米", by_drop)
        merged = by_drop["哆米"]
        # performer 侧只有 Stash 扁平断言,保留带真实目录的 creator。
        self.assertEqual(merged["keep_kind"], "creator")
        self.assertEqual(merged["keep_name"], "哆米 Dolmi24")
        self.assertIn("账号别名", merged["match_evidence"])
        self.assertIn("真包含", merged["match_evidence"])
        self.assertIn("2/3 部", merged["match_evidence"])

    def test_collect_still_refuses_a_name_that_is_not_alias_backed(self):
        """`桉X合集` 的多出词「合集」不是 `桉X` 的已登记别名,仍然留给人工。

        目录名里的「合集」可能是同人的合集,也可能是多人合辑;判子集只说明
        文件重叠,说明不了那一条目录就是这个人。
        """
        self.assertNotIn(
            "桉X", {row["drop_name"] for row in collect(self.con)})


class StageNameDuplicateTests(unittest.TestCase):
    """艺名已登记为别名、那个名字却还自己占一条实体。三道闸都拿实测假阳性做样本。"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.db = fresh_ledger(self.tmp.name)
        self.con = sqlite3.connect(self.db)
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,code) "
            "VALUES(?,'local',?,?,'video',?)",
            [(1, "/x/1.mp4", "1.mp4", "CEAD-275"), (2, "/x/2.mp4", "2.mp4", "SKY-250"),
             (3, "/x/3.mp4", "3.mp4", "MIZD-998"), (4, "/x/4.mp4", "4.mp4", "ABP-951"),
             (5, "/x/5.mp4", "5.mp4", "DPMX-016"),
             (6, "/x/6.mp4", "6.mp4", "300MIUM-1219")])
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(?,?,?,?,'t','t')",
            [(20, "performer", "森泽佳奈", "森泽佳奈"),
             (21, "performer", "飯岡かなこ", "飯岡かなこ"),
             (22, "performer", "白石亚子", "白石亚子"),
             (23, "performer", "Ako Shiraishi", "ako shiraishi"),
             (24, "performer", "绫乃梓", "绫乃梓"),
             (25, "performer", "NOZOMI", "nozomi")])
        self.con.executemany(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence)"
            " VALUES(?,?,?,?,0.9)",
            [(20, "飯岡かなこ", "飯岡かなこ", "r18:performer"),
             (22, "Ako Shiraishi", "ako shiraishi", "r18:performer"),
             (23, "Suzu Hirasawa", "suzu hirasawa", "r18:performer"),
             (23, "平沢すず", "平沢すず", "avdb-actor-mapping@8e2d5b7a"),
             (24, "Nozomi", "nozomi", "r18:performer")])
        self.con.executemany(
            "INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id)"
            " VALUES(?,?,'performer',?)",
            [(23, "r18", "平沢すず"), (24, "stash", "538"), (25, "stash", "610")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(?,?,'performer',?,0.9)",
            [(1, 20, "r18:performer"), (2, 21, "javbus:performer"),
             (3, 23, "r18:performer"), (4, 22, "r18:performer"),
             (5, 24, "r18:performer"), (6, 25, "javbus:performer")])
        self.con.commit()

    def tearDown(self):
        self.con.close()
        self.tmp.cleanup()

    def drops(self):
        return {str(row["drop_name"]): row for row in collect(self.con)}

    def test_a_stage_name_registered_as_another_entity_alias_is_a_duplicate(self):
        """r18 早已把 `飯岡かなこ` 记成 `森泽佳奈` 的别名,那个名字却还自己占一条实体。"""
        row = self.drops()["飯岡かなこ"]
        self.assertEqual(row["keep_id"], 20)
        self.assertEqual(row["keep_name"], "森泽佳奈")
        self.assertIn("飯岡かなこ", str(row["match_evidence"]))
        self.assertIn("r18:performer", str(row["match_evidence"]))

    def test_an_entity_whose_own_aliases_name_someone_else_is_left_to_a_human(self):
        """`Ako Shiraishi` 的每个别名和 r18 引用都指向 平沢すず——规范名写错了人。

        并进 `白石亚子` 就是把两位女优搅在一起,不可逆。这条要改的是规范名,不是合并。
        """
        self.assertNotIn("Ako Shiraishi", self.drops())

    def test_two_entities_with_different_ids_at_one_provider_are_two_people(self):
        """`NOZOMI` 是素人企划里的通名,撞上的是 `绫乃梓` 罗马字读音那条别名。

        两条各有自己的 stash performer id,那就是站方也认为的两个人。
        """
        self.assertNotIn("NOZOMI", self.drops())

    def test_a_localization_alias_is_not_evidence_of_a_second_stage_name(self):
        """简繁与本地化别名说的是同一个名字的另一种写法,证不了这是那个人的另一个艺名。"""
        self.con.execute(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(30,'performer','綾乃梓','綾乃梓','t','t')")
        self.con.execute(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence)"
            " VALUES(24,'綾乃梓','綾乃梓','kanji-simplification',1.0)")
        self.con.execute(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(1,30,'performer','legacy:asset',0.8)")
        self.con.commit()
        self.assertNotIn("綾乃梓", self.drops())

    def test_the_merge_keeps_the_alias_holder_and_moves_its_assets(self):
        counts = apply_rows(self.con, collect(self.con))
        self.con.commit()
        self.assertEqual(counts["merged"], 1)
        self.assertIsNone(self.con.execute("SELECT 1 FROM entity WHERE id=21").fetchone())
        self.assertEqual(self.con.execute(
            "SELECT count(*) FROM asset_entity WHERE entity_id=20").fetchone()[0], 2)
        self.assertIn("飯岡かなこ", [row[0] for row in self.con.execute(
            "SELECT alias FROM entity_alias WHERE entity_id=20")])


class DirectoryIdentityTests(unittest.TestCase):
    """名录页号是身份,比名字和别名都强:换过艺名的人靠它才认得出来。"""

    LINK = "http://www.prestige-av.com/special/shiraishi_ako.php"

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.db = fresh_ledger(self.tmp.name)
        self.con = sqlite3.connect(self.db)
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,code)"
            " VALUES(?,'local',?,?,'video',?)",
            [(1, "/x/1.mp4", "1.mp4", "ABP-951"), (2, "/x/2.mp4", "2.mp4", "MIZD-998")])
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(?,'performer',?,?,'t','t')",
            [(40, "白石亚子", "白石亚子"), (41, "Ako Shiraishi", "ako shiraishi")])
        self.con.executemany(
            "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence)"
            " VALUES(?,?,?,'r18:performer',0.9)",
            [(40, "白石あこ", "白石あこ"), (41, "平沢すず", "平沢すず")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(?,?,'performer','r18:performer',0.9)",
            [(1, 40), (2, 41)])
        self.con.commit()

    def tearDown(self):
        self.con.close()
        self.tmp.cleanup()

    def install(self, entity_id, evidence, url=None):
        self.con.execute(
            "INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,is_sensitive,"
            "metadata_json,created_at,updated_at)"
            " VALUES(?,'official','T-POWERS',?,'www.prestige-av.com',0,?,'t','t')",
            (entity_id, url or f"{self.LINK}?{entity_id}",
             json.dumps({"evidence": evidence}, ensure_ascii=False)))
        self.con.commit()

    def test_two_entities_on_one_directory_page_are_one_person(self):
        """艺名换过一次,账本各存了一条;两侧各按自己的名字检索,落到同一页。"""
        self.install(40, "minnano-av actress37250 资料表「公式サイト」;经「白石あこ」检索命中")
        self.install(41, "minnano-av actress37250 资料表「公式サイト」;经「平沢すず」检索命中")
        rows = collect(self.con)
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0]["keep_name"], "白石亚子")
        self.assertEqual(rows[0]["drop_name"], "Ako Shiraishi")
        self.assertEqual(rows[0]["evidence"], "名录页号")
        self.assertIn("actress37250", str(rows[0]["match_evidence"]))

    def test_the_localized_name_is_the_one_that_survives(self):
        """用户 2026-09-04 的口径:用更知名的名字,其余叫法留在别名里。"""
        self.install(40, "minnano-av actress37250 经「白石あこ」检索命中")
        self.install(41, "minnano-av actress37250 经「平沢すず」检索命中")
        apply_rows(self.con, collect(self.con))
        self.con.commit()
        self.assertIsNone(self.con.execute("SELECT 1 FROM entity WHERE id=41").fetchone())
        aliases = [row[0] for row in self.con.execute(
            "SELECT alias FROM entity_alias WHERE entity_id=40")]
        self.assertIn("Ako Shiraishi", aliases)
        self.assertIn("平沢すず", aliases)

    def test_two_different_pages_are_two_people(self):
        """同一家事务所的两位女优也会共用一条官网 URL——URL 不是身份,页号才是。"""
        self.install(40, "minnano-av actress37250 经「白石あこ」检索命中", url=self.LINK)
        self.install(41, "minnano-av actress99999 经「平沢すず」检索命中", url=self.LINK)
        self.assertEqual(collect(self.con), [])

    def test_a_link_without_a_page_number_is_not_evidence(self):
        self.install(40, "X @shiraishi 简介外链")
        self.install(41, "X @suzu 简介外链")
        self.assertEqual(collect(self.con), [])

    def test_three_entities_on_one_page_are_left_to_a_human(self):
        """三条要先决定并的顺序,那不是脚本该替人做的。"""
        self.con.execute(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(42,'performer','平泽铃','平泽铃','t','t')")
        self.con.execute(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(1,42,'performer','r18:performer',0.9)")
        for entity_id in (40, 41, 42):
            self.install(entity_id, f"minnano-av actress37250 经「{entity_id}」检索命中")
        self.assertEqual(collect(self.con), [])




class NamedPairTests(unittest.TestCase):
    """命令行点名的一对:证据在站外时,唯一说得清来由的就是那段话。"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.db = fresh_ledger(self.tmp.name)
        self.con = sqlite3.connect(self.db)
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(?,?,?,?,'t','t')",
            [(1, "performer", "五十岚星兰", "五十岚星兰"),
             (2, "performer", "五十嵐星蘭", "五十嵐星蘭"),
             (3, "studio", "别的种类", "别的种类"),
             (6, "performer", "加山優衣", "加山優衣")])
        self.con.commit()

    def tearDown(self):
        self.con.close()
        self.tmp.cleanup()

    def pairs(self, *values, engaged=None):
        return named_pairs(self.con, list(values), engaged)

    def test_the_evidence_is_carried_through_to_the_review_file(self):
        row = self.pairs("1:2:javdb 资料页把五个艺名列在一起")[0]
        self.assertEqual(row["match_evidence"], "javdb 资料页把五个艺名列在一起")
        self.assertEqual((row["keep_id"], row["drop_id"]), (1, 2))
        self.assertEqual(row["drop_name"], "五十嵐星蘭")

    def test_a_pair_without_evidence_is_refused(self):
        """合并不可逆。没有证据的一对说不清来由,比不合更糟。"""
        with self.assertRaises(SystemExit):
            self.pairs("1:2:")
        with self.assertRaises(SystemExit):
            self.pairs("1:2")

    def test_two_different_kinds_are_never_the_same_identity(self):
        with self.assertRaises(SystemExit):
            self.pairs("1:3:随便什么理由")

    def test_an_entity_already_planned_by_a_detector_is_refused(self):
        """自动判据先合会把 id 删掉,第二趟再引用同一个 id 就是合进一条不存在的实体。"""
        with self.assertRaises(SystemExit):
            self.pairs("1:2:证据", engaged={2})

    def test_a_missing_entity_is_refused(self):
        with self.assertRaises(SystemExit):
            self.pairs("1:99:证据")

    def test_one_entity_on_both_sides_is_refused(self):
        with self.assertRaises(SystemExit):
            self.pairs("1:1:证据")

    def test_one_survivor_can_take_in_several_old_stage_names(self):
        """一个人有几个旧艺名就有几条要并进来,保留侧当然会重复出现。"""
        rows = self.pairs("1:2:javdb 同一页", "1:6:javdb 同一页")
        self.assertEqual([(row["keep_id"], row["drop_id"]) for row in rows],
                         [(1, 2), (1, 6)])

    def test_an_entity_dropped_earlier_cannot_come_back_as_a_survivor(self):
        """前一对已经把它删了,再拿它当保留侧就是往一条不存在的实体里搬东西。"""
        with self.assertRaises(SystemExit):
            self.pairs("1:2:证据", "2:6:另一段证据")

    def test_an_entity_dropped_earlier_cannot_be_dropped_twice(self):
        with self.assertRaises(SystemExit):
            self.pairs("1:2:证据", "6:2:另一段证据")

    def test_a_survivor_cannot_turn_into_a_drop_later(self):
        """顺序写反:先说留它,后面又要丢它,两条不可能同时成立。"""
        with self.assertRaises(SystemExit):
            self.pairs("1:2:证据", "6:1:另一段证据")

    def test_merging_series_rewrites_the_flat_column(self):
        """只搬关系不改 `asset.series`,卡片上还写着被丢弃的系列名。"""
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(?,'series',?,?,'t','t')",
            [(4, "Night Safari", "night safari"), (5, "旧写法", "旧写法")])
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,code,series)"
            " VALUES(?,'local',?,?,'video',?,?)",
            [(1, "/x/1.mp4", "1.mp4", "AKA-022", "旧写法"),
             (2, "/x/2.mp4", "2.mp4", "AKA-024", "Night Safari")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source)"
            " VALUES(?,?,'series','r18:series')",
            [(1, 5), (2, 4)])
        self.con.commit()
        with self.con:
            counts = apply_rows(self.con, self.pairs("4:5:外部资料页说这两部同属一个系列"))
        self.assertEqual(counts["merged"], 1)
        self.assertEqual(
            [row[0] for row in self.con.execute("SELECT series FROM asset ORDER BY id")],
            ["Night Safari", "Night Safari"])


class PairsOnlyTests(unittest.TestCase):
    """`--pairs-only` 只执行人工点名那批,自动判据整趟不跑。"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name).resolve()
        self.db = fresh_ledger(self.root)
        self.con = sqlite3.connect(self.db)
        self.con.executemany(
            "INSERT INTO asset(id,location,path,name,medium,creator)"
            " VALUES(?,'local',?,?,'video',?)",
            [(1, "/x/1.mp4", "1.mp4", "小桃"), (2, "/x/2.mp4", "2.mp4", "小桃")])
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(?,?,?,?,'t','t')",
            [(10, "creator", "小桃", "小桃"),
             (11, "performer", "小桃", "小桃"),
             (20, "performer", "五十岚星兰", "五十岚星兰"),
             (21, "performer", "五十嵐星蘭", "五十嵐星蘭")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(?,?,?,?,1.0)",
            [(1, 10, "creator", "legacy:asset"), (2, 10, "creator", "legacy:asset"),
             (1, 11, "performer", "performer"),
             (1, 21, "performer", "performer")])
        self.con.commit()
        self.con.close()
        self.review = self.root / "merge.csv"
        self.projections = self.root / "projections.csv"

    def tearDown(self):
        self.tmp.cleanup()

    def invoke(self, *extra):
        args = build_parser().parse_args([
            "--db", str(self.db),
            "--review-csv", str(self.review),
            "--projection-review-csv", str(self.projections),
            *extra,
        ])
        return run(args)

    def entity_ids(self):
        connection = sqlite3.connect(self.db)
        try:
            return [row[0] for row in connection.execute(
                "SELECT id FROM entity ORDER BY id")]
        finally:
            connection.close()

    def test_the_detectors_still_run_without_the_switch(self):
        """对照:不给开关时,同名那组照旧进复核 CSV。"""
        self.assertEqual(self.invoke(), 0)
        self.assertIn("小桃", {row["drop_name"] for row in read_rows(self.review)})

    def test_only_the_named_pair_reaches_the_review_file(self):
        self.assertEqual(self.invoke("--pairs-only", "--pair", "20:21:javdb 同一页"), 0)
        rows = read_rows(self.review)
        self.assertEqual([row["drop_id"] for row in rows], ["21"])
        self.assertEqual(rows[0]["evidence"], "人工指定")
        self.assertEqual(read_rows(self.projections), [])

    def test_the_detected_group_is_not_written_to_the_ledger(self):
        self.assertEqual(self.invoke(
            "--pairs-only", "--pair", "20:21:javdb 同一页",
            "--apply", "--backup", str(self.root / "backup.db")), 0)
        self.assertEqual(self.entity_ids(), [10, 11, 20])

    def test_the_switch_without_a_pair_is_refused(self):
        """只处理人工那批却一对也没给,跑下去就是一趟什么都不做的空写入。"""
        with self.assertRaises(SystemExit) as caught:
            self.invoke("--pairs-only")
        self.assertNotEqual(caught.exception.code, 0)
        self.assertEqual(self.entity_ids(), [10, 11, 20, 21])


class ScrapedPerformerProvenanceTests(unittest.TestCase):
    """刮削链新建的 performer 也是发行元数据,保留它而不是目录名投影出来的 creator。"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.db = fresh_ledger(self.tmp.name)
        self.con = sqlite3.connect(self.db)
        self.con.execute(
            "INSERT INTO asset(id,location,path,name,medium,creator)"
            " VALUES(1,'local','/x/1.mp4','1.mp4','video','三上悠亜')")
        self.con.executemany(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(?,?,'三上悠亜','三上悠亜','t','t')",
            [(1, "creator"), (2, "performer")])
        self.con.commit()

    def tearDown(self):
        self.con.close()
        self.tmp.cleanup()

    def link(self, source):
        self.con.execute(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(1,1,'creator','legacy:asset',1.0)")
        self.con.execute(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(1,2,'performer',?,1.0)", (source,))
        self.con.commit()
        return collect(self.con)[0]

    def test_a_javinizer_source_keeps_the_performer(self):
        for source in ("javinizer:javdb:performer", "javinizer:r18dev:performer",
                       "javinizer:dmm:performer"):
            with self.subTest(source=source):
                self.con.execute("DELETE FROM asset_entity")
                row = self.link(source)
                self.assertEqual((row["keep_kind"], row["keep_id"]), ("performer", 2))
                self.assertEqual(row["evidence"], "发行元数据")

    def test_the_stash_flat_source_still_keeps_the_creator(self):
        row = self.link("performer")
        self.assertEqual((row["keep_kind"], row["keep_id"]), ("creator", 1))


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