Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions DesktopClock.Tests/LocalizationTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> English = ReadStrings("");

public static IEnumerable<object[]> 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<Match>())
.Select(m => m.Groups.Cast<Group>().Skip(1).First(g => g.Success).Value)
.Distinct()
.ToList();

Assert.NotEmpty(used);
Assert.DoesNotContain(used, k => !English.ContainsKey(k));
}

private static HashSet<string> Placeholders(string text) => new(PlaceholderRegex.Matches(text).Cast<Match>().Select(m => m.Value));

private static Dictionary<string, string> ReadStrings(string culturePart)
{
using var stream = typeof(Loc).Assembly.GetManifestResourceStream($"{ResourcePrefix}{culturePart}resources");
using var reader = new ResourceReader(stream);
return reader.Cast<DictionaryEntry>().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.");
}
}
23 changes: 23 additions & 0 deletions DesktopClock.Tests/RelativeTimeFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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)));
}
}
1 change: 1 addition & 0 deletions DesktopClock.Tests/TimeStringFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace DesktopClock.Tests;

[UseUICulture("en-US")]
public class TimeStringFormatterTests
{
[Fact]
Expand Down
25 changes: 25 additions & 0 deletions DesktopClock.Tests/UseUICultureAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Globalization;
using System.Reflection;
using System.Threading;
using Xunit.Sdk;

namespace DesktopClock.Tests;

/// <summary>
/// Runs a test with the given UI language so text assertions don't depend on the machine's display language.
/// </summary>
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;
}
}
16 changes: 8 additions & 8 deletions DesktopClock/ClockTheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,25 @@ public static IReadOnlyList<ClockTheme> 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),
};
Expand Down Expand Up @@ -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);
}
}
9 changes: 5 additions & 4 deletions DesktopClock/CountdownTargetEditor.xaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<UserControl x:Class="DesktopClock.CountdownTargetEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DesktopClock"
xmlns:p="clr-namespace:DesktopClock.Properties">
<StackPanel>
<!-- One-click targets for common scenarios, computed when clicked -->
<TextBlock Text="Common targets"
<TextBlock Text="{local:Loc CommonTargets}"
Style="{StaticResource EditorCaption}" />
<WrapPanel x:Name="PresetsPanel"
Margin="0,0,0,10" />

<TextBlock Text="Custom target"
<TextBlock Text="{local:Loc CustomTarget}"
Style="{StaticResource EditorCaption}" />
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
Expand All @@ -20,7 +21,7 @@
<TextBox Grid.Column="0"
Text="{Binding CountdownTo, Source={x:Static p:Settings.Default}, Mode=TwoWay}" />
<Button Grid.Column="2"
Content="Reset"
Content="{local:Loc Reset}"
Click="ResetTarget" />
</Grid>

Expand All @@ -31,7 +32,7 @@
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="10,7">
<StackPanel>
<TextBlock Text="Preview"
<TextBlock Text="{local:Loc Preview}"
FontSize="12"
Foreground="{DynamicResource TextSecondaryBrush}"
Margin="0,0,0,2" />
Expand Down
10 changes: 5 additions & 5 deletions DesktopClock/CountdownTargetEditor.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ public partial class CountdownTargetEditor : UserControl
{
private static readonly (string Name, Func<DateTime> GetTarget)[] Presets =
{
("Midnight tonight", () => DateTime.Today.AddDays(1)),
("Tomorrow morning", () => DateTime.Today.AddDays(1).AddHours(9)),
("Friday afternoon", () => NextOccurrence(DayOfWeek.Friday, 17)),
("New Year", () => new DateTime(DateTime.Today.Year + 1, 1, 1)),
(Loc.Get("TargetMidnight"), () => DateTime.Today.AddDays(1)),
(Loc.Get("TargetTomorrowMorning"), () => DateTime.Today.AddDays(1).AddHours(9)),
(Loc.Get("TargetFridayAfternoon"), () => NextOccurrence(DayOfWeek.Friday, 17)),
(Loc.Get("TargetNewYear"), () => new DateTime(DateTime.Today.Year + 1, 1, 1)),
};

private bool _built;
Expand Down Expand Up @@ -101,7 +101,7 @@ private void UpdatePreview()
var target = Settings.Default.CountdownTo;

PreviewText.Text = target == default
? "No target. The clock shows the current time."
? Loc.Get("NoCountdownTarget")
: $"{target:f} — {RelativeTimeFormatter.Format(target, DateTime.Now)}";
}

Expand Down
5 changes: 5 additions & 0 deletions DesktopClock/DesktopClock.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@
<ItemGroup>
<Resource Include="DesktopClock.ico" />
</ItemGroup>

<ItemGroup>
<!-- Translations compile into the exe instead of satellite assemblies, which Costura doesn't embed, so the app stays a single file. See Loc. -->
<EmbeddedResource Update="Localization\Strings.*.resx" WithCulture="false" />
</ItemGroup>
</Project>
11 changes: 6 additions & 5 deletions DesktopClock/FormatEditor.xaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<UserControl x:Class="DesktopClock.FormatEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DesktopClock">
<StackPanel>
<!-- One-click presets for common scenarios, each showing its live rendered output -->
<TextBlock Text="Common formats"
<TextBlock Text="{local:Loc CommonFormats}"
Style="{StaticResource EditorCaption}" />
<WrapPanel x:Name="PresetsPanel"
Margin="0,0,0,10" />

<!-- Free-form format with building blocks that insert tokens at the cursor -->
<TextBlock Text="Custom format"
<TextBlock Text="{local:Loc CustomFormat}"
Style="{StaticResource EditorCaption}" />
<TextBox x:Name="FormatTextBox"
AcceptsReturn="True"
Expand All @@ -18,7 +19,7 @@
TextChanged="FormatTextBox_TextChanged" />
<WrapPanel x:Name="TokensPanel"
Margin="0,0,0,10">
<TextBlock Text="Insert:"
<TextBlock Text="{local:Loc InsertToken}"
FontSize="12"
Foreground="{DynamicResource TextSecondaryBrush}"
VerticalAlignment="Center"
Expand All @@ -32,7 +33,7 @@
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="10,7">
<StackPanel>
<TextBlock Text="Preview"
<TextBlock Text="{local:Loc Preview}"
FontSize="12"
Foreground="{DynamicResource TextSecondaryBrush}"
Margin="0,0,0,2" />
Expand Down
Loading