diff --git a/TodayWaifu/aliases.py b/TodayWaifu/aliases.py new file mode 100644 index 0000000..a9328f3 --- /dev/null +++ b/TodayWaifu/aliases.py @@ -0,0 +1,111 @@ +"""鸣潮角色别名:默认关闭,开启后优先用 XutheringWavesUID 的别名表。 + +配置项 DailyWifeAliasSource 决定来源,默认 off 即不做别名解析: +- xwuid:用 XWUID 的两份表(data/XutheringWavesUID/resource/map/alias/char_alias.json 内置表 + + alias/char_alias.json 用户自定义表,按 XWUID 的语义合并取并集);该插件未安装时 + 自动改用插件根随包分发的 role_aliases.json +- local:始终用插件自带的别名表 + +只做精确匹配,不用子串模糊匹配——子串匹配会让单字角色名(如「心」)误命中「鉴心」。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from gsuid_core.data_store import get_res_path + +from .constants import _cfg +from .resource_paths import BASE_DIR + +ROLE_ALIAS_FILE_NAME = "role_aliases.json" +ALIAS_SOURCE_OFF = "off" +ALIAS_SOURCE_XWUID = "xwuid" +ALIAS_SOURCE_LOCAL = "local" + +# 别名 -> 标准角色名;来源或文件 mtime 变化时重建 +_ALIAS_INDEX: dict[str, str] = {} +_ALIAS_SIGNATURE: tuple[float, ...] = () + + +def bundled_alias_path() -> Path: + """随插件分发的别名表,用户没装 XWUID 时靠它提供基础别名。""" + return BASE_DIR / ROLE_ALIAS_FILE_NAME + + +def _xwuid_alias_paths() -> tuple[Path, ...]: + root = get_res_path("XutheringWavesUID") + return ( + root / "resource" / "map" / "alias" / "char_alias.json", + root / "alias" / "char_alias.json", + ) + + +def alias_source() -> str: + """当前实际生效的来源:off 关闭解析;xwuid 的表不可用时回退 local。""" + configured = str(_cfg("DailyWifeAliasSource") or ALIAS_SOURCE_OFF).strip().lower() + if configured == ALIAS_SOURCE_LOCAL: + return ALIAS_SOURCE_LOCAL + if configured == ALIAS_SOURCE_XWUID: + if any(path.is_file() for path in _xwuid_alias_paths()): + return ALIAS_SOURCE_XWUID + return ALIAS_SOURCE_LOCAL + return ALIAS_SOURCE_OFF + + +def _alias_source_paths() -> tuple[Path, ...]: + """按配置与可用性返回要读取的别名表。""" + if alias_source() == ALIAS_SOURCE_LOCAL: + return (bundled_alias_path(),) + return _xwuid_alias_paths() + + +def _path_signature(paths: tuple[Path, ...]) -> tuple[float, ...]: + return tuple(path.stat().st_mtime if path.is_file() else 0.0 for path in paths) + + +def _merge_into(index: dict[str, str], standard: str, aliases: object) -> None: + index.setdefault(standard, standard) + if not isinstance(aliases, list): + return + for alias in aliases: + text = str(alias).strip() + # 标准名优先:其它角色的别名不得覆盖已成形的标准名 + if text and text != standard and index.get(text, standard) == standard: + index[text] = standard + + +def _load_alias_index() -> dict[str, str]: + """合并插件自带与 XWUID 的别名表;按 mtime 缓存,文件缺失时跳过。""" + global _ALIAS_INDEX, _ALIAS_SIGNATURE + paths = _alias_source_paths() + signature = _path_signature(paths) + if _ALIAS_INDEX and signature == _ALIAS_SIGNATURE: + return _ALIAS_INDEX + + index: dict[str, str] = {} + for path in paths: + if not path.is_file(): + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + continue + if not isinstance(raw, dict): + continue + # 先收标准名,再挂别名,保证标准名不会被别的角色的别名顶掉 + for standard, aliases in raw.items(): + _merge_into(index, str(standard).strip(), aliases) + + _ALIAS_INDEX = index + _ALIAS_SIGNATURE = signature + return _ALIAS_INDEX + + +def resolve_role_name(name: str) -> str: + """把别名解析为标准角色名;关闭别名或解析不到时原样返回。""" + clean = name.strip() + if not clean or alias_source() == ALIAS_SOURCE_OFF: + return clean + return _load_alias_index().get(clean, clean) diff --git a/TodayWaifu/roles.py b/TodayWaifu/roles.py index 18787c1..c6afa6d 100644 --- a/TodayWaifu/roles.py +++ b/TodayWaifu/roles.py @@ -23,6 +23,7 @@ ) from .state import CANDIDATE_CACHE from .domain import WifeRecord, RoleCandidate +from .aliases import resolve_role_name from .payloads import RoleAccumulator, NamedRoleAccumulator from .constants import ( LOG_PREFIX, @@ -377,7 +378,9 @@ def _load_pgr_local_candidates() -> tuple[RoleCandidate, ...]: def _normalize_role_name(name: str) -> str: # 不同来源对间隔号的写法不一致,统一为「·」后再比较,避免同一角色被判为不同名字 - return name.replace('・', '·').replace('•', '·').strip() + unified = name.replace('・', '·').replace('•', '·').strip() + # 再按 XWUID 别名表归一,用户可以用「小西王」这类别名称呼角色 + return resolve_role_name(unified) _MALE_ROLE_NAMES_NORM = {_normalize_role_name(n) for n in EXCLUDED_ROLE_NAMES} diff --git a/config_default.py b/config_default.py index cdeed8e..5c863fa 100644 --- a/config_default.py +++ b/config_default.py @@ -46,6 +46,16 @@ 'gallery', options=['local', 'gallery'], ), + '_DividerRoleAlias': GsDivider('角色别名', ''), + 'DailyWifeAliasSource': GsStrConfig( + '角色别名数据源', + '选择 off 关闭别名解析(默认);选择 xwuid 使用 XWUID 插件的别名表' + '(未安装该插件时自动改用插件自带的别名表);选择 local 始终使用插件自带的别名表。' + '别名用于「指定老婆」这类需要匹配角色名的场景,而指定角色仅限机器人主人' + '或指定老婆白名单用户使用', + 'off', + options=['off', 'xwuid', 'local'], + ), 'DailyWifeCustomRolePilePath': GsStrConfig( '本地角色图片目录', '图片数据源为 local 时生效。留空时自动查找 gsuid_core/data/XutheringWavesUID/custom_role_pile', diff --git a/role_aliases.json b/role_aliases.json new file mode 100644 index 0000000..3392836 --- /dev/null +++ b/role_aliases.json @@ -0,0 +1,758 @@ +{ + "丹瑾": [ + "丹瑾", + "dj", + "丹谨", + "丹锦", + "小西王" + ], + "丽贝卡": [ + "丽贝卡", + "丽贝卡酱", + "里贝卡", + "大枪枪", + "瑞贝卡", + "Rebecca", + "lbk" + ], + "仇远": [ + "仇远", + "qy", + "亚索", + "仇", + "大远哥", + "钓鱼佬", + "竹竹爆", + "竹竹侠", + "李白", + "武侠梦", + "江湖梦", + "球员", + "瞎子", + "校花的贴身高手", + "竹" + ], + "今汐": [ + "今汐", + "jx", + "xx", + "不汐汐", + "今令尹", + "今夕", + "令尹", + "天宿庭光", + "小笼包", + "小龙包", + "汐呀", + "汐汐", + "神里凌华", + "镇关汐", + "龙女", + "小龙女" + ], + "凌阳": [ + "凌阳", + "ly", + "xb", + "动物朋友", + "羚羊", + "芝士雪豹", + "雪豹" + ], + "千咲": [ + "千咲", + "JK", + "chisa", + "jk", + "jk妹", + "jk梦", + "优华", + "剪刀", + "剪刀女", + "千口关", + "千笑", + "咲花", + "朽叶千咲", + "朽葉千咲", + "格温", + "浅笑", + "小千", + "千小妹", + "吃啥", + "电锯女", + "神秘剪刀女", + "神秘电锯女", + "黑长直" + ], + "卜灵": [ + "卜灵", + "bl", + "不灵", + "卦", + "小道长", + "灵感菇", + "炮姐", + "电磁炮", + "算卦的", + "道爷" + ], + "卡卡罗": [ + "卡卡罗", + "kkl", + "ciaciallo", + "中卡", + "卡罗", + "牢卡", + "牢罗", + "索拉里斯最强共鸣者" + ], + "卡提希娅": [ + "卡提希娅", + "kt", + "ktxy", + "卡提", + "卡提西亚", + "卡提西娅", + "卡提辖", + "卡缇", + "卡缇希娅", + "卡缇西亚", + "卡通", + "圣女", + "大卡", + "小卡", + "希呀", + "希娅", + "希希", + "弗卢德里斯", + "弗露德利斯", + "流浪骑士", + "芙露德莉斯", + "青枝月桂", + "青枝月桂沙拉" + ], + "吟霖": [ + "吟霖", + "yl", + "女特务", + "幽默女特务", + "搜查官", + "潜入搜查官", + "神秘女特务", + "银临" + ], + "嘉贝莉娜": [ + "嘉贝莉娜", + "jb", + "jbln", + "佳贝", + "加倍", + "加贝", + "千珏", + "嘉娜", + "嘉燃", + "嘉贝", + "大贝姐", + "背背佳", + "莉娜", + "贝利亚", + "贝姐", + "贝贝嘉" + ], + "坎特蕾拉": [ + "坎特蕾拉", + "ktll", + "sm", + "坎大雷", + "坎特", + "坎特雷拉", + "家主", + "暗奶", + "毒药", + "水母", + "🪼", + "水母精", + "翡萨烈", + "翡萨烈家主", + "菲萨列", + "菲萨列家主", + "菲萨烈", + "菲萨烈家主", + "蕾拉", + "雷拉" + ], + "夏空": [ + "夏空", + "xk", + "下空", + "吟游诗人", + "哒哒", + "哒哒哒", + "夏", + "大角", + "恰空", + "托卡塔", + "歌者", + "诗人", + "这诗人啊", + "马大芳" + ], + "奥古斯塔": [ + "奥古斯塔", + "ag", + "agst", + "太阳", + "奥古", + "奥古斯坦", + "巧乐兹", + "总督", + "日" + ], + "守岸人": [ + "守岸人", + "sar", + "xa", + "人机", + "仁济", + "受案人", + "守夜人", + "守岸", + "守岸入", + "小岸", + "岸宝", + "蒙娜丽莎", + "蝴蝶", + "蝴蝶精" + ], + "安可": [ + "安可", + "ak", + "可莉", + "安克", + "羊咩", + "羊咩的" + ], + "尤诺": [ + "尤诺", + "yn", + "优诺", + "孙尚香", + "弓凛", + "月", + "谁啊", + "谕女" + ], + "布兰特": [ + "布兰特", + "blt", + "不兰特", + "布莱特", + "火龙王", + "牢布", + "牢船", + "大船矛", + "大船锚", + "船长" + ], + "弗洛洛": [ + "弗洛洛", + "fll", + "佛洛洛", + "厨神小弗贵", + "小弗", + "弗弗", + "弗糯糯", + "弗落落", + "法拉利", + "洛洛", + "神秘洞穴女", + "芙洛洛", + "浮洛洛", + "沉洛洛", + "芙芙", + "芙莉莲", + "若叶睦", + "阴暗洞穴女", + "顶级大厨" + ], + "心": [ + "心", + "心月狐", + "hsin", + "狐狸", + "妲己", + "安塔瑞斯", + "firefox", + "心宿" + ], + "忌炎": [ + "忌炎", + "jy", + "咕咕河豚", + "🐔👀", + "将军", + "急眼", + "河豚", + "牢忌", + "鸡眼", + "龙王" + ], + "折枝": [ + "折枝", + "zz", + "折纸", + "画家", + "画师", + "画画女" + ], + "散华": [ + "散华", + "sh", + "三华", + "三花", + "凌华" + ], + "景燃": [ + "景燃", + "小哥", + "寻幽客", + "邪王真眼", + "jr", + "葫芦娃", + "鬼猫挈灯" + ], + "桃祈": [ + "桃祈", + "桃祁", + "tq", + "桃气", + "淘气", + "陶琦" + ], + "椿": [ + "椿", + "camellya", + "chun", + "一日花", + "命定之种", + "唐椿", + "大傻椿", + "大头椿", + "小春", + "小椿", + "执花", + "春", + "春花", + "椿宝", + "花女" + ], + "洛可可": [ + "洛可可", + "lkk", + "佩洛", + "大副", + "源能礼花枪", + "牢洛", + "罗可可", + "蓝莓味", + "蓝莓味小孩" + ], + "洛瑟菈": [ + "洛瑟菈", + "校长", + "lsl", + "罗瑟拉", + "洛瑟拉" + ], + "清宵": [ + "清宵", + "青宵", + "青霄", + "清霄", + "司骑", + "清宵真人", + "高跟鞋仙子", + "大龙女", + "剑修", + "清元子", + "清静的夜晚", + "剑仙", + "女剑仙" + ], + "渊武": [ + "渊武", + "yw" + ], + "漂泊者·导电": [ + "漂泊者·导电", + "dz", + "电主", + "电漂", + "雷主", + "漂泊者导电", + "导电主", + "Man!", + "电击小子", + "导电漂泊者", + "漂泊者・导电" + ], + "漂泊者·气动": [ + "漂泊者·气动", + "fz", + "哈气漂", + "气动漂泊者", + "漂泊者气动", + "风主", + "风漂", + "漂泊者・气动" + ], + "漂泊者·湮灭": [ + "漂泊者·湮灭", + "az", + "暗主", + "暗漂", + "湮灭漂泊者", + "漂泊者湮灭", + "猫眼石", + "漂泊者・湮灭" + ], + "漂泊者·衍射": [ + "漂泊者·衍射", + "gz", + "光主", + "光漂", + "漂泊者衍射", + "衍射主", + "衍射漂泊者", + "漂泊者・衍射" + ], + "灯灯": [ + "灯灯", + "dd", + "仓鼠", + "啾啾", + "噔噔", + "登登", + "等等" + ], + "炽霞": [ + "炽霞", + "cx", + "今州飞羽侠", + "小芳", + "炽焰游侠", + "赤焰游侠", + "赤霞", + "马小芳", + "马皇后" + ], + "爱弥斯": [ + "爱弥斯", + "飞行雪绒", + "飞行雪融", + "爱米斯", + "艾米斯", + "两抽", + "电子幽灵", + "ams", + "虚拟歌姬", + "电子歌姬", + "ciallo", + "粉七实", + "小爱", + "小爱同学", + "机兵", + "七实", + "歌姬" + ], + "珂莱塔": [ + "珂莱塔", + "klt", + "td", + "二小姐", + "冰土豆", + "冰枪", + "冰龙王", + "呀土豆", + "土豆", + "土豆地雷", + "土豆雷", + "小土豆", + "柯莱塔", + "柯莱特", + "欧泊", + "科莱塔", + "科莱特", + "莫塔里" + ], + "琳奈": [ + "琳奈", + "ln", + "Lynae", + "林奈", + "绫乃", + "琳乃", + "玲奈", + "绫奈", + "铃奈", + "叮咚鸡", + "叮咚女", + "神秘叮咚女", + "叮咚🐔", + "叮咚姬", + "神秘摩托女", + "摩托女" + ], + "白芷": [ + "白芷", + "bz", + "白纸", + "研究员" + ], + "相里要": [ + "相里要", + "xly", + "乡里要", + "向里要", + "里哥", + "香料", + "香梨", + "鸡哥", + "🐔哥" + ], + "秋水": [ + "秋水", + "qs" + ], + "秧秧": [ + "秧秧", + "yy", + "写信女", + "央央", + "女主角", + "女路人", + "殃殃", + "泱泱", + "看板娘", + "神秘写信女", + "路人女主", + "阳阳" + ], + "秧秧·玄翎": [ + "秧秧·玄翎", + "秧秧玄翎", + "秧秧sp", + "sp秧秧", + "xl", + "yyxl", + "玄翎", + "正宫", + "白月光", + "小蓝鸟", + "小剑雀", + "玄翎雀", + "没礼帽", + "软剑硬化工程师", + "玄令", + "玄羽" + ], + "穗穗": [ + "穗穗", + "穗者", + "万穗爷", + "惠惠", + "重明折花", + "suisui", + "金丝雀", + "小黄鸟", + "大小姐", + "宴江山", + "冷鸟", + "守秧人", + "守穗人", + "ss" + ], + "绯雪": [ + "绯雪", + "fx", + "菲雪", + "hiyuki", + "绯行雪绒", + "绯行雪融", + "神秘太刀女", + "飞雪" + ], + "维里奈": [ + "维里奈", + "verina", + "wln", + "xw", + "奈奈", + "妈妈", + "小维", + "星星花", + "⭐⭐🌸", + "⭐🌸", + "纳西妲", + "维", + "维利奈", + "维妈", + "维神", + "草神", + "豆芽" + ], + "莫宁": [ + "莫宁", + "守校人", + "轰炸姬", + "学妹", + "浮游炮", + "古德莫宁", + "mn", + "morning", + "Mornye" + ], + "莫特斐": [ + "莫特斐", + "mtf", + "模特非", + "石头人" + ], + "菲比": [ + "菲比", + "菲菲公主", + "fb", + "信教", + "修女", + "小蛋糕", + "教士", + "菲比啾比", + "菲芘", + "非比" + ], + "西格莉卡": [ + "西格莉卡", + "西格利卡", + "茜格莉卡", + "西西", + "xglk", + "莉卡", + "西格", + "格力", + "粑粑柑", + "耙耙柑", + "🍊", + "𤆵𤆵柑", + "罗伊女", + "弹弓女", + "柑柑橘", + "橘橘宝", + "砂糖桔", + "小橘子", + "贴身高手的校花", + "高手的贴身校花", + "橘子" + ], + "赞妮": [ + "赞妮", + "zn", + "咱妮", + "妮头车", + "打工人", + "泥头车", + "焰光的夜行者", + "猿神", + "社畜", + "蝙蝠侠", + "赞你", + "赞尼", + "金狮子", + "雇员" + ], + "达妮娅": [ + "达妮娅", + "达尼娅", + "达妮亚", + "达尼亚", + "粉切黑", + "达达尼亚", + "dny", + "娅娅", + "10521", + "好娅娅", + "水蜜桃" + ], + "釉瑚": [ + "釉瑚", + "yh", + "杂鱼", + "油壶", + "游湖", + "玉湖", + "雌小鬼" + ], + "鉴心": [ + "鉴心", + "小道士", + "道士", + "道馆" + ], + "锁暝": [ + "锁暝", + "锁瞑", + "门主", + "陀螺", + "魔界人", + "sm", + "锁冥" + ], + "长离": [ + "长离", + "cl", + "凤凰", + "火鸟", + "凰后", + "参事", + "常离", + "短离", + "离妃", + "离火", + "策士", + "长狸", + "长理" + ], + "陆·赫斯": [ + "陆·赫斯", + "陆赫斯", + "陆", + "🦌", + "🫎", + "6hz", + "lhs", + "心理医生", + "大镰刀", + "校医", + "陆・赫斯" + ], + "露帕": [ + "露帕", + "lp", + "lupa", + "七丘最强角斗士", + "旗手", + "最强角斗士", + "火队守岸人", + "狼女", + "索拉里斯最强角斗士", + "露怕", + "鲁帕", + "鹿帕" + ], + "露西": [ + "露西", + "Lucyna ", + "Lucy", + "lx", + "露西娜" + ] +} \ No newline at end of file diff --git a/tests/compatibility_manifest.json b/tests/compatibility_manifest.json index 06dcefd..891c47c 100644 --- a/tests/compatibility_manifest.json +++ b/tests/compatibility_manifest.json @@ -22,6 +22,14 @@ "key": "DailyLoliImageSource", "expr": "GsStrConfig('萝莉图片数据源', '选择 local 只使用本地萝莉图库;选择 gallery 优先使用远程图库接口,接口不可用时回退本地。默认 gallery', 'gallery', options=['local', 'gallery'])" }, + { + "key": "_DividerRoleAlias", + "expr": "GsDivider('角色别名', '')" + }, + { + "key": "DailyWifeAliasSource", + "expr": "GsStrConfig('角色别名数据源', '选择 off 关闭别名解析(默认);选择 xwuid 使用 XWUID 插件的别名表(未安装该插件时自动改用插件自带的别名表);选择 local 始终使用插件自带的别名表。别名用于「指定老婆」这类需要匹配角色名的场景,而指定角色仅限机器人主人或指定老婆白名单用户使用', 'off', options=['off', 'xwuid', 'local'])" + }, { "key": "DailyWifeCustomRolePilePath", "expr": "GsStrConfig('本地角色图片目录', '图片数据源为 local 时生效。留空时自动查找 gsuid_core/data/XutheringWavesUID/custom_role_pile', '')" diff --git a/tests/test_role_alias.py b/tests/test_role_alias.py new file mode 100644 index 0000000..062fc36 --- /dev/null +++ b/tests/test_role_alias.py @@ -0,0 +1,199 @@ +"""角色别名:默认用 XWUID 的别名表,没装该插件时回退插件自带的表,控制台可切换。 + +DailyWifeAliasSource 决定来源(xwuid / local)。测试注入假的 _cfg 与临时目录,覆盖 +「XWUID 可用时用它的表」「XWUID 不可用时回退插件自带表」「配置成 local 时只用自带表」 +「标准名不被顶掉」「未知名原样返回」以及 _normalize_role_name 的集成效果。 +""" + +import ast +import json +import tempfile +import unittest +from typing import Any +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ALIASES_PATH = ROOT / 'TodayWaifu' / 'aliases.py' +ROLES_PATH = ROOT / 'TodayWaifu' / 'roles.py' + +# 插件自带表(随插件分发) +BUNDLED = { + '丹瑾': ['丹瑾', 'dj', '丹谨', '小西王'], + '丽贝卡': ['丽贝卡', '大枪枪'], + '心': ['心', '心月狐'], +} +# XWUID 的两份表:内置 + 用户自定义 +XWUID_BUILTIN = { + '丹瑾': ['丹瑾', 'xwuid专属别名'], + '新角色': ['新角色', '别名一'], +} +XWUID_CUSTOM = { + '丹瑾': ['用户加的名'], +} + + +def _load_aliases_module(root: Path, source: str) -> dict[str, Any]: + """按 AST 加载 aliases.py,把 BASE_DIR / get_res_path / _cfg 换成可控实现。""" + tree = ast.parse(ALIASES_PATH.read_text(encoding='utf-8-sig')) + tree.body = [ + node + for node in tree.body + if not ( + isinstance(node, ast.ImportFrom) + and (node.level == 1 or node.module == 'gsuid_core.data_store') + ) + ] + globals_dict: dict[str, Any] = { + 'get_res_path': lambda name='': root if not name else root / name, + 'BASE_DIR': root, + '_cfg': lambda key: source if key == 'DailyWifeAliasSource' else None, + } + exec(compile(tree, str(ALIASES_PATH), 'exec'), globals_dict) + return globals_dict + + +def _write_json(path: Path, payload: dict[str, list[str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False), encoding='utf-8') + + +def _write_bundled(root: Path) -> None: + _write_json(root / 'role_aliases.json', BUNDLED) + + +def _write_xwuid(root: Path) -> None: + _write_json(root / 'XutheringWavesUID' / 'resource' / 'map' / 'alias' / 'char_alias.json', XWUID_BUILTIN) + _write_json(root / 'XutheringWavesUID' / 'alias' / 'char_alias.json', XWUID_CUSTOM) + + +class AliasSourceTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_prefers_xwuid_when_installed(self) -> None: + """装了 XWUID:走它的表,自带表不参与。""" + _write_bundled(self.root) + _write_xwuid(self.root) + mod = _load_aliases_module(self.root, 'xwuid') + self.assertEqual(mod['alias_source'](), 'xwuid') + self.assertEqual(mod['resolve_role_name']('xwuid专属别名'), '丹瑾') + self.assertEqual(mod['resolve_role_name']('用户加的名'), '丹瑾') + # 自带表独有、XWUID 没有的别名不应生效 + self.assertEqual(mod['resolve_role_name']('小西王'), '小西王') + + def test_falls_back_to_bundled_when_xwuid_missing(self) -> None: + """核心诉求:没装 XWUID 时自动回退插件自带的别名表。""" + _write_bundled(self.root) + mod = _load_aliases_module(self.root, 'xwuid') + self.assertEqual(mod['alias_source'](), 'local') + self.assertEqual(mod['resolve_role_name']('小西王'), '丹瑾') + self.assertEqual(mod['resolve_role_name']('心月狐'), '心') + + def test_configured_local_ignores_xwuid(self) -> None: + """控制台选 local 时,即使装了 XWUID 也只用自带表。""" + _write_bundled(self.root) + _write_xwuid(self.root) + mod = _load_aliases_module(self.root, 'local') + self.assertEqual(mod['alias_source'](), 'local') + self.assertEqual(mod['resolve_role_name']('小西王'), '丹瑾') + self.assertEqual(mod['resolve_role_name']('xwuid专属别名'), 'xwuid专属别名') + + def test_disabled_by_default(self) -> None: + """默认关闭:off 时不做任何别名解析。""" + _write_bundled(self.root) + _write_xwuid(self.root) + mod = _load_aliases_module(self.root, 'off') + self.assertEqual(mod['alias_source'](), 'off') + self.assertEqual(mod['resolve_role_name']('小西王'), '小西王') + self.assertEqual(mod['resolve_role_name']('xwuid专属别名'), 'xwuid专属别名') + + def test_unknown_config_value_disables_aliases(self) -> None: + """配置值异常时保守处理:关闭别名,而不是挑一份表来用。""" + _write_bundled(self.root) + mod = _load_aliases_module(self.root, '某个奇怪的值') + self.assertEqual(mod['alias_source'](), 'off') + self.assertEqual(mod['resolve_role_name']('小西王'), '小西王') + + +class AliasResolveTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + _write_bundled(self.root) + _write_xwuid(self.root) + self.mod = _load_aliases_module(self.root, 'xwuid') + self.resolve = self.mod['resolve_role_name'] + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_standard_name_stays_itself(self) -> None: + for name in ('丹瑾', '丽贝卡', '心', '新角色'): + with self.subTest(name=name): + self.assertEqual(self.resolve(name), name) + + def test_unknown_name_returned_as_is(self) -> None: + self.assertEqual(self.resolve('不存在的角色'), '不存在的角色') + self.assertEqual(self.resolve(''), '') + + def test_strips_surrounding_whitespace(self) -> None: + self.assertEqual(self.resolve(' xwuid专属别名 '), '丹瑾') + + def test_all_tables_missing_degrades_to_identity(self) -> None: + """两份表都不存在时不能报错,名字原样返回。""" + with tempfile.TemporaryDirectory() as empty: + mod = _load_aliases_module(Path(empty), 'xwuid') + self.assertEqual(mod['resolve_role_name']('小西王'), '小西王') + + def test_broken_json_is_skipped(self) -> None: + """XWUID 自定义表损坏时跳过它,其余表继续生效。""" + bad = self.root / 'XutheringWavesUID' / 'alias' / 'char_alias.json' + bad.write_text('{不是合法 JSON', encoding='utf-8') + mod = _load_aliases_module(self.root, 'xwuid') + self.assertEqual(mod['resolve_role_name']('xwuid专属别名'), '丹瑾') + + def test_index_rebuilds_when_files_change(self) -> None: + """别名表更新后要能热生效(按 mtime 重建索引)。""" + self.assertEqual(self.resolve('新别名'), '新别名') + bundled = self.root / 'role_aliases.json' + data = json.loads(bundled.read_text(encoding='utf-8')) + data['丹瑾'] = [*data['丹瑾'], '新别名'] + _write_json(bundled, data) + # 当前用的是 XWUID 表,改自带表不该影响结果 + self.assertEqual(self.resolve('新别名'), '新别名') + builtin = self.root / 'XutheringWavesUID' / 'resource' / 'map' / 'alias' / 'char_alias.json' + data = json.loads(builtin.read_text(encoding='utf-8')) + data['丹瑾'] = [*data['丹瑾'], '新别名'] + _write_json(builtin, data) + self.assertEqual(self.resolve('新别名'), '丹瑾') + + def test_bundled_alias_path_points_into_plugin_root(self) -> None: + self.assertEqual(self.mod['bundled_alias_path'](), self.root / 'role_aliases.json') + + +class NormalizeRoleNameTests(unittest.TestCase): + def test_normalize_applies_alias_and_separator(self) -> None: + globals_dict: dict[str, Any] = {'resolve_role_name': lambda name: '丹瑾' if name == '小西王' else name} + tree = ast.parse(ROLES_PATH.read_text(encoding='utf-8-sig')) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == '_normalize_role_name' + ) + future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0) + module = ast.Module(body=[future, function], type_ignores=[]) + ast.fix_missing_locations(module) + exec(compile(module, str(ROLES_PATH), 'exec'), globals_dict) + normalize = globals_dict['_normalize_role_name'] + + self.assertEqual(normalize('小西王'), '丹瑾') + self.assertEqual(normalize('折枝・x'), '折枝·x') + self.assertEqual(normalize(' 丽贝卡 '), '丽贝卡') + + +if __name__ == '__main__': + unittest.main()