diff --git a/data/io.github.sam_fic.filecollector.metainfo.xml b/data/io.github.sam_fic.filecollector.metainfo.xml index e37d98b..4d74e7f 100644 --- a/data/io.github.sam_fic.filecollector.metainfo.xml +++ b/data/io.github.sam_fic.filecollector.metainfo.xml @@ -32,7 +32,7 @@
  • Choosing Chinese or English in preferences now takes effect on the Windows portable package: gettext ignored the LANGUAGE variable whenever the locale fell back to "C" (the default when no LANG/LC_* variable is set), so the setting appeared to do nothing; the app now sets LANG alongside LANGUAGE
  • "Follow System" now actually follows the Windows user locale (e.g. zh-CN): the bundled gettext build cannot see the Windows locale on its own, so the app resolves it via g_win32_getlocale and feeds it to LANG
  • The resolved locale survives application restarts (previously the locale env left behind by a restart chain could pin the old language), while a deliberately set external LANG is still respected
  • -
  • Windows packaging CI now runs a runtime i18n smoke test (CLI --help must come out in Chinese), so a broken translation chain fails the build instead of reaching users
  • +
  • Windows packaging CI now runs a runtime i18n smoke test that checks the packaged binary can load the zh_CN catalog for CLI --help; it validates catalog reachability, not the preference or follow-system language paths (that coverage lands in the following patch release)
  • @@ -40,7 +40,7 @@

    Project file compatibility fixes:

    diff --git a/docs/I18N.md b/docs/I18N.md index 77ab607..98609f2 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -114,7 +114,13 @@ sudo meson install | 新增含 `_()` 的源文件 | 将路径添加到 `POTFILES` → 重新生成 `.pot` → 合并 → 翻译 | | 新增 `.blp` 文件中的字符串 | 先编译 Blueprint → 重新生成 `.pot` → 合并 → 翻译 | | 新增界面语言 | 在 `LINGUAS` 添加语言代码 → 创建 `.po` → 在 `meson.build` 的 `languages` 数组添加 → 翻译 | -| 临时切换运行语言 | Linux/macOS:`LANGUAGE=en filecollector`,对 GUI 和 CLI 均生效;**Windows 下 `LANGUAGE` 会被 gettext 忽略**(无 `LANG`/`LC_*` 变量时 locale 落回 "C",这是 GNU gettext 的既定行为),须改用 `LANG=zh_CN.UTF-8 filecollector` 或 `LANG=en filecollector` | +| 临时切换运行语言 | Linux/macOS:`LANGUAGE=en filecollector`,对 GUI 和 CLI 均生效;Windows:GNU gettext **仅在当前 locale 为 "C" 时**忽略 `LANGUAGE`(无 `LANG`/`LC_*` 变量时 locale 落回 "C"),故临时切换用 `LANG=zh_CN.UTF-8 filecollector` 或 `LANG=en filecollector` | + +**语言环境的生效范围**:偏好设置里的「中文 / English / 跟随系统」在 `main()` 里、`app.run()` 之前统一应用,GUI、纯 CLI(`--help` / `--load` / `--export`)和二次实例走同一条链路。Windows 上: + +- 显式选择中文或英文时,应用会同时设置 `LANGUAGE` / `LANG` 并清掉外部的 `LC_ALL` / `LC_MESSAGES`(它们的优先级高于 `LANG`,残留 `LC_ALL=C` 会让设置失效)。 +- 「跟随系统」通过 `g_win32_getlocale()` 读取 Windows 用户 locale;若外部明确设置了真实语言(`LC_ALL` / `LC_MESSAGES` / `LANG` 之一),则尊重外部设置,仅在变量为空或为 `C` / `C.*` / `POSIX` 时才接管。 +- Windows 打包脚本的 i18n 冒烟测试使用干净环境与临时 `XDG_CONFIG_HOME`,由应用自己解析设置,不再靠外部注入 `LANG` 来「绕过」这段代码。 --- diff --git a/src/cli.vala b/src/cli.vala index 8ca98d5..438704a 100644 --- a/src/cli.vala +++ b/src/cli.vala @@ -501,32 +501,18 @@ public class CliController : GLib.Object { checked_paths.clear (); checked_dirs.clear (); - // 字段名兼容: 本版写 checked_files, Flet 版写 checked_entries. 优先读自己的, - // 缺失时回退到另一个, 两个版本互相打开项目时勾选状态不丢. - Json.Array? checked_arr = null; - if (root.has_member ("checked_files")) { - checked_arr = root.get_array_member ("checked_files"); - } else if (root.has_member ("checked_entries")) { - checked_arr = root.get_array_member ("checked_entries"); - } - if (checked_arr != null) { - for (int i = 0; i < checked_arr.get_length (); i++) { - var p = checked_arr.get_string_element (i); - // Flet 旧文件可能是对象数组等非字符串元素, get_string_element - // 对其返回 null, 跳过而不是把 null 收进集合 - if (p == null || p == "") continue; - var canonical = ProjectManager.canonicalize_checked_path (p, work_dir); - if (canonical != null) { - checked_paths.add (canonical); - } + // 与 ProjectManager.load_project_file 共用字段兼容 / null 防御 / 路径 + // 归一化规则, 并同样按存在性过滤, 保证 GUI 与 CLI 加载结果一致, + // 也不会把不存在的路径经 --save 写回项目文件。 + foreach (var p in ProjectManager.read_string_array (root, "checked_files", "checked_entries")) { + var canonical = ProjectManager.canonicalize_checked_path (p, work_dir); + if (canonical != null && File.new_for_path (canonical).query_exists ()) { + checked_paths.add (canonical); } } - var checked_dirs_arr = root.has_member ("checked_dirs") ? root.get_array_member ("checked_dirs") : null; - if (checked_dirs_arr != null) { - for (int i = 0; i < checked_dirs_arr.get_length (); i++) { - checked_dirs.add (checked_dirs_arr.get_string_element (i)); - } + foreach (var d in ProjectManager.read_string_array (root, "checked_dirs", "")) { + checked_dirs.add (d); } var items_arr = root.has_member ("items") ? root.get_array_member ("items") : null; @@ -571,11 +557,8 @@ public class CliController : GLib.Object { } common_phrases.clear (); - var phrases_arr = root.has_member ("common_phrases") ? root.get_array_member ("common_phrases") : null; - if (phrases_arr != null) { - for (int i = 0; i < phrases_arr.get_length (); i++) { - common_phrases.add (phrases_arr.get_string_element (i)); - } + foreach (var phrase in ProjectManager.read_string_array (root, "common_phrases", "")) { + common_phrases.add (phrase); } stdout.printf (_("✓ Loaded from project file: %s (%d items)\n"), path, items.size); diff --git a/src/main.vala b/src/main.vala index 434a998..2da6f73 100644 --- a/src/main.vala +++ b/src/main.vala @@ -78,6 +78,93 @@ public class FileCollectorApp : Adw.Application { setup_app_icon_resource (); } + // 语言环境必须在 GApplication 启动之前统一准备: startup() 早于 + // command_line(), 而纯 CLI 分支会在 command_line() 里直接 return。 + // 把设置留在 command_line() 会让 --help / --load / --export 全部跳过它。 + private static void apply_language_environment () { + var lang_setting = FileCollectorWindow.load_settings_language (); + if (lang_setting == "en") { + GLib.Environment.set_variable ("LANGUAGE", "en", true); +#if WINDOWS + // Windows 的 gettext 只在当前 locale 非 "C" 时才读取 LANGUAGE + // (GNU gettext 既定行为); 无 LANG/LC_* 时 locale 落回 "C", + // LANGUAGE 被整体忽略 -> 语言设置永远只有英文。 + // LC_ALL / LC_MESSAGES 优先级高于 LANG, 必须一并摘掉, 否则从 + // Git Bash / MSYS2 (常见 LC_ALL=C) 启动时设置依旧失效。 + GLib.Environment.set_variable ("LANG", "en", true); + GLib.Environment.unset_variable ("LC_ALL"); + GLib.Environment.unset_variable ("LC_MESSAGES"); + GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true); +#endif + } else if (lang_setting == "zh") { + GLib.Environment.set_variable ("LANGUAGE", "zh_CN", true); +#if WINDOWS + GLib.Environment.set_variable ("LANG", "zh_CN.UTF-8", true); + GLib.Environment.unset_variable ("LC_ALL"); + GLib.Environment.unset_variable ("LC_MESSAGES"); + GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true); +#endif + } else { + GLib.Environment.unset_variable ("LANGUAGE"); +#if WINDOWS + follow_windows_system_locale (); +#endif + } + + Intl.setlocale (LocaleCategory.ALL, ""); + setup_i18n_default (); + } + +#if WINDOWS + // "C" / "C.*" / "POSIX" / 空都不表达语言偏好, 可由应用覆盖。 + private static bool is_placeholder_locale (string? value) { + string v = value ?? ""; + return v == "" || v == "C" || v == "POSIX" || v.has_prefix ("C."); + } + + // g_win32_getlocale() 无环境变量时返回 "xx_YY" 或 "xx_YY@Latn"。 + // POSIX locale 名里 codeset 必须排在 @modifier 之前, 直接拼 + // "xx_YY@Latn" + ".UTF-8" 会得到 UCRT 无法解析的垃圾值。 + private static string build_windows_lang (string sys_locale) { + string base_name = sys_locale; + string modifier = ""; + int at = base_name.index_of ("@"); + if (at > 0) { + modifier = base_name.substring (at); + base_name = base_name.substring (0, at); + } + if (base_name.contains (".")) return base_name + modifier; + return base_name + ".UTF-8" + modifier; + } + + private static void follow_windows_system_locale () { + // g_win32_getlocale() 按 LC_ALL -> LC_MESSAGES -> LANG 原样返回首个 + // 非空值, 只有三者全空才查 GetThreadLocale。重启子进程带着上一会话 + // 写入的 LANG (再拼 .UTF-8 会得到垃圾值), Git Bash 还会塞 + // LANG=C.UTF-8, 所以取系统 locale 前必须把三个变量全部摘掉。 + bool lang_is_ours = GLib.Environment.get_variable ("FILECOLLECTOR_LANG_MANAGED") != null; + bool external_override = + !is_placeholder_locale (GLib.Environment.get_variable ("LC_ALL")) || + !is_placeholder_locale (GLib.Environment.get_variable ("LC_MESSAGES")) || + (!lang_is_ours && !is_placeholder_locale (GLib.Environment.get_variable ("LANG"))); + if (external_override) return; // 用户在 shell 里明确设了真实语言, 尊重 + + GLib.Environment.unset_variable ("LC_ALL"); + GLib.Environment.unset_variable ("LC_MESSAGES"); + GLib.Environment.unset_variable ("LANG"); + // 不恢复被摘掉的变量: 能走到这里说明它们要么由本应用写入, 要么是 + // "C"/空; 恢复 C 会反过来压过 LANG, 让 gettext 再次忽略 LANGUAGE。 + var sys_locale = GLib.Win32.getlocale (); + if (sys_locale != null && sys_locale.length > 0 && sys_locale != "C") { + GLib.Environment.set_variable ("LANG", build_windows_lang (sys_locale), true); + GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true); + } else { + GLib.Environment.unset_variable ("LANG"); + GLib.Environment.unset_variable ("FILECOLLECTOR_LANG_MANAGED"); + } + } +#endif + protected override int command_line (ApplicationCommandLine command_line) { var args = command_line.get_arguments (); @@ -124,85 +211,12 @@ public class FileCollectorApp : Adw.Application { return 0; } + // 语言环境已在 main() 里统一准备, GUI / CLI / 二次实例走同一条链路。 if (!force_gui && has_cli_args) { - Intl.setlocale (LocaleCategory.ALL, ""); - setup_i18n_default (); var cli = new CliController (); return cli.run (filtered_args); } - var lang_setting = FileCollectorWindow.load_settings_language (); - if (lang_setting == "en") { - GLib.Environment.set_variable ("LANGUAGE", "en", true); -#if WINDOWS - // Windows 的 gettext 只在当前 locale 非 "C" 时才读取 LANGUAGE - // (GNU gettext 的既定行为); 而此环境在无 LANG/LC_* 变量时 locale - // 落回 "C", LANGUAGE 被整体忽略 -> 语言设置永远只有英文。 - // 实测须同时设 LANG 让 locale 脱离 "C": - // 仅 LANGUAGE=zh_CN -> 英文 - // LANGUAGE=zh_CN + LANG=zh_CN.UTF-8 -> 中文 - // zh 用 "zh_CN.UTF-8" 是为了与 locale/ 下的目录名一致 (gettext - // 按 LANG 值逐级尝试 zh_CN -> zh 查找 .mo), 且 UCRT 可解析。 - GLib.Environment.set_variable ("LANG", "en", true); - GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true); -#endif - } else if (lang_setting == "zh") { - GLib.Environment.set_variable ("LANGUAGE", "zh_CN", true); -#if WINDOWS - GLib.Environment.set_variable ("LANG", "zh_CN.UTF-8", true); - GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true); -#endif - } else { - GLib.Environment.unset_variable ("LANGUAGE"); -#if WINDOWS - // 跟随系统: 此 gettext 构建在没有 LANG/LC_* 变量时拿不到 Windows - // 的用户语言 (locale 落回 "C" -> 英文, 见上方注释), 因此把 Windows - // 用户 locale 翻成 Unix 风格名字 (zh-CN -> "zh_CN") 喂给 LANG。 - // - // g_win32_getlocale() 的实现是: 有 LANG/LC_* 环境变量时原样返回 - // 其值, 只有全空才查 Windows (GetThreadLocale)。重启子进程总带着 - // 上一会话写入的 LANG (拼 .UTF-8 会得到垃圾值), Git Bash 还会塞 - // LANG=C.UTF-8 —— 所以先临时摘掉这三个变量, 强制走 Win32 路径。 - // - // FILECOLLECTOR_LANG_MANAGED 标记 LANG 是否由本应用写入: - // - 有标记 (重启链路) -> 无条件覆盖, 保证 zh/en 切回"跟随系统" - // 时不会残留旧语言; - // - 无标记但 LANG 是 "C"/"C.*"/"POSIX" (Git Bash 默认, 不表达 - // 语言偏好) -> 视同未设置, 覆盖为系统 locale; - // - 无标记且 LANG 有真实语言 (用户在 shell 里故意设的) -> 尊重。 - bool lang_is_ours = GLib.Environment.get_variable ("FILECOLLECTOR_LANG_MANAGED") != null; - string inherited = GLib.Environment.get_variable ("LANG") ?? ""; - bool lang_is_c = inherited == "C" || inherited.has_prefix ("C.") || inherited == "POSIX"; - if (lang_is_ours || inherited.length == 0 || lang_is_c) { - string? saved_lc_all = GLib.Environment.get_variable ("LC_ALL"); - string? saved_lc_msg = GLib.Environment.get_variable ("LC_MESSAGES"); - string? saved_lang = GLib.Environment.get_variable ("LANG"); - GLib.Environment.unset_variable ("LC_ALL"); - GLib.Environment.unset_variable ("LC_MESSAGES"); - GLib.Environment.unset_variable ("LANG"); - var sys_locale = GLib.Win32.getlocale (); - if (saved_lc_all != null) { - GLib.Environment.set_variable ("LC_ALL", saved_lc_all, true); - } - if (saved_lc_msg != null) { - GLib.Environment.set_variable ("LC_MESSAGES", saved_lc_msg, true); - } - if (saved_lang != null) { - GLib.Environment.set_variable ("LANG", saved_lang, true); - } - if (sys_locale != null && sys_locale.length > 0 && !sys_locale.has_prefix ("C")) { - GLib.Environment.set_variable ("LANG", sys_locale + ".UTF-8", true); - GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true); - } else { - GLib.Environment.unset_variable ("LANG"); - } - } -#endif - } - - Intl.setlocale (LocaleCategory.ALL, ""); - setup_i18n_default (); - activate (); if (force_gui && has_cli_args) { @@ -512,6 +526,9 @@ public class FileCollectorApp : Adw.Application { // 先检测并接管父进程控制台 (管道环境下为空操作) win32_console_init (); #endif + // 必须在 app.run() 之前: startup() 与纯 CLI 分支都早于 command_line() + // 的自定义逻辑, 语言设置放在那里会漏掉 CLI 入口。 + apply_language_environment (); var app = new FileCollectorApp (); int ret = app.run (args); // 清理 BinaryConverter 复用的临时基目录, 防止 /tmp 下泄漏 diff --git a/src/services/project_manager.vala b/src/services/project_manager.vala index 7414c86..d490154 100644 --- a/src/services/project_manager.vala +++ b/src/services/project_manager.vala @@ -3,20 +3,53 @@ using Gee; public class ProjectManager : GLib.Object { // 项目文件里勾选路径的归一化: Flet 版可能写正斜杠或相对 work_dir 的路径, - // 而本版勾选匹配 (path in checked_files, CheckStateModel) 是字符串精确比较, - // 必须转成 File.get_path () 的规范形态才能命中; 无法解析时原样返回。 + // 而本版勾选匹配 (path in checked_files) 是字符串精确比较, 必须转成 + // File.get_path () 的规范形态才能命中。 + // + // 关键边界: File.new_for_path () 内部会 g_canonicalize_filename (path, NULL), + // 相对路径在 relative_to == NULL 时会被绑到当前工作目录。work_dir 缺失时 + // 绝不能让它按 CWD 解析 —— 便携包双击启动的 CWD 是任意的, 会勾错同名文件, + // 还会被 CLI 的 --save 写回项目文件。此时返回 null, 由调用方丢弃该条目。 public static string? canonicalize_checked_path (string path, File? work_dir) { if (path == "") return null; try { - var f = File.new_for_path (path); - if (!GLib.Path.is_absolute (path) && work_dir != null) { + File f; + if (GLib.Path.is_absolute (path)) { + f = File.new_for_path (path); + } else if (work_dir != null) { f = work_dir.resolve_relative_path (path); + } else { + return null; } var canonical = f.get_path (); - return (canonical != null && canonical != "") ? canonical : path; + return (canonical != null && canonical != "") ? canonical : null; } catch (Error e) { - return path; + return null; + } + } + + // 字符串数组读取的统一规则, GUI (load_project_file) 与 CLI (load_project) + // 共用: 取到 Json.Array 才算命中, 命中失败继续回退到 fallback 字段; + // 非字符串元素会被 get_string_element 返回 null, 一律跳过, 不能塞进集合。 + // fallback 传 "" 表示无回退字段。 + public static Gee.ArrayList read_string_array ( + Json.Object obj, string primary, string fallback + ) { + var result = new Gee.ArrayList (); + Json.Array? arr = null; + if (obj.has_member (primary)) { + arr = obj.get_array_member (primary); + } + if (arr == null && fallback != "" && obj.has_member (fallback)) { + arr = obj.get_array_member (fallback); } + if (arr == null) return result; + for (int i = 0; i < arr.get_length (); i++) { + var value = arr.get_string_element (i); + if (value == null || value == "") continue; + result.add (value); + } + return result; } public static void load_project_file ( @@ -68,32 +101,18 @@ public class ProjectManager : GLib.Object { use_absolute = root.get_boolean_member_with_default ("use_absolute", false); show_header = root.get_boolean_member_with_default ("show_header", false); - // 字段名兼容: 本版写 checked_files, Flet 版写 checked_entries. 优先读自己的, - // 缺失时回退到另一个, 两个版本互相打开项目时勾选状态不丢. - Json.Array? checked_arr = null; - if (root.has_member ("checked_files")) { - checked_arr = root.get_array_member ("checked_files"); - } else if (root.has_member ("checked_entries")) { - checked_arr = root.get_array_member ("checked_entries"); - } - if (checked_arr != null) { - for (int i = 0; i < checked_arr.get_length (); i++) { - var p = checked_arr.get_string_element (i); - // Flet 旧文件可能是对象数组等非字符串元素, get_string_element - // 对其返回 null, 跳过而不是把 null 收进集合 - if (p == null || p == "") continue; - var canonical = canonicalize_checked_path (p, work_dir); - if (canonical != null && File.new_for_path (canonical).query_exists ()) { - checked_paths.add (canonical); - } + // checked_files / checked_entries 兼容 + 路径归一化; GUI 与 CLI 走同一套规则。 + foreach (var p in read_string_array (root, "checked_files", "checked_entries")) { + var canonical = canonicalize_checked_path (p, work_dir); + if (canonical != null && File.new_for_path (canonical).query_exists ()) { + checked_paths.add (canonical); } } - var checked_dirs_arr = root.has_member ("checked_dirs") ? root.get_array_member ("checked_dirs") : null; - if (checked_dirs_arr != null) { - for (int i = 0; i < checked_dirs_arr.get_length (); i++) { - checked_dirs.add (checked_dirs_arr.get_string_element (i)); - } + // 目录勾选只做 null / 类型防御, 不改路径形态: checked_dirs 在 UI 里 + // 与 checked_files 的生成路径不同, 贸然归一化会改变既有树状态语义。 + foreach (var d in read_string_array (root, "checked_dirs", "")) { + checked_dirs.add (d); } var items_arr = root.has_member ("items") ? root.get_array_member ("items") : null; @@ -127,11 +146,8 @@ public class ProjectManager : GLib.Object { } common_phrases.clear (); - var phrases_arr = root.has_member ("common_phrases") ? root.get_array_member ("common_phrases") : null; - if (phrases_arr != null) { - for (int i = 0; i < phrases_arr.get_length (); i++) { - common_phrases.add (phrases_arr.get_string_element (i)); - } + foreach (var phrase in read_string_array (root, "common_phrases", "")) { + common_phrases.add (phrase); } // 工作区快照(向后兼容:旧工程文件无 snapshots 字段时为空) @@ -179,17 +195,16 @@ public class ProjectManager : GLib.Object { } } - var scp = so.has_member ("checked_files") ? so.get_array_member ("checked_files") : null; - if (scp != null) { - for (int j = 0; j < scp.get_length (); j++) snap.checked_paths.add (scp.get_string_element (j)); + // 快照同样兼容 checked_entries 并做 null 防御; 路径不做归一化, + // 保留保存时的原样, 避免恢复快照时改变既有勾选语义。 + foreach (var p in read_string_array (so, "checked_files", "checked_entries")) { + snap.checked_paths.add (p); } - var scd = so.has_member ("checked_dirs") ? so.get_array_member ("checked_dirs") : null; - if (scd != null) { - for (int j = 0; j < scd.get_length (); j++) snap.checked_dirs.add (scd.get_string_element (j)); + foreach (var d in read_string_array (so, "checked_dirs", "")) { + snap.checked_dirs.add (d); } - var sph = so.has_member ("common_phrases") ? so.get_array_member ("common_phrases") : null; - if (sph != null) { - for (int j = 0; j < sph.get_length (); j++) snap.common_phrases.add (sph.get_string_element (j)); + foreach (var ph in read_string_array (so, "common_phrases", "")) { + snap.common_phrases.add (ph); } snap.ai_mode = so.get_string_member_with_default ("ai_mode", "default"); diff --git a/tests/meson.build b/tests/meson.build index 3b5d2e5..ef3871c 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -183,3 +183,20 @@ test_office_text_extractor_exe = executable('test_office_text_extractor', dependencies: [glib_dep, gio_dep, gee_dep], ) test('test_office_text_extractor', test_office_text_extractor_exe) + +# ─── 13. ProjectManager 项目文件加载 (字段兼容 / 路径归一化 / null 防御) ─── +# load_project_file 依赖 ItemData / WorkspaceSnapshot / ConfigManager.atomic_write_json, +# 后者由 stubs.vala 提供; 源文件闭包与 test_app_state 保持一致。 +project_manager_src = files(join_paths(src_dir, 'services', 'project_manager.vala')) +test_project_manager_exe = executable('test_project_manager', + 'test_project_manager.vala', + project_manager_src, + app_state_src, + item_data_src, + token_estimator_src, + workspace_snapshot_src, + directory_item_src, + stubs_src, + dependencies: [glib_dep, gio_dep, gee_dep, json_dep, m_dep], +) +test('test_project_manager', test_project_manager_exe) diff --git a/tests/test_project_manager.vala b/tests/test_project_manager.vala new file mode 100644 index 0000000..4cfb3ed --- /dev/null +++ b/tests/test_project_manager.vala @@ -0,0 +1,264 @@ +// ProjectManager 单元测试 +// 覆盖 4.10.1 / 4.10.2 的项目文件兼容性修复: +// - checked_files / checked_entries 字段回退 (primary 非数组也要回退) +// - 非字符串 / null / 空串元素防御 +// - 绝对路径与相对路径归一化 +// - work_dir 缺失时不得把相对路径绑到进程 CWD +// CLI 的 load_project 已改为复用 ProjectManager 的这两个 public helper, +// 因此这里覆盖了 GUI / CLI 共用的读取规则。 + +using GLib; +using Gee; + +int pass_count = 0; +int fail_count = 0; + +void pass (string desc) { print ("PASS: %s\n", desc); pass_count++; } +void fail (string desc, string detail = "") { + print ("FAIL: %s%s%s\n", desc, detail.length > 0 ? " - " : "", detail); + fail_count++; +} +void assert_true (string desc, bool cond) { + if (cond) pass (desc); + else fail (desc); +} +void assert_eq_int (string desc, int expected, int actual) { + if (expected == actual) pass (desc); + else fail (desc, @"期望 $expected, 实际 $actual"); +} +void assert_eq_str (string desc, string expected, string actual) { + if (expected == actual) pass (desc); + else fail (desc, @"期望 '$expected', 实际 '$actual'"); +} + +// Vala 不允许在命名空间级给 owned 字段带初始化器 (会被 valac 拒绝), +// 统一由 setup_fixture() 在 main() 里赋值。 +private static string tmp_dir; +private static string real_file; +private static string expected_path; + +private static void setup_fixture () throws Error { + tmp_dir = GLib.Path.build_filename ( + GLib.Environment.get_tmp_dir (), "filecollector-pm-tests"); + GLib.DirUtils.create_with_parents (tmp_dir, 0755); + real_file = GLib.Path.build_filename (tmp_dir, "a.txt"); + GLib.FileUtils.set_contents (real_file, "hello"); + expected_path = File.new_for_path (real_file).get_path () ?? ""; +} + +private static string write_json (string name, Json.Builder builder) throws Error { + string path = GLib.Path.build_filename (tmp_dir, name); + var gen = new Json.Generator (); + gen.set_root (builder.get_root ()); + // to_stream / File.replace 都是项目里已使用的 API, 不依赖 to_data 绑定差异。 + var stream = File.new_for_path (path).replace (null, false, FileCreateFlags.NONE); + gen.to_stream (stream, null); + stream.close (); + return path; +} + +// Windows 上把 \ 换成 /, 模拟 Flet 版写出的正斜杠路径; Linux 上无变化。 +private static string forward_slashes (string path) { + var sb = new StringBuilder (); + for (int i = 0; i < path.length; i++) { + char c = path[i]; + sb.append_c (c == '\\' ? '/' : c); + } + return sb.str; +} + +// ─── 1. 路径归一化 ──────────────────────────────────────────────── + +void test_canonicalize_checked_path () { + print ("\n=== test_canonicalize_checked_path ===\n"); + var wd = File.new_for_path (tmp_dir); + + assert_eq_str ("absolute path normalizes to File.get_path()", + expected_path, + ProjectManager.canonicalize_checked_path (real_file, null) ?? ""); + + assert_eq_str ("forward-slash absolute path normalizes", + expected_path, + ProjectManager.canonicalize_checked_path (forward_slashes (real_file), null) ?? ""); + + assert_eq_str ("relative path resolves against work_dir", + expected_path, + ProjectManager.canonicalize_checked_path ("a.txt", wd) ?? ""); + + // B3: File.new_for_path() 内部会把相对路径按 CWD 规范化, 不能用它兜底。 + assert_true ("relative path without work_dir is dropped (no CWD binding)", + ProjectManager.canonicalize_checked_path ("a.txt", null) == null); + + assert_true ("empty path is dropped", + ProjectManager.canonicalize_checked_path ("", wd) == null); +} + +// ─── 2. 字段回退与 null 防御 ────────────────────────────────────── + +void test_read_string_array () { + print ("\n=== test_read_string_array ===\n"); + + // primary 存在但值不是数组 -> 必须继续回退, 不能"命中即停"。 + var b = new Json.Builder (); + b.begin_object (); + b.set_member_name ("checked_files"); + b.add_null_value (); + b.set_member_name ("checked_entries"); + b.begin_array (); + b.add_string_value ("kept"); + b.add_null_value (); + b.begin_object (); b.end_object (); + b.add_string_value (""); + b.end_array (); + b.end_object (); + Json.Object? obj = b.get_root ().get_object (); + if (obj == null) { fail ("fallback fixture root is not an object"); return; } + var values = ProjectManager.read_string_array (obj, "checked_files", "checked_entries"); + assert_eq_int ("falls back when primary is not an array", 1, values.size); + if (values.size == 1) { + assert_eq_str ("null/object/empty elements are skipped", "kept", values.get (0)); + } + + // primary 是正常数组时优先, 不读 fallback。 + var b2 = new Json.Builder (); + b2.begin_object (); + b2.set_member_name ("checked_files"); + b2.begin_array (); b2.add_string_value ("own"); b2.end_array (); + b2.set_member_name ("checked_entries"); + b2.begin_array (); b2.add_string_value ("other"); b2.end_array (); + b2.end_object (); + Json.Object? obj2 = b2.get_root ().get_object (); + if (obj2 == null) { fail ("primary fixture root is not an object"); return; } + var own = ProjectManager.read_string_array (obj2, "checked_files", "checked_entries"); + assert_eq_int ("primary array wins", 1, own.size); + if (own.size == 1) { + assert_eq_str ("primary value kept", "own", own.get (0)); + } + + // 无回退字段: null 元素不能进入集合。 + var b3 = new Json.Builder (); + b3.begin_object (); + b3.set_member_name ("checked_dirs"); + b3.begin_array (); + b3.add_null_value (); + b3.add_string_value ("dir"); + b3.end_array (); + b3.end_object (); + Json.Object? obj3 = b3.get_root ().get_object (); + if (obj3 == null) { fail ("checked_dirs fixture root is not an object"); return; } + var dirs = ProjectManager.read_string_array (obj3, "checked_dirs", ""); + assert_eq_int ("checked_dirs null element skipped", 1, dirs.size); +} + +// ─── 3. 端到端加载 ──────────────────────────────────────────────── + +private static string build_project_fixture (string name, bool with_work_dir) throws Error { + var b = new Json.Builder (); + b.begin_object (); + if (with_work_dir) { + b.set_member_name ("work_dir"); + b.add_string_value (tmp_dir); + } + b.set_member_name ("checked_entries"); + b.begin_array (); + b.add_string_value (forward_slashes (real_file)); + b.add_null_value (); + b.begin_object (); b.end_object (); + b.add_string_value ("missing.txt"); + b.end_array (); + b.set_member_name ("checked_dirs"); + b.begin_array (); + b.add_null_value (); + b.add_string_value ("some-dir"); + b.end_array (); + b.set_member_name ("common_phrases"); + b.begin_array (); + b.add_null_value (); + b.add_string_value ("phrase"); + b.end_array (); + b.end_object (); + return write_json (name, b); +} + +private static void load_fixture ( + string path, + out Gee.HashSet checked, + out Gee.HashSet dirs, + out Gee.ArrayList phrases +) throws Error { + var items = new Gee.ArrayList (); + var snaps = new Gee.ArrayList (); + checked = new Gee.HashSet (); + dirs = new Gee.HashSet (); + phrases = new Gee.ArrayList (); + File? wd = null; + string? pf = null; + bool ua = false; + bool sh = false; + ProjectManager.load_project_file ( + path, items, checked, dirs, phrases, snaps, + out wd, out pf, out ua, out sh); +} + +void test_load_project_file () { + print ("\n=== test_load_project_file ===\n"); + try { + string path = build_project_fixture ("flet-compat.fcol", true); + Gee.HashSet checked; + Gee.HashSet dirs; + Gee.ArrayList phrases; + load_fixture (path, out checked, out dirs, out phrases); + + // 正斜杠绝对路径 + 存在性过滤后只剩 1 条; null / 对象 / 不存在项都被剔除。 + assert_eq_int ("only existing checked path survives", 1, checked.size); + assert_true ("forward-slash path matches canonical path", + checked.contains (expected_path)); + assert_eq_int ("checked_dirs null element skipped", 1, dirs.size); + assert_eq_int ("common_phrases null element skipped", 1, phrases.size); + } catch (Error e) { + fail ("load_project_file with checked_entries", e.message); + } +} + +void test_relative_path_without_work_dir () { + print ("\n=== test_relative_path_without_work_dir ===\n"); + try { + // 项目文件缺 work_dir, 只写相对勾选路径。 + // 若实现用 File.new_for_path() 兜底, 会绑到 CWD 并可能误命中同名文件。 + var b = new Json.Builder (); + b.begin_object (); + b.set_member_name ("checked_entries"); + b.begin_array (); + b.add_string_value ("a.txt"); + b.end_array (); + b.end_object (); + string path = write_json ("no-work-dir.fcol", b); + + Gee.HashSet checked; + Gee.HashSet dirs; + Gee.ArrayList phrases; + load_fixture (path, out checked, out dirs, out phrases); + assert_eq_int ("relative paths dropped when work_dir is missing", 0, checked.size); + } catch (Error e) { + fail ("load_project_file without work_dir", e.message); + } +} + +public static int main (string[] args) { + print ("========== ProjectManager 测试开始 ==========\n"); + try { + setup_fixture (); + } catch (Error e) { + print ("FAIL: fixture setup - %s\n", e.message); + return 1; + } + + test_canonicalize_checked_path (); + test_read_string_array (); + test_load_project_file (); + test_relative_path_without_work_dir (); + + print ("\n========== 测试结果 ==========\n"); + print ("PASS: %d, FAIL: %d\n", pass_count, fail_count); + return fail_count > 0 ? 1 : 0; +} diff --git a/tools/build-windows-package.sh b/tools/build-windows-package.sh index f336a07..ccfb710 100755 --- a/tools/build-windows-package.sh +++ b/tools/build-windows-package.sh @@ -147,17 +147,64 @@ BAT # 打包前用 PE 导入表做依赖闭包校验, 任何一个非系统 DLL 缺失都直接失败。 python3 tools/verify_windows_package.py "${STAGING_DIR}" -# i18n 运行时冒烟: --help 走完整 gettext 链 (bindtextdomain -> zh_CN.mo 加载)。 -# 曾出过: .mo 在包里、清单校验也过, 但 gettext 运行时根本没加载译文, 语言 -# 设置/跟随系统在用户侧永远只有英文 (Windows 下 gettext 在 locale 为 "C" 时 -# 忽略 LANGUAGE)。这里真实运行主程序验证译文可达, 而不是只看清单。 -echo "Running i18n runtime smoke test (LANG=zh_CN.UTF-8 --help -> Chinese) ..." -HELP_OUT="$(cd "${STAGING_DIR}/bin" && LANG=zh_CN.UTF-8 ./filecollector.exe --help 2>/dev/null || true)" -if ! printf '%s\n' "${HELP_OUT}" | grep -q '命令行模式'; then - echo "i18n smoke test failed: --help output is not Chinese with LANG=zh_CN.UTF-8" >&2 - printf '%s\n' "${HELP_OUT}" | head -5 >&2 - exit 1 +# ─── i18n 运行时冒烟 ────────────────────────────────────────────────── +# 验证"应用自己"能把偏好设置 / 跟随系统落地, 而不只是 .mo 文件存在。 +# 旧写法用外部 LANG=zh_CN.UTF-8 跑 --help, 恰好绕过了 CLI 分支的语言设置, +# 测不到设置不生效的回归。这里用干净环境 + 临时 XDG_CONFIG_HOME 里的 +# settings.json 驱动, 由应用自己完成 locale 解析。 +SMOKE_TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${SMOKE_TMP_DIR}"' EXIT +mkdir -p "${SMOKE_TMP_DIR}/filecollector" +# Windows 原生 exe 不认 MSYS2 的 POSIX 路径; -m 产出 C:/... 形式, GLib 可解析 +# 且不会被 MSYS2 当成 POSIX 绝对路径改写。 +if command -v cygpath >/dev/null 2>&1; then + SMOKE_CONFIG_HOME="$(cygpath -m "${SMOKE_TMP_DIR}")" +else + SMOKE_CONFIG_HOME="$(cd "${SMOKE_TMP_DIR}" && pwd -W)" fi +SMOKE_SETTINGS="${SMOKE_TMP_DIR}/filecollector/settings.json" +SMOKE_ERR="${SMOKE_TMP_DIR}/help.stderr" + +# $1 用例名, $2 期望语言 (zh|en), $3 额外的 env 赋值 (可为空) +run_i18n_smoke () { + local label="$1" expected="$2" extra="$3" out + # env -u 清掉所有外部语言变量; timeout 防止 GUI 子系统 exe 挂起拖死 CI。 + if ! out="$(cd "${STAGING_DIR}/bin" && env -u LANG -u LC_ALL -u LC_MESSAGES \ + -u LANGUAGE -u FILECOLLECTOR_LANG_MANAGED \ + XDG_CONFIG_HOME="${SMOKE_CONFIG_HOME}" ${extra} \ + timeout 30 ./filecollector.exe --help 2>"${SMOKE_ERR}")"; then + echo "i18n smoke [${label}]: filecollector.exe failed or timed out" >&2 + sed -n '1,20p' "${SMOKE_ERR}" >&2 + return 1 + fi + # 用 bash 字符串匹配, 不经过管道: 避免 pipefail 下 grep -q 的 SIGPIPE(141) 误报。 + if [[ "${expected}" == "zh" && "${out}" != *"命令行模式"* ]] || + [[ "${expected}" == "en" && ( "${out}" != *"CLI Mode"* || "${out}" == *"命令行模式"* ) ]]; then + echo "i18n smoke [${label}]: --help language mismatch (expected ${expected})" >&2 + printf '%s\n' "${out}" >&2 + sed -n '1,10p' "${SMOKE_ERR}" >&2 + return 1 + fi + echo "i18n smoke [${label}]: OK" +} + +echo '{"language": "zh"}' > "${SMOKE_SETTINGS}" +run_i18n_smoke "preferences=zh, clean env" zh "" + +echo '{"language": "en"}' > "${SMOKE_SETTINGS}" +run_i18n_smoke "preferences=en, clean env" en "" + +# LC_ALL 优先级高于 LANG: 应用显式选中文时必须能压过外部 LC_ALL=C。 +echo '{"language": "zh"}' > "${SMOKE_SETTINGS}" +run_i18n_smoke "preferences=zh, LC_ALL=C" zh "LC_ALL=C" + +# 跟随系统分支不得覆盖用户在 shell 里明确设置的真实语言。 +echo '{}' > "${SMOKE_SETTINGS}" +run_i18n_smoke "follow-system, external LANG respected" zh "LANG=zh_CN.UTF-8" + +# 干净环境走 g_win32_getlocale; CI runner 是 en-US, 应回退到英文原文。 +echo '{}' > "${SMOKE_SETTINGS}" +run_i18n_smoke "follow-system, clean env" en "" PACKAGE_PATH="${DIST_DIR}/filecollector-windows-${VERSION}-x64.zip" (