diff --git a/DesktopClock.Tests/LocalizationTests.cs b/DesktopClock.Tests/LocalizationTests.cs new file mode 100644 index 0000000..4f74c73 --- /dev/null +++ b/DesktopClock.Tests/LocalizationTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using System.Text.RegularExpressions; + +namespace DesktopClock.Tests; + +public class LocalizationTests +{ + private const string ResourcePrefix = "DesktopClock.Localization.Strings."; + + private static readonly Regex PlaceholderRegex = new(@"\{[^{}]*\}"); + private static readonly Regex UrlRegex = new(@"https://[\w./-]*\w"); + + // Alt access keys that share a window must not collide. + private static readonly string[][] AccessKeyGroups = + { + ["MenuCopy", "MenuHide", "MenuAlwaysOnTop", "MenuSettings", "MenuCheckForUpdates", "MenuGiveFeedback", "MenuExit"], + ["NavDisplay", "NavCountdown", "NavWindow", "NavThemePresets", "NavTypography", "NavAppearance", "NavAlerts", "NavShortcuts", "NavDiscoverMore", "NavCredits", "SettingsFolder", "SettingsFile", "NewClock"], + }; + + private static readonly Dictionary English = ReadStrings(""); + + public static IEnumerable Translations => + typeof(Loc).Assembly.GetManifestResourceNames() + .Where(n => n.StartsWith(ResourcePrefix) && n != ResourcePrefix + "resources") + .Select(n => new object[] { n.Substring(ResourcePrefix.Length, n.Length - ResourcePrefix.Length - ".resources".Length) }); + + [Fact] + public void Translations_AreEmbeddedInTheExe() + { + Assert.NotEmpty(Translations); + Assert.Equal("_Copy", Loc.Get("MenuCopy", CultureInfo.GetCultureInfo("en-US"))); + } + + [Theory] + [MemberData(nameof(Translations))] + public void Translation_HasTheSameKeysAsEnglish(string culture) + { + var translation = ReadStrings(culture + "."); + + Assert.Empty(English.Keys.Except(translation.Keys)); + Assert.Empty(translation.Keys.Except(English.Keys)); + Assert.DoesNotContain(translation, t => string.IsNullOrWhiteSpace(t.Value)); + } + + [Theory] + [MemberData(nameof(Translations))] + public void Translation_KeepsPlaceholdersAndLinks(string culture) + { + foreach (var translated in ReadStrings(culture + ".")) + { + var english = English[translated.Key]; + Assert.True(Placeholders(english).SetEquals(Placeholders(translated.Value)), $"{culture} {translated.Key}: placeholders changed in \"{translated.Value}\""); + + foreach (Match url in UrlRegex.Matches(english)) + Assert.True(translated.Value.Contains(url.Value), $"{culture} {translated.Key}: missing {url.Value}"); + } + } + + [Theory] + [MemberData(nameof(Translations))] + public void Translation_HasOneUniqueAccessKeyPerLabel(string culture) + { + var translation = ReadStrings(culture + "."); + + foreach (var key in English.Keys.Where(k => English[k].Contains('_'))) + { + var value = translation[key]; + var underscore = value.IndexOf('_'); + Assert.True(underscore >= 0 && underscore == value.LastIndexOf('_') && underscore < value.Length - 1 && char.IsLetterOrDigit(value[underscore + 1]), + $"{culture} {key}: \"{value}\" needs exactly one underscore before a letter"); + } + + foreach (var group in AccessKeyGroups) + { + var letters = group.Select(k => char.ToUpperInvariant(translation[k][translation[k].IndexOf('_') + 1])).ToList(); + Assert.True(letters.Distinct().Count() == letters.Count, $"{culture}: duplicate access keys in {string.Join(", ", group)}"); + } + } + + [Theory] + [MemberData(nameof(Translations))] + public void Translation_HasPluralFormsForItsLanguage(string culture) + { + var expectedForms = CultureInfo.GetCultureInfo(culture).TwoLetterISOLanguageName switch + { + "pl" or "ru" or "uk" => 3, + "id" or "ja" or "ko" or "tr" or "vi" or "zh" => 1, + _ => 2, + }; + + foreach (var plural in ReadStrings(culture + ".").Where(t => English[t.Key].Contains('|'))) + Assert.True(plural.Value.Split('|').Length == expectedForms, $"{culture} {plural.Key}: expected {expectedForms} forms in \"{plural.Value}\""); + } + + [Fact] + public void Get_FallsBackToTheClosestTranslationThenEnglish() + { + var german = ReadStrings("de."); + + Assert.Equal(german["MenuCopy"], Loc.Get("MenuCopy", CultureInfo.GetCultureInfo("de-AT"))); + Assert.Equal("_Copy", Loc.Get("MenuCopy", CultureInfo.GetCultureInfo("sv-SE"))); + Assert.Equal("NoSuchKey", Loc.Get("NoSuchKey")); + } + + [Fact] + public void Source_OnlyUsesKeysThatExist() + { + var sourceFolder = FindSourceFolder(); + var usage = new Regex(@"Loc\.(?:Get|Format)\(""(\w+)""|=""\{local:Loc (\w+)\}""|""(Relative\w+)"""); + var used = Directory.EnumerateFiles(sourceFolder, "*.*", SearchOption.AllDirectories) + .Where(f => f.EndsWith(".cs") || f.EndsWith(".xaml")) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) + .SelectMany(f => usage.Matches(File.ReadAllText(f)).Cast()) + .Select(m => m.Groups.Cast().Skip(1).First(g => g.Success).Value) + .Distinct() + .ToList(); + + Assert.NotEmpty(used); + Assert.DoesNotContain(used, k => !English.ContainsKey(k)); + } + + private static HashSet Placeholders(string text) => new(PlaceholderRegex.Matches(text).Cast().Select(m => m.Value)); + + private static Dictionary ReadStrings(string culturePart) + { + using var stream = typeof(Loc).Assembly.GetManifestResourceStream($"{ResourcePrefix}{culturePart}resources"); + using var reader = new ResourceReader(stream); + return reader.Cast().ToDictionary(e => (string)e.Key, e => (string)e.Value); + } + + private static string FindSourceFolder() + { + for (var dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); dir != null; dir = dir.Parent) + { + if (File.Exists(Path.Combine(dir.FullName, "DesktopClock.sln"))) + return Path.Combine(dir.FullName, "DesktopClock"); + } + + throw new DirectoryNotFoundException("Couldn't find the solution folder."); + } +} diff --git a/DesktopClock.Tests/RelativeTimeFormatterTests.cs b/DesktopClock.Tests/RelativeTimeFormatterTests.cs index 20ed00b..edb7161 100644 --- a/DesktopClock.Tests/RelativeTimeFormatterTests.cs +++ b/DesktopClock.Tests/RelativeTimeFormatterTests.cs @@ -1,7 +1,9 @@ using System; +using System.Globalization; namespace DesktopClock.Tests; +[UseUICulture("en-US")] public class RelativeTimeFormatterTests { private static readonly DateTime Now = new(2026, 3, 15, 12, 34, 56, 500); @@ -92,4 +94,25 @@ public void Format_IgnoresDateTimeKind() Assert.Equal("in 3 hours", RelativeTimeFormatter.Format(target, now)); } + + [Theory] + [InlineData("de", "00:01:00", "in einer Minute")] + [InlineData("de", "3.00:00:00", "in 3 Tagen")] + [InlineData("fr", "01:00:00", "dans une heure")] + [InlineData("ja", "03:00:00", "3時間後")] + [InlineData("zh-CN", "2.00:00:00", "2天后")] + [InlineData("pl", "00:01:00", "za minutę")] + [InlineData("pl", "00:12:00", "za 12 minut")] + [InlineData("pl", "00:22:00", "za 22 minuty")] + [InlineData("ru", "11.00:00:00", "через 11 дней")] + [InlineData("ru", "21.00:00:00", "через 21 день")] + [InlineData("ru", "22.00:00:00", "через 22 дня")] + [InlineData("ru", "25.00:00:00", "через 25 дней")] + [InlineData("uk", "00:03:00", "через 3 хвилини")] + public void Format_UsesTheLanguagesPluralForms(string culture, string offset, string expected) + { + var target = Now + TimeSpan.Parse(offset); + + Assert.Equal(expected, RelativeTimeFormatter.Format(target, Now, CultureInfo.GetCultureInfo(culture))); + } } diff --git a/DesktopClock.Tests/TimeStringFormatterTests.cs b/DesktopClock.Tests/TimeStringFormatterTests.cs index c08fcdf..c25feb4 100644 --- a/DesktopClock.Tests/TimeStringFormatterTests.cs +++ b/DesktopClock.Tests/TimeStringFormatterTests.cs @@ -3,6 +3,7 @@ namespace DesktopClock.Tests; +[UseUICulture("en-US")] public class TimeStringFormatterTests { [Fact] diff --git a/DesktopClock.Tests/UseUICultureAttribute.cs b/DesktopClock.Tests/UseUICultureAttribute.cs new file mode 100644 index 0000000..1023809 --- /dev/null +++ b/DesktopClock.Tests/UseUICultureAttribute.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Reflection; +using System.Threading; +using Xunit.Sdk; + +namespace DesktopClock.Tests; + +/// +/// Runs a test with the given UI language so text assertions don't depend on the machine's display language. +/// +public sealed class UseUICultureAttribute(string culture) : BeforeAfterTestAttribute +{ + private CultureInfo _originalCulture; + + public override void Before(MethodInfo methodUnderTest) + { + _originalCulture = Thread.CurrentThread.CurrentUICulture; + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture); + } + + public override void After(MethodInfo methodUnderTest) + { + Thread.CurrentThread.CurrentUICulture = _originalCulture; + } +} diff --git a/DesktopClock/ClockTheme.cs b/DesktopClock/ClockTheme.cs index 2fbd36e..5a09b61 100644 --- a/DesktopClock/ClockTheme.cs +++ b/DesktopClock/ClockTheme.cs @@ -45,25 +45,25 @@ public static IReadOnlyList GetBuiltInThemes() return new[] { CreateSystemTheme(), - new ClockTheme("Accent", "Segoe UI", "SemiBold", + new ClockTheme(Loc.Get("ThemeAccent"), "Segoe UI", "SemiBold", Color.FromRgb(0xFF, 0xFF, 0xFF), SystemThemeService.GetSystemAccentColor(), backgroundEnabled: true, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Smoke", "Segoe UI", "Normal", + new ClockTheme(Loc.Get("ThemeSmoke"), "Segoe UI", "Normal", Color.FromRgb(0xF2, 0xF2, 0xF2), Color.FromRgb(0x0A, 0x0A, 0x10), backgroundEnabled: true, backgroundOpacity: 0.55, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Terminal", "Consolas", "Bold", + new ClockTheme(Loc.Get("ThemeTerminal"), "Consolas", "Bold", Color.FromRgb(0x00, 0xE5, 0xFF), Color.FromRgb(0x0C, 0x0C, 0x0C), backgroundEnabled: true, backgroundOpacity: 0.85, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Midnight", "Segoe UI", "SemiBold", + new ClockTheme(Loc.Get("ThemeMidnight"), "Segoe UI", "SemiBold", Color.FromRgb(0x4C, 0xC2, 0xFF), Color.FromRgb(0x1B, 0x1B, 0x1B), backgroundEnabled: true, backgroundOpacity: 0.95, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Paper", "Georgia", "Normal", + new ClockTheme(Loc.Get("ThemePaper"), "Georgia", "Normal", Color.FromRgb(0x1A, 0x1A, 0x1A), Color.FromRgb(0xFA, 0xF9, 0xF6), backgroundEnabled: true, backgroundOpacity: 0.97, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Minimal", "Segoe UI", "Light", + new ClockTheme(Loc.Get("ThemeMinimal"), "Segoe UI", "Light", Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0x00, 0x00, 0x00), backgroundEnabled: false, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 0), - new ClockTheme("Chalk", "Segoe UI", "SemiBold", + new ClockTheme(Loc.Get("ThemeChalk"), "Segoe UI", "SemiBold", Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0x00, 0x00, 0x00), backgroundEnabled: false, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 1.5), }; @@ -101,7 +101,7 @@ private static ClockTheme CreateSystemTheme() outerColor = Color.FromRgb(247, 247, 247); } - return new ClockTheme("System", "Consolas", "Normal", textColor, outerColor, + return new ClockTheme(Loc.Get("ThemeSystem"), "Consolas", "Normal", textColor, outerColor, backgroundEnabled: true, backgroundOpacity: 0.9, backgroundCornerRadius: 1, outlineThickness: 0.2); } } diff --git a/DesktopClock/CountdownTargetEditor.xaml b/DesktopClock/CountdownTargetEditor.xaml index 33ebe9a..a3d5a5f 100644 --- a/DesktopClock/CountdownTargetEditor.xaml +++ b/DesktopClock/CountdownTargetEditor.xaml @@ -1,15 +1,16 @@ - - @@ -20,7 +21,7 @@ @@ -104,8 +104,8 @@ IsEnabled="{x:Static p:Settings.CanBeSaved}"> - + @@ -114,8 +114,8 @@ IsEnabled="{x:Static p:Settings.CanBeSaved}"> - + @@ -132,159 +132,159 @@ - - - + - Learn more + - - - - - - - + - Learn more + - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -296,7 +296,7 @@ Text="{Binding Settings.TextColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - - @@ -331,16 +331,16 @@ Text="{Binding Settings.TextOpacity, StringFormat=0.00}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -352,7 +352,7 @@ Text="{Binding Settings.OuterColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - - - - @@ -393,11 +393,11 @@ Text="{Binding Settings.BackgroundOpacity, StringFormat=0.00}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -415,11 +415,11 @@ Text="{Binding Settings.BackgroundCornerRadius, StringFormat=0.0}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -437,11 +437,11 @@ Text="{Binding Settings.OutlineThickness, StringFormat=0.0}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -454,30 +454,30 @@