"""label 属于片商(ADR-0049);上级可以一级套一级,片商合计旗下每一级的作品(ADR-0051)。""" import sqlite3 import tempfile import unittest from pathlib import Path from peach import web_contract as rm_web from peach.entities import merge_entity from support.ledger import fresh_ledger from scripts.install_label_makers import install, plan class LabelMakerTests(unittest.TestCase): """KMP 旗下两个 label、妄想族旗下一个,妄想族自己在账本里还没有实体。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) root = Path(self.tmp.name).resolve() self.db = fresh_ledger(root) self.con = sqlite3.connect(self.db) self.con.row_factory = sqlite3.Row self.addCleanup(self.con.close) self.con.executemany( "INSERT INTO asset(id,location,path,name,medium,size,first_seen)" " VALUES(?,'local',?,?,'video',100,'2026-01-01')", [(1, r"R:\Media\a.mp4", "a.mp4"), (2, r"R:\Media\b.mp4", "b.mp4"), (3, r"R:\Media\c.mp4", "c.mp4")]) self.con.executemany( "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)" " VALUES(?,'studio',?,?,'t','t')", [(5607, "K M Produce", "k m produce"), (5569, "BAZOOKA", "bazooka"), (5638, "S級素人", "s級素人"), (8248, "ABC/妄想族", "abc/妄想族")]) self.con.executemany( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(?,?,'studio','test',1.0)", [(1, 5569), (2, 5607), (3, 8248)]) self.con.commit() self.rows = [ {"label": "BAZOOKA", "maker": "K M Produce", "evidence": "KMP 名录"}, {"label": "S級素人", "maker": "K M Produce", "evidence": "KMP 名录"}, {"label": "ABC/妄想族", "maker": "妄想族", "evidence": "妄想族名录"}, ] logos = root / "logos"; logos.mkdir() avatars = root / "avatars"; avatars.mkdir() self.contract = rm_web.WebContract(self.db, avatar_root=avatars, logo_root=logos, cover_root=root / "covers") def apply(self, rows=None, *, create_makers=True): planned = plan(self.con, rows or self.rows, create_makers=create_makers) with self.con: install(self.con, planned, source="t.csv") return planned def maker_of(self, label_id): row = self.con.execute( "SELECT e.canonical_name FROM label_maker lm JOIN entity e ON e.id=lm.maker_id" " WHERE lm.label_id=?", (label_id,)).fetchone() return row[0] if row else None def test_a_maker_missing_from_the_ledger_is_only_created_when_asked(self): planned = plan(self.con, self.rows, create_makers=False) self.assertEqual([item["action"] for item in planned], ["insert", "insert", "skip"]) self.assertIn("--create-makers", planned[2]["reason"]) self.apply() self.assertEqual(self.maker_of(8248), "妄想族") def test_rerunning_the_same_table_writes_nothing(self): self.apply() self.assertEqual({item["action"] for item in self.apply()}, {"skip"}) self.assertEqual(self.con.execute("SELECT count(*) FROM label_maker").fetchone()[0], 3) def test_a_label_changing_hands_is_overwritten_not_added(self): self.apply() planned = self.apply([{"label": "BAZOOKA", "maker": "妄想族", "evidence": "转手"}]) self.assertEqual(planned[0]["action"], "update") self.assertEqual(self.maker_of(5569), "妄想族") def test_a_maker_can_itself_sit_under_another_maker(self): """ADR-0051:妄想族 → 素人ホイホイ → 素人ホイホイpower 这样的三层照收。""" self.apply() planned = self.apply([{"label": "K M Produce", "maker": "妄想族", "evidence": "x"}, {"label": "S級素人", "maker": "BAZOOKA", "evidence": "x"}]) self.assertEqual([item["action"] for item in planned], ["insert", "update"]) self.assertEqual(self.maker_of(5607), "妄想族") self.assertEqual(self.maker_of(5638), "BAZOOKA") def test_a_chain_that_loops_back_is_refused(self): self.apply() planned = plan(self.con, [{"label": "K M Produce", "maker": "BAZOOKA", "evidence": "x"}], create_makers=True) self.assertEqual(planned[0]["action"], "skip") self.assertIn("成环", planned[0]["reason"]) same_batch = plan(self.con, [{"label": "ABC/妄想族", "maker": "S級素人", "evidence": "x"}, {"label": "S級素人", "maker": "ABC/妄想族", "evidence": "x"}], create_makers=True) self.assertEqual([item["action"] for item in same_batch], ["skip", "skip"]) def test_one_table_builds_a_middle_layer_missing_from_the_ledger(self): """中间那一级账本里还没有:同一张表里它既是上级又是 label,先建出来再挂上去。""" planned = self.apply([{"label": "BAZOOKA", "maker": "素人ホイホイ", "evidence": "x"}, {"label": "素人ホイホイ", "maker": "妄想族", "evidence": "x"}]) self.assertEqual([item["action"] for item in planned], ["insert", "insert"]) self.assertEqual(self.maker_of(5569), "素人ホイホイ") middle = self.con.execute( "SELECT id FROM entity WHERE canonical_name='素人ホイホイ'").fetchall() self.assertEqual(len(middle), 1) self.assertEqual(self.maker_of(middle[0][0]), "妄想族") def test_a_label_missing_from_the_ledger_is_not_created_unless_it_is_also_a_maker(self): planned = plan(self.con, [{"label": "無名", "maker": "K M Produce", "evidence": "x"}], create_makers=True) self.assertEqual(planned[0]["action"], "skip") def test_each_page_names_only_the_neighbouring_layers(self): """资料页只链直接上级和直接下级;作品详情列整条链,从近到远。""" self.apply(self.rows + [{"label": "K M Produce", "maker": "妄想族", "evidence": "x"}]) middle = rm_web.q_entity(self.contract, {"kind": "studio", "name": "K M Produce"}) self.assertEqual(middle["maker"]["name"], "妄想族") self.assertEqual([label["name"] for label in middle["labels"]], ["BAZOOKA", "S級素人"]) bottom = rm_web.q_entity(self.contract, {"kind": "studio", "name": "BAZOOKA"}) self.assertEqual(bottom["maker"]["name"], "K M Produce") studio = rm_web.q_item(self.contract, 1)["entity_refs"]["studio"] self.assertEqual([maker["name"] for maker in studio[0]["makers"]], ["K M Produce", "妄想族"]) def test_the_label_page_points_at_its_maker_and_keeps_its_own_works(self): self.apply() page = rm_web.q_entity(self.contract, {"kind": "studio", "name": "BAZOOKA"}) self.assertEqual(page["maker"]["name"], "K M Produce") self.assertEqual(page["asset_count"], 1) self.assertEqual(page["labels"], []) def test_the_maker_page_counts_its_labels_works_as_its_own(self): """ADR-0051 修订:旗下 label 出的片都归片商,名册每格的数是那个 label 的合计。""" self.apply() page = rm_web.q_entity(self.contract, {"kind": "studio", "name": "K M Produce"}) self.assertEqual([(label["k"], label["n"]) for label in page["labels"]], [("BAZOOKA", 1), ("S級素人", 0)]) self.assertEqual(page["asset_count"], 2) self.assertIsNone(page["maker"]) items = rm_web.q_items(self.contract, {"studio": "K M Produce"}) self.assertEqual(sorted(item["id"] for item in items["items"]), [1, 2]) def test_vanished_works_drop_out_of_the_counts_and_trashed_ones_stay(self): """已消失的作品文件不在盘上,不进资料页和名册的作品数;回收站里的照旧算。""" self.apply() self.con.execute("UPDATE asset SET disposal='vanished' WHERE id=1") self.con.execute("UPDATE asset SET disposal='trash' WHERE id=2") self.con.commit() page = rm_web.q_entity(self.contract, {"kind": "studio", "name": "K M Produce"}) self.assertEqual([(label["k"], label["n"]) for label in page["labels"]], [("BAZOOKA", 0), ("S級素人", 0)]) self.assertEqual(page["asset_count"], 1) def test_a_maker_with_no_works_of_its_own_counts_every_layer_below(self): """妄想族 → K M Produce → BAZOOKA:妄想族自己一部片都没挂,合计是三层的并集。""" self.apply(self.rows + [{"label": "K M Produce", "maker": "妄想族", "evidence": "x"}]) top = rm_web.q_entity(self.contract, {"kind": "studio", "name": "妄想族"}) self.assertEqual(top["asset_count"], 3) self.assertEqual([(label["k"], label["n"]) for label in top["labels"]], [("K M Produce", 2), ("ABC/妄想族", 1)]) def test_the_maker_representative_can_be_a_labels_work(self): """BAZOOKA 那部最大、归 K M Produce;ABC/妄想族 那部更大,但不归它。""" self.apply() self.con.executemany("UPDATE asset SET snapshot_path='s.jpg',size=? WHERE id=?", [(500, 1), (200, 2), (900, 3)]) self.con.commit() page = rm_web.q_entity(self.contract, {"kind": "studio", "name": "K M Produce"}) self.assertEqual(page["representative_asset_id"], 1) def test_the_maker_photos_and_sample_sets_include_its_labels(self): """图集、总数、分页和番号样张集都连同旗下 label 一起算,ABC/妄想族 的不算。""" self.apply() for asset_id, studio in ((21, 5569), (22, 5607), (23, 8248)): path = rf"R:\Media\stills\{asset_id}.jpg" self.con.execute( "INSERT INTO asset(id,location,path,name,medium,size,first_seen)" " VALUES(?,'local',?,?,'image',10,'2026-01-01')", (asset_id, path, f"{asset_id}.jpg")) self.con.execute( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(?,?,'studio','test',1.0)", (asset_id, studio)) for asset_id, code in ((1, "SSIS-057"), (2, "SSIS-058"), (3, "IPX-001")): self.con.execute("UPDATE asset SET code=? WHERE id=?", (code, asset_id)) self.con.execute( "INSERT INTO code_sample_image(code,position,url,site,source,fetched_at)" " VALUES(?,1,'https://pics.dmm.co.jp/x.jpg','dmm','test','2026-09-25T00:00:00Z')", (code,)) self.con.commit() photos = rm_web.q_entity_photos(self.contract, {"kind": "studio", "name": "K M Produce"}) self.assertEqual([(item["kind"], item["id"], item["n"]) for item in photos["sets"]], [("code", "code:SSIS-057", 1), ("code", "code:SSIS-058", 1), ("dir", 21, 2)]) self.assertEqual((photos["total"], photos["sample_total"]), (2, 2)) self.assertEqual([item["id"] for item in photos["items"]], [21, 22]) def test_the_index_lists_makers_with_their_totals_and_folds_labels_in(self): """索引页只列最上层,数的是合计;搜名字时 label 照常搜得到。""" self.apply() index = rm_web.q_index(self.contract, "studios") self.assertEqual([(row["k"], row["n"]) for row in index["items"]], [("K M Produce", 2), ("妄想族", 1)]) found = rm_web.q_index(self.contract, "studios", q="BAZOOKA") self.assertEqual([(row["k"], row["n"]) for row in found["items"]], [("BAZOOKA", 1)]) def test_the_index_picks_each_makers_representative_from_every_layer(self): """K M Produce 那格取 BAZOOKA 那部;妄想族自己没挂片,取 ABC/妄想族 那部。 K M Produce 名下最大的那部没有截图,不当代表作。搜名字时 label 那格取它自己的。 """ self.apply() self.con.executemany("UPDATE asset SET snapshot_path='s.jpg',size=? WHERE id=?", [(500, 1), (200, 2), (900, 3)]) self.con.execute( "INSERT INTO asset(id,location,path,name,medium,size,first_seen)" " VALUES(4,'local',?,'d.mp4','video',999,'2026-01-01')", (r"R:\Media\d.mp4",)) self.con.execute( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(4,5607,'studio','test',1.0)") self.con.commit() index = rm_web.q_index(self.contract, "studios") self.assertEqual([(row["k"], row["rep"]) for row in index["items"]], [("K M Produce", 1), ("妄想族", 3)]) found = rm_web.q_index(self.contract, "studios", q="妄想族") self.assertEqual([(row["k"], row["rep"]) for row in found["items"]], [("ABC/妄想族", 3), ("妄想族", 3)]) def test_the_detail_carries_the_maker_beside_the_label(self): self.apply() studio = rm_web.q_item(self.contract, 3)["entity_refs"]["studio"] self.assertEqual([ref["name"] for ref in studio], ["ABC/妄想族"]) self.assertEqual([maker["name"] for maker in studio[0]["makers"]], ["妄想族"]) self.assertNotIn("makers", rm_web.q_item(self.contract, 2)["entity_refs"]["studio"][0]) def test_merging_a_label_carries_its_maker_over(self): self.apply() self.con.execute( "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)" " VALUES(9000,'studio','BAZOOKA!','bazooka!','t','t')") merge_entity(self.con, target_id=9000, source_id=5569, source_name="BAZOOKA", alias_source="test") self.assertEqual(self.maker_of(9000), "K M Produce") self.assertIsNone(self.maker_of(5569)) def test_merging_a_maker_moves_its_labels(self): self.apply() self.con.execute( "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)" " VALUES(9001,'studio','KMP','kmp','t','t')") merge_entity(self.con, target_id=9001, source_id=5607, source_name="K M Produce", alias_source="test") self.assertEqual({self.maker_of(5569), self.maker_of(5638)}, {"KMP"}) def test_merging_a_label_into_its_own_maker_drops_the_self_link(self): self.apply() merge_entity(self.con, target_id=5607, source_id=5569, source_name="BAZOOKA", alias_source="test") self.assertIsNone(self.maker_of(5607)) self.assertEqual(self.con.execute( "SELECT count(*) FROM label_maker WHERE label_id=maker_id").fetchone()[0], 0) def test_merging_the_top_of_a_chain_into_its_bottom_breaks_the_loop(self): """BAZOOKA → K M Produce → 妄想族,把妄想族并进 BAZOOKA:K M Produce 改挂 BAZOOKA,BAZOOKA 自己那一行上级会绕回它自己,删掉。""" self.apply(self.rows + [{"label": "K M Produce", "maker": "妄想族", "evidence": "x"}]) top = self.con.execute("SELECT id FROM entity WHERE canonical_name='妄想族'").fetchone()[0] merge_entity(self.con, target_id=5569, source_id=top, source_name="妄想族", alias_source="test") self.assertIsNone(self.maker_of(5569)) self.assertEqual(self.maker_of(5607), "BAZOOKA") if __name__ == "__main__": unittest.main()