mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-10 05:48:07 +08:00
472 lines
20 KiB
Python
472 lines
20 KiB
Python
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",
|
||
"未识别到媒体信息,类型:电视剧,id:123": "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 == []
|