Skip to content
143 changes: 143 additions & 0 deletions PCL.Core.Test/LocalizationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PCL.Core.App.Localization;

namespace PCL.Core.Test;

[TestClass]
public class LocalizationTest
{
private static readonly string[] LanguageFiles = LocalizationService.SupportedLanguages
.Select(language => language.Code)
.ToArray();

[TestMethod]
public void AllLanguageDictionariesShouldContainBaseKeys()
{
var baseKeys = LoadKeys("zh-CN");

foreach (var language in LanguageFiles.Where(language => language != "zh-CN"))
{
var keys = LoadKeys(language);
var missing = baseKeys.Except(keys).ToArray();

Assert.IsEmpty(missing, $"{language} 缺少语言键:{string.Join(", ", missing)}");
}
}


[TestMethod]
public void LanguageDictionariesShouldNotContainDuplicateKeys()
{
foreach (var language in LanguageFiles)
{
var keys = LoadKeyList(language);
var duplicated = keys
.GroupBy(key => key)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToArray();

Assert.IsEmpty(duplicated, $"{language} 存在重复语言键:{string.Join(", ", duplicated)}");
}
}

[TestMethod]
public void LanguageKeysShouldUseDotNaming()
{
foreach (var language in LanguageFiles)
foreach (var key in LoadKeys(language))
{
Assert.IsFalse(string.IsNullOrWhiteSpace(key), $"{language} 存在空语言键");
Assert.IsTrue(key.Contains('.'), $"{language} 的语言键缺少分组分隔符:{key}");
Assert.IsFalse(key.Contains(' '), $"{language} 的语言键不应包含空格:{key}");
}
}


[TestMethod]
public void SupportedLanguagesShouldHaveValidCultureAndResourceDictionary()
{
foreach (var language in LocalizationService.SupportedLanguages)
{
CultureInfo.GetCultureInfo(language.CultureName);

var filePath = Path.Combine(GetRepositoryRoot(), "PCL.Core", "App", "Localization", "Languages",
language.Code + ".xaml");
Assert.IsTrue(File.Exists(filePath), $"{language.Code} 缺少语言资源文件");
}
}


[TestMethod]
public void FontProfileShouldFollowCultureGlyphStandard()
{
Assert.AreEqual(LocalizationFontProfile.SimplifiedChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-CN"));
Assert.AreEqual(LocalizationFontProfile.SimplifiedChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-Hans"));
Assert.AreEqual(LocalizationFontProfile.TraditionalChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-TW"));
Assert.AreEqual(LocalizationFontProfile.TraditionalChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh_HK"));
Assert.AreEqual(LocalizationFontProfile.TraditionalChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-Hant-HK"));
Assert.AreEqual(LocalizationFontProfile.Japanese,
LocalizationFontService.ResolveProfileFromCultureName("ja-JP"));
Assert.AreEqual(LocalizationFontProfile.Korean,
LocalizationFontService.ResolveProfileFromCultureName("ko-KR"));
Assert.AreEqual(LocalizationFontProfile.English,
LocalizationFontService.ResolveProfileFromCultureName("en-US"));
Assert.AreEqual(LocalizationFontProfile.English,
LocalizationFontService.ResolveProfileFromCultureName("en-GB"));
Assert.AreEqual(LocalizationFontProfile.Other,
LocalizationFontService.ResolveProfileFromCultureName("fr-FR"));
Assert.AreEqual(LocalizationFontProfile.Other,
LocalizationFontService.ResolveProfileFromCultureName("es-ES"));
Assert.AreEqual(LocalizationFontProfile.Other,
LocalizationFontService.ResolveProfileFromCultureName("pt-BR"));
}

[TestMethod]
public void FontProfileAliasesShouldNotMakeLanguageResourceSupported()
{
Assert.IsFalse(LocalizationService.IsLanguageSupported("zh-HK"));
Assert.AreEqual(LocalizationService.DefaultLanguageCode, LocalizationService.ResolveLanguage("zh-HK").Code);
}

private static HashSet<string> LoadKeys(string language)
{
return LoadKeyList(language).ToHashSet();
}

private static string[] LoadKeyList(string language)
{
var filePath = Path.Combine(GetRepositoryRoot(), "PCL.Core", "App", "Localization", "Languages",
language + ".xaml");
var document = XDocument.Load(filePath);
var keyAttributeName = XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml");
return document.Descendants()
.Select(element => element.Attribute(keyAttributeName)?.Value)
.Where(key => !string.IsNullOrWhiteSpace(key))
.Select(key => key!)
.ToArray();
}

private static string GetRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (Directory.Exists(Path.Combine(directory.FullName, "PCL.Core"))) return directory.FullName;
directory = directory.Parent;
}

Assert.Fail("无法定位仓库根目录");
return string.Empty;
}
}
24 changes: 23 additions & 1 deletion PCL.Core/App/Config.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using PCL.Core.App.Configuration;
using PCL.Core.App.Configuration;

namespace PCL.Core.App;

Expand Down Expand Up @@ -226,6 +226,27 @@ public static partial class Config
/// </summary>
[ConfigItem<bool>("DetailedInstanceClassification", false, ConfigSource.Local)] public partial bool DetailedInstanceClassification { get; set; }

/// <summary>
/// 本地化配置。
/// </summary>
[ConfigGroup("Localization", ConfigSource.Local)] partial class LocalizationConfigGroup
{
/// <summary>
/// UI 语言。auto 表示跟随系统语言。
/// </summary>
[ConfigItem<string>("UiLanguage", "auto")] public partial string Language { get; set; }

/// <summary>
/// UI 展示格式所使用的区域性。auto 表示跟随系统区域格式。
/// </summary>
[ConfigItem<string>("UiFormatCulture", "auto")] public partial string FormatCulture { get; set; }

/// <summary>
/// 区域覆盖。auto 表示自动判断。
/// </summary>
[ConfigItem<string>("UiRegion", "auto")] public partial string Region { get; set; }
}

/// <summary>
/// 界面主题配置。
/// </summary>
Expand Down Expand Up @@ -410,6 +431,7 @@ partial class HideConfigGroup
// 子页面 设置
[ConfigItem<bool>("UiHiddenSetupLaunch", false, ConfigSource.Local)] public partial bool SetupLaunch { get; set; }
[ConfigItem<bool>("UiHiddenSetupUi", false, ConfigSource.Local)] public partial bool SetupUi { get; set; }
[ConfigItem<bool>("UiHiddenSetupLauncherLanguage", false, ConfigSource.Local)] public partial bool SetupLauncherLanguage { get; set; }
[ConfigItem<bool>("UiHiddenSetupLauncherMisc", false, ConfigSource.Local)] public partial bool SetupLauncherMisc { get; set; }
[ConfigItem<bool>("UiHiddenSetupGameManage", false, ConfigSource.Local)] public partial bool SetupGameManage { get; set; }
[ConfigItem<bool>("UiHiddenSetupJava", false, ConfigSource.Local)] public partial bool SetupJava { get; set; }
Expand Down
129 changes: 129 additions & 0 deletions PCL.Core/App/Localization/Lang.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Globalization;
using System.Windows;
using PCL.Core.App.IoC;

namespace PCL.Core.App.Localization;

/// <summary>
/// <p>
/// 本地化文本与展示格式访问辅助。
/// </p>
/// <p>
/// <see cref="Lang" /> 用于代码中读取本地化文本资源,以及按照当前展示区域性格式化文本参数、日期时间和数值。
/// 它面向 C# 代码侧调用;XAML 中的静态文本优先使用 <c>DynamicResource</c>,
/// XAML 绑定值的格式化优先使用 <see cref="LocalizationFormatConverter" />。
/// </p>
/// <p>
/// 文本资源来自当前应用的资源字典。正常运行时优先通过
/// <see cref="Application.Current" /> 查找资源;在应用生命周期早期或测试环境中,
/// 会回退到 <see cref="Lifecycle.CurrentApplication" /> 进行一次安全查找。
/// </p>
/// <p>
/// 该类中的格式化方法使用 <see cref="CultureInfo.CurrentCulture" />,
/// 因此会跟随 <see cref="LocalizationService" /> 当前设置的展示格式区域性。
/// 它们只适合生成展示给用户看的文本,不应用于配置文件、日志、协议、缓存键、文件名等
/// 需要稳定格式的场景;这些场景应显式使用 <see cref="CultureInfo.InvariantCulture" />。
/// </p>
/// </summary>
public static class Lang
{
/// <summary>
/// 获取指定资源键对应的本地化文本。
/// </summary>
/// <param name="key">
/// 资源键。不能为空、空字符串或空白字符串。
/// </param>
/// <returns>
/// 找到资源时返回本地化文本;
/// 未找到资源时,调试构建返回 <c>!key!</c>,发布构建返回 <paramref name="key" /> 本身。
/// </returns>
/// <exception cref="ArgumentException">
/// <paramref name="key" /> 为空、空字符串或空白字符串。
/// </exception>
public static string Text(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

if (Application.Current?.TryFindResource(key) is string text) return text;
if (_LifecycleSafeFindResource(key) is string fallbackText) return fallbackText;

#if DEBUG
return $"!{key}!";
#else
return key;
#endif
}

/// <summary>
/// 获取指定资源键对应的本地化格式文本,并使用当前展示区域性格式化参数。
/// 资源文本应使用标准 .NET 复合格式字符串,例如 <c>{0}</c>、<c>{1:N2}</c>。
/// 该方法适合代码中生成用户可见句子,例如提示、说明、状态文本。
/// </summary>
/// <param name="key">
/// 资源键。不能为空、空字符串或空白字符串。
/// </param>
/// <param name="args">
/// 用于填充资源文本中格式占位符的参数。
/// </param>
/// <returns>
/// 使用当前展示区域性格式化后的本地化文本。
/// </returns>
public static string Text(string key, params object?[] args)
{
return string.Format(CultureInfo.CurrentCulture, Text(key), args);
}

/// <summary>
/// <p>使用当前展示区域性格式化日期时间。</p>
/// </summary>
/// <param name="value">
/// 要格式化的日期时间。
/// </param>
/// <param name="format">
/// 标准或自定义日期时间格式字符串,默认使用 <c>G</c>。
/// </param>
/// <returns>
/// 使用当前展示区域性格式化后的日期时间文本。
/// </returns>
public static string Date(DateTime value, string format = "G")
{
return value.ToString(format, CultureInfo.CurrentCulture);
}

/// <summary>
/// 使用当前展示区域性格式化数值。
/// </summary>
/// <typeparam name="T">
/// 实现 <see cref="IFormattable" /> 的数值或可格式化类型。
/// </typeparam>
/// <param name="value">
/// 要格式化的值。
/// </param>
/// <param name="format">
/// 标准或自定义格式字符串。为 <see langword="null" /> 时使用类型默认格式。
/// </param>
/// <returns>
/// 使用当前展示区域性格式化后的文本。
/// </returns>
public static string Number<T>(T value, string? format = null) where T : IFormattable
{
return value.ToString(format, CultureInfo.CurrentCulture);
}

private static object? _LifecycleSafeFindResource(string key)
{
try
{
return Lifecycle.CurrentApplication.TryFindResource(key);
}
catch (InvalidOperationException)
{
return null;
}
catch (NullReferenceException)
{
return null;
}
}
}
32 changes: 32 additions & 0 deletions PCL.Core/App/Localization/Languages/en-GB.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">

<sys:String x:Key="Localization.Meta.Code">en-GB</sys:String>
<sys:String x:Key="Localization.Meta.Name">English (United Kingdom)</sys:String>

<sys:String x:Key="Localization.Language.Auto">Follow system ({0})</sys:String>

<sys:String x:Key="Localization.FormatCulture.FollowLanguage">Follow UI language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Title">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.CardTitle">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.UiLanguage">Display language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.FormatCulture">Regional format</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Hint">Multi-language support is currently limited to certain new interfaces. Others will be updated in upcoming migrations.</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Changed">Language settings updated</sys:String>

<sys:String x:Key="Localization.FormatCulture.Auto">Follow system regional format</sys:String>

<sys:String x:Key="Common.Action.Confirm">OK</sys:String>
<sys:String x:Key="Common.Action.Cancel">Cancel</sys:String>
<sys:String x:Key="Common.Action.Close">Close</sys:String>
<sys:String x:Key="Common.Action.Search">Search</sys:String>
<sys:String x:Key="Common.Action.Refresh">Refresh</sys:String>
<sys:String x:Key="Common.Action.Reset">Reset</sys:String>

<sys:String x:Key="Main.Title.Launch">Launch</sys:String>
<sys:String x:Key="Main.Title.Download">Download</sys:String>
<sys:String x:Key="Main.Title.Settings">Settings</sys:String>
<sys:String x:Key="Main.Title.Tools">Tools</sys:String>
</ResourceDictionary>
32 changes: 32 additions & 0 deletions PCL.Core/App/Localization/Languages/en-US.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">

<sys:String x:Key="Localization.Meta.Code">en-US</sys:String>
<sys:String x:Key="Localization.Meta.Name">English (US)</sys:String>

<sys:String x:Key="Localization.Language.Auto">Follow system ({0})</sys:String>

<sys:String x:Key="Localization.FormatCulture.FollowLanguage">Follow UI language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Title">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.CardTitle">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.UiLanguage">Display language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.FormatCulture">Regional format</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Hint">Multi-language support is currently limited to certain new interfaces. Others will be updated in upcoming migrations.</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Changed">Language settings updated</sys:String>

<sys:String x:Key="Localization.FormatCulture.Auto">Follow system regional format</sys:String>

<sys:String x:Key="Common.Action.Confirm">OK</sys:String>
<sys:String x:Key="Common.Action.Cancel">Cancel</sys:String>
<sys:String x:Key="Common.Action.Close">Close</sys:String>
<sys:String x:Key="Common.Action.Search">Search</sys:String>
<sys:String x:Key="Common.Action.Refresh">Refresh</sys:String>
<sys:String x:Key="Common.Action.Reset">Reset</sys:String>

<sys:String x:Key="Main.Title.Launch">Launch</sys:String>
<sys:String x:Key="Main.Title.Download">Download</sys:String>
<sys:String x:Key="Main.Title.Settings">Settings</sys:String>
<sys:String x:Key="Main.Title.Tools">Tools</sys:String>
</ResourceDictionary>
Loading
Loading