Skip to content
Merged
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
81 changes: 81 additions & 0 deletions WheelWizard.Test/Features/Localization/PluralRulesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Globalization;
using WheelWizard.Localization;

namespace WheelWizard.Test.Features.Localization;

[Collection("SettingsFeature")]
public class PluralRulesTests
{
[Theory]
[InlineData("1", PluralCategory.One)]
[InlineData("1.0", PluralCategory.One)]
[InlineData("0", PluralCategory.Other)]
[InlineData("2", PluralCategory.Other)]
[InlineData("1.5", PluralCategory.Other)]
[InlineData("-1", PluralCategory.Other)]
[InlineData("21", PluralCategory.Other)]
public void SelectsSimpleCategory(string count, PluralCategory expected) =>
Assert.Equal(expected, PluralRules.Select(decimal.Parse(count, CultureInfo.InvariantCulture)));

[Fact]
public void GlobalTranslationSupportsNamedAndPositionalCountsAndLanguageFallback()
{
var previous = LocalizationProvider.Current;
var service = new EmbeddedYamlLocalizationService(typeof(PluralRulesTests).Assembly);
LocalizationProvider.Use(service);
try
{
Assert.Equal("One item for Alex", TranslationFunctions.t("items", count: 1, "Alex"));
Assert.Equal("2 items for Alex", TranslationFunctions.t("items", 2, "Alex"));
Assert.Equal("Value 3", TranslationFunctions.t("plain", 3));
service.SetLanguage("ru");
Assert.Equal("ru other", TranslationFunctions.t("items", count: 22));
Assert.Equal("English other", TranslationFunctions.t("fallback", count: 22));
Assert.Equal("English other", TranslationFunctions.t("en.fallback", count: 0));
}
finally
{
LocalizationProvider.Use(previous);
}
}

[Theory]
[InlineData("pt", "pt other")]
[InlineData("pt-BR", "pt other")]
[InlineData("pt-PT", "pt other")]
[InlineData("PT_pt", "pt other")]
public void GlobalTranslationUsesTheSameRuleForEveryRegion(string locale, string expected)
{
var previous = LocalizationProvider.Current;
var service = new EmbeddedYamlLocalizationService(typeof(PluralRulesTests).Assembly);
LocalizationProvider.Use(service);
try
{
Assert.Equal(expected, TranslationFunctions.t($"{locale}.items", count: 0));
Assert.Equal("English other", TranslationFunctions.t($"{locale}.fallback", count: 0));
Assert.Equal("pt one", TranslationFunctions.t($"{locale}.items", count: 1));
service.SetLanguage(locale);
Assert.Equal(expected, TranslationFunctions.t("items", count: 0));
}
finally
{
LocalizationProvider.Use(previous);
}
}

[Fact]
public void LegacyNumericKeysRemainUnchanged()
{
var previous = LocalizationProvider.Current;
LocalizationProvider.Use(new EmbeddedYamlLocalizationService());
try
{
Assert.Equal("1 day", TranslationFunctions.t_legacy("en.time.days.n", 1));
Assert.Equal("2 days", TranslationFunctions.t_legacy("en.time.days.n", 2));
}
finally
{
LocalizationProvider.Use(previous);
}
}
}
4 changes: 2 additions & 2 deletions WheelWizard.Test/Features/Settings/SettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,8 @@ public void TranslationFunction_UsesSpecificNumberVariant_WhenItExists()
{
localizationService.SetLanguage("en");

Assert.Equal("1 day", TranslationFunctions.t("time.days.n", 1));
Assert.Equal("2 days", TranslationFunctions.t("time.days.n", 2));
Assert.Equal("1 day", TranslationFunctions.t_legacy("time.days.n", 1));
Assert.Equal("2 days", TranslationFunctions.t_legacy("time.days.n", 2));
}
finally
{
Expand Down
16 changes: 16 additions & 0 deletions WheelWizard.Test/Resources/Languages/plural-fixture.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
en:
items:
one: "One item for {$2}"
other: "{$1} items for {$2}"
fallback:
one: "English one"
other: "English other"
plain: "Value {$1}"
ru:
items:
one: "ru one"
few: "ru few"
many: "ru many"
other: "ru other"
fallback:
one: "ru one"
4 changes: 4 additions & 0 deletions WheelWizard.Test/Resources/Languages/pt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pt:
items:
one: "pt one"
other: "pt other"
3 changes: 3 additions & 0 deletions WheelWizard.Test/WheelWizard.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,7 @@
<ProjectReference Include="..\WheelWizard\WheelWizard.csproj"/>
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Resources\Languages\*.yml" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public sealed class EmbeddedYamlLocalizationService : ILocalizationService
public EmbeddedYamlLocalizationService()
: this(typeof(EmbeddedYamlLocalizationService).Assembly) { }

internal EmbeddedYamlLocalizationService(Assembly resourceAssembly)
public EmbeddedYamlLocalizationService(Assembly resourceAssembly)
{
_translations = LoadTranslations(resourceAssembly);
if (_translations.Count == 0)
Expand Down Expand Up @@ -49,6 +49,22 @@ public string Translate(string key)
return TranslateForLanguage(key, CurrentLanguage);
}

public string TranslatePlural(string key, decimal count, string? languageCode = null)
{
var language = NormalizeLanguage(languageCode ?? CurrentLanguage);
var category = PluralRules.Select(count).ToString().ToLowerInvariant();
foreach (var candidate in new[] { language, DefaultLanguage }.Distinct())
{
if (
TryGetValue(candidate, $"{key}.{category}", out var value)
|| TryGetValue(candidate, $"{key}.other", out value)
|| TryGetValue(candidate, key, out value)
)
return value;
}
return key;
}

public string TranslateForLanguage(string key, string languageCode)
{
if (string.IsNullOrWhiteSpace(key))
Expand Down
1 change: 1 addition & 0 deletions WheelWizard/Features/Localization/ILocalizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public interface ILocalizationService

void SetLanguage(string languageCode);
string Translate(string key);
string TranslatePlural(string key, decimal count, string? languageCode = null);
string TranslateForLanguage(string key, string languageCode);
bool TryTranslateForLanguage(string key, string languageCode, out string value);
bool HasLanguage(string languageCode);
Expand Down
13 changes: 13 additions & 0 deletions WheelWizard/Features/Localization/PluralRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace WheelWizard.Localization;

public enum PluralCategory
{
One,
Other,
}

public static class PluralRules
{
// Every language uses the same simple rule, including decimal counts such as 1.0.
public static PluralCategory Select(decimal count) => count == 1 ? PluralCategory.One : PluralCategory.Other;
}
52 changes: 44 additions & 8 deletions WheelWizard/Features/Localization/TranslationFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,44 @@ namespace WheelWizard.Localization;

public static class TranslationFunctions
{
#pragma warning disable IDE1006 // Naming Styles
public static string t(string key, params object?[] args)
{
if (args.Length > 0 && TryCount(args[0], out var count))
return TranslateCount(key, count, args);
var prefixed = TrySplitLanguageKey(key, out var language, out var translationKey);
return Format(
prefixed ? LocalizationProvider.TranslateForLanguage(translationKey, language) : LocalizationProvider.Translate(translationKey),
args
);
}

/// <summary>Count supplies {$1}; additional arguments supply {$2}, {$3}, etc.</summary>
public static string t(string key, decimal count, params object?[] args) => TranslateCount(key, count, [count, .. args]);

private static string TranslateCount(string key, decimal count, object?[] args)
{
var prefixed = TrySplitLanguageKey(key, out var language, out var translationKey);
return Format(LocalizationProvider.Current.TranslatePlural(translationKey, count, prefixed ? language : null), args);
}

private static bool TryCount(object? value, out decimal count)
{
count = 0;
if (value is not (sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal))
return false;
try
{
count = Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture);
return true;
}
catch (OverflowException)
{
return false;
}
}

#pragma warning disable IDE1006 // Naming Styles
public static string t_legacy(string key, params object?[] args)
#pragma warning restore IDE1006 // Naming Styles
{
var hasLanguagePrefix = TrySplitLanguageKey(key, out var languageCode, out var translationKey);
Expand Down Expand Up @@ -33,39 +69,39 @@ public static string tTime(TimeSpan timeSpan)
{
var days = timeSpan.Days;
var hours = timeSpan.Hours;
var dayText = t("time.days.n", days);
var dayText = t_legacy("time.days.n", days);
if (hours == 0)
return dayText;

var hourText = t("time.hours.n", hours);
var hourText = t_legacy("time.hours.n", hours);
return $"{dayText} {hourText}";
}

if (Math.Abs(timeSpan.TotalHours) >= 1)
{
var hours = timeSpan.Hours;
var minutes = timeSpan.Minutes;
var hourText = t("time.hours.n", hours);
var hourText = t_legacy("time.hours.n", hours);
if (minutes == 0)
return hourText;

var minuteText = t("time.minutes.n", minutes);
var minuteText = t_legacy("time.minutes.n", minutes);
return $"{hourText} {minuteText}";
}

if (Math.Abs(timeSpan.TotalMinutes) >= 1)
{
var minutes = timeSpan.Minutes;
var seconds = timeSpan.Seconds;
var minuteText = t("time.minutes.n", minutes);
var minuteText = t_legacy("time.minutes.n", minutes);
if (seconds == 0)
return minuteText;

var secondText = t("time.seconds.n", seconds);
var secondText = t_legacy("time.seconds.n", seconds);
return $"{minuteText} {secondText}";
}

return t("time.seconds.n", timeSpan.Seconds);
return t_legacy("time.seconds.n", timeSpan.Seconds);
}

private static string ResolveNumberVariant(string translationKey, string languageCode, object?[] args)
Expand Down
4 changes: 2 additions & 2 deletions WheelWizard/Views/Layout.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ public void UpdateFriendCount()
{
var friends = GameLicenseService.ActiveCurrentFriends;
FriendsButton.BoxText = $"{friends.Count(friend => friend.IsOnline)}/{friends.Count}";
FriendsButton.BoxTip = t("hover.friends_online.n", friends.Count(friend => friend.IsOnline));
FriendsButton.BoxTip = t_legacy("hover.friends_online.n", friends.Count(friend => friend.IsOnline));
}

public void UpdateSidebarProfile()
Expand All @@ -292,7 +292,7 @@ public void UpdatePlayerAndRoomCount(LiveRoomsService sender)
{
var playerCount = sender.PlayerCount;
RoomsButton.BoxText = playerCount.ToString();
RoomsButton.BoxTip = t("hover.players_online.n", playerCount);
RoomsButton.BoxTip = t_legacy("hover.players_online.n", playerCount);
UpdateFriendCount();
}

Expand Down
Loading