Ctrl K
ComunidadGuías…

tests/test_agency_entity.py

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

Archivo original ↗Preguntar a KiBot
"""事务所是实体:有资料页、有成员、搜得到。"""
import re
import shutil
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 peach.migrations import needs_foreign_keys_off, upgrade
from peach.web_entity import PROFILE_KINDS, scope_predicate
from scripts.dedupe_entity_links import collect as dedupe_plan
from scripts.dedupe_entity_links import destination
from scripts.install_agencies import collect as agency_plan
from scripts.install_agencies import apply_rows as install_agencies
from scripts.install_agencies import split_name
from support.ledger import fresh_ledger

ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = ROOT / "migrations"
STAMP = "2026-09-05T00:00:00Z"


class ForeignKeysOffMarkerTests(unittest.TestCase):
    """重建父表的迁移要能关掉外键,而且只有它能。"""

    def test_the_marker_is_read_from_the_leading_comment_block(self):
        self.assertTrue(needs_foreign_keys_off(
            "-- peach:foreign_keys=off\n-- 说明\nCREATE TABLE t(id INTEGER);\n"))

    def test_a_migration_without_the_marker_keeps_foreign_keys_on(self):
        self.assertFalse(needs_foreign_keys_off("-- 说明\nCREATE TABLE t(id INTEGER);\n"))

    def test_the_marker_after_the_first_statement_does_not_count(self):
        """pragma 要在整个文件开跑之前决定,写在语句之间等于没写。"""
        self.assertFalse(needs_foreign_keys_off(
            "CREATE TABLE t(id INTEGER);\n-- peach:foreign_keys=off\n"))

    def test_rebuilding_entity_keeps_every_child_row(self):
        """外键开着时 DROP TABLE entity 会 CASCADE 删空子表,这条迁移不能那样。"""
        with tempfile.TemporaryDirectory() as tmp:
            older = Path(tmp).resolve() / "migrations"
            older.mkdir()
            for path in sorted(MIGRATIONS.glob("[0-9][0-9][0-9][0-9]_*.sql")):
                if path.name < "0025":
                    shutil.copy2(path, older / path.name)
            db = Path(tmp).resolve() / "ledger.db"
            upgrade(db, older)
            con = sqlite3.connect(db)
            con.execute("INSERT INTO entity(id,kind,canonical_name,normalized_name,"
                        "created_at,updated_at) VALUES(7,'performer','阿花','阿花','t','t')")
            con.execute("INSERT INTO entity_alias(entity_id,alias,normalized_alias,source)"
                        " VALUES(7,'Hana','hana','test')")
            con.execute("INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,"
                        "created_at,updated_at) VALUES(7,'social','X','https://x.com/h','x.com','t','t')")
            con.commit()
            con.close()

            upgrade(db, MIGRATIONS)

            con = sqlite3.connect(db)
            try:
                counts = (con.execute("SELECT count(*) FROM entity_alias").fetchone()[0],
                          con.execute("SELECT count(*) FROM entity_link").fetchone()[0])
                name = con.execute("SELECT canonical_name FROM entity WHERE id=7").fetchone()[0]
                broken = len(con.execute("PRAGMA foreign_key_check").fetchall())
            finally:
                # Windows 上临时目录删不掉带着打开句柄的库文件,断言失败也要先关。
                con.close()
        self.assertEqual(counts, (1, 1))
        self.assertEqual(name, "阿花")
        self.assertEqual(broken, 0)

    def test_the_search_index_trigger_survives_the_rebuild(self):
        """DROP TABLE 把挂在 entity 上的触发器一起带走,迁移必须补回来。"""
        with tempfile.TemporaryDirectory() as tmp:
            db = Path(tmp).resolve() / "ledger.db"
            upgrade(db, MIGRATIONS)
            con = sqlite3.connect(db)
            try:
                triggers = con.execute(
                    "SELECT count(*) FROM sqlite_master WHERE type='trigger'"
                    " AND name='asset_search_entity_update'").fetchone()[0]
            finally:
                con.close()
        self.assertEqual(triggers, 1)

    def test_agency_is_an_accepted_entity_kind(self):
        with tempfile.TemporaryDirectory() as tmp:
            db = Path(tmp).resolve() / "ledger.db"
            upgrade(db, MIGRATIONS)
            con = sqlite3.connect(db)
            try:
                con.execute(
                    "INSERT INTO entity(kind,canonical_name,normalized_name,"
                    "created_at,updated_at) VALUES('agency','Capsule','capsule','t','t')")
                with self.assertRaises(sqlite3.IntegrityError):
                    con.execute(
                        "INSERT INTO entity(kind,canonical_name,normalized_name,"
                        "created_at,updated_at) VALUES('label','X','x','t','t')")
            finally:
                con.close()


class AgencyNameTests(unittest.TestCase):
    """括号里是读音或旧名,括号外才是现用名。"""

    def test_a_plain_name_has_no_aliases(self):
        self.assertEqual(split_name("Capsule Agency"), ("Capsule Agency", []))

    def test_the_reading_in_parentheses_becomes_an_alias(self):
        self.assertEqual(split_name("ACT(アクト)"), ("ACT", ["アクト"]))

    def test_a_former_name_loses_its_prefix(self):
        self.assertEqual(split_name("Wish(元・GIRFY)"), ("Wish", ["GIRFY"]))

    def test_a_former_name_after_the_closing_bracket_is_kept(self):
        self.assertEqual(
            split_name("SO MODEL AGENT(ソウ モデルエージェント)旧・Eightman Production"),
            ("SO MODEL AGENT", ["ソウ モデルエージェント", "Eightman Production"]))


class LinkDestinationTests(unittest.TestCase):
    """写法不同、去处相同的链接是一条,不是两条。"""

    def test_a_trailing_slash_is_the_same_destination(self):
        self.assertEqual(destination("http://kawakami-yuu.livedoor.biz"),
                         destination("http://kawakami-yuu.livedoor.biz/"))

    def test_a_post_belongs_to_the_same_account_as_its_profile(self):
        self.assertEqual(destination("https://x.com/yu_kinao"),
                         destination("https://x.com/yu_kinao/status/1221041995415580672"))

    def test_two_accounts_on_one_platform_stay_apart(self):
        self.assertNotEqual(destination("https://x.com/one"), destination("https://x.com/two"))

    def test_a_query_string_still_distinguishes_pages(self):
        self.assertNotEqual(destination("https://a.jp/t?id=3"), destination("https://a.jp/t?id=4"))

    def test_percent_escapes_are_compared_by_byte_not_by_case(self):
        """凉森玲梦的官网被两个来源各按自己的大小写写了一遍,指向同一个页面。"""
        self.assertEqual(
            destination("https://www.t-powers.co.jp/talent/%e6%b6%bc%e6%a3%ae%e3%82%8c%e3%82%80/"),
            destination("https://www.t-powers.co.jp/talent/%E6%B6%BC%E6%A3%AE%E3%82%8C%E3%82%80/"))

    def test_two_escaped_paths_that_differ_stay_apart(self):
        self.assertNotEqual(destination("https://a.jp/t/%E6%B6%BC"),
                            destination("https://a.jp/t/%E6%A3%AE"))


class AgencyLedgerTests(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)"
            " 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,metadata_json,"
            "created_at,updated_at) VALUES(?,?,?,?,?,'t','t')",
            [(11, "performer", "七泽美亚", "七泽美亚",
              '{"agency":{"name":"Capsule Agency","checked_at":"2026-09-04T00:00:00Z"}}'),
             (12, "performer", "神雪", "神雪",
              '{"agency":{"name":"Capsule Agency","checked_at":"2026-09-04T00:00:00Z"}}'),
             (13, "performer", "白石亚子", "白石亚子",
              '{"agency":{"name":"ACT(アクト)","checked_at":"2026-09-04T00:00:00Z"}}'),
             (20, "tag", "足交", "足交", "{}")])
        self.con.executemany(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(?,?,?,'test',1.0)",
            [(1, 11, "performer"), (2, 12, "performer"), (3, 13, "performer"),
             (1, 20, "tag")])
        # 归属域名的共识:三条同标签的链接指向同一个主机。
        self.con.executemany(
            "INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,"
            "created_at,updated_at) VALUES(?,'official','ACT(アクト)',?,"
            "'actentertainment.jp','t','t')",
            [(11, "https://actentertainment.jp/a"), (12, "https://actentertainment.jp/b"),
             (13, "https://actentertainment.jp/c")])
        self.con.commit()
        rows = agency_plan(self.con)
        install_agencies(self.con, rows, STAMP)
        self.con.commit()
        self.logos = root / "logos"; self.logos.mkdir()
        self.avatars = root / "avatars"; self.avatars.mkdir()
        self.contract = rm_web.WebContract(
            self.db, avatar_root=self.avatars, logo_root=self.logos, cover_root=root / "covers")

    def page(self, kind, name):
        return rm_web.q_entity(self.contract, {"kind": kind, "name": name})

    def photos(self, name, kind="agency"):
        return rm_web.q_entity_photos(self.contract, {"kind": kind, "name": name})

    def counted_once(self, photos):
        """总数、分页和图集数的是同一张图:各一次,图集字节数也只算一份。"""
        self.assertEqual(
            (photos["total"], [item["id"] for item in photos["items"]],
             [(item["kind"], item["n"], item["bytes"]) for item in photos["sets"]]),
            (1, [21], [("dir", 1, 10)]))

    def add_pictures(self, rows):
        """(id, 路径, 挂在哪几位名下, disposal)。"""
        for asset_id, path, owners, disposal in rows:
            self.con.execute(
                "INSERT INTO asset(id,location,path,name,medium,size,first_seen,disposal)"
                " VALUES(?,'local',?,?,'image',10,'2026-01-01',?)",
                (asset_id, path, path.rsplit("\\", 1)[-1], disposal))
            self.con.executemany(
                "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
                " VALUES(?,?,'performer','test',1.0)", [(asset_id, owner) for owner in owners])
        self.con.commit()

    def add_samples(self, codes):
        """作品 id → 番号,每个番号配一张官方样张。"""
        for asset_id, code in codes.items():
            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',?)", (code, STAMP))
        self.con.commit()

    def test_every_performer_with_an_agency_gets_exactly_one_membership(self):
        self.assertEqual(
            self.con.execute("SELECT count(*) FROM entity_membership").fetchone()[0], 3)

    def test_the_agency_profile_counts_the_works_of_its_members(self):
        page = self.page("agency", "Capsule Agency")
        self.assertEqual(page["asset_count"], 2)
        self.assertEqual(page["member_count"], 2)

    def test_the_agency_profile_lists_its_roster_not_co_stars(self):
        names = [person["k"] for person in self.page("agency", "Capsule Agency")
                 ["related_performers"]]
        self.assertEqual(sorted(names), ["七泽美亚", "神雪"])

    def test_the_agency_profile_carries_the_tags_of_its_members_works(self):
        self.assertIn("足交", [tag["k"] for tag in
                              self.page("agency", "Capsule Agency")["tags"]])

    def test_the_agency_representative_is_picked_from_its_members_works(self):
        """ACT 那位的片更大,但不是这家的。"""
        self.con.executemany("UPDATE asset SET snapshot_path='s.jpg',size=? WHERE id=?",
                             [(200, 1), (300, 2), (900, 3)])
        self.con.commit()
        self.assertEqual(self.page("agency", "Capsule Agency")["representative_asset_id"], 2)

    def test_the_agency_photos_and_sample_sets_are_those_of_its_members(self):
        """事务所自己不挂图:图集、总数、分页和番号样张集都从成员名下取。

        ACT 那位的图和片不算,回收站里的图也不算。
        """
        self.add_pictures([
            (21, r"R:\Media\summer\01.jpg", [11], None),
            (22, r"R:\Media\summer\02.jpg", [12], None),
            (23, r"R:\Media\winter\01.jpg", [12], None),
            (24, r"R:\Media\summer\03.jpg", [11], "trash"),
            (25, r"R:\Media\act\01.jpg", [13], None)])
        self.add_samples({1: "SSIS-057", 2: "SSIS-058", 3: "IPX-001"})
        photos = self.photos("Capsule Agency")
        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), ("dir", 23, 1)])
        self.assertEqual((photos["total"], photos["sample_total"]), (3, 2))
        self.assertEqual([item["id"] for item in photos["items"]], [21, 22, 23])

    def test_a_picture_of_two_members_is_counted_and_listed_once(self):
        self.add_pictures([(21, r"R:\Media\duo\01.jpg", [11, 12], None)])
        self.counted_once(self.photos("Capsule Agency"))

    def test_a_picture_filed_twice_under_one_entity_is_counted_once(self):
        """主键含 role 与 source:同一位名下,复核再写一行也还是同一张图。"""
        self.add_pictures([(21, r"R:\Media\solo\01.jpg", [11], None)])
        self.con.execute("INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
                         " VALUES(21,11,'performer','review',1.0)")
        self.con.commit()
        self.counted_once(self.photos("七泽美亚", "performer"))

    def test_a_performer_profile_points_at_her_agency_entity(self):
        home = self.page("performer", "七泽美亚")["agency"]
        self.assertEqual(home["canonical_name"], "Capsule Agency")
        self.assertEqual(home["checked_at"], "2026-09-04T00:00:00Z")

    def test_an_agency_profile_has_no_agency_of_its_own(self):
        self.assertIsNone(self.page("agency", "Capsule Agency")["agency"])

    def test_the_agency_is_reachable_by_its_former_name(self):
        """`ACT(アクト)` 的读音留成别名,按它也要能打开这一页。"""
        self.assertEqual(self.page("agency", "アクト")["canonical_name"], "ACT")

    def test_the_agency_keeps_its_own_official_site(self):
        page = self.page("agency", "ACT")
        self.assertEqual([link["url"] for link in page["links"]],
                         ["https://actentertainment.jp/"])

    def test_agency_is_a_profile_kind(self):
        self.assertIn("agency", PROFILE_KINDS)

    def test_an_unknown_kind_is_still_refused(self):
        self.assertEqual(rm_web.q_entity(self.contract, {"kind": "label", "name": "x"}),
                         {"error": "invalid entity"})

    def test_the_catalog_can_be_filtered_by_agency(self):
        items = rm_web.q_items(self.contract, {"agency": "Capsule Agency", "limit": "10"})
        self.assertEqual(sorted(item["id"] for item in items["items"]), [1, 2])

    def test_searching_an_agency_name_finds_the_works_of_its_members(self):
        """用户搜「Capsule」时,账本里确实有它,只是此前没有一条路把它接上作品。"""
        items = rm_web.q_items(self.contract, {"q": "Capsule", "limit": "10"})
        self.assertEqual(sorted(item["id"] for item in items["items"]), [1, 2])

    def test_a_short_query_reaches_the_agency_too(self):
        """短查询走 LIKE 分支,两条分支要覆盖同样的身份写法。"""
        items = rm_web.q_items(self.contract, {"q": "AC", "limit": "10"})
        self.assertEqual([item["id"] for item in items["items"]], [3])

    def test_agency_facets_come_from_the_same_rows_as_its_items(self):
        facets = rm_web.q_facets(self.contract, scope_kind="agency",
                                 scope_name="Capsule Agency")
        self.assertEqual(sum(row["n"] for row in facets["locations"]), 2)

    def index(self, kind, q=""):
        return {row["k"]: row for row in
                rm_web.q_index(self.contract, kind, q=q, limit=50)["items"]}

    def test_the_agency_index_lists_every_agency_by_headcount(self):
        """事务所自己不挂作品,一家有多少艺人才是它的规模。"""
        rows = self.index("agencies")
        self.assertEqual(sorted(rows), ["ACT", "Capsule Agency"])
        self.assertEqual(rows["Capsule Agency"]["members"], 2)
        self.assertEqual(rows["ACT"]["members"], 1)

    def test_the_agency_index_counts_works_the_same_way_the_profile_does(self):
        """判据只有 `scope_predicate` 一份,两个页面数出来的才是同一个数。"""
        rows = self.index("agencies")
        for name in rows:
            with self.subTest(agency=name):
                self.assertEqual(rows[name]["n"], self.page("agency", name)["asset_count"])

    def test_the_agency_index_hands_out_its_own_mark_not_a_members_frame(self):
        """索引页那格图取的是官网圆标:作品截图是某位成员某部片的画面。"""
        rows = self.index("agencies")
        self.assertIsNotNone(rows["ACT"]["mark"])
        self.assertFalse(rows["ACT"]["has_avatar"])

    def test_the_agency_index_can_be_filtered_by_name(self):
        self.assertEqual(sorted(self.index("agencies", q="Capsule")), ["Capsule Agency"])

    def test_both_halves_of_the_maker_switch_have_an_index(self):
        """开关的两半都得有索引页,否则那个开关有一边是死的。"""
        for kind in ("studios", "agencies"):
            with self.subTest(kind=kind):
                self.assertEqual(rm_web.q_index(self.contract, kind, limit=5)["kind"], kind)

    def test_the_agency_asks_whether_its_logo_is_installed(self):
        """标识按名字落盘,厂牌和事务所同一个仓:两边都得先问过再出图。"""
        self.assertIn("has_logo", self.page("agency", "ACT"))
        self.assertIn("has_logo", self.index("agencies")["ACT"])
        (self.logos / "ACT.img").write_bytes(b"x")
        self.contract = rm_web.WebContract(
            self.db, avatar_root=self.avatars, logo_root=self.logos)
        self.assertTrue(self.page("agency", "ACT")["has_logo"])

    def test_the_agency_profile_hands_out_the_link_id_for_its_mark(self):
        page = self.page("agency", "ACT")
        self.assertEqual(page["mark_link_id"],
                         next(link["link_id"] for link in page["links"]))

    def test_an_archived_official_site_is_listed_but_not_used_as_the_mark(self):
        """关门事务所的官网只剩存档快照:链接照列,门面不取存档站的圆标。"""
        agency_id = self.con.execute(
            "SELECT id FROM entity WHERE kind='agency' AND canonical_name='Capsule Agency'"
        ).fetchone()[0]
        self.con.execute("DELETE FROM entity_link WHERE entity_id=?", (agency_id,))
        self.con.execute(
            "INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,created_at,updated_at)"
            " VALUES(?,'official','官网存档(2020-01)',"
            "'https://web.archive.org/web/20200101000000/https://capsule.bz/','web.archive.org',"
            "'t','t')", (agency_id,))
        self.con.commit()
        page = self.page("agency", "Capsule Agency")
        self.assertEqual([link["hostname"] for link in page["links"]], ["web.archive.org"])
        self.assertIsNone(page["mark_link_id"])
        self.assertIsNone(self.index("agencies")["Capsule Agency"]["mark"])

    def test_the_agency_roster_carries_the_focus_the_big_layout_needs(self):
        """名册摆的是竖幅大图,几何居中会切掉脸,取景要随资料一起下发。"""
        roster = self.page("agency", "Capsule Agency")["related_performers"]
        self.assertTrue(roster)
        for person in roster:
            with self.subTest(person=person["k"]):
                self.assertIn("avatar_focus", person)

    def test_the_scope_predicate_takes_one_placeholder_either_way(self):
        self.assertEqual(scope_predicate("performer", "ae.entity_id").count("?"), 1)
        self.assertEqual(scope_predicate("agency", "ae.entity_id").count("?"), 1)

    def test_the_index_scope_predicate_reads_the_agency_from_each_row(self):
        """索引页一句 SQL 数完所有事务所,判据那一侧填的是列名不是占位符。"""
        self.assertIn("agency_id=e.id", scope_predicate("agency", "ae.entity_id", "e.id"))

    def test_merging_a_performer_carries_her_membership_over(self):
        self.con.execute(
            "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES(99,'performer','Mia Nanasawa','mia nanasawa','t','t')")
        moved = merge_entity(self.con, target_id=99, source_id=11,
                             source_name="七泽美亚", alias_source="merge:test")
        # 资料页读的是另一条只读连接,不提交就还看得见合并前的账本。
        self.con.commit()
        self.assertEqual(moved["memberships"], 1)
        self.assertEqual(self.con.execute(
            "SELECT count(*) FROM entity_membership WHERE member_id=11").fetchone()[0], 0)
        self.assertEqual(self.page("agency", "Capsule Agency")["member_count"], 2)

    def test_merging_two_agencies_moves_the_roster(self):
        target = self.con.execute(
            "SELECT id FROM entity WHERE kind='agency' AND canonical_name='ACT'").fetchone()[0]
        source = self.con.execute(
            "SELECT id FROM entity WHERE kind='agency' AND canonical_name='Capsule Agency'"
        ).fetchone()[0]
        moved = merge_entity(self.con, target_id=target, source_id=source,
                             source_name="Capsule Agency", alias_source="merge:test")
        self.con.commit()
        self.assertEqual(moved["members"], 2)
        self.assertEqual(self.page("agency", "ACT")["member_count"], 3)

    def test_duplicate_links_on_one_entity_collapse_to_the_https_one(self):
        self.con.executemany(
            "INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,"
            "created_at,updated_at) VALUES(11,'social','X',?,'x.com','t','t')",
            [("https://x.com/mia",), ("https://x.com/mia/status/1",)])
        self.con.execute(
            "INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,"
            "created_at,updated_at) VALUES(12,'official','ACT',"
            "'http://actentertainment.jp/b','actentertainment.jp','t','t')")
        self.con.commit()
        verdicts = {row["url"]: row["verdict"] for row in dedupe_plan(self.con)}
        self.assertEqual(verdicts["https://x.com/mia"], "keep")
        self.assertEqual(verdicts["https://x.com/mia/status/1"], "delete")
        self.assertEqual(verdicts["https://actentertainment.jp/b"], "keep")
        self.assertEqual(verdicts["http://actentertainment.jp/b"], "delete")


class DeepLinkRouteTests(unittest.TestCase):
    """资料页的地址得能直接打开,而不只是在应用里点得到。"""

    def test_every_entity_route_is_served_as_a_page(self):
        """前端认得的每一种实体路由,服务端都要发页面。

        少一条不会报错,只会在直接打开地址时回 404:应用内点进去照常,刷新一下就没了。
        所以判据取自前端 core 的路由表本身,加一种实体就自动多一条要求。
        """
        routes = re.search(r"const ENTITY_ROUTES(?::[^=]*)?=\{([^}]*)\}",
                           (ROOT / "frontend" / "src" / "core" / "index.ts").read_text(encoding="utf-8"))
        self.assertIsNotNone(routes, "frontend/src/core/index.ts 里没有 ENTITY_ROUTES")
        wanted = re.findall(r"[:,]\s*'([a-z]+)'", "," + routes.group(1))
        self.assertIn("agencies", wanted, "路由表没解析出来")
        pages = (ROOT / "src" / "peach" / "routes_pages.py").read_text(encoding="utf-8")
        for route in wanted:
            with self.subTest(route=route):
                self.assertIn(f'"/{route}/{{name:path}}"', pages)


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