Ctrl K
ComunidadGuías…

tests/test_search_suggest.py

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

Archivo original ↗Preguntar a KiBot
"""搜索栏补全:补出来的每一项都点得开,口径与 `/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()