From 02ef1dcdc46ec2d03463a731b6f44cde655dc3ab Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Sat, 6 Jun 2026 17:34:09 +0200 Subject: [PATCH 1/2] Add safe localization support --- App.xaml | 1 + App.xaml.cs | 2 + Locales/en-US.xaml | 529 ++++++++++++++++++ Locales/zh-CN.xaml | 529 ++++++++++++++++++ Models/ApplicationSettingsModel.cs | 4 + Services/ApplicationSettingsService.cs | 20 +- Services/ILocalizationService.cs | 44 ++ Services/LocalizationService.cs | 265 +++++++++ Services/NotificationService.cs | 93 ++- Services/ServiceConfiguration.cs | 1 + .../ApplicationSettingsModelTests.cs | 15 + .../ApplicationSettingsServiceTests.cs | 37 ++ .../LocalizationServiceTests.cs | 105 ++++ .../SettingsViewModelThemeTests.cs | 28 +- ViewModels/SettingsViewModel.cs | 38 ++ Views/SettingsView.xaml | 37 +- 16 files changed, 1715 insertions(+), 33 deletions(-) create mode 100644 Locales/en-US.xaml create mode 100644 Locales/zh-CN.xaml create mode 100644 Services/ILocalizationService.cs create mode 100644 Services/LocalizationService.cs create mode 100644 Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs diff --git a/App.xaml b/App.xaml index 592dae1..58f3c0d 100644 --- a/App.xaml +++ b/App.xaml @@ -10,6 +10,7 @@ + diff --git a/App.xaml.cs b/App.xaml.cs index 9aa438f..4916d2a 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -220,10 +220,12 @@ protected override void OnStartup(StartupEventArgs e) { var settingsService = this.ServiceProvider.GetRequiredService(); var themeService = this.ServiceProvider.GetRequiredService(); + var localizationService = this.ServiceProvider.GetRequiredService(); Task.Run(async () => await settingsService.LoadSettingsAsync()).GetAwaiter().GetResult(); var settings = settingsService.Settings; loadedSettings = settings; + localizationService.ApplyLanguage(settings.Language); effectiveStartMinimized = startMinimized || settings.StartMinimized; var useDarkTheme = settings.HasUserThemePreference ? settings.UseDarkTheme diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml new file mode 100644 index 0000000..41967ab --- /dev/null +++ b/Locales/en-US.xaml @@ -0,0 +1,529 @@ + + + + ThreadPilot - Process & Power Plan Manager + Administrator Privileges Recommended + ThreadPilot is running with limited privileges. Some features may not be available. + Administrator access is required for: + • Modifying power plan configurations + • Changing process CPU affinity and priority + • Applying system-level optimizations and tweaks + • Managing protected processes + Continue Without Elevation + Request Elevation + + + Process Management + CPU Masks + Power Plans + Rules + Diagnostics + ThreadPilot Activity + Tweaks + Settings + + + Startup minimized + Enable Startup minimized when you want ThreadPilot to start silently in the tray on future Windows sign-ins. + Recommended + Don't show again + Open Settings + + + ThreadPilot Activity + Shows actions performed by ThreadPilot, including applied rules, affinity changes, priority changes, power plan changes, settings changes, tweaks, optimizations, and safe failure messages. + Search: + Category: + Level: + From: + To: + Refresh + Clear Display + Export Activity + Cleanup Diagnostic Files + Open Diagnostic Folder + + + Time + Status + Category + Message + Details + ID + Copy Entry + Refresh + No log entries to display + Adjust filters or refresh logs to load recent ThreadPilot activity. + + + Activity Details + Timestamp: + Status: + Category: + Message: + Details: + Correlation ID: + No log entry selected + Files: + Size: + Debug Diagnostics + Max Size (MB): + Retention (Days): + Save Settings + + + CPU Masks + Create reusable CPU affinity presets for per-process use. + Selecting a mask here only edits the preset. It does not change CPU affinity until you apply it to a process or save it in a process rule. + New + Delete + Duplicate Mask + Mask Information + Name: + Description: + Enabled + Default preset + Pre-selected when ThreadPilot needs a fallback mask or when creating per-process rules. It does not apply CPU affinity automatically. + Disable to temporarily hide this mask + Select CPUs + Toggle CPUs to edit this mask preset. Changes are saved automatically and do not affect running processes. All Cores is the protected default preset. + Mask names, descriptions, CPU selections, and options are saved automatically. + Create a new CPU mask + Delete selected mask + CPUs + CPU + + + Optional Diagnostics + Diagnostics are optional and intended for troubleshooting. For in-game overlays and detailed performance graphs, use dedicated tools. + Quick tips: + 1. Open diagnostics only when you need a focused troubleshooting snapshot. + 2. Review hotspots only as a hint before creating automation rules. + 3. Stop live metrics when you are done troubleshooting. + Continue to Diagnostics + + Active Processes + Top CPU and memory consumers with one-click rule creation. + Search processes by name + Rule-backed only + Actionable only + Rule Impact + Create/Update Rule from Selection + Create or update an automation rule from the selected hotspot process + + Stability Timeline + Clear History + Clear the displayed metrics and timeline history + Core Snapshot + Per-core utilization for quick imbalance checks. + Toggle Core Panel + Toggle Process Panel + Start Live Metrics + Start live metrics collection for this dashboard + Stop Live Metrics + Pause live metrics collection; background automation is separate + Refresh + Refresh the current dashboard snapshot + + Global Power Plan + Memory Used + CPU % + Mem % + Threads + Events + Severity + Detail + Time + Category + Process + Selected hotspot process + Metrics + Processes + + + Power Plans + Manage the active Windows power plan and import custom .pow profiles + Windows Power Plans + Select the Windows plan to make active. + Set as Active Windows Plan + Change the active Windows power plan to the selected plan + Refresh Plans + Refresh the list of available power plans + Custom Power Plans + Local .pow files ready to import into Windows. + Import Selected .pow + Import the selected custom .pow file into Windows + Add .pow File + Add a new custom power plan file to the custom library + Delete + Active + + + Rules & Automation + Automation monitoring watches process start/stop events and applies configured power plan, CPU mask, and priority rules. + Rule Editor + Create a rule from an executable or running process to automate power plan, CPU mask, and priority behavior. + Executable Match: + Match by Path + Match processes by full path instead of just executable name + Browse for Executable + Click to select an executable file (.exe) from your computer + Choose an executable. Match by path is more precise; matching by name applies to any process with that executable name. + Use Running Process: + Select a currently running process to use its executable + Use Selected Process + Use the executable from the selected running process + Rule Power Plan (global switch): + When this rule matches, Windows switches the global active power plan. + Rule CPU Mask: + Optional: Select a CPU mask to apply when this process starts + Rule Priority + Optional: Select the process priority to apply when this process starts + Association Priority: + Higher priority associations take precedence when multiple match + Add Rule + Update Selected Rule + Update the selected association + Clear Selection + Clear the selected executable + + Current Rules + Define per-process rules. Power plans are global; masks and priorities apply to matching processes. + Remove + Remove the selected association + Default Power Plan (fallback when no rule matches) + Power plan to use when no associated processes are running: + Set Default Power Plan + + Automation Monitoring Settings + Enable Event-Based Automation (WMI) + Enable Automation Fallback Polling + Polling Interval (seconds): + Power Plan Change Delay (ms): + Prevent Duplicate Power Plan Changes + Save Configuration + No automation rules yet + Status: + Start Automation Monitoring + Stop Automation Monitoring + + Applied to each matching process when the automation rule runs. + Applied only to matching processes; it does not change the global Windows power plan. + Selected Executable: + Description: + Automation Service + Rules + Executable + Power Plan + CPU Mask + Priority + Description + Process Priority: + + + Process Management + Search, filter, and control active process configurations + Search processes by name + Hide Windows system processes + Hide System Processes + Hide processes with very low CPU usage + Hide Idle Processes + Show only applications with visible windows + Active Apps Only + Lock process list + Pause process list refresh and sorting updates while you work with the current list. Background monitoring and saved-rule auto-apply continue while ThreadPilot is running. + Sort by: + Choose how to sort the process list + + Selected Process + No process selected + Select a process from the table to enable affinity, priority, power plan, and rule actions. + Power Plan / Pending Settings + Choose the power plan to activate manually or include with quick apply + Set Power Plan + Activate the selected Windows power plan + + Pending core mask + Select a mask to stage it. This does not change OS affinity until Apply Affinity is clicked. + Apply Affinity + Apply the pending core mask to the selected process and verify the Windows affinity state + Apply Affinity and Save as Rule + Save current process settings as a rule. These settings will be automatically applied when this process starts in the future. + + Set CPU Priority + Apply the selected priority to the process + Enforce Priority by Registry + Apply priority through Windows registry. Process must be restarted for changes to take effect. This setting persists across system reboots. + Set Priority + Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive. + Realtime (blocked) + + Disable Idle Server + Prevents the system from entering idle state while this process is running. Useful for games and performance-critical applications. + + Profile / Rule + Enter a profile name to save or load settings + Save Profile + Save current CPU affinity and priority as a reusable profile + Load Profile + Load a previously saved profile + Save Current Settings as Rule + + Last ThreadPilot action: + Refresh + Refresh the process list + Load More + Load more processes + + Process Name + Window Title + CPU Usage + Memory Usage + + Affinity staged in the UI. It is not applied until Apply Affinity is clicked. + Affinity currently reported by Windows for the selected process + Read-only preview of current or pending CPU selection + Open CPU Masks tab to create a new custom mask + Shows whether Hyper-Threading (Intel) or SMT (AMD) is present and active on this system + + Copy Process Info + Open Executable Location + Clear CPU Sets + Apply Pending Settings + Apply the pending affinity and selected power plan to the selected process + Rules and changes are applied by ThreadPilot only when configured. + + Above Normal + Below Normal + High + Idle + Low + Normal + Realtime + Batch + Custom + + Has Window + Window + PID + ID + Memory (MB) + Affinity + Priority + Rules + Name + Set Memory Priority + Very Low + Medium + Refresh Process Info + + + Application Settings + Configure notifications, system tray, and application behavior + Notifications + Enable notifications + Notification Level + All + Warnings/Errors only + Silent + Enable balloon notifications (system tray) + Enable toast notifications (Windows 10+) + Notification Types + Power plan changes + Process monitoring events + Error notifications + Success notifications + Duration (ms): + Test Notification + + System Tray + Show tray icon + Minimize to tray + Close to tray (instead of exit) + Start minimized + Enable pending settings apply from tray + Enable automation monitoring controls from tray + Show detailed tooltips + + Basic Preferences + Autostart + Start with Windows + Automatically start ThreadPilot when Windows starts + Low impact mode in background + Lower ThreadPilot's process priority while minimized or hidden to tray. + + Appearance + Use dark theme + Applies a global Dark/Light theme across all tabs + + Language + 简体中文 (Simplified Chinese) + English (English) + Applies a global display language across the entire application interface. + + Rules & automation + Saved rule auto-apply + Apply saved rules when matching processes start + Applies enabled saved rules while ThreadPilot is running. This does not install a Windows Service and does not use registry/IFEO persistence. + Enable WMI process events + Use Windows Management Instrumentation for process start/stop events + Enable fallback polling + Use polling as backup when WMI process events fail + Polling interval (ms): + Fallback polling (ms): + + Advanced + Enable notification history + Max history items: + Auto-hide notifications + Enable notification sound + Enable debug logging + Enable performance counters + + About + Version: + Copyright © 2026 PrimeBuild + Translator: Ylimhs + License: AGPLv3 + Check for updates + Checks on demand using the official GitHub Releases. + + Reset to Defaults + Export Configuration + Import Configuration + Save Settings + ThreadPilot + + + ThreadPilot Settings + Unsaved Settings + You have unsaved changes. Save before closing, discard the changes, or cancel to keep editing. + Cancel + Discard + Save + + + System Tweaks & Optimizations + Configure Windows system optimizations and performance tweaks + Refresh All + Refresh all tweak states + Toggle this Windows tweak + Status + + + Core Parking + Controls CPU core parking for power management + C-States + Controls CPU C-States for power management + SysMain Service + Windows Superfetch/SysMain service for memory management + Prefetch + Windows Prefetch feature for faster application loading + Power Throttling + Windows Power Throttling for energy efficiency + HPET + High Precision Event Timer for system timing + High Scheduling Category + High scheduling priority for gaming applications + Menu Show Delay + Delay before showing context menus + + + Ready + Refreshing system tweaks... + Last refreshed: {0} + Failed to refresh system tweaks + Refresh failed + Toggling {0}... + {0} enabled successfully + {0} disabled successfully + {0} has been enabled + {0} has been disabled + System Tweak Updated + Failed to toggle {0} + Failed to enable {0} + Failed to disable {0} + System Tweak Failed + Failed to {0} {1} + Error toggling {0} + Error toggling {0}: {1} + enable + disable + enabled + disabled + Enabled + Disabled + Not Available + Loading system tweaks... + System tweaks loaded successfully + + + Open Dashboard + Open Diagnostics + Pause Automation Monitoring + Resume Automation Monitoring + System Status + No process selected + Selected: {0} + Apply Pending Settings to Selected Process + Apply Pending Settings to {0} + 🔋 Power Plans + 📋 Profiles + Settings + Exit + No profiles available + Power Plan: {0} + Automation WMI Error + Automation Active + Automation Disabled + Unknown + 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2} + + + Windows denied this change. The process may require administrator rights or may be protected. + The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it. + Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions. + This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection. + This CPU selection does not match the current CPU topology. Review or recreate the preset. + The process exited before ThreadPilot could apply the change. + Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible. + High priority can improve responsiveness for some workloads but may reduce system responsiveness. + Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive. + Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions. + Applies saved rules when a matching process starts. Some protected or anti-cheat processes may reject changes. Administrator mode can help with normal permission issues but cannot bypass protection. + The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it. + + + Power Plan Changed + Power plan changed from '{0}' to '{1}' + Power plan changed to '{0}' for process '{1}' + Process Monitoring Enabled + Process Monitoring Disabled + CPU Affinity Applied + CPU affinity set for '{0}': {1} + Game Boost Activated + Game Boost mode activated for {0} + Game Boost Deactivated + Game Boost mode deactivated after {0} + Process Monitor Error + Affinity blocked + Priority blocked + + + ThreadPilot requires administrator privileges to manage process affinity and power plans. Would you like to restart the application with administrator privileges? + Administrator Privileges Required + Failed to restart with administrator privileges. Please manually run ThreadPilot as administrator. + Elevation Failed + Running with Administrator privileges + Running with limited privileges + + + Could not query Core Parking value via powercfg + Could not query C-States value via powercfg + Prefetch registry key not found + Power Throttling not available on this system + PriorityControl registry key not found + Desktop registry key not found + diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml new file mode 100644 index 0000000..3a9ce20 --- /dev/null +++ b/Locales/zh-CN.xaml @@ -0,0 +1,529 @@ + + + + ThreadPilot - 进程与电源计划管理器 + 推荐以管理员权限运行 + ThreadPilot 当前正以受限权限运行。某些功能可能无法使用。 + 需要管理员访问权限以执行以下操作: + • 修改电源计划配置 + • 更改进程 CPU 关联性和优先级 + • 应用系统级优化和调整 + • 管理受保护的进程 + 不提升权限继续 + 请求权限提升 + + + 进程管理 + CPU 掩码 + 电源计划 + 自动化规则 + 性能诊断 + 活动日志 + 系统微调 + 应用设置 + + + 启动时最小化 + 开启此项后,ThreadPilot 会在 Windows 登录时静默启动至系统托盘。 + 推荐 + 不再显示 + 打开设置 + + + ThreadPilot 活动日志 + 显示 ThreadPilot 执行的操作,包括已应用规则、关联性更改、优先级更改、电源计划更改、设置更改、调整、优化以及安全故障消息。 + 搜索: + 类别: + 级别: + 从: + 至: + 刷新 + 清除显示 + 导出活动 + 清理诊断文件 + 打开诊断文件夹 + + + 时间 + 状态 + 类别 + 消息 + 详情 + ID + 复制条目 + 刷新 + 没有要显示的日志条目 + 调整过滤器或刷新日志以加载最近的 ThreadPilot 活动。 + + + 活动详情 + 时间戳: + 状态: + 类别: + 消息: + 详情: + 相关性 ID: + 未选择日志条目 + 文件: + 大小: + 调试诊断 + 最大大小 (MB): + 保留天数 (天): + 保存设置 + + + CPU 掩码 + 创建可复用的 CPU 关联性预设以供每个进程使用。 + 在此处选择掩码仅编辑预设。在将其应用于进程或保存在进程规则中之前,它不会更改 CPU 关联性。 + 新建 + 删除 + 复制掩码 + 掩码信息 + 名称: + 描述: + 已启用 + 默认预设 + 当 ThreadPilot 需要后备掩码或创建针对每个进程的规则时预先选择。它不会自动应用 CPU 关联性。 + 禁用以暂时隐藏此掩码 + 选择 CPU 核心 + 切换 CPU 以编辑此掩码预设。更改将自动保存,且不会影响正在运行的进程。“所有核心”是受保护的默认预设。 + 掩码名称、描述、CPU 选择和选项会自动保存。 + 创建新的 CPU 掩码 + 删除选定的掩码 + CPU 核心 + CPU + + + 可选性能诊断 + 诊断是可选的,仅用于故障排除。对于游戏内覆盖层和详细的性能图表,请使用专用工具。 + 快速提示: + 1. 仅在需要针对性的故障排除快照时才打开诊断。 + 2. 在创建自动化规则之前,仅将热点查看作为提示。 + 3. 故障排除完成后,停止实时指标收集。 + 继续前往诊断 + + 活动进程 + 通过一键规则创建来监控 CPU 和内存的占用大户。 + 按名称搜索进程 + 仅限包含规则的 + 仅限可操作的 + 规则影响 + 基于选择创建/更新规则 + 为选定的热点进程创建或更新自动化规则 + + 稳定性时间线 + 清除历史记录 + 清除显示的指标和时间轴历史记录 + 核心快照 + 单核利用率,用于快速的不平衡检查。 + 切换核心面板 + 切换进程面板 + 开始实时指标 + 为此控制面板启动实时指标收集 + 停止实时指标 + 暂停实时指标收集;后台自动化会单独运行 + 刷新 + 刷新当前的仪表板快照 + + 全局电源计划 + 已用内存 + CPU % + 内存 % + 线程数 + 事件 + 严重性 + 详情 + 时间 + 类别 + 进程 + 选定的热点进程 + 指标 + 进程 + + + 电源计划管理 + 管理活动的 Windows 电源计划并导入自定义 .pow 配置文件 + Windows 电源计划 + 选择要激活的 Windows 电源计划。 + 设为活动 Windows 电源计划 + 将活动的 Windows 电源计划更改为所选计划 + 刷新计划 + 刷新可用电源计划的列表 + 自定义电源计划 + 已准备好导入 Windows 的本地 .pow 文件。 + 导入选中的 .pow + 将所选的自定义 .pow 文件导入 Windows + 添加 .pow 文件 + 向自定义库添加新的自定义电源计划文件 + 删除 + 活动 + + + 规则与自动化 + 自动化监控监视进程的启动/停止事件,并应用配置的电源计划、CPU 掩码和优先级规则。 + 规则编辑器 + 从可执行文件或运行中的进程创建规则,以实现电源计划、CPU 掩码和优先级行为的自动化。 + 可执行文件匹配: + 按路径匹配 + 按完整路径而不是仅按可执行文件名称匹配进程 + 浏览可执行文件 + 点击以从您的计算机选择可执行文件 (.exe) + 选择可执行文件。按路径匹配更精确;按名称匹配适用于任何具有该可执行文件名称的进程。 + 使用运行中的进程: + 选择当前运行的进程以使用其可执行文件 + 使用所选进程 + 使用所选运行中进程的可执行文件 + 规则电源计划 (全局切换): + 当此规则匹配时,Windows 将切换全局活动电源计划。 + 规则 CPU 掩码: + 可选: 选择在该进程启动时要应用的 CPU 掩码 + 规则优先级 + 可选: 选择在该进程启动时要应用的进程优先级 + 关联优先级: + 当有多个匹配时,优先级较高的关联优先使用 + 添加规则 + 更新选定规则 + 更新所选的关联 + 清除选择 + 清除选定的可执行文件 + + 当前规则 + 定义针对每个进程的规则。电源计划是全局的;掩码和优先级适用于匹配的进程。 + 删除 + 删除所选关联 + 默认电源计划 (当无规则匹配时的后备方案) + 当没有关联的进程在运行时要使用的电源计划: + 设置默认电源计划 + + 自动化监控设置 + 启用基于事件的自动化 (WMI) + 启用自动化后备轮询 + 轮询间隔 (秒): + 电源计划切换延迟 (毫秒): + 防止重复更改电源计划 + 保存配置 + 尚无自动化规则 + 状态: + 启动自动化监控 + 停止自动化监控 + + 在运行自动化规则时应用于每个匹配的进程。 + 仅应用于匹配的进程;它不会更改全局 Windows 电源计划。 + 选定的可执行文件: + 描述: + 自动化服务 + 规则 + 可执行文件 + 电源计划 + CPU 掩码 + 优先级 + 描述 + 进程优先级: + + + 系统进程管理 + 搜索、过滤和控制活动的进程配置 + 按名称搜索进程 + 隐藏 Windows 系统进程 + 隐藏系统进程 + 隐藏 CPU 使用率极低的进程 + 隐藏空闲进程 + 仅显示具有可见窗口的应用程序 + 仅限活动应用 + 锁定进程列表 + 在您处理当前列表时暂停进程列表刷新和排序更新。ThreadPilot 运行时,后台监控和保存的规则自动应用仍将继续。 + 排序方式: + 选择如何对进程列表进行排序 + + 选定的进程 + 未选择进程 + 从表格中选择一个进程以启用关联性、优先级、电源计划和规则操作。 + 电源计划 / 待处理设置 + 选择要手动激活的电源计划,或包含在快速应用中 + 设置电源计划 + 激活所选的 Windows 电源计划 + + 待处理的核心掩码 + 选择一个掩码来暂存它。在点击“应用关联性”之前,这不会更改操作系统关联性。 + 应用关联性 + 将待处理的核心掩码应用于选定的进程,并验证 Windows 关联性状态 + 应用关联性并保存为规则 + 将当前进程设置保存为规则。以后此进程启动时将自动应用这些设置。 + + 设置 CPU 优先级 + 将所选优先级应用于进程 + 通过注册表强制执行优先级 + 通过 Windows 注册表应用优先级。必须重新启动进程才能使更改生效。此设置在系统重启后仍然有效。 + 设置优先级 + 实时优先级已被 ThreadPilot 阻止,因为它可能会使 Windows 不稳定或无响应。 + 实时 (已阻止) + + 禁用空闲服务 + 在此进程运行时阻止系统进入空闲状态。适用于游戏和对性能要求严苛的应用程序。 + + 配置文件 / 规则 + 输入配置文件名称以保存或加载设置 + 保存配置文件 + 将当前 CPU 关联性和优先级保存为可复用的配置文件 + 加载配置文件 + 加载以前保存的配置文件 + 保存当前设置为规则 + + 最后一次 ThreadPilot 操作: + 刷新 + 刷新进程列表 + 加载更多 + 加载更多进程 + + 进程名称 + 窗口标题 + CPU 使用率 + 内存使用 + + 关联性已暂存在 UI 中。在点击“应用关联性”之前不会应用。 + Windows 当前报告的所选进程的关联性 + 当前或待处理 CPU 选择的只读预览 + 打开 CPU 掩码选项卡以创建新的自定义掩码 + 显示此系统上是否具有并启用了超线程 (Intel) 或 SMT (AMD) + + 复制进程信息 + 打开可执行文件位置 + 清除 CPU 集 + 应用待处理设置 + 将待处理的关联性和选定的电源计划应用于选定的进程 + ThreadPilot 仅在配置后才应用规则和更改。 + + 高于正常 + 低于正常 + + 空闲 + + 正常 + 实时 + 批处理 + 自定义 + + 有窗口 + 窗口 + PID + ID + 内存 (MB) + 关联性 + 优先级 + 规则 + 名称 + 设置内存优先级 + 极低 + + 刷新进程信息 + + + ThreadPilot 系统设置 + 配置通知、系统托盘和应用程序行为 + 系统通知 + 启用应用通知 + 通知级别 + 全部显示 + 仅警告与错误 + 静音模式 + 启用系统托盘气泡通知 + 启用 Windows 吐司通知 (Windows 10+) + 通知事件类别 + 电源计划变更 + 进程监控事件 + 错误与异常通知 + 操作成功提示 + 持续显示时间 (毫秒): + 测试通知发送 + + 系统托盘栏 + 显示托盘图标 + 最小化到托盘 + 关闭主窗口时最小化到托盘 + 开机启动时最小化 + 允许从托盘菜单应用待处理设置 + 允许从托盘菜单控制自动化监控 + 托盘图标显示详细鼠标提示 + + 基本偏好设置 + 系统开机启动 + 随 Windows 开机启动 + 在 Windows 系统启动时自动开启 ThreadPilot + 后台低系统影响模式 + 在最小化或隐藏至托盘时自动降低 ThreadPilot 的进程优先级。 + + 外观主题 + 启用暗黑主题 + 在所有选项卡上全局应用 亮色/暗色 视觉风格 + + 显示语言 + 简体中文 (Simplified Chinese) + English (English) + 在整个应用程序界面应用全局的语言设置。 + + 规则与自动化 + 保存规则自动应用 + 在匹配的进程启动时自动应用已保存规则 + 在 ThreadPilot 运行时自动应用已启用的保存规则。这不会注册 Windows 服务,也不使用注册表/IFEO 进行常驻。 + 启用 WMI 进程启动监控 + 使用 Windows Management Instrumentation 来监听进程启动/退出事件 + 启用后备轮询监控 + 如果 WMI 进程事件订阅失败,自动启用定时轮询作为备份 + 轮询间隔时间 (毫秒): + 后备轮询间隔时间 (毫秒): + + 高级与调试设置 + 启用通知历史记录 + 最大历史条目数: + 自动渐隐通知气泡 + 播放提示音 + 开启调试诊断日志记录 + 启用内部性能计数器 + + 关于应用 + 软件版本: + 版权所有 © 2026 PrimeBuild + 翻译人员:Ylimhs + 软件授权: AGPLv3 + 检查新版本 + 按需通过官方 GitHub Releases API 检索最新稳定版本。 + + 重置为默认值 + 导出配置包 + 导入配置包 + 保存并应用设置 + ThreadPilot + + + ThreadPilot 设置 + 未保存的设置 + 您有尚未保存的更改。请选择在关闭前保存、放弃修改或取消并返回编辑。 + 取消 + 放弃更改 + 保存 + + + 系统微调与优化 + 配置 Windows 系统级优化项和性能微调选项 + 刷新状态 + 刷新所有微调选项的状态 + 切换此 Windows 系统微调项 + 当前状态 + + + 核心休眠 (Core Parking) + 控制 CPU 核心休眠以进行电源管理 + C-States 状态 + 控制 CPU C-States 以进行节能电源管理 + SysMain 服务 + Windows Superfetch/SysMain 服务,用于内存管理 + 预取 (Prefetch) + Windows Prefetch 功能,用于加速应用程序加载 + 电源限流 (Power Throttling) + Windows 电源限流功能,用于能效优化 + HPET 定时器 + 高精度事件计时器,用于系统精准定时 + 高调度优先级类别 + 为游戏应用程序提供高调度优先级 + 菜单显示延迟 + 显示上下文菜单之前的延迟时间 + + + 就绪 + 正在刷新系统微调选项... + 上次刷新时间: {0} + 刷新系统微调失败 + 刷新失败 + 正在切换 {0}... + {0} 已成功启用 + {0} 已成功禁用 + {0} 已启用 + {0} 已禁用 + 系统微调已更新 + 无法切换 {0} + 未能启用 {0} + 未能禁用 {0} + 系统微调失败 + 未能{0}{1} + 切换 {0} 出错 + 切换 {0} 时出错: {1} + 启用 + 禁用 + 启用 + 禁用 + 已启用 + 已禁用 + 不可用 + 正在加载系统微调... + 系统微调已成功加载 + + + 打开主控制面板 + 打开性能诊断 + 暂停自动化监控 + 恢复自动化监控 + 系统状态 + 未选择进程 + 已选择: {0} + 应用待处理设置到所选进程 + 应用待处理设置到 {0} + 🔋 电源计划 + 📋 配置文件 + 应用设置 + 退出程序 + 无可用配置文件 + 电源计划: {0} + 自动化 WMI 错误 + 自动化监控已启用 + 自动化监控已禁用 + 未知 + 💻 CPU: {0:F1}% | 内存: {1:F1}% | {2} + + + Windows 拒绝了此更改。该进程可能需要管理员权限或受保护。 + 该进程似乎受到反作弊系统或进程保护的保护。ThreadPilot 不会尝试绕过它。 + 管理员模式可能有助于解决普通的权限问题,但无法绕过反作弊系统或受保护的进程限制。 + 在此拓扑上,此 CPU 选择无法由传统关联性 API 安全地表示。此选择需要使用 CPU 集。 + 此 CPU 选择与当前 CPU 拓扑不匹配。请检查或重新创建预设。 + 在 ThreadPilot 应用更改之前,进程已退出。 + Windows CPU 集不可用或拒绝了此选择。ThreadPilot 将仅在可能时使用 safe 后备方案。 + 高优先级可以提高某些工作负载的响应速度,但可能会降低系统整体响应速度。 + 实时优先级已被 ThreadPilot 阻止,因为它可能会使 Windows 不稳定或无响应。 + 普通进程可能支持持久的启动时优先级,但这不会绕过受保护进程或反作弊系统的限制。 + 在匹配的进程启动时应用保存的规则。某些受保护或反作弊进程可能会拒绝更改。管理员模式有助于解决普通权限问题,但无法绕过进程保护。 + 该进程似乎受到反作弊系统或进程保护的保护。ThreadPilot 不会尝试对其进行改写。 + + + 电源计划已更改 + 电源计划已从“{0}”更改为“{1}” + 已为进程“{1}”将电源计划切换为“{0}” + 进程监控已启用 + 进程监控已禁用 + CPU 关联性已应用 + 已为“{0}”设置 CPU 关联性: {1} + 游戏加速已启用 + 已为 {0} 启用游戏加速模式 + 游戏加速已关闭 + 游戏加速模式已关闭,持续时间: {0} + 进程监控器错误 + 关联性应用被阻止 + 优先级应用被阻止 + + + ThreadPilot 需要管理员权限来管理进程关联性和电源计划。 您想以管理员权限重新启动应用程序吗? + 需要管理员权限 + 无法以管理员权限重新启动。请手动以管理员身份运行 ThreadPilot。 + 权限提升失败 + 以管理员权限运行中 + 以受限权限运行中 + + + 无法通过 powercfg 查询核心停车值 + 无法通过 powercfg 查询 C-States 值 + 未找到预取注册表键 + 此系统上不支持电源限流 + 未找到 PriorityControl 注册表键 + 未找到 Desktop 注册表键 + diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs index 1875e4e..40e6c78 100644 --- a/Models/ApplicationSettingsModel.cs +++ b/Models/ApplicationSettingsModel.cs @@ -151,6 +151,9 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel [ObservableProperty] private bool hasUserThemePreference = false; + [ObservableProperty] + private string language = LocalizationService.DefaultLanguage; + // Monitoring Settings [ObservableProperty] private int pollingIntervalMs = 5000; @@ -249,6 +252,7 @@ public void CopyFrom(ApplicationSettingsModel other) this.ClearMasksOnClose = other.ClearMasksOnClose; this.UseDarkTheme = other.UseDarkTheme; this.HasUserThemePreference = other.HasUserThemePreference; + this.Language = LocalizationService.NormalizeLanguage(other.Language); // Monitoring Settings this.PollingIntervalMs = other.PollingIntervalMs; diff --git a/Services/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs index dc7a7f5..02d52b7 100644 --- a/Services/ApplicationSettingsService.cs +++ b/Services/ApplicationSettingsService.cs @@ -246,16 +246,18 @@ public void ValidateAndFixSettings() this.settings.MaxNotificationHistoryItems = 1000; } - // Validate custom icon path - if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath)) - { - if (!File.Exists(this.settings.CustomTrayIconPath)) - { + // Validate custom icon path + if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath)) + { + if (!File.Exists(this.settings.CustomTrayIconPath)) + { this.logger.LogWarning("Custom tray icon file not found: {Path}", this.settings.CustomTrayIconPath); - this.settings.UseCustomTrayIcon = false; - } - } - } + this.settings.UseCustomTrayIcon = false; + } + } + + this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language); + } public async Task ExportSettingsAsync(string filePath) { diff --git a/Services/ILocalizationService.cs b/Services/ILocalizationService.cs new file mode 100644 index 0000000..5361e75 --- /dev/null +++ b/Services/ILocalizationService.cs @@ -0,0 +1,44 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + /// + /// Service for managing application localization and display language. + /// + public interface ILocalizationService + { + /// + /// Gets the current display language. + /// + string CurrentLanguage { get; } + + /// + /// Event fired when the active language changes. + /// + event EventHandler? LanguageChanged; + + /// + /// Applies the specified display language. + /// + void ApplyLanguage(string? language); + + /// + /// Gets the localized string for the specified key. + /// + string GetString(string key); + } +} diff --git a/Services/LocalizationService.cs b/Services/LocalizationService.cs new file mode 100644 index 0000000..baf4792 --- /dev/null +++ b/Services/LocalizationService.cs @@ -0,0 +1,265 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Windows; + using Microsoft.Extensions.Logging; + + /// + /// Service for managing application localization and display language. + /// + public class LocalizationService : ILocalizationService + { + public const string DefaultLanguage = "en-US"; + public const string SimplifiedChineseLanguage = "zh-CN"; + + private const string EnUsDictionaryPath = "Locales/en-US.xaml"; + private const string ZhCnDictionaryPath = "Locales/zh-CN.xaml"; + + private readonly ILogger logger; + private readonly IReadOnlyDictionary? englishStrings; + private readonly IReadOnlyDictionary? chineseStrings; + private ResourceDictionary? activeLocaleDictionary; + private ResourceDictionary? englishFallbackDictionary; + private Uri? activeLocaleUri; + + public string CurrentLanguage { get; private set; } = DefaultLanguage; + + public event EventHandler? LanguageChanged; + + public LocalizationService(ILogger logger) + : this(logger, englishStrings: null, chineseStrings: null) + { + } + + public LocalizationService( + ILogger logger, + IReadOnlyDictionary? englishStrings, + IReadOnlyDictionary? chineseStrings) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.englishStrings = englishStrings; + this.chineseStrings = chineseStrings; + } + + public static string NormalizeLanguage(string? language) + { + if (string.Equals(language, SimplifiedChineseLanguage, StringComparison.OrdinalIgnoreCase)) + { + return SimplifiedChineseLanguage; + } + + return DefaultLanguage; + } + + public void ApplyLanguage(string? language) + { + var normalizedLanguage = NormalizeLanguage(language); + var targetUri = new Uri(GetDictionaryPath(normalizedLanguage), UriKind.Relative); + + this.CurrentLanguage = normalizedLanguage; + + var appResources = System.Windows.Application.Current?.Resources; + if (appResources == null) + { + this.activeLocaleUri = targetUri; + this.LanguageChanged?.Invoke(this, normalizedLanguage); + return; + } + + try + { + ResourceDictionary? matchingDictionary = null; + for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) + { + var dictionary = appResources.MergedDictionaries[i]; + var source = dictionary.Source?.OriginalString; + if (IsLocaleDictionary(source)) + { + if (matchingDictionary == null && + string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + matchingDictionary = dictionary; + continue; + } + + appResources.MergedDictionaries.RemoveAt(i); + } + } + + if (matchingDictionary != null) + { + appResources.MergedDictionaries.Remove(matchingDictionary); + appResources.MergedDictionaries.Insert(0, matchingDictionary); + this.activeLocaleDictionary = matchingDictionary; + } + else + { + var nextDictionary = new ResourceDictionary { Source = targetUri }; + appResources.MergedDictionaries.Insert(0, nextDictionary); + this.activeLocaleDictionary = nextDictionary; + } + + this.activeLocaleUri = targetUri; + this.logger.LogInformation("Applied display language {Language}", normalizedLanguage); + this.LanguageChanged?.Invoke(this, normalizedLanguage); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage); + } + } + + public string GetString(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return string.Empty; + } + + if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized)) + { + return localized; + } + + if (this.TryGetStringFromApplicationResources(key, out localized)) + { + return localized; + } + + if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized)) + { + return localized; + } + + if (this.CurrentLanguage != DefaultLanguage && + this.TryGetStringFromOverrides(DefaultLanguage, key, out localized)) + { + return localized; + } + + if (this.CurrentLanguage != DefaultLanguage && + this.TryGetStringFromEnglishFallbackDictionary(key, out localized)) + { + return localized; + } + + return key; + } + + private static string GetDictionaryPath(string language) + { + return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath; + } + + private static bool IsLocaleDictionary(string? source) + { + return !string.IsNullOrWhiteSpace(source) && + (source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) || + source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase)); + } + + private static bool TryGetString(ResourceDictionary dictionary, string key, out string value) + { + if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text)) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private bool TryGetStringFromOverrides(string language, string key, out string value) + { + var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings; + if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text)) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private bool TryGetStringFromApplicationResources(string key, out string value) + { + value = string.Empty; + var app = System.Windows.Application.Current; + if (app == null) + { + return false; + } + + try + { + if (app.Dispatcher.CheckAccess()) + { + return TryGetApplicationResourceValue(app, key, out value); + } + + var found = false; + var dispatcherValue = string.Empty; + app.Dispatcher.Invoke(() => + { + found = TryGetApplicationResourceValue(app, key, out dispatcherValue); + }); + value = dispatcherValue; + return found; + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key); + return false; + } + } + + private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value) + { + if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text)) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value) + { + value = string.Empty; + try + { + this.englishFallbackDictionary ??= new ResourceDictionary + { + Source = new Uri(EnUsDictionaryPath, UriKind.Relative), + }; + return TryGetString(this.englishFallbackDictionary, key, out value); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary"); + return false; + } + } + } +} diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs index 7ba56c6..3298302 100644 --- a/Services/NotificationService.cs +++ b/Services/NotificationService.cs @@ -35,6 +35,7 @@ public class NotificationService : INotificationService, IDisposable private readonly ILogger logger; private readonly IApplicationSettingsService settingsService; private readonly ISystemTrayService systemTrayService; + private readonly ILocalizationService localizationService; private readonly List notificationHistory; private ApplicationSettingsModel settings; private bool disposed = false; @@ -50,11 +51,13 @@ public class NotificationService : INotificationService, IDisposable public NotificationService( ILogger logger, IApplicationSettingsService settingsService, - ISystemTrayService systemTrayService) + ISystemTrayService systemTrayService, + ILocalizationService localizationService) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); + this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); this.notificationHistory = new List(); this.settings = this.settingsService.Settings; @@ -102,6 +105,9 @@ public async Task ShowNotificationAsync(NotificationModel notification) try { + notification.Title = this.TryGetLocalizedNotificationString(notification.Title); + notification.Message = this.TryGetLocalizedNotificationString(notification.Message); + // Check if notifications are enabled if (!this.AreNotificationsEnabled(notification.Type)) { @@ -176,11 +182,18 @@ public async Task ShowPowerPlanChangeNotificationAsync(string oldPlan, string ne return; } + var title = this.GetLocalizedString("Notification_PowerPlanChangedTitle"); var message = string.IsNullOrEmpty(processName) - ? $"Power plan changed from '{oldPlan}' to '{newPlan}'" - : $"Power plan changed to '{newPlan}' for process '{processName}'"; + ? string.Format( + this.GetLocalizedString("Notification_PowerPlanChangedFormat"), + oldPlan, + newPlan) + : string.Format( + this.GetLocalizedString("Notification_PowerPlanChangedProcessFormat"), + newPlan, + processName); - var notification = new NotificationModel("Power Plan Changed", message, NotificationType.PowerPlanChange) + var notification = new NotificationModel(title, message, NotificationType.PowerPlanChange) { Category = "PowerPlan", SourceService = "PowerPlanService", @@ -197,7 +210,9 @@ public async Task ShowProcessMonitoringNotificationAsync(string message, bool is return; } - var title = isEnabled ? "Process Monitoring Enabled" : "Process Monitoring Disabled"; + var title = isEnabled + ? this.GetLocalizedString("Notification_ProcessMonitoringEnabled") + : this.GetLocalizedString("Notification_ProcessMonitoringDisabled"); var type = isEnabled ? NotificationType.Success : NotificationType.Warning; var notification = new NotificationModel(title, message, type) @@ -212,9 +227,15 @@ public async Task ShowProcessMonitoringNotificationAsync(string message, bool is public async Task ShowCpuAffinityNotificationAsync(string processName, string affinityInfo) { + var title = this.GetLocalizedString("Notification_CpuAffinityAppliedTitle"); + var message = string.Format( + this.GetLocalizedString("Notification_CpuAffinityAppliedFormat"), + processName, + affinityInfo); + var notification = new NotificationModel( - "CPU Affinity Applied", - $"CPU affinity set for '{processName}': {affinityInfo}", + title, + message, NotificationType.CpuAffinity) { Category = "CpuAffinity", @@ -396,6 +417,63 @@ private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventAr this.UpdateSettings(e.NewSettings); } + private string GetLocalizedString(string key) + { + var localized = this.localizationService.GetString(key); + return string.IsNullOrEmpty(localized) ? key : localized; + } + + private string TryGetLocalizedNotificationString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + var key = input switch + { + "Game Boost Activated" => "Notification_GameBoostActivatedTitle", + "Game Boost Deactivated" => "Notification_GameBoostDeactivatedTitle", + "Process Monitor Error" => "Notification_ProcessMonitorErrorTitle", + "Affinity blocked" => "Notification_AffinityBlockedTitle", + "Priority blocked" => "Notification_PriorityBlockedTitle", + _ => null, + }; + + if (key != null) + { + var localized = this.GetLocalizedString(key); + if (!string.Equals(localized, key, StringComparison.Ordinal)) + { + return localized; + } + } + + const string GameBoostActivatedPrefix = "Game Boost mode activated for "; + if (input.StartsWith(GameBoostActivatedPrefix, StringComparison.OrdinalIgnoreCase)) + { + var processName = input[GameBoostActivatedPrefix.Length..]; + var format = this.GetLocalizedString("Notification_GameBoostActivatedFormat"); + if (!string.Equals(format, "Notification_GameBoostActivatedFormat", StringComparison.Ordinal)) + { + return string.Format(format, processName); + } + } + + const string GameBoostDeactivatedPrefix = "Game Boost mode deactivated after "; + if (input.StartsWith(GameBoostDeactivatedPrefix, StringComparison.OrdinalIgnoreCase)) + { + var duration = input[GameBoostDeactivatedPrefix.Length..]; + var format = this.GetLocalizedString("Notification_GameBoostDeactivatedFormat"); + if (!string.Equals(format, "Notification_GameBoostDeactivatedFormat", StringComparison.Ordinal)) + { + return string.Format(format, duration); + } + } + + return input; + } + public void Dispose() { if (this.disposed) @@ -416,4 +494,3 @@ public void Dispose() } } } - diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 1bf3426..23dc385 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -165,6 +165,7 @@ private static IServiceCollection ConfigureApplicationLevelServices(this IServic // Application configuration and settings services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // User interface services diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs index 1fd0f35..214ac48 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs @@ -13,6 +13,21 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility() Assert.False(settings.StartMinimized); Assert.True(settings.ApplyPersistentRulesOnProcessStart); Assert.False(settings.HasSeenStartupMinimizedSuggestion); + Assert.Equal("en-US", settings.Language); + } + + [Fact] + public void CopyFrom_CopiesLanguage() + { + var source = new ApplicationSettingsModel + { + Language = "zh-CN", + }; + var target = new ApplicationSettingsModel(); + + target.CopyFrom(source); + + Assert.Equal("zh-CN", target.Language); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs index cd9589f..eb712c4 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs @@ -26,6 +26,7 @@ public async Task LoadSettingsAsync_CreatesDefaults_WhenFileIsMissing() Assert.False(service.Settings.EnableSelfAffinityLimit); Assert.True(service.Settings.AutostartWithWindows); Assert.False(service.Settings.StartMinimized); + Assert.Equal("en-US", service.Settings.Language); } [Fact] @@ -148,6 +149,42 @@ public async Task LoadSettingsAsync_PreservesStartupMinimizedSuggestionDismissal Assert.True(service.Settings.HasSeenStartupMinimizedSuggestion); } + [Fact] + public async Task LoadSettingsAsync_PreservesSupportedLanguage() + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = """ + { + "language": "zh-CN" + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.Equal("zh-CN", service.Settings.Language); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("fr-FR")] + [InlineData("zh")] + public async Task LoadSettingsAsync_FallsBackToEnglish_WhenLanguageIsInvalid(string language) + { + var storage = new FakeSettingsStorage(); + storage.Files[TestPaths.SettingsFilePath] = $$""" + { + "language": "{{language}}" + } + """; + var service = CreateService(storage); + + await service.LoadSettingsAsync(); + + Assert.Equal("en-US", service.Settings.Language); + } + [Fact] public async Task ImportSettingsAsync_Throws_WhenFileIsMissing() { diff --git a/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs new file mode 100644 index 0000000..a8eaa40 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs @@ -0,0 +1,105 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Services; + + public sealed class LocalizationServiceTests + { + [Fact] + public void Constructor_DefaultsToEnglish() + { + var service = CreateService(); + + Assert.Equal("en-US", service.CurrentLanguage); + } + + [Fact] + public void ApplyLanguage_AppliesChinese_WhenSupported() + { + var service = CreateService(); + + service.ApplyLanguage("zh-CN"); + + Assert.Equal("zh-CN", service.CurrentLanguage); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("fr-FR")] + [InlineData("zh")] + public void ApplyLanguage_FallsBackToEnglish_WhenLanguageIsInvalid(string? language) + { + var service = CreateService(); + service.ApplyLanguage("zh-CN"); + + service.ApplyLanguage(language); + + Assert.Equal("en-US", service.CurrentLanguage); + } + + [Fact] + public void GetString_UsesEnglishFallback_WhenActiveLanguageMissesKey() + { + var service = CreateService( + new Dictionary + { + ["Shared_Key"] = "English fallback", + }, + new Dictionary()); + service.ApplyLanguage("zh-CN"); + + var result = service.GetString("Shared_Key"); + + Assert.Equal("English fallback", result); + } + + [Fact] + public void GetString_ReturnsKey_WhenNoTranslationExists() + { + var service = CreateService(); + + var result = service.GetString("Missing_Key"); + + Assert.Equal("Missing_Key", result); + } + + [Fact] + public void LocaleFiles_DefineEnglishDefaultAndOptionalChineseLanguageLabels() + { + var root = FindRepositoryRoot(); + var english = File.ReadAllText(Path.Combine(root, "Locales", "en-US.xaml")); + var chinese = File.ReadAllText(Path.Combine(root, "Locales", "zh-CN.xaml")); + var appXaml = File.ReadAllText(Path.Combine(root, "App.xaml")); + + Assert.Contains("Source=\"Locales/en-US.xaml\"", appXaml, StringComparison.Ordinal); + Assert.DoesNotContain("Source=\"Locales/zh-CN.xaml\"", appXaml, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", english, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", english, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageEnUs\"", chinese, StringComparison.Ordinal); + Assert.Contains("x:Key=\"SettingsView_LanguageZhCn\"", chinese, StringComparison.Ordinal); + } + + private static LocalizationService CreateService( + IReadOnlyDictionary? englishStrings = null, + IReadOnlyDictionary? chineseStrings = null) + { + return new LocalizationService( + NullLogger.Instance, + englishStrings, + chineseStrings); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot_1.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new InvalidOperationException("Repository root was not found."); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index 499b7cd..84588e7 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -94,11 +94,30 @@ public void SettingsView_ExposesPersistentRuleAutoApplyToggle() "SettingsView.xaml"); var serialized = File.ReadAllText(settingsViewPath); - Assert.Contains("Text=\"Rules & automation\" Style=\"{StaticResource SectionHeaderStyle}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Text=\"Apply saved rules when matching processes start\"", serialized, StringComparison.Ordinal); + Assert.Contains("Text=\"{DynamicResource SettingsView_RulesAutomation}\" Style=\"{StaticResource SectionHeaderStyle}\"", serialized, StringComparison.Ordinal); + Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStart}\"", serialized, StringComparison.Ordinal); Assert.Contains("TextWrapping=\"Wrap\"", serialized, StringComparison.Ordinal); Assert.Contains("IsChecked=\"{Binding Settings.ApplyPersistentRulesOnProcessStart}\"", serialized, StringComparison.Ordinal); - Assert.Contains("This does not install a Windows Service and does not use registry/IFEO persistence.", serialized, StringComparison.Ordinal); + Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStartDescription}\"", serialized, StringComparison.Ordinal); + } + + [Fact] + public void SettingsView_ExposesOptionalChineseLanguageSelection() + { + var settingsViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "SettingsView.xaml"); + var serialized = File.ReadAllText(settingsViewPath); + + Assert.Contains("SelectedValue=\"{Binding Settings.Language}\"", serialized, StringComparison.Ordinal); + Assert.Contains("Tag=\"en-US\"", serialized, StringComparison.Ordinal); + Assert.Contains("Tag=\"zh-CN\"", serialized, StringComparison.Ordinal); } private sealed class Harness @@ -121,6 +140,8 @@ private sealed class Harness public Mock Tray { get; } = new(MockBehavior.Loose); + public Mock Localization { get; } = new(MockBehavior.Loose); + public Mock Logging { get; } = new(MockBehavior.Loose); public ActivityAuditService Audit { get; } = new(NullLogger.Instance); @@ -159,6 +180,7 @@ public SettingsViewModel CreateViewModel() => this.Theme.Object, this.Tray.Object, new GitHubUpdateChecker(new Mock().Object), + this.Localization.Object, this.Logging.Object, this.Audit); } diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 55c81a1..51d2ef3 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -49,6 +49,7 @@ public partial class SettingsViewModel : BaseViewModel private readonly IThemeService themeService; private readonly ISystemTrayService systemTrayService; private readonly GitHubUpdateChecker gitHubUpdateChecker; + private readonly ILocalizationService localizationService; private ApplicationSettingsModel savedSettingsSnapshot; private bool isSyncingFromService = false; private bool? appliedThemePreference; @@ -106,6 +107,7 @@ public SettingsViewModel( IThemeService themeService, ISystemTrayService systemTrayService, GitHubUpdateChecker gitHubUpdateChecker, + ILocalizationService localizationService, IEnhancedLoggingService? enhancedLoggingService = null, IActivityAuditService? activityAuditService = null) : base(logger, enhancedLoggingService, activityAuditService) @@ -119,6 +121,7 @@ public SettingsViewModel( this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); this.gitHubUpdateChecker = gitHubUpdateChecker ?? throw new ArgumentNullException(nameof(gitHubUpdateChecker)); + this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); // Get version and strip the git commit hash (everything after '+') var rawVersion = typeof(App).Assembly @@ -179,6 +182,13 @@ private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.Pro return; } + if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.Language), StringComparison.Ordinal)) + { + this.UpdatePendingChangesState(); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: true); + return; + } + if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.ApplyPersistentRulesOnProcessStart), StringComparison.Ordinal)) { this.UpdatePendingChangesState(); @@ -220,6 +230,31 @@ private void ApplyThemePreference(bool useDarkTheme, bool logUserAction) } } + private void ApplyLanguagePreference(string language, bool logUserAction) + { + var normalizedLanguage = LocalizationService.NormalizeLanguage(language); + try + { + this.localizationService.ApplyLanguage(normalizedLanguage); + this.Settings.Language = normalizedLanguage; + var languageName = normalizedLanguage == LocalizationService.SimplifiedChineseLanguage + ? "Simplified Chinese" + : "English"; + this.StatusMessage = $"Language changed to {languageName}."; + + if (logUserAction) + { + _ = this.LogUserActionAsync("LanguageChanged", $"Language changed to {languageName}"); + } + } + catch (Exception ex) + { + this.StatusMessage = "Failed to change language."; + this.Logger.LogError(ex, "Failed to apply language preference {Language}", normalizedLanguage); + _ = this.LogUserActionAsync("LanguageChangeFailed", $"Failed to change language to {normalizedLanguage}: {ex.Message}"); + } + } + partial void OnHasUnsavedChangesChanged(bool value) { OnPropertyChanged(nameof(CanSaveSettings)); @@ -279,6 +314,7 @@ private async Task SaveSettingsAsync() this.Settings.UseDarkTheme = useDarkTheme; this.isSyncingFromService = false; this.ApplyThemePreference(useDarkTheme, logUserAction: false); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); // Update monitoring services with new settings this.processMonitorManagerService.UpdateSettings(); @@ -549,6 +585,7 @@ public async Task RefreshSettingsAsync() this.Settings.UseDarkTheme = useDarkTheme; this.isSyncingFromService = false; this.ApplyThemePreference(useDarkTheme, logUserAction: false); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); this.SetSavedSettingsSnapshot(this.Settings); this.StatusMessage = "Settings loaded"; @@ -590,6 +627,7 @@ private void OnSettingsServiceSettingsChanged(object? sender, ApplicationSetting this.Settings.DefaultPowerPlanName = this.cachedDefaultPowerPlanName; } this.SetSavedSettingsSnapshot(this.Settings); + this.ApplyLanguagePreference(this.Settings.Language, logUserAction: false); this.StatusMessage = "Settings synchronized"; } finally diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml index f2b3ba5..c72bd35 100644 --- a/Views/SettingsView.xaml +++ b/Views/SettingsView.xaml @@ -178,49 +178,60 @@ - + - - + - - - - - + - + + + + + + + + + - + - + - - From 9d5670e88bfd9928fff74326f4ec1b1e4ba1198d Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Sat, 6 Jun 2026 17:57:08 +0200 Subject: [PATCH 2/2] Increase localization test coverage --- Services/LocalizationService.cs | 66 +++---- .../LocalizationServiceTests.cs | 95 +++++++++++ .../NotificationServiceLocalizationTests.cs | 161 ++++++++++++++++++ .../SettingsViewModelThemeTests.cs | 43 +++++ 4 files changed, 334 insertions(+), 31 deletions(-) create mode 100644 Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs diff --git a/Services/LocalizationService.cs b/Services/LocalizationService.cs index baf4792..9e3270b 100644 --- a/Services/LocalizationService.cs +++ b/Services/LocalizationService.cs @@ -85,37 +85,7 @@ public void ApplyLanguage(string? language) try { - ResourceDictionary? matchingDictionary = null; - for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) - { - var dictionary = appResources.MergedDictionaries[i]; - var source = dictionary.Source?.OriginalString; - if (IsLocaleDictionary(source)) - { - if (matchingDictionary == null && - string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) - { - matchingDictionary = dictionary; - continue; - } - - appResources.MergedDictionaries.RemoveAt(i); - } - } - - if (matchingDictionary != null) - { - appResources.MergedDictionaries.Remove(matchingDictionary); - appResources.MergedDictionaries.Insert(0, matchingDictionary); - this.activeLocaleDictionary = matchingDictionary; - } - else - { - var nextDictionary = new ResourceDictionary { Source = targetUri }; - appResources.MergedDictionaries.Insert(0, nextDictionary); - this.activeLocaleDictionary = nextDictionary; - } - + this.ApplyLanguageDictionary(appResources, targetUri); this.activeLocaleUri = targetUri; this.logger.LogInformation("Applied display language {Language}", normalizedLanguage); this.LanguageChanged?.Invoke(this, normalizedLanguage); @@ -163,6 +133,40 @@ public string GetString(string key) return key; } + private void ApplyLanguageDictionary(ResourceDictionary appResources, Uri targetUri) + { + ResourceDictionary? matchingDictionary = null; + for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) + { + var dictionary = appResources.MergedDictionaries[i]; + var source = dictionary.Source?.OriginalString; + if (IsLocaleDictionary(source)) + { + if (matchingDictionary == null && + string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + matchingDictionary = dictionary; + continue; + } + + appResources.MergedDictionaries.RemoveAt(i); + } + } + + if (matchingDictionary != null) + { + appResources.MergedDictionaries.Remove(matchingDictionary); + appResources.MergedDictionaries.Insert(0, matchingDictionary); + this.activeLocaleDictionary = matchingDictionary; + } + else + { + var nextDictionary = new ResourceDictionary { Source = targetUri }; + appResources.MergedDictionaries.Insert(0, nextDictionary); + this.activeLocaleDictionary = nextDictionary; + } + } + private static string GetDictionaryPath(string language) { return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath; diff --git a/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs index a8eaa40..4f1d0d5 100644 --- a/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/LocalizationServiceTests.cs @@ -1,5 +1,7 @@ namespace ThreadPilot.Core.Tests { + using System.Reflection; + using System.Windows; using Microsoft.Extensions.Logging.Abstractions; using ThreadPilot.Services; @@ -23,6 +25,61 @@ public void ApplyLanguage_AppliesChinese_WhenSupported() Assert.Equal("zh-CN", service.CurrentLanguage); } + [Fact] + public void ApplyLanguage_FiresLanguageChangedWithNormalizedLanguage() + { + var service = CreateService(); + var observedLanguages = new List(); + service.LanguageChanged += (_, language) => observedLanguages.Add(language); + + service.ApplyLanguage("zh-cn"); + service.ApplyLanguage("unsupported"); + + Assert.Equal(new[] { "zh-CN", "en-US" }, observedLanguages); + } + + [Fact] + public void ApplyLanguage_RemovesDuplicateAndOldLocaleDictionaries() + { + var resources = new ResourceDictionary(); + var nonLocaleDictionary = CreateDictionaryWithSource("Themes/FluentDark.xaml"); + var oldEnglishDictionary = CreateDictionaryWithSource("Locales/en-US.xaml"); + var duplicateChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml"); + var matchingChineseDictionary = CreateDictionaryWithSource("Locales/zh-CN.xaml"); + resources.MergedDictionaries.Add(nonLocaleDictionary); + resources.MergedDictionaries.Add(oldEnglishDictionary); + resources.MergedDictionaries.Add(duplicateChineseDictionary); + resources.MergedDictionaries.Add(matchingChineseDictionary); + var service = CreateService(); + + InvokeApplyLanguageDictionary(service, resources, new Uri("Locales/zh-CN.xaml", UriKind.Relative)); + + Assert.Equal(2, resources.MergedDictionaries.Count); + Assert.Same(matchingChineseDictionary, resources.MergedDictionaries[0]); + Assert.Same(nonLocaleDictionary, resources.MergedDictionaries[1]); + Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, oldEnglishDictionary)); + Assert.DoesNotContain(resources.MergedDictionaries, dictionary => ReferenceEquals(dictionary, duplicateChineseDictionary)); + } + + [Fact] + public void GetString_UsesCurrentLanguageOverrideBeforeEnglishFallback() + { + var service = CreateService( + new Dictionary + { + ["Shared_Key"] = "English", + }, + new Dictionary + { + ["Shared_Key"] = "Chinese", + }); + service.ApplyLanguage("zh-CN"); + + var result = service.GetString("Shared_Key"); + + Assert.Equal("Chinese", result); + } + [Theory] [InlineData(null)] [InlineData("")] @@ -65,6 +122,15 @@ public void GetString_ReturnsKey_WhenNoTranslationExists() Assert.Equal("Missing_Key", result); } + [Fact] + public void GetString_ReturnsEmpty_WhenKeyIsBlank() + { + var service = CreateService(); + + Assert.Equal(string.Empty, service.GetString(string.Empty)); + Assert.Equal(string.Empty, service.GetString(" ")); + } + [Fact] public void LocaleFiles_DefineEnglishDefaultAndOptionalChineseLanguageLabels() { @@ -101,5 +167,34 @@ private static string FindRepositoryRoot() return directory?.FullName ?? throw new InvalidOperationException("Repository root was not found."); } + + private static ResourceDictionary CreateDictionaryWithSource(string source) + { + var dictionary = new ResourceDictionary(); + var sourceField = typeof(ResourceDictionary).GetField("_source", BindingFlags.Instance | BindingFlags.NonPublic); + if (sourceField == null) + { + throw new InvalidOperationException("ResourceDictionary source field was not found."); + } + + sourceField.SetValue(dictionary, new Uri(source, UriKind.Relative)); + return dictionary; + } + + private static void InvokeApplyLanguageDictionary( + LocalizationService service, + ResourceDictionary resources, + Uri targetUri) + { + var method = typeof(LocalizationService).GetMethod( + "ApplyLanguageDictionary", + BindingFlags.Instance | BindingFlags.NonPublic); + if (method == null) + { + throw new InvalidOperationException("ApplyLanguageDictionary method was not found."); + } + + method.Invoke(service, new object[] { resources, targetUri }); + } } } diff --git a/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs b/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs new file mode 100644 index 0000000..0383448 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/NotificationServiceLocalizationTests.cs @@ -0,0 +1,161 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class NotificationServiceLocalizationTests + { + [Fact] + public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedTitleAndFormat() + { + var harness = new Harness(new Dictionary + { + ["Notification_PowerPlanChangedTitle"] = "Localized power title", + ["Notification_PowerPlanChangedFormat"] = "Changed {0} -> {1}", + }); + var service = harness.CreateService(); + + await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance"); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Localized power title", notification.Title); + Assert.Equal("Changed Balanced -> Performance", notification.Message); + harness.Tray.Verify( + tray => tray.ShowTrayNotification( + "Localized power title", + "Changed Balanced -> Performance", + NotificationType.PowerPlanChange, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShowPowerPlanChangeNotificationAsync_UsesLocalizedProcessFormat() + { + var harness = new Harness(new Dictionary + { + ["Notification_PowerPlanChangedTitle"] = "Power", + ["Notification_PowerPlanChangedProcessFormat"] = "{1}: {0}", + }); + var service = harness.CreateService(); + + await service.ShowPowerPlanChangeNotificationAsync("Balanced", "Performance", "game.exe"); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Power", notification.Title); + Assert.Equal("game.exe: Performance", notification.Message); + } + + [Theory] + [InlineData(true, "Enabled localized", NotificationType.Success)] + [InlineData(false, "Disabled localized", NotificationType.Warning)] + public async Task ShowProcessMonitoringNotificationAsync_UsesLocalizedTitle(bool isEnabled, string expectedTitle, NotificationType expectedType) + { + var harness = new Harness(new Dictionary + { + ["Notification_ProcessMonitoringEnabled"] = "Enabled localized", + ["Notification_ProcessMonitoringDisabled"] = "Disabled localized", + }); + var service = harness.CreateService(); + + await service.ShowProcessMonitoringNotificationAsync("Monitoring changed", isEnabled); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal(expectedTitle, notification.Title); + Assert.Equal("Monitoring changed", notification.Message); + Assert.Equal(expectedType, notification.Type); + } + + [Fact] + public async Task ShowCpuAffinityNotificationAsync_UsesLocalizedTitleAndFormat() + { + var harness = new Harness(new Dictionary + { + ["Notification_CpuAffinityAppliedTitle"] = "Affinity localized", + ["Notification_CpuAffinityAppliedFormat"] = "{0} uses {1}", + }); + var service = harness.CreateService(); + + await service.ShowCpuAffinityNotificationAsync("game.exe", "CPU 0, 1"); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Affinity localized", notification.Title); + Assert.Equal("game.exe uses CPU 0, 1", notification.Message); + } + + [Fact] + public async Task ShowNotificationAsync_LocalizesKnownAndDynamicGameBoostStrings() + { + var harness = new Harness(new Dictionary + { + ["Notification_GameBoostActivatedTitle"] = "Boost title", + ["Notification_GameBoostActivatedFormat"] = "Boosted {0}", + }); + var service = harness.CreateService(); + + await service.ShowNotificationAsync( + "Game Boost Activated", + "Game Boost mode activated for game.exe", + NotificationType.Information); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Boost title", notification.Title); + Assert.Equal("Boosted game.exe", notification.Message); + } + + [Fact] + public async Task ShowNotificationAsync_KeepsOriginalText_WhenLocalizationKeyIsMissing() + { + var harness = new Harness(new Dictionary()); + var service = harness.CreateService(); + + await service.ShowNotificationAsync( + "Affinity blocked", + "Unmapped notification message", + NotificationType.Warning); + + var notification = Assert.Single(service.NotificationHistory); + Assert.Equal("Affinity blocked", notification.Title); + Assert.Equal("Unmapped notification message", notification.Message); + } + + private sealed class Harness + { + private readonly IReadOnlyDictionary localizedStrings; + + public Mock Settings { get; } = new(MockBehavior.Loose); + + public Mock Tray { get; } = new(MockBehavior.Loose); + + public Mock Localization { get; } = new(MockBehavior.Loose); + + public Harness(IReadOnlyDictionary localizedStrings) + { + this.localizedStrings = localizedStrings; + this.Settings.SetupGet(service => service.Settings).Returns(new ApplicationSettingsModel + { + EnableToastNotifications = false, + }); + this.Localization + .Setup(service => service.GetString(It.IsAny())) + .Returns(this.GetLocalizedString); + } + + public NotificationService CreateService() + { + return new NotificationService( + NullLogger.Instance, + this.Settings.Object, + this.Tray.Object, + this.Localization.Object); + } + + private string GetLocalizedString(string key) + { + return this.localizedStrings.TryGetValue(key, out var value) ? value : key; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index 84588e7..f03e1cd 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -1,6 +1,7 @@ namespace ThreadPilot.Core.Tests { using System.Collections.ObjectModel; + using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging.Abstractions; using Moq; using ThreadPilot.Models; @@ -80,6 +81,45 @@ public async Task ChangingApplyPersistentRulesOnProcessStart_UpdatesSettingAndLo Assert.Contains(entries, entry => entry.Message == "[Settings] Apply saved rules at process start enabled."); } + [Fact] + public async Task ChangingLanguage_AppliesLanguageAndLogsVisibleActivityEntry() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.Language = "zh-CN"; + + harness.Localization.Verify(service => service.ApplyLanguage("zh-CN"), Times.Once); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "LanguageChanged", + "Language changed to Simplified Chinese", + null), + Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Language changed to Simplified Chinese", entry.Message); + Assert.Equal("Language changed to Simplified Chinese.", viewModel.StatusMessage); + } + + [Fact] + public async Task SaveSettingsCommand_PersistsSelectedLanguage() + { + var harness = new Harness(); + ApplicationSettingsModel? savedSettings = null; + harness.SettingsService + .Setup(service => service.UpdateSettingsAsync(It.IsAny())) + .Callback(settings => savedSettings = (ApplicationSettingsModel)settings.Clone()) + .Returns(Task.CompletedTask); + var viewModel = harness.CreateViewModel(); + viewModel.Settings.Language = "zh-CN"; + + await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); + + Assert.NotNull(savedSettings); + Assert.Equal("zh-CN", savedSettings.Language); + Assert.False(viewModel.HasUnsavedChanges); + } + [Fact] public void SettingsView_ExposesPersistentRuleAutoApplyToggle() { @@ -154,6 +194,9 @@ public Harness(bool initialDarkTheme = false) HasUserThemePreference = initialDarkTheme, }; this.SettingsService.SetupGet(service => service.Settings).Returns(this.settings); + this.Autostart + .Setup(service => service.CheckAutostartStatusAsync()) + .ReturnsAsync(true); this.PowerPlans .Setup(service => service.GetPowerPlansAsync()) .ReturnsAsync(new ObservableCollection());