tests/test_performer_localization.py
Rama master · réplica de lectura del repositorio original.
"""番号体系女优中译:来源匹配、同人合并、日本字形归一与兼容标签同步。"""
import csv
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
import opencc
from peach.migrations import upgrade
from peach import entry_links
from scripts.audit_performer_entry_names import collect as audit_entries
from scripts.localize_performer_names import (
JP_KANJI_TO_SIMPLIFIED, KANJI_ALIAS_SOURCE, apply_rows, collect, main,
read_identity_review, read_mapping, report_conflicts, simplify_kanji,
strip_zero_width,
)
ROOT = Path(__file__).resolve().parents[1]
OPENCC_T2S = opencc.OpenCC("t2s")
OPENCC_JP2T = opencc.OpenCC("jp2t")
class PerformerLocalizationTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name).resolve()
self.db = root / "ledger.db"
upgrade(self.db, ROOT / "migrations")
self.con = sqlite3.connect(self.db)
self.con.executemany(
"INSERT INTO asset(id,location,path,name,medium) VALUES(?,'local',?,?,'video')",
[(1, "/x/1.mp4", "1.mp4"), (2, "/x/2.mp4", "2.mp4"),
(3, "/x/3.mp4", "3.mp4"), (4, "/x/4.mp4", "4.mp4"),
(5, "/x/5.mp4", "5.mp4"), (6, "/x/6.mp4", "6.mp4"),
(7, "/x/7.mp4", "7.mp4"), (8, "/x/8.mp4", "8.mp4"),
(9, "/x/9.mp4", "9.mp4"), (10, "/x/10.mp4", "10.mp4"),
(11, "/x/11.mp4", "11.mp4")])
self.con.executemany(
"INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) "
"VALUES(?,'performer',?,?, 't','t')",
[(10, "Alice Shaku", "alice shaku"),
(11, "Mio Hayakawa", "mio hayakawa"),
(12, "吉川蓮", "吉川蓮"),
(13, "Unknown Roman", "unknown roman"),
(14, "account_01", "account_01"),
(15, "Hitomi Hoshiya", "hitomi hoshiya"),
(16, "斎藤満里奈", "斎藤満里奈"),
(17, "野々宮蘭", "野々宮蘭"),
(18, "飯岡かなこ", "飯岡かなこ"),
(20, "安斋拉拉", "安斋拉拉"),
(21, "\u200c斋藤亚美里", "\u200c斋藤亚美里"),
(22, "斋藤未来", "斋藤未来")])
self.con.executemany(
"INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) "
"VALUES(?,?,'performer',?,1.0)",
[(1, 10, "r18:performer"), (2, 11, "r18:performer"),
(3, 12, "r18:performer"), (4, 13, "r18:performer"),
(4, 14, "performer"), (5, 15, "r18:performer"),
(6, 16, "r18:performer"), (7, 17, "performer"),
(8, 18, "r18:performer"), (9, 20, "r18:performer"),
(10, 21, "r18:performer"), (11, 22, "r18:performer")])
self.con.executemany(
"INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(?,?,1.0,'r18:performer')",
[(1, "演员:Alice Shaku"), (2, "演员:Mio Hayakawa"),
(3, "演员:吉川蓮"), (4, "演员:Unknown Roman"),
(5, "演员:Hitomi Hoshiya"), (6, "演员:斎藤満里奈"),
(7, "演员:野々宮蘭"), (8, "演员:飯岡かなこ"),
(9, "演员:安斋拉拉"), (10, "演员:\u200c斋藤亚美里"),
(11, "演员:斋藤未来")])
self.con.execute(
"INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) "
"VALUES(15,'r18','performer_name','星谷瞳')")
self.con.executemany(
"INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence) "
"VALUES(?,?,?,'r18:performer',0.9)",
[(20, "安齋らら", "安齋らら"), (21, "斎藤あみり", "斎藤あみり"),
(22, "さいとうみらい", "さいとうみらい")])
self.con.commit()
self.mapping = root / "actors.xml"
self.mapping.write_text("""<?xml version="1.0" encoding="UTF-8"?>
<actor-mapping><actor>
<a zh_cn="釈アリス" zh_tw="釋愛麗絲" jp="釈アリス"
keyword="釈アリス,釋愛麗絲,释爱丽丝,Shaku Alice" tmdb_id="5294947" verified="1" />
<a zh_cn="吉川莲" zh_tw="吉川蓮" jp="吉川蓮"
keyword="Mio Hayakawa,早川美緒,吉川蓮" tmdb_id="123" verified="1" />
<a zh_cn="Hitomi Hoshiya" zh_tw="星谷瞳" jp="星谷瞳"
keyword="Hitomi Hoshiya,星谷瞳" tmdb_id="456" verified="1" />
</actor></actor-mapping>""", encoding="utf-8")
self.review = root / "review.csv"
with self.review.open("w", encoding="utf-8-sig", newline="") as handle:
fields = ("entity_id", "current_name", "japanese_name", "kana", "former_names")
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
writer.writerows([
{"entity_id": 10, "current_name": "Alice Shaku", "japanese_name": "釈アリス",
"kana": "しゃくありす", "former_names": ""},
{"entity_id": 11, "current_name": "Mio Hayakawa", "japanese_name": "早川美緒",
"kana": "はやかわみお", "former_names": ""},
{"entity_id": 12, "current_name": "Ren Yoshikawa", "japanese_name": "吉川蓮",
"kana": "よしかわれん", "former_names": ""},
{"entity_id": 13, "current_name": "Unknown Roman", "japanese_name": "未収録花子",
"kana": "", "former_names": ""},
{"entity_id": 15, "current_name": "Hitomi Hoshiya", "japanese_name": "星谷瞳",
"kana": "ほしやひとみ", "former_names": ""},
])
def tearDown(self):
self.con.close()
self.tmp.cleanup()
def plan(self):
return collect(
self.con, read_mapping(self.mapping), read_identity_review(self.review), "abc123")
def test_collect_localizes_alice_merges_one_identity_and_skips_accounts(self):
rows = {int(row["entity_id"]): row for row in self.plan()}
self.assertEqual(rows[10]["target_name"], "释爱丽丝")
self.assertEqual(rows[10]["action"], "localize")
self.assertEqual(rows[11]["target_name"], "吉川莲")
self.assertEqual(rows[11]["action"], "merge-and-localize")
self.assertEqual(rows[12]["action"], "merge-drop")
self.assertEqual(rows[12]["merge_target_id"], 11)
self.assertEqual(rows[13]["target_name"], "未収録花子")
self.assertEqual(rows[13]["action"], "localize-jp-fallback")
self.assertEqual(rows[14]["action"], "keep-non-release")
self.assertEqual(rows[15]["target_name"], "星谷瞳")
self.assertEqual(rows[15]["action"], "localize")
self.assertIn("r18-nonlatin-release-name", rows[15]["resolution"])
def test_javinizer_release_sources_use_verified_name_mappings(self):
for source in ("javinizer:r18dev:performer", "javinizer:javbus:performer",
"javinizer:javdb:performer"):
with self.subTest(source=source):
self.con.execute("UPDATE asset_entity SET source=? WHERE entity_id=10", (source,))
rows = {int(row["entity_id"]): row for row in self.plan()}
self.assertEqual(rows[10]["target_name"], "释爱丽丝")
self.assertEqual(rows[10]["action"], "localize")
self.assertEqual(rows[14]["action"], "keep-non-release")
def test_apply_preserves_aliases_and_rewrites_actor_tags(self):
counts = apply_rows(self.con, self.plan(), "abc123")
self.con.commit()
names = dict(self.con.execute("SELECT id,canonical_name FROM entity WHERE kind='performer'"))
self.assertEqual(names[10], "释爱丽丝")
self.assertEqual(names[11], "吉川莲")
self.assertNotIn(12, names)
self.assertEqual(names[13], "未収録花子")
self.assertEqual(names[14], "account_01")
self.assertEqual(names[15], "星谷瞳")
aliases = {row[0] for row in self.con.execute(
"SELECT alias FROM entity_alias WHERE entity_id=10")}
self.assertTrue({"Alice Shaku", "釈アリス", "しゃくありす", "釋愛麗絲"} <= aliases)
tags = dict(self.con.execute("SELECT asset_id,tag FROM asset_tag ORDER BY asset_id"))
self.assertEqual(tags[1], "演员:释爱丽丝")
self.assertEqual(tags[2], "演员:吉川莲")
self.assertEqual(tags[3], "演员:吉川莲")
self.assertEqual(tags[4], "演员:未収録花子")
self.assertEqual(tags[5], "演员:星谷瞳")
self.assertEqual(counts["merged"], 1)
self.assertEqual(self.con.execute("PRAGMA foreign_key_check").fetchall(), [])
def test_kanji_simplification_only_touches_names_it_can_finish(self):
"""字形归一只处理纯汉字名;假名和罗马字要的是译名,不是逐字换。"""
self.assertEqual(simplify_kanji("高山涼音"), "高山凉音")
self.assertEqual(simplify_kanji("斎藤満里奈"), "斋藤满里奈")
self.assertEqual(simplify_kanji("浜崎真緒"), "滨崎真绪")
# 「々」是叠字符号,展开成前一个字,否则名字还是半个日文。
self.assertEqual(simplify_kanji("野々宮蘭"), "野野宫兰")
# 简体没有对应字形的日本汉字原样留下,中文资料页也是这么写的。
self.assertEqual(simplify_kanji("桜咲姫莉"), "樱咲姬莉")
self.assertEqual(simplify_kanji("辻井穂香"), "辻井穗香")
# 逐字换只会得到半中半日的名字,这类名字留给映射 XML。
self.assertEqual(simplify_kanji("飯岡かなこ"), "飯岡かなこ")
self.assertEqual(simplify_kanji("Alice Shaku"), "Alice Shaku")
# 已经是简体的名字不该被再动一次。
self.assertEqual(simplify_kanji("凉森玲梦"), "凉森玲梦")
def test_collect_simplifies_kanji_without_any_mapping_entry(self):
"""映射里没有这个人,字形照样要归一——这一道不需要外部来源。"""
rows = {int(row["entity_id"]): row for row in collect(
self.con, [], read_identity_review(self.review), "kanji-only")}
self.assertEqual(rows[16]["target_name"], "斋藤满里奈")
self.assertEqual(rows[16]["action"], "localize-kanji")
self.assertIn("kanji-simplification", rows[16]["resolution"])
# 账号型 performer 不翻译,但换字形不是翻译:同一个字的两种写法而已。
self.assertEqual(rows[17]["target_name"], "野野宫兰")
self.assertEqual(rows[17]["action"], "localize-kanji")
self.assertEqual(rows[18]["action"], "keep-unresolved")
self.assertEqual(rows[18]["target_name"], "飯岡かなこ")
def test_upstream_glyph_noise_in_the_canonical_name_is_normalised(self):
"""`斎`/`齋` 都是 `斋`,`斉`/`齊` 才是 `齐`;零宽字符根本不该出现在名字里。"""
self.assertEqual(strip_zero_width("\u200c斋藤亚美里"), "斋藤亚美里")
# 换的是名字里实际写的那个字,不按日文一侧的写法去推断中文名写错了。
self.assertEqual(simplify_kanji("斎藤満里奈"), "斋藤满里奈")
self.assertEqual(simplify_kanji("安齋良良"), "安斋良良")
self.assertEqual(simplify_kanji("斉藤美来"), "齐藤美来")
rows = {int(row["entity_id"]): row for row in collect(
self.con, [], read_identity_review(self.review), "kanji-only")}
# 日文名写 `安齋らら`,中文名这个 `斋` 没写错,不动。
self.assertEqual(rows[20]["target_name"], "安斋拉拉")
self.assertEqual(rows[20]["action"], "keep-unresolved")
# 日文名写 `斎藤あみり` 同样不构成改字的理由,这一行只去掉零宽字符。
self.assertEqual(rows[21]["target_name"], "斋藤亚美里")
self.assertEqual(rows[21]["action"], "localize-kanji")
self.assertEqual(rows[22]["target_name"], "斋藤未来")
self.assertEqual(rows[22]["action"], "keep-unresolved")
def test_apply_records_kanji_rows_under_their_own_alias_source(self):
plan = collect(self.con, [], read_identity_review(self.review), "kanji-only")
apply_rows(self.con, plan, "kanji-only")
self.con.commit()
names = dict(self.con.execute("SELECT id,canonical_name FROM entity"))
self.assertEqual(names[16], "斋藤满里奈")
self.assertEqual(names[17], "野野宫兰")
self.assertEqual(names[18], "飯岡かなこ")
aliases = dict(self.con.execute(
"SELECT alias,source FROM entity_alias WHERE entity_id=16"))
# 旧字形留成别名,否则来源那边再抓一次就又建一个新实体。
self.assertEqual(aliases["斎藤満里奈"], KANJI_ALIAS_SOURCE)
tags = dict(self.con.execute("SELECT asset_id,tag FROM asset_tag ORDER BY asset_id"))
self.assertEqual(tags[6], "演员:斋藤满里奈")
self.assertEqual(tags[7], "演员:野野宫兰")
def test_simplified_name_colliding_with_an_existing_entity_is_a_conflict(self):
"""换完字形撞上库里已有的同名实体时不猜,交给人合并。"""
self.con.execute(
"INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) "
"VALUES(19,'performer','斋藤满里奈','斋藤满里奈','t','t')")
self.con.commit()
rows = {int(row["entity_id"]): row for row in collect(
self.con, [], read_identity_review(self.review), "kanji-only")}
self.assertEqual(rows[16]["action"], "conflict")
self.assertIn("target-name-conflict", rows[16]["resolution"])
# 待裁决的那一行只挡住自己:同一批里其它人的字形照样归一,
# 否则库里一处未合并的同人就能无限期冻住全部改名。
counts = apply_rows(self.con, list(rows.values()), "kanji-only")
self.assertEqual(counts["conflicts_skipped"], 1)
names = dict(self.con.execute("SELECT id,canonical_name FROM entity"))
self.assertEqual(names[16], "斎藤満里奈")
self.assertEqual(names[17], "野野宫兰")
self.assertEqual(report_conflicts(list(rows.values())), 1)
def test_mapping_xml_is_optional_and_its_revision_is_not(self):
"""那份 XML 不在仓库里,也可能已经不在这台机器上;缺了它字形归一仍要能跑。"""
out = Path(self.tmp.name) / "plan.csv"
main(["--db", str(self.db), "--identity-review", str(self.review),
"--review-csv", str(out)])
planned = {row["current_name"]: row for row in csv.DictReader(
out.open(encoding="utf-8-sig"))}
self.assertEqual(planned["斎藤満里奈"]["target_name"], "斋藤满里奈")
self.assertEqual(planned["斎藤満里奈"]["revision"], "kanji-only")
with self.assertRaises(SystemExit):
main(["--db", str(self.db), "--mapping-xml", str(self.mapping),
"--review-csv", str(out)])
def test_review_csv_keeps_its_source_and_current_japanese_name(self):
root = Path(self.tmp.name).resolve()
mapping_csv = root / "mapping.csv"
with mapping_csv.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=("current_name", "adopt", "verdict"))
writer.writeheader()
writer.writerow({"current_name": "飯岡かなこ", "adopt": "饭冈加奈子", "verdict": "ok"})
self.con.execute("INSERT INTO entity_alias VALUES(18,'坂本マリア','坂本マリア','auto:performer-alias',1)")
self.con.execute("UPDATE asset_entity SET source='javinizer:avbase:performer' WHERE entity_id=18")
self.con.execute("INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) "
"VALUES(18,'javdb','performer','sample')")
self.con.commit()
self.assertEqual(main([
"--db", str(self.db), "--mapping-csv", str(mapping_csv),
"--mapping-source", "review:performer-localization", "--mapping-revision", "review-1",
"--only", "18",
"--identity-review", str(root / "absent.csv"), "--review-csv", str(root / "plan.csv"),
"--apply", "--backup", str(root / "backup.db"),
]), 0)
name, metadata_json = self.con.execute("SELECT canonical_name,metadata_json FROM entity WHERE id=18").fetchone()
self.assertEqual(name, "饭冈加奈子")
self.assertEqual(self.con.execute("SELECT canonical_name FROM entity WHERE id=16").fetchone()[0],
"斎藤満里奈")
self.assertEqual(json.loads(metadata_json)["name_localization"]["source"],
"review:performer-localization")
aliases = [{"alias": row[0], "source": row[1]} for row in self.con.execute(
"SELECT alias,source FROM entity_alias WHERE entity_id=18")]
self.assertEqual(entry_links.japanese_name(name, aliases), "飯岡かなこ")
def test_mapping_source_requires_a_csv(self):
with self.assertRaises(SystemExit):
main(["--mapping-source", "review:performer-localization"])
def test_selected_member_of_a_merge_group_requires_the_whole_group(self):
root = Path(self.tmp.name).resolve()
out = root / "selected-plan.csv"
self.assertEqual(main([
"--db", str(self.db), "--mapping-xml", str(self.mapping),
"--mapping-revision", "r1", "--identity-review", str(self.review),
"--only", "11", "--review-csv", str(out),
]), 1)
with out.open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["action"], "conflict")
self.assertEqual(rows[0]["resolution"], "partial-merge-group-selection")
def test_full_entry_audit_includes_release_and_account_performers(self):
self.con.execute("INSERT INTO entity_alias VALUES(18,'坂本マリア','坂本マリア','auto:performer-alias',1)")
self.con.execute("INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) "
"VALUES(18,'javdb','performer','sample')")
rows = {row["entity_id"]: row for row in audit_entries(self.con)}
self.assertEqual(len(rows), self.con.execute("SELECT count(*) FROM entity WHERE kind='performer'").fetchone()[0])
self.assertEqual(rows[18]["entry_name"], "飯岡かなこ")
self.assertEqual(rows[18]["name_changed"], 1)
self.assertIn("/dm3/cn/actresses/", rows[18]["url"])
self.assertEqual(rows[18]["cn_status"], "假名规范名,中文来源未取得")
self.assertEqual(rows[14]["cn_status"], "非发行艺人,不判中译")
self.assertEqual(rows[14]["url"], "")
def test_entry_audit_uses_the_source_glyphs_for_missav(self):
self.con.execute("UPDATE entity SET canonical_name='七泽美亚',metadata_json='{}' WHERE id=18")
self.con.execute("DELETE FROM entity_alias WHERE entity_id=18")
self.con.execute("INSERT INTO entity_alias VALUES(18,'七沢みあ','七沢みあ','javdb-actor-page',1)")
self.con.execute("INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id) "
"VALUES(18,'javdb','performer','sample')")
directory = entry_links.directory_index({"source": entry_links.DIRECTORY_URL,
"entries": [{"name": "七泽美亚", "url": "https://missav.ws/dm3/cn/actresses/"
"%E4%B8%83%E6%B2%A2%E3%81%BF%E3%81%82"}]})
rows = {row["entity_id"]: row for row in audit_entries(self.con, directory)}
self.assertEqual(rows[18]["entry_name"], "七沢みあ")
self.assertEqual(rows[18]["directory_status"], "已匹配")
self.assertEqual(rows[18]["url"], "https://missav.ws/dm3/cn/actresses/"
"%E4%B8%83%E6%B2%A2%E3%81%BF%E3%81%82")
class KanjiTableAgainstOpenCCTests(unittest.TestCase):
"""字形表逐字对 opencc 复核,手写的对应关系不作数。
这张表是手写的,写错一个字就是把人改名成另一个人:`斎` 曾被写成 `齐`,于是
`斎藤満里奈` 落成 `齐藤满里奈`(账本实体 8013),而 `斎` 的简体是 `斋`。
"""
#: 两步取参考值:`t2s` 只认繁体,日本新字体(`斎`、`嶋`)它原样返回;先用 `jp2t`
#: 还原成旧字体再 `t2s`,才能得到简体。反过来先 `jp2t` 会把本来就是繁体的字换错
#: (`並` → `竝`、`緒` → `緖`),所以顺序不能颠倒。
@staticmethod
def reference(ch):
direct = OPENCC_T2S.convert(ch)
return direct if direct != ch else OPENCC_T2S.convert(OPENCC_JP2T.convert(ch))
#: opencc 给不出可用简体、由本表核对的字形。前两个是简体里确实没有的字形,
#: opencc 原样留着(`嶋` → `嶋`、`姫` → `姫`),本库按大陆资料页的写法归到常用字;
#: `渋` 的旧字体是 `澁`,简体写 `涩`;`郷` 的旧字体为 `鄕`,简体写 `乡`。
CURATED = {"嶋": "岛", "姫": "姬", "渋": "涩", "郷": "乡"}
def test_every_mapping_matches_opencc_or_is_a_declared_exception(self):
disagree = {ch: (ours, self.reference(ch))
for ch, ours in JP_KANJI_TO_SIMPLIFIED.items()
if self.reference(ch) != ours}
self.assertEqual({ch: ours for ch, (ours, _) in disagree.items()}, self.CURATED)
def test_the_declared_exceptions_are_still_ones_opencc_cannot_do(self):
"""例外要留在名单里,得是 opencc 真的给不出这个简体,不是我们不想照它写。"""
for ch, ours in self.CURATED.items():
self.assertNotEqual(self.reference(ch), ours, ch)
if __name__ == "__main__":
unittest.main()