"""搜索栏补全:补出来的每一项都点得开,口径与 `/api/items` 同源。 这一域最容易出的不是报错,是两侧漂开:补全按一套判据取词、搜索按另一套判据找片, 于是下拉里给出的名字搜出零条,或者搜得到的标签补不出来。所以这里的断言分两层—— 一层钉具体行为,一层遍历每个补全项拿它去真的搜一次。 """ from __future__ import annotations import sqlite3 import tempfile import unittest from pathlib import Path from peach import web_contract as rm_web from peach.catalog_rules import normalise_code_key from peach.web_entity import SUGGEST_GROUPS, suggest_kinds from support.ledger import fresh_ledger STAMP = "2026-09-07T00:00:00Z" class LedgerFixture(unittest.TestCase): """一份小账本:几部片挂着一位有别名的女优,另有厂牌、系列、标签和事务所。""" 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,code," "catalog_title,disposal) VALUES(?,'local',?,?,'video',100,'2026-01-01',?,?,?)", [(1, r"R:\Media\one.mp4", "one.mp4", None, None, None), (2, r"R:\Media\two.mp4", "two.mp4", None, None, None), (3, r"R:\Media\three.mp4", "three.mp4", None, None, None), (4, r"R:\Media\ABW-123.mp4", "ABW-123.mp4", "ABW-123", "潜入取材の一日", None), (5, r"R:\Media\ABW-999.mp4", "ABW-999.mp4", "ABW-999", "已删除的一部", "trash"), # 既无番号也无发行标题,只有一个相机文件名。 (6, r"R:\Media\IMG_2757_682.MOV", "IMG_2757_682.MOV", None, None, None), # 文件名里有别人的番号,它自己没有番号。 (7, r"R:\Media\old-ABW-recording.mp4", "old-ABW-recording.mp4", None, None, None)], ) self.con.executemany( "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)" " VALUES(?,?,?,?,'t','t')", [(11, "performer", "涼森れむ", "涼森れむ"), (12, "performer", "神雪", "神雪"), (13, "studio", "ラグジュTV", "ラグジュtv"), (14, "series", "ドキュメント", "ドキュメント"), (15, "tag", "足交", "足交"), # 与女优同名的标签是另一个身份的冒充,标签组不该收它。 (16, "tag", "神雪", "神雪"), # 时长标签是筛选控件,不是可搜的词。 (17, "tag", "短片-2分内", "短片-2分内"), (18, "agency", "Capsule Agency", "capsule agency"), # 名下没有任何作品的人和事务所:账本里有身份,按名字搜是空的。 (19, "performer", "涼森みつ", "涼森みつ"), (20, "agency", "Capsule Reserve", "capsule reserve"), # 只出现在回收站那条上的标签。 (21, "tag", "足底", "足底"), # 三个字的标签落在搜索的 FTS 分支上,两个字的落在 LIKE 分支上。 (22, "tag", "巨乳系", "巨乳系"), # 同一个字头的一批标签,用来看每组的条数上限。 (23, "tag", "足元", "足元"), (24, "tag", "足指", "足指"), (25, "tag", "足首", "足首"), (26, "tag", "足裏", "足裏"), (27, "tag", "足音", "足音"), # 「MO」是 MOODYZ 的词首、SO MODEL AGENT 里一个词的词首, # 而在 kemonokai 中间。两个字母的拉丁输入只该命中前两个。 (28, "studio", "MOODYZ", "moodyz"), (29, "creator", "kemonokai", "kemonokai"), (30, "agency", "SO MODEL AGENT", "so model agent")], ) self.con.executemany( "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source)" " VALUES(?,?,?,'test')", [(11, "凉森", "凉森"), (18, "カプセル", "カプセル")], ) self.con.execute( "INSERT INTO entity_search_term(entity_id,term,purpose,source,created_at)" " VALUES(11,'Remu','discovery','test',?)", (STAMP,)) self.con.executemany( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(?,?,?,'test',1.0)", [(1, 11, "performer"), (2, 11, "performer"), (3, 11, "performer"), (1, 12, "performer"), (1, 13, "studio"), (2, 13, "studio"), (2, 14, "series"), (1, 15, "tag"), (2, 15, "tag"), (3, 16, "tag"), (3, 17, "tag"), (5, 21, "tag"), (1, 22, "tag"), (2, 22, "tag"), (1, 23, "tag"), (1, 24, "tag"), (1, 25, "tag"), (1, 26, "tag"), (1, 27, "tag"), (3, 28, "studio"), (3, 29, "creator")], ) self.con.executemany( "INSERT INTO entity_membership(member_id,agency_id,source,checked_at)" " VALUES(?,?,'test',?)", [(11, 18, STAMP), (12, 18, STAMP), (19, 20, STAMP), (29, 30, STAMP)], ) self.con.commit() self.contract = rm_web.WebContract(self.db) def suggest(self, query, limit=5): return rm_web.q_suggest(self.contract, query, limit) def group(self, query, kind, limit=5): for group in self.suggest(query, limit)["groups"]: if group["kind"] == kind: return group["items"] return [] def values(self, query, kind, limit=5): return [item["value"] for item in self.group(query, kind, limit)] def total(self, query): return rm_web.q_items(self.contract, {"q": query, "limit": "10"})["total"] class SuggestTests(LedgerFixture): """补全按输入给出馆藏里的身份与作品。""" def test_a_canonical_name_completes_from_its_first_characters(self): self.assertEqual(self.values("涼森", "performer"), ["涼森れむ"]) def test_the_suggestion_carries_how_many_works_are_behind_it(self): self.assertEqual(self.group("涼森", "performer")[0]["n"], 3) def test_an_alias_completes_to_the_canonical_name(self): """输入的写法是别名,补出来的必须是这个人在馆藏里的统称。""" self.assertEqual(self.values("凉森", "performer"), ["涼森れむ"]) def test_a_hit_on_an_alias_says_which_writing_matched(self): """不说明凭什么,用户会以为补错了人。""" self.assertEqual(self.group("凉森", "performer")[0]["matched"], "凉森") def test_a_hit_on_the_canonical_name_says_nothing_extra(self): self.assertEqual(self.group("涼森", "performer")[0]["matched"], "") def test_a_search_term_reaches_the_same_person(self): self.assertEqual(self.values("Remu", "performer"), ["涼森れむ"]) def test_a_person_with_no_works_never_shows_up(self): """`涼森みつ` 的名字也以「涼森」开头,但她名下一部片都没有。""" self.assertNotIn("涼森みつ", self.values("涼森", "performer")) def test_studios_and_series_have_their_own_groups(self): self.assertEqual(self.values("ラグジュ", "studio"), ["ラグジュTV"]) self.assertEqual(self.values("ドキュ", "series"), ["ドキュメント"]) def test_an_agency_counts_the_works_of_its_members(self): self.assertEqual(self.group("Capsule", "agency")[0]["n"], 3) def test_an_agency_reachable_only_by_its_former_name_still_completes(self): self.assertEqual(self.values("カプセル", "agency"), ["Capsule Agency"]) def test_an_agency_whose_roster_has_no_works_never_shows_up(self): self.assertNotIn("Capsule Reserve", self.values("Capsule", "agency")) def test_a_tag_completes_like_any_other_identity(self): self.assertIn("足交", self.values("足交", "tag")) def test_a_tag_that_is_really_a_performer_name_stays_out(self): self.assertEqual(self.values("神雪", "tag"), []) self.assertEqual(self.values("神雪", "performer"), ["神雪"]) def test_a_length_tag_is_a_filter_control_not_a_word(self): self.assertEqual(self.values("短片", "tag"), []) def test_a_tag_only_seen_in_the_trash_stays_out(self): self.assertEqual(self.values("足底", "tag"), []) def test_a_code_completes_to_a_work_that_can_be_opened(self): items = self.group("ABW", "asset") self.assertEqual(items[0]["value"], "ABW-123") self.assertEqual(items[0]["id"], 4) def test_a_work_in_the_trash_is_not_offered(self): self.assertNotIn("ABW-999", self.values("ABW", "asset")) def test_a_work_completes_by_its_release_title_too(self): self.assertEqual(self.values("潜入", "asset"), ["ABW-123"]) def test_a_hit_in_the_file_name_ranks_last(self): """`old-ABW-recording.mp4` 里有别人的番号。它排在真的叫这个番号的那条之后。""" self.assertEqual(self.values("ABW", "asset"), ["ABW-123", "old-ABW-recording"]) def test_local_extras_do_not_supply_work_suggestions_or_performer_counts(self): self.con.executemany( "INSERT INTO asset(id,location,path,name,medium,size,first_seen,code) " "VALUES(?,'local',?,?,'video',100,'2026-01-01','ABW-777')", [(90, r"R:\Media\ABW-777-clip2.mp4", "ABW-777-clip2.mp4"), (91, r"R:\Media\ABW-777\Extras\Disc 1\making.mp4", "making.mp4")]) self.con.executemany( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) " "VALUES(?,11,'performer','test',1.0)", [(90,), (91,)]) self.con.commit() self.assertEqual(self.values("ABW-777", "asset"), []) self.assertEqual(self.group("涼森", "performer")[0]["n"], 3) def test_a_file_name_loses_its_extension(self): """`.MOV` 是存储事实,不是这部片叫什么。""" self.assertEqual(self.values("IMG", "asset"), ["IMG_2757_682"]) def test_an_empty_query_offers_nothing(self): """空输入下该出现的是搜索记录和推荐,不是补全。""" self.assertEqual(self.suggest("")["groups"], []) def test_groups_come_back_in_the_declared_order(self): order = [kind for kind, _ in SUGGEST_GROUPS] kinds = [group["kind"] for group in self.suggest("涼")["groups"]] self.assertEqual(kinds, sorted(kinds, key=order.index)) def test_each_group_carries_its_own_label(self): labels = {group["kind"]: group["label"] for group in self.suggest("涼森")["groups"]} self.assertEqual(labels["performer"], "女优") def test_a_group_never_exceeds_the_asked_for_size(self): """六个标签以「足」开头,一组只给得下五个。""" self.assertEqual(len(self.group("足", "tag", limit=5)), 5) self.assertEqual(len(self.group("足", "tag", limit=2)), 2) def test_a_prefix_hit_outranks_a_hit_in_the_middle(self): """「足交」是前缀命中,其余几个是同一个字打头的同辈。""" self.assertEqual(self.values("足交", "tag")[0], "足交") def test_every_suggestion_finds_something_when_actually_searched(self): """补全与搜索同源的唯一验收方式:拿补出来的词去真的搜一次。 逐条搜是这条测试的成本,也是它的意义——两侧判据漂开时,别的断言都还绿着。 """ for query in ("涼森", "凉森", "Remu", "ラグジュ", "ドキュ", "Capsule", "カプセル", "足", "足交", "巨乳", "ABW", "潜入", "MO", "mon", "IMG"): for group in self.suggest(query)["groups"]: for item in group["items"]: with self.subTest(query=query, kind=group["kind"], value=item["value"]): self.assertGreater( self.total(item["value"]), 0, f"补全给出 {item['value']},搜它却是空的") def test_a_suggested_work_opens_the_row_it_points_at(self): for item in self.group("ABW", "asset"): self.assertEqual(rm_web.q_item(self.contract, item["id"])["id"], item["id"]) class SuggestCardTests(LedgerFixture): """下拉栏画得出每一行:人的脸、事务所与近作,作品的小图与署名,每类的总数。""" def setUp(self): super().setUp() root = Path(self.tmp.name) covers = root / "covers" covers.mkdir() (covers / "ABW-123.jpg").write_bytes(b"\xff\xd8\xff\xd9") snapshot = root / "two.jpg" snapshot.write_bytes(b"\xff\xd8\xff\xd9") # ABW-123 有官方封面,two.mp4 只有抽帧,one/three 两样都没有。 self.con.execute("INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(4,11,'performer','test',1.0)") self.con.execute("UPDATE asset SET snapshot_path=? WHERE id=2", (str(snapshot),)) self.con.commit() self.contract = rm_web.WebContract(self.db, cover_root=covers) def test_a_person_row_names_her_agency(self): self.assertEqual(self.group("涼森", "performer")[0]["agency"], "Capsule Agency") def test_a_person_row_brings_only_works_that_have_a_picture(self): works = self.group("涼森", "performer")[0]["works"] self.assertEqual({work["id"] for work in works}, {2, 4}) cover = next(work for work in works if work["id"] == 4) self.assertTrue(cover["has_cover"]) self.assertEqual(cover["code"], "ABW-123") def test_a_face_that_cannot_be_drawn_is_not_offered(self): """没装实体图、代表作也没有抽帧:页面直接画首字母,不先出一张等 404 的图。""" item = self.group("神雪", "performer")[0] self.assertFalse(item["has_image"]) self.assertIsNone(item["rep"]) def test_a_suggested_work_carries_its_credit_and_picture(self): item = self.group("ABW", "asset")[0] self.assertEqual(item["who"], "涼森れむ") self.assertTrue(item["card"]["has_cover"]) def test_a_suggested_work_without_a_picture_has_no_card(self): self.assertIsNone(self.group("IMG", "asset")[0]["card"]) def test_each_group_says_how_many_it_matched_in_all(self): """六个标签以「足」开头,一组只列五个,总数仍是六。""" tags = next(group for group in self.suggest("足")["groups"] if group["kind"] == "tag") self.assertEqual((len(tags["items"]), tags["total"]), (5, 6)) def test_the_work_total_counts_every_hit(self): works = next(group for group in self.suggest("ABW", limit=1)["groups"] if group["kind"] == "asset") self.assertEqual((len(works["items"]), works["total"]), (1, 2)) def test_asking_for_one_kind_returns_only_that_kind(self): groups = rm_web.q_suggest(self.contract, "涼", 20, ("performer",))["groups"] self.assertEqual([group["kind"] for group in groups], ["performer"]) def test_an_unknown_kind_is_dropped(self): self.assertEqual(suggest_kinds("asset,bogus,performer"), ("performer", "asset")) def test_an_agency_row_carries_its_site_mark_slot(self): """事务所没有标识文件,门面是官网圆标;这份夹具里没有官网,那一格就是空的。""" self.assertIsNone(self.group("Capsule", "agency")[0]["mark"]) class AgencyGroupTests(LedgerFixture): """事务所这一组的排序与计数。 事务所自己不挂作品,名下作品数是跨成员的一次去重计数;排序先看整串前缀命中, 再看作品数,最后按名字。单个字母在真实账本上能命中十几家事务所,这一组的 先后和总数由这里钉住。 """ def setUp(self): super().setUp() self.con.executemany( "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)" " VALUES(?,?,?,?,'t','t')", [(40, "agency", "Alpha Talent", "alpha talent"), (41, "performer", "星空", "星空"), (42, "agency", "Blue Agent", "blue agent"), (43, "performer", "月野", "月野"), # 唯一的成员只在回收站那条上出现过:名下没有可见作品。 (44, "agency", "Amber Empty", "amber empty"), (45, "performer", "雨宮", "雨宮")], ) self.link(4, 41) self.link(2, 43) self.link(5, 45) self.con.executemany( "INSERT INTO entity_membership(member_id,agency_id,source,checked_at)" " VALUES(?,?,'test',?)", [(41, 40, STAMP), (43, 42, STAMP), (45, 44, STAMP)], ) self.con.commit() def link(self, asset_id, entity_id, source="test"): self.con.execute( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(?,?,'performer',?,1.0)", (asset_id, entity_id, source)) self.con.commit() def capsule_works(self): return self.group("Capsule", "agency")[0]["n"] def test_agencies_rank_by_prefix_hit_then_works_then_name(self): """单个字母比词首:`Alpha Talent` 整串以它开头,其余三家是某个词的词首。 前缀命中压过作品更多的 `Capsule Agency`;同为词首命中的按作品数,作品数 相同的按名字。 """ self.assertEqual( [(item["value"], item["n"]) for item in self.group("a", "agency")], [("Alpha Talent", 1), ("Capsule Agency", 3), ("Blue Agent", 1), ("SO MODEL AGENT", 1)]) def test_the_total_counts_every_agency_with_works(self): """五家命中「a」。`Amber Empty` 名下没有可见作品,不进列表,也不进总数。""" agencies = next(group for group in self.suggest("a", limit=1)["groups"] if group["kind"] == "agency") self.assertEqual((len(agencies["items"]), agencies["total"]), (1, 4)) def test_the_same_work_registered_by_another_source_counts_once(self): self.link(1, 11, source="scrape") self.assertEqual(self.capsule_works(), 3) def test_a_member_work_in_the_trash_is_not_counted(self): self.link(5, 12) self.assertEqual(self.capsule_works(), 3) def test_a_member_picture_is_not_counted(self): self.con.execute( "INSERT INTO asset(id,location,path,name,medium,size,first_seen)" " VALUES(8,'local',?,'still.jpg','image',100,'2026-01-01')", (r"R:\Media\still.jpg",)) self.link(8, 12) self.assertEqual(self.capsule_works(), 3) class ShortLatinQueryTests(LedgerFixture): """两个字母的拉丁输入比词首,不比子串。 子串比出来的多半是别的词中间那两个字母:真实账本上「MO」会捞出 `kemonokai`、 「Pr」会捞出 `chf3_prob4`,而用户在打的是 MOODYZ 和 Prestige。汉字和假名没有 分词空格,那一侧仍按子串,否则「凉森」这种两字输入连自己都补不出来。 """ def test_a_two_letter_query_completes_a_name_that_starts_with_it(self): self.assertEqual(self.values("MO", "studio"), ["MOODYZ"]) def test_a_two_letter_query_reaches_the_head_of_an_inner_word(self): self.assertEqual(self.values("MO", "agency"), ["SO MODEL AGENT"]) def test_a_two_letter_query_skips_the_middle_of_a_word(self): self.assertEqual(self.values("MO", "creator"), []) def test_a_third_letter_brings_the_substring_match_back(self): self.assertEqual(self.values("mon", "creator"), ["kemonokai"]) def test_an_extension_in_a_file_name_is_not_a_word_head(self): """`IMG_2757_682.MOV` 里的 `.MOV` 不该让这条片出现在「MO」的补全里。""" self.assertEqual(self.values("MO", "asset"), []) def test_two_han_characters_still_match_inside_a_name(self): self.assertEqual(self.values("乳系", "tag"), ["巨乳系"]) def test_two_kana_still_match_inside_a_name(self): self.assertEqual(self.values("ュメ", "series"), ["ドキュメント"]) class ShortQueryReachesEveryKindTests(LedgerFixture): """搜索的两条分支必须覆盖同样的 kind。 三字以上走 FTS,它索引的 `entities` 聚合作品名下的全部实体、不挑 kind;两字以内 走 LIKE。两边认的 kind 不一样时,同一个标签写三个字搜得到、写两个字搜不到, 用户看到的是搜索时灵时不灵,而补全会照样把那个词补出来。 """ def test_a_two_character_tag_query_finds_its_works(self): self.assertEqual(self.total("足交"), 2) def test_a_three_character_tag_query_finds_its_works(self): self.assertEqual(self.total("巨乳系"), 2) def test_both_branches_agree_on_the_same_tag(self): """`巨乳系` 三个字走 FTS,`巨乳` 两个字走 LIKE,命中的是同一批作品。""" self.assertEqual(self.total("巨乳"), self.total("巨乳系")) def test_a_two_character_series_query_finds_its_works(self): self.assertEqual(self.total("ドキ"), 1) def test_a_two_character_performer_query_still_works(self): self.assertEqual(self.total("凉森"), 3) class OneWorkOneSlotTests(LedgerFixture): """同一部作品在下拉栏里只占一格。 真实账本上一部片常有几条来源挂着同一位女优(`r18` 与 `javinizer` 各记一条),同一个 番号也常存着几个文件。逐行取的话,一个人的四张近作里会有两张是同一张封面,视频组 也会并排摆出三张一样的图。 """ def setUp(self): super().setUp() root = Path(self.tmp.name) covers = root / "covers" covers.mkdir() for code in ("ABW-123", "ABW-124"): (covers / f"{code}.jpg").write_bytes(b"\xff\xd8\xff\xd9") frame = root / "ssis-001-b.jpg" frame.write_bytes(b"\xff\xd8\xff\xd9") self.con.executemany( "INSERT INTO asset(id,location,path,name,medium,size,first_seen,code," "catalog_title,snapshot_path) VALUES(?,'local',?,?,'video',100,'2026-01-01',?,?,?)", [# ABW-123 的另外两个文件,一个写法规范,一个是下划线小写。 (8, r"R:\Media\ABW-123-B.mp4", "ABW-123-B.mp4", "ABW-123", "潜入取材の一日", None), (9, r"R:\Media\abw_123.mp4", "abw_123.mp4", "abw_123", None, None), (10, r"R:\Media\ABW-124.mp4", "ABW-124.mp4", "ABW-124", "二日目", None), # SSIS-001 没有封面,两个文件里只有后入库的那个抽过帧。 (11, r"R:\Media\SSIS-001.mp4", "SSIS-001.mp4", "SSIS-001", None, None), (12, r"R:\Media\SSIS-001-B.mp4", "SSIS-001-B.mp4", "SSIS-001", None, str(frame))], ) self.con.executemany( "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)" " VALUES(?,11,'performer',?,1.0)", [(4, "test"), (8, "test"), (9, "test"), (10, "test"), (10, "r18"), (11, "test"), (12, "test")], ) self.con.commit() self.contract = rm_web.WebContract(self.db, cover_root=covers) def work_keys(self): works = self.group("涼森", "performer")[0]["works"] return [normalise_code_key(work["code"]) for work in works] def test_a_work_credited_by_two_sources_takes_one_slot(self): self.assertEqual(self.work_keys().count("ABW-124"), 1) def test_every_file_of_one_code_takes_one_slot(self): """ABW-123 存着三个文件,其中一个番号写成 `abw_123`,画出来都是同一张封面。""" self.assertEqual(self.work_keys().count("ABW-123"), 1) def test_a_work_shows_up_through_the_file_that_has_a_frame(self): """SSIS-001 没有封面,出面的必须是抽过帧的那个文件,否则整部作品掉出近作。""" works = self.group("涼森", "performer")[0]["works"] self.assertEqual([work["id"] for work in works], [12, 10, 4]) self.assertTrue(works[0]["has_thumb"]) def test_the_video_group_lists_each_work_once(self): self.assertEqual(self.values("ABW", "asset"), ["ABW-124", "ABW-123", "old-ABW-recording"]) def test_the_video_group_opens_the_file_written_the_canonical_way(self): item = next(item for item in self.group("ABW", "asset") if item["value"] == "ABW-123") self.assertEqual(item["id"], 4) def test_the_video_total_counts_works_not_files(self): works = next(group for group in self.suggest("ABW", limit=1)["groups"] if group["kind"] == "asset") self.assertEqual((len(works["items"]), works["total"]), (1, 3)) if __name__ == "__main__": unittest.main()