Files
MoviePilot/tests/test_locale_helper.py
2026-07-07 06:04:45 +08:00

472 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ast
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from fastapi import HTTPException
from app.factory import localized_http_exception_handler
from app.helper.locale import LocaleHelper
from app.helper.progress import ProgressHelper
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
from app.schemas.response import Response
def _has_chinese(text: str) -> bool:
"""判断文本是否包含中文字符。"""
return any("\u4e00" <= char <= "\u9fff" for char in text)
def _sample_message_expression(value: ast.AST) -> list[str]:
"""从接口 message 表达式中生成用于翻译校验的样本文本。"""
if isinstance(value, ast.Constant) and isinstance(value.value, str):
return [value.value]
if isinstance(value, ast.JoinedStr):
parts = []
has_chinese = False
index = 0
for item in value.values:
if isinstance(item, ast.Constant) and isinstance(item.value, str):
parts.append(item.value)
has_chinese = has_chinese or _has_chinese(item.value)
else:
index += 1
parts.append(f"样例{index}")
return ["".join(parts)] if has_chinese else []
if isinstance(value, ast.IfExp):
return _sample_message_expression(value.body) + _sample_message_expression(value.orelse)
if isinstance(value, ast.BoolOp):
samples = []
for item in value.values:
samples.extend(_sample_message_expression(item))
return samples
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add):
left_samples = _sample_message_expression(value.left)
right_samples = _sample_message_expression(value.right)
if left_samples and right_samples:
return [left + right for left in left_samples for right in right_samples]
if left_samples:
return [f"{left}样例" for left in left_samples]
if right_samples:
return [f"样例{right}" for right in right_samples]
return []
def _sample_progress_text_expression(value: ast.AST) -> list[str]:
"""从进度 text 表达式中生成用于翻译校验的样本文本。"""
if isinstance(value, ast.Constant) and isinstance(value.value, str):
return [value.value]
if isinstance(value, ast.JoinedStr):
samples = [""]
placeholder_index = 0
for item in value.values:
if isinstance(item, ast.Constant) and isinstance(item.value, str):
choices = [item.value]
elif isinstance(item, ast.FormattedValue):
choices = [
sample
for sample in _sample_progress_text_expression(item.value)
if _has_chinese(sample)
]
if not choices:
placeholder_index += 1
choices = [f"样例{placeholder_index}"]
else:
placeholder_index += 1
choices = [f"样例{placeholder_index}"]
samples = [prefix + choice for prefix in samples for choice in choices]
return [sample for sample in samples if _has_chinese(sample)]
if isinstance(value, ast.IfExp):
return (
_sample_progress_text_expression(value.body)
+ _sample_progress_text_expression(value.orelse)
)
if isinstance(value, ast.BoolOp):
samples = []
for item in value.values:
samples.extend(_sample_progress_text_expression(item))
return samples
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add):
left_samples = _sample_progress_text_expression(value.left)
right_samples = _sample_progress_text_expression(value.right)
if left_samples and right_samples:
return [left + right for left in left_samples for right in right_samples]
if left_samples:
return [f"{left}样例" for left in left_samples]
if right_samples:
return [f"样例{right}" for right in right_samples]
return []
def _is_progress_text_call(node: ast.Call) -> bool:
"""判断调用是否可能写入前端展示的进度文本。"""
func = ast.unparse(node.func)
return (
func.endswith("progress_callback")
or func.endswith(".update")
or func.endswith(".end")
)
def test_locale_helper_normalizes_supported_aliases():
"""语言别名应规范化为项目支持的语言标识。"""
assert LocaleHelper.normalize_locale("en") == "en-US"
assert LocaleHelper.normalize_locale("zh_Hant") == "zh-TW"
assert LocaleHelper.normalize_locale("unknown") == "zh-CN"
def test_locale_helper_reads_request_headers_by_priority():
"""请求语言应优先使用显式前端语言头,再回退 Accept-Language。"""
request = SimpleNamespace(
headers={
"x-moviepilot-locale": "en-US",
"accept-language": "zh-TW,zh;q=0.9",
}
)
assert LocaleHelper.get_locale_from_request(request) == "en-US"
def test_locale_helper_reads_query_locale_before_headers():
"""SSE 请求可通过查询参数显式指定前端语言。"""
request = SimpleNamespace(
query_params={"locale": "zh-TW"},
headers={
"x-moviepilot-locale": "en-US",
"accept-language": "en-US",
},
)
assert LocaleHelper.get_locale_from_request(request) == "zh-TW"
def test_locale_helper_parses_accept_language_quality():
"""Accept-Language 应按 q 权重选择支持的语言。"""
request = SimpleNamespace(
headers={
"accept-language": "fr-FR, en-US;q=0.8, zh-TW;q=0.9",
}
)
assert LocaleHelper.get_locale_from_request(request) == "zh-TW"
def test_locale_helper_translates_module_name_and_keeps_missing_text():
"""翻译存在时返回目标语言,缺失时返回默认文本。"""
assert LocaleHelper.translate(
"system.modules.DoubanModule.name",
locale="en-US",
default="豆瓣",
) == "Douban"
assert LocaleHelper.translate_text("模块不支持测试", locale="en-US") == "Module does not support testing"
assert LocaleHelper.translate_text(
"requirements.txt 文件下载失败", locale="en-US"
) == "Failed to download requirements.txt"
assert LocaleHelper.translate_text("未收录的中文错误", locale="en-US") == "未收录的中文错误"
def test_locale_helper_translates_dynamic_message_patterns():
"""动态消息应保留变量并翻译模板文本。"""
assert LocaleHelper.translate_text(
"无法连接Qbittorrent下载器默认",
locale="en-US",
) == "Unable to connect to qBittorrent downloader: 默认"
assert LocaleHelper.translate_text(
"无法连接 api.themoviedb.org错误码403",
locale="en-US",
) == "Unable to connect to api.themoviedb.org, error code: 403"
assert LocaleHelper.translate_text(
"飞书 工作 未就绪",
locale="zh-TW",
) == "飛書 工作 尚未就緒"
def test_locale_helper_translates_common_backend_response_messages():
"""常见后端链路消息应能生成英文展示文本。"""
samples = {
"QQ Bot 默认 未就绪": "QQ Bot 默认 is not ready",
"微信 ClawBot 工作 未就绪:未登录": "WeChat ClawBot 工作 is not ready: 未登录",
"无法打开网站!": "Unable to open the site!",
"错误403 Forbidden": "Error: 403 Forbidden",
"站点【https://example.com】不存在": "Site [https://example.com] does not exist",
"下载字幕文件失败状态码404 Not Found": "Failed to download subtitle file, status code: 404 Not Found",
"未获取到第 2 季的总集数": "Unable to get the total episode count for season 2",
"电影.mkv 已在整理队列中": "电影.mkv is already in the organization queue",
"未识别到媒体信息类型电视剧id123": "Unable to recognize media information, type: 电视剧, id: 123",
"默认 的下载目录 /downloads 不存在": "Download directory for 默认 does not exist: /downloads",
"不支持 Local 到 Alist 的文件整理": "File organization from Local to Alist is not supported",
"文件 /tmp/a.mkv 不存在": "File /tmp/a.mkv does not exist",
"添加种子任务失败:种子无效": "Failed to add torrent task: 种子无效",
"检查授权状态失败: timeout": "Failed to check authorization status: timeout",
"未找到名为 工作 的微信 ClawBot 通知配置": (
"No WeChat ClawBot notification configuration named 工作 was found"
),
"整理记录不存在: 1, 2": "Organization record does not exist: 1, 2",
"插件要求 MoviePilot 版本 >=2.14.0,当前版本 2.13.0 不满足,已拒绝安装": (
"The plugin requires MoviePilot version >=2.14.0, but current version 2.13.0 "
"does not satisfy it. Installation was rejected"
),
"已安排一次性 release 升级并重启": "Scheduled one-shot release upgrade and restart",
"已将微信 ClawBot 登录缓存从 old 迁移到 new": (
"Migrated WeChat ClawBot login cache from old to new"
),
"搜索完成,共 3 个资源": "Search completed, 3 resources",
"正在搜索关键字,已完成 1 / 6 个请求 ...": (
"Searching 关键字, completed 1/6 requests ..."
),
"正在搜索字幕关键字,已完成 1 / 6 个请求 ...": (
"Searching subtitles 关键字, completed 1/6 requests ..."
),
"未开启任何支持字幕搜索的有效站点,无法搜索字幕": (
"No valid subtitle-search site is enabled, unable to search subtitles"
),
"用户名或密码错误": "Incorrect username or password",
"工具 'query_schedulers' 未找到": "Tool 'query_schedulers' was not found",
"插件 test 不存在或未加载": "Plugin test does not exist or is not loaded",
"站点 1 不存在": "Site 1 does not exist",
"智能助手未启用,请先在系统设置中开启。": (
"The assistant is not enabled. Enable it in system settings first."
),
"智能助手执行失败: timeout": "Assistant execution failed: timeout",
}
for message, expected in samples.items():
assert LocaleHelper.translate_text(message, locale="en-US") == expected
def test_response_auto_fills_message_i18n_from_locale_context():
"""通用 Response 应根据请求语言上下文自动补充多语言消息。"""
token = LocaleHelper.set_current_locale("en-US")
try:
response = Response(success=False, message="模块不支持测试")
finally:
LocaleHelper.reset_current_locale(token)
assert response.message == "模块不支持测试"
assert response.message_i18n == "Module does not support testing"
def test_http_exception_handler_adds_detail_i18n_from_locale_context():
"""HTTPException 响应应补充多语言 detail 字段。"""
token = LocaleHelper.set_current_locale("en-US")
try:
response = asyncio.run(
localized_http_exception_handler(
None,
HTTPException(status_code=401, detail="用户名或密码错误"),
)
)
finally:
LocaleHelper.reset_current_locale(token)
payload = json.loads(response.body)
assert payload["detail"] == "用户名或密码错误"
assert payload["detail_i18n"] == "Incorrect username or password"
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
"""通用进度字典应返回展示字段翻译,同时保留缓存中的原始中文。"""
progress = ProgressHelper("__test_i18n_progress")
progress.start()
progress.update(
text="开始同步媒体服务器,共 2 个 ...",
data={"error": "后台服务不存在"},
)
detail = progress.get(locale="en-US")
assert detail is not None
assert detail["text"] == "开始同步媒体服务器,共 2 个 ..."
assert detail["text_i18n"] == "Starting media server sync, 2 servers ..."
assert detail["data"]["error"] == "后台服务不存在"
assert detail["data"]["error_i18n"] == "Background service does not exist"
detail["data"]["error_i18n"] = "mutated"
second_detail = progress.get(locale="en-US")
assert second_detail is not None
assert second_detail["data"]["error_i18n"] == "Background service does not exist"
def test_schedule_info_auto_fills_i18n_display_fields():
"""后台服务数据应补充前端展示所需的多语言字段。"""
token = LocaleHelper.set_current_locale("en-US")
try:
schedule = ScheduleInfo(
id="mediaserver_sync",
name="同步媒体服务器",
provider="[系统]",
status="等待",
next_run="2小时3分钟",
progress_text="开始同步媒体服务器,共 2 个 ...",
progress_detail=ScheduleProgress(
id="mediaserver_sync",
name="同步媒体服务器",
provider="[系统]",
text="媒体服务器 Emby 无可同步媒体库",
error="后台服务不存在",
),
)
finally:
LocaleHelper.reset_current_locale(token)
assert schedule.name_i18n == "Sync Media Servers"
assert schedule.provider_i18n == "[System]"
assert schedule.status_i18n == "Waiting"
assert schedule.next_run_i18n == "2h 3m"
assert schedule.progress_text_i18n == "Starting media server sync, 2 servers ..."
assert schedule.progress_detail.text_i18n == "Media server Emby has no libraries to sync"
assert schedule.progress_detail.error_i18n == "Background service does not exist"
def test_scheduler_progress_patterns_translate_dynamic_texts():
"""定时任务进度动态模板应翻译固定词并保留业务变量。"""
assert (
LocaleHelper.translate_text("同步媒体服务器 开始执行 ...", locale="en-US")
== "Starting Sync Media Servers ..."
)
assert (
LocaleHelper.translate_text("同步媒体服务器 执行完成", locale="en-US")
== "Sync Media Servers completed"
)
assert (
LocaleHelper.translate_text("同步媒体服务器 执行失败", locale="zh-TW")
== "同步媒體伺服器 執行失敗"
)
assert (
LocaleHelper.translate_text("工作流动作1/3动作A 执行完成", locale="en-US")
== "Workflow action (1/3) 动作A completed"
)
def test_scheduler_progress_texts_have_english_translations():
"""定时任务相关进度文案应有英文翻译,避免前端切英文后回退中文。"""
untranslated = []
progress_paths = [
Path("app/scheduler.py"),
Path("app/chain/torrents.py"),
Path("app/chain/mediaserver.py"),
Path("app/chain/site.py"),
Path("app/chain/recommend.py"),
Path("app/chain/transfer.py"),
Path("app/chain/subscribe.py"),
Path("app/chain/workflow.py"),
]
for path in progress_paths:
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not _is_progress_text_call(node):
continue
for keyword in node.keywords:
if keyword.arg != "text":
continue
for message in _sample_progress_text_expression(keyword.value):
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{keyword.value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_literal_messages_have_english_translations():
"""接口直接返回的中文 message 应有英文翻译,避免前端切英文后回退中文。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
for keyword in node.keywords:
if keyword.arg != "message":
continue
value = keyword.value
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
continue
message = value.value
if not any("\u4e00" <= char <= "\u9fff" for char in message):
continue
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_dynamic_messages_have_english_translations():
"""接口动态拼接的中文 message 应由模板翻译覆盖。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
for keyword in node.keywords:
if keyword.arg != "message":
continue
value = keyword.value
if not isinstance(value, ast.JoinedStr):
continue
parts = []
has_chinese = False
index = 0
for item in value.values:
if isinstance(item, ast.Constant) and isinstance(item.value, str):
parts.append(item.value)
has_chinese = has_chinese or _has_chinese(item.value)
else:
index += 1
parts.append(f"样例{index}")
if not has_chinese:
continue
message = "".join(parts)
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_message_expressions_have_english_translations():
"""接口 message 表达式中的中文分支应有英文翻译。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if ast.unparse(node.func) not in {"schemas.Response", "Response"}:
continue
for keyword in node.keywords:
if keyword.arg != "message":
continue
for message in _sample_message_expression(keyword.value):
if not _has_chinese(message):
continue
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{keyword.value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_http_exception_details_have_english_translations():
"""HTTPException 的中文 detail 应有英文翻译。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if not ast.unparse(node.func).endswith("HTTPException"):
continue
for keyword in node.keywords:
if keyword.arg != "detail":
continue
for message in _sample_message_expression(keyword.value):
if not _has_chinese(message):
continue
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{keyword.value.lineno}:{message}")
assert untranslated == []