From 62539673435dba02ecd835e29c7993aa5e08958e Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Tue, 25 Aug 2026 02:04:44 +0300 Subject: [PATCH 1/3] feat(localization): add resource-based localization foundation --- .../Constants/LocalizationConstants.cs | 32 +++ .../Interfaces/Common/ILocalizationService.cs | 44 ++++ .../App/AppLifecycleTests.cs | 2 + .../LocalizationCultureCollection.cs | 13 ++ .../Services/LocalizationServiceTests.cs | 154 +++++++++++++ .../LocalizationModuleTests.cs | 31 +++ .../Localization/TestStrings.fr.resx | 21 ++ .../Resources/Localization/TestStrings.resx | 24 ++ GenHub/GenHub/App.axaml.cs | 3 + .../GenHub/Common/Markup/LocalizeExtension.cs | 43 ++++ .../Common/Services/LocalizationResources.cs | 17 ++ .../Common/Services/LocalizationService.cs | 211 ++++++++++++++++++ .../DependencyInjection/AppServices.cs | 1 + .../DependencyInjection/LocalizationModule.cs | 39 ++++ GenHub/GenHub/Properties/AssemblyInfo.cs | 4 + .../Resources/Localization/Strings.resx | 19 ++ docs/dev/constants.md | 14 ++ docs/dev/index.md | 6 + docs/dev/localization.md | 96 ++++++++ 19 files changed, 774 insertions(+) create mode 100644 GenHub/GenHub.Core/Constants/LocalizationConstants.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx create mode 100644 GenHub/GenHub/Common/Markup/LocalizeExtension.cs create mode 100644 GenHub/GenHub/Common/Services/LocalizationResources.cs create mode 100644 GenHub/GenHub/Common/Services/LocalizationService.cs create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs create mode 100644 GenHub/GenHub/Properties/AssemblyInfo.cs create mode 100644 GenHub/GenHub/Resources/Localization/Strings.resx create mode 100644 docs/dev/localization.md diff --git a/GenHub/GenHub.Core/Constants/LocalizationConstants.cs b/GenHub/GenHub.Core/Constants/LocalizationConstants.cs new file mode 100644 index 000000000..9ec61dfa3 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/LocalizationConstants.cs @@ -0,0 +1,32 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants used by the application localization infrastructure. +/// +public static class LocalizationConstants +{ + /// + /// The neutral culture embedded in the main application assembly. + /// + public const string DefaultCultureName = "en"; + + /// + /// The property name used to notify bindings that all indexer values changed. + /// + public const string IndexerPropertyName = "Item[]"; + + /// + /// The application resource key used to expose the localization service to XAML. + /// + public const string ResourceServiceKey = "LocalizationService"; + + /// + /// The fully qualified base name of the application's string resources. + /// + public const string StringResourceBaseName = "GenHub.Resources.Localization.Strings"; + + /// + /// The suffix used by .NET satellite resource assemblies. + /// + public const string SatelliteAssemblySuffix = ".resources.dll"; +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs b/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs new file mode 100644 index 000000000..5d62f4ce2 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Common; + +/// +/// Provides localized application strings and runtime culture switching. +/// +public interface ILocalizationService : INotifyPropertyChanged +{ + /// + /// Gets the cultures backed by the neutral resource or a deployed satellite assembly. + /// + IReadOnlyList AvailableCultures { get; } + + /// + /// Gets the culture currently used for localized strings and culture-aware formatting. + /// + CultureInfo CurrentCulture { get; } + + /// + /// Gets a localized string by resource key. + /// + /// The resource key to resolve. + /// The localized value, its English fallback, or the key when no resource exists. + string this[string key] { get; } + + /// + /// Gets and optionally formats a localized string. + /// + /// The resource key to resolve. + /// Optional format arguments. + /// The localized value, its English fallback, or the key when no resource exists. + string GetString(string key, params object[] arguments); + + /// + /// Changes the active culture when it has a deployed translation. + /// + /// The culture to activate. + /// A result indicating whether the requested culture was available and applied. + OperationResult SetCulture(CultureInfo culture); +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs index 76bb26337..6143238e6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs @@ -48,10 +48,12 @@ public void App_Constructor_WithValidServices_DoesNotThrow() var services = new ServiceCollection(); var mockUserSettingsService = new Mock(); var mockConfigurationProvider = new Mock(); + var mockLocalizationService = new Mock(); var mockProfileLauncherFacade = new Mock(); services.AddSingleton(typeof(IUserSettingsService), mockUserSettingsService.Object); services.AddSingleton(typeof(IConfigurationProviderService), mockConfigurationProvider.Object); + services.AddSingleton(typeof(ILocalizationService), mockLocalizationService.Object); services.AddSingleton(typeof(IProfileLauncherFacade), mockProfileLauncherFacade.Object); var serviceProvider = services.BuildServiceProvider(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs new file mode 100644 index 000000000..f954de2a5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs @@ -0,0 +1,13 @@ +namespace GenHub.Tests.Core.Collections; + +/// +/// Prevents culture-mutating localization tests from running beside unrelated tests. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class LocalizationCultureCollection +{ + /// + /// The collection name used by culture-mutating tests. + /// + public const string Name = "Localization culture"; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs new file mode 100644 index 000000000..e7c5ce760 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Tests.Core.Collections; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Common.Services; + +/// +/// Unit tests for resource fallback, discovery, and runtime culture switching. +/// +[Collection(LocalizationCultureCollection.Name)] +public sealed class LocalizationServiceTests : IDisposable +{ + private const string TestResourceBaseName = "GenHub.Tests.Core.Resources.Localization.TestStrings"; + + private readonly CultureInfo? _originalDefaultCulture; + private readonly CultureInfo? _originalDefaultUiCulture; + private readonly CultureInfo _originalThreadCulture; + private readonly CultureInfo _originalThreadUiCulture; + private readonly LocalizationService _service; + + /// + /// Initializes a new instance of the class. + /// + public LocalizationServiceTests() + { + _originalDefaultCulture = CultureInfo.DefaultThreadCurrentCulture; + _originalDefaultUiCulture = CultureInfo.DefaultThreadCurrentUICulture; + _originalThreadCulture = CultureInfo.CurrentCulture; + _originalThreadUiCulture = CultureInfo.CurrentUICulture; + + var resourceAssembly = typeof(LocalizationServiceTests).Assembly; + var assemblyName = resourceAssembly.GetName().Name + ?? throw new InvalidOperationException("The test assembly name could not be resolved."); + var baseDirectory = Path.GetDirectoryName(resourceAssembly.Location) + ?? throw new InvalidOperationException("The test assembly directory could not be resolved."); + var resources = new LocalizationResources( + new ResourceManager(TestResourceBaseName, resourceAssembly), + $"{assemblyName}{LocalizationConstants.SatelliteAssemblySuffix}", + baseDirectory, + CultureInfo.GetCultureInfo(LocalizationConstants.DefaultCultureName)); + + _service = new LocalizationService(resources, NullLogger.Instance); + } + + /// + /// Restores process-wide culture defaults changed by localization tests. + /// + public void Dispose() + { + CultureInfo.CurrentCulture = _originalThreadCulture; + CultureInfo.CurrentUICulture = _originalThreadUiCulture; + CultureInfo.DefaultThreadCurrentCulture = _originalDefaultCulture; + CultureInfo.DefaultThreadCurrentUICulture = _originalDefaultUiCulture; + } + + /// + /// Verifies that the neutral language and deployed test satellite are discovered automatically. + /// + [Fact] + public void AvailableCultures_DiscoversNeutralAndSatelliteCultures() + { + var cultureNames = _service.AvailableCultures.Select(culture => culture.Name).ToList(); + + Assert.Equal(["en", "fr"], cultureNames); + } + + /// + /// Verifies that a translated value is loaded from the active satellite assembly. + /// + [Fact] + public void GetString_UsesActiveCultureTranslation() + { + var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr")); + + Assert.True(result.Success); + Assert.Equal("Bonjour", _service.GetString("Greeting")); + } + + /// + /// Verifies that missing translated values fall back to the neutral English resource. + /// + [Fact] + public void GetString_MissingTranslation_FallsBackToEnglish() + { + var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr")); + + Assert.True(result.Success); + Assert.Equal("English fallback", _service.GetString("FallbackOnly")); + } + + /// + /// Verifies that formatted translations use the active culture and supplied arguments. + /// + [Fact] + public void GetString_WithArguments_FormatsTranslatedValue() + { + var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr")); + + Assert.True(result.Success); + Assert.Equal("Bonjour, General!", _service.GetString("FormattedGreeting", "General")); + } + + /// + /// Verifies that a completely unknown key remains visible for diagnostics. + /// + [Fact] + public void GetString_UnknownKey_ReturnsKey() + { + Assert.Equal("Missing.Resource.Key", _service.GetString("Missing.Resource.Key")); + } + + /// + /// Verifies that changing culture refreshes both the culture and all indexer bindings. + /// + [Fact] + public void SetCulture_AvailableCulture_RaisesLiveBindingNotifications() + { + var propertyNames = new List(); + _service.PropertyChanged += (_, eventArgs) => propertyNames.Add(eventArgs.PropertyName); + + var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr")); + + Assert.True(result.Success); + Assert.Equal("fr", _service.CurrentCulture.Name); + Assert.Equal("fr", CultureInfo.CurrentCulture.Name); + Assert.Equal("fr", CultureInfo.CurrentUICulture.Name); + Assert.Equal( + [nameof(_service.CurrentCulture), LocalizationConstants.IndexerPropertyName], + propertyNames); + } + + /// + /// Verifies that an unavailable culture returns a failure without changing state. + /// + [Fact] + public void SetCulture_UnavailableCulture_ReturnsFailureWithoutChangingCulture() + { + var originalCulture = _service.CurrentCulture; + + var result = _service.SetCulture(CultureInfo.GetCultureInfo("es")); + + Assert.True(result.Failed); + Assert.Equal(originalCulture, _service.CurrentCulture); + Assert.Contains("not available", result.FirstError, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs new file mode 100644 index 000000000..0dc9f6c6f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs @@ -0,0 +1,31 @@ +using GenHub.Core.Interfaces.Common; +using GenHub.Infrastructure.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace GenHub.Tests.Core.Infrastructure.DependencyInjection; + +/// +/// Tests localization dependency injection registration. +/// +public sealed class LocalizationModuleTests +{ + /// + /// Verifies that localization resolves as one shared service with embedded English resources. + /// + [Fact] + public void AddLocalizationServices_RegistersSingletonWithDefaultResources() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalizationServices(); + + using var provider = services.BuildServiceProvider(); + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + Assert.Same(first, second); + Assert.Equal("en", first.CurrentCulture.Name); + Assert.Equal("GenHub", first.GetString("App.Name")); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx new file mode 100644 index 000000000..fbdf49f48 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx @@ -0,0 +1,21 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Bonjour, {0}! + + + Bonjour + + diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx new file mode 100644 index 000000000..535afc9bb --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx @@ -0,0 +1,24 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + English fallback + + + Hello, {0}! + + + Hello + + diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 434d14a6b..221aa53fe 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -26,6 +26,7 @@ public partial class App : Application private readonly IServiceProvider _serviceProvider; private readonly IUserSettingsService _userSettingsService; private readonly IConfigurationProviderService _configurationProvider; + private readonly ILocalizationService _localizationService; private readonly IProfileLauncherFacade _profileLauncherFacade; private readonly IThemeService? _themeService; @@ -38,6 +39,7 @@ public App(IServiceProvider serviceProvider) _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _userSettingsService = _serviceProvider.GetService() ?? throw new InvalidOperationException("IUserSettingsService not registered"); _configurationProvider = _serviceProvider.GetService() ?? throw new InvalidOperationException("IConfigurationProviderService not registered"); + _localizationService = _serviceProvider.GetRequiredService(); _profileLauncherFacade = _serviceProvider.GetRequiredService(); _themeService = _serviceProvider.GetService(); } @@ -48,6 +50,7 @@ public App(IServiceProvider serviceProvider) public override void Initialize() { AvaloniaXamlLoader.Load(this); + Resources[LocalizationConstants.ResourceServiceKey] = _localizationService; } /// diff --git a/GenHub/GenHub/Common/Markup/LocalizeExtension.cs b/GenHub/GenHub/Common/Markup/LocalizeExtension.cs new file mode 100644 index 000000000..922271721 --- /dev/null +++ b/GenHub/GenHub/Common/Markup/LocalizeExtension.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia; +using Avalonia.Data; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; + +namespace GenHub.Common.Markup; + +/// +/// Creates a live one-way binding to a localized resource key. +/// +/// The resource key to bind. +public sealed class LocalizeExtension(string key) +{ + /// + /// Gets the resource key resolved by the extension. + /// + public string Key { get; } = string.IsNullOrWhiteSpace(key) + ? throw new ArgumentException("A localization resource key is required.", nameof(key)) + : key; + + /// + /// Provides a live binding when localization is initialized, or the key for design-time fallback. + /// + /// A localization binding or the unresolved resource key. + public object ProvideValue() + { + if (Application.Current?.TryGetResource( + LocalizationConstants.ResourceServiceKey, + theme: null, + out var resource) != true || + resource is not ILocalizationService localizationService) + { + return Key; + } + + return new Binding($"[{Key}]", BindingMode.OneWay) + { + FallbackValue = Key, + Source = localizationService, + }; + } +} diff --git a/GenHub/GenHub/Common/Services/LocalizationResources.cs b/GenHub/GenHub/Common/Services/LocalizationResources.cs new file mode 100644 index 000000000..0499b4160 --- /dev/null +++ b/GenHub/GenHub/Common/Services/LocalizationResources.cs @@ -0,0 +1,17 @@ +using System.Globalization; +using System.Resources; + +namespace GenHub.Common.Services; + +/// +/// Describes the resource set and deployment location used by localization services. +/// +/// The resource manager used for string lookup. +/// The expected satellite assembly file name. +/// The directory containing culture-specific satellite directories. +/// The neutral fallback culture. +internal sealed record LocalizationResources( + ResourceManager ResourceManager, + string SatelliteAssemblyFileName, + string BaseDirectory, + CultureInfo DefaultCulture); diff --git a/GenHub/GenHub/Common/Services/LocalizationService.cs b/GenHub/GenHub/Common/Services/LocalizationService.cs new file mode 100644 index 000000000..c69e5e482 --- /dev/null +++ b/GenHub/GenHub/Common/Services/LocalizationService.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Common.Services; + +/// +/// Provides resource-based localization with English fallback and live binding notifications. +/// +internal sealed class LocalizationService( + LocalizationResources resources, + ILogger logger) : ILocalizationService +{ + private static readonly PropertyChangedEventArgs CurrentCultureChangedEventArgs = new(nameof(CurrentCulture)); + private static readonly PropertyChangedEventArgs IndexerChangedEventArgs = new(LocalizationConstants.IndexerPropertyName); + + private readonly IReadOnlyList _availableCultures = DiscoverAvailableCultures(resources, logger); + private readonly object _cultureLock = new(); + + /// + public IReadOnlyList AvailableCultures => _availableCultures; + + /// + public CultureInfo CurrentCulture { get; private set; } = resources.DefaultCulture; + + /// + public string this[string key] => GetString(key); + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + public string GetString(string key, params object[] arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(arguments); + + CultureInfo culture; + lock (_cultureLock) + { + culture = CurrentCulture; + } + + try + { + var value = resources.ResourceManager.GetString(key, culture); + if (value is null) + { + logger.LogWarning( + "Localization resource '{ResourceKey}' was not found for culture '{CultureName}' or its English fallback", + key, + culture.Name); + return key; + } + + if (arguments.Length == 0) + { + return value; + } + + try + { + return string.Format(culture, value, arguments); + } + catch (FormatException ex) + { + logger.LogError(ex, "Localization resource '{ResourceKey}' contains an invalid format string", key); + return value; + } + } + catch (MissingManifestResourceException ex) + { + logger.LogError(ex, "The default localization resource set could not be loaded"); + return key; + } + catch (MissingSatelliteAssemblyException ex) + { + logger.LogError(ex, "The fallback localization satellite assembly could not be loaded"); + return key; + } + } + + /// + public OperationResult SetCulture(CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture); + + var availableCulture = AvailableCultures.FirstOrDefault(candidate => + string.Equals(candidate.Name, culture.Name, StringComparison.OrdinalIgnoreCase)); + if (availableCulture is null) + { + return OperationResult.CreateFailure($"Culture '{culture.Name}' is not available."); + } + + var cultureChanged = false; + lock (_cultureLock) + { + cultureChanged = !string.Equals( + CurrentCulture.Name, + availableCulture.Name, + StringComparison.OrdinalIgnoreCase); + CurrentCulture = ApplyThreadCulture(availableCulture); + } + + if (cultureChanged) + { + PropertyChanged?.Invoke(this, CurrentCultureChangedEventArgs); + PropertyChanged?.Invoke(this, IndexerChangedEventArgs); + } + + return OperationResult.CreateSuccess(); + } + + private static CultureInfo ApplyThreadCulture(CultureInfo culture) + { + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + return culture; + } + + private static IReadOnlyList DiscoverAvailableCultures( + LocalizationResources localizationResources, + ILogger localizationLogger) + { + var cultures = new List { localizationResources.DefaultCulture }; + var cultureNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + localizationResources.DefaultCulture.Name, + }; + + try + { + var directories = Directory.GetDirectories(localizationResources.BaseDirectory) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + + foreach (var directory in directories) + { + var satelliteAssemblyPath = Path.Combine(directory, localizationResources.SatelliteAssemblyFileName); + if (!File.Exists(satelliteAssemblyPath)) + { + continue; + } + + var cultureName = Path.GetFileName(directory); + try + { + var culture = CultureInfo.GetCultureInfo(cultureName); + if (localizationResources.ResourceManager.GetResourceSet( + culture, + createIfNotExists: true, + tryParents: false) is null) + { + localizationLogger.LogWarning( + "Ignoring satellite assembly without the GenHub string resource set for culture '{CultureName}'", + culture.Name); + continue; + } + + if (cultureNames.Add(culture.Name)) + { + cultures.Add(culture); + } + } + catch (CultureNotFoundException) + { + localizationLogger.LogWarning( + "Ignoring localization directory with invalid culture name '{CultureName}'", + cultureName); + } + catch (MissingManifestResourceException ex) + { + localizationLogger.LogWarning( + ex, + "Ignoring satellite assembly with missing localization resources for culture '{CultureName}'", + cultureName); + } + catch (MissingSatelliteAssemblyException ex) + { + localizationLogger.LogWarning( + ex, + "Ignoring missing localization satellite assembly for culture '{CultureName}'", + cultureName); + } + } + } + catch (DirectoryNotFoundException ex) + { + localizationLogger.LogWarning(ex, "Localization base directory was not found"); + } + catch (IOException ex) + { + localizationLogger.LogWarning(ex, "Failed to scan localization satellite assemblies"); + } + catch (UnauthorizedAccessException ex) + { + localizationLogger.LogWarning(ex, "Access was denied while scanning localization satellite assemblies"); + } + + return cultures.AsReadOnly(); + } +} diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs index 08f4e8cd6..762f3fdbd 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs @@ -25,6 +25,7 @@ public static IServiceCollection ConfigureApplicationServices( // Register core services in dependency order services.AddLoggingModule(); + services.AddLocalizationServices(); services.AddValidationServices(); services.AddGameDetectionService(); services.AddGameInstallation(); diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs new file mode 100644 index 000000000..553b5c756 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.Resources; +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using Microsoft.Extensions.DependencyInjection; + +namespace GenHub.Infrastructure.DependencyInjection; + +/// +/// Dependency injection module for application localization services. +/// +public static class LocalizationModule +{ + /// + /// Registers the shared resource-based localization services. + /// + /// The service collection to register services with. + /// The updated service collection. + public static IServiceCollection AddLocalizationServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + var resourceAssembly = typeof(LocalizationModule).Assembly; + var assemblyName = resourceAssembly.GetName().Name + ?? throw new InvalidOperationException("The GenHub assembly name could not be resolved."); + var localizationResources = new LocalizationResources( + new ResourceManager(LocalizationConstants.StringResourceBaseName, resourceAssembly), + $"{assemblyName}{LocalizationConstants.SatelliteAssemblySuffix}", + AppContext.BaseDirectory, + CultureInfo.GetCultureInfo(LocalizationConstants.DefaultCultureName)); + + services.AddSingleton(localizationResources); + services.AddSingleton(); + + return services; + } +} diff --git a/GenHub/GenHub/Properties/AssemblyInfo.cs b/GenHub/GenHub/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..4e92378b5 --- /dev/null +++ b/GenHub/GenHub/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Resources; +using GenHub.Core.Constants; + +[assembly: NeutralResourcesLanguage(LocalizationConstants.DefaultCultureName)] diff --git a/GenHub/GenHub/Resources/Localization/Strings.resx b/GenHub/GenHub/Resources/Localization/Strings.resx new file mode 100644 index 000000000..59113bc85 --- /dev/null +++ b/GenHub/GenHub/Resources/Localization/Strings.resx @@ -0,0 +1,19 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + GenHub + Application name shown in window titles and other product identity surfaces. + + diff --git a/docs/dev/constants.md b/docs/dev/constants.md index d592b6d40..ec49c161c 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -67,6 +67,20 @@ Application-wide constants for GenHub. --- +## LocalizationConstants Class + +Constants used by the application localization foundation. + +| Constant | Value | Description | +| --- | --- | --- | +| `DefaultCultureName` | `"en"` | Neutral culture embedded in the main application assembly | +| `IndexerPropertyName` | `"Item[]"` | Change-notification name used to refresh all localized indexer bindings | +| `ResourceServiceKey` | `"LocalizationService"` | Application resource key used by the Avalonia markup extension | +| `StringResourceBaseName` | `"GenHub.Resources.Localization.Strings"` | Fully qualified .NET resource base name | +| `SatelliteAssemblySuffix` | `".resources.dll"` | Standard suffix used to identify satellite assemblies | + +--- + ## AppUpdateConstants Class Constants related to application updates and Velopack. diff --git a/docs/dev/index.md b/docs/dev/index.md index 0beaac591..efd37f5a0 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -121,6 +121,12 @@ public class ConfigurationProviderService : IConfigurationProviderService --- +### Localization + +GenHub uses a shared [resource-based localization system](./localization.md) with English fallback, automatic satellite-language discovery, and live Avalonia binding updates. + +--- + ### Logging Structured logging is provided via `Microsoft.Extensions.Logging`: diff --git a/docs/dev/localization.md b/docs/dev/localization.md new file mode 100644 index 000000000..0cc35da6b --- /dev/null +++ b/docs/dev/localization.md @@ -0,0 +1,96 @@ +--- +title: Localization +description: Resource, fallback, discovery, and live-binding conventions for GenHub translations +--- + +# Localization + +GenHub localizes application UI text with .NET `.resx` resources. English is the neutral language embedded in `GenHub.dll`; translated resources are compiled into culture-specific satellite assemblies. + +The foundation is intentionally small: + +- `ILocalizationService` resolves and formats strings, exposes available cultures, and changes the active culture. +- `LocalizationService` uses .NET `ResourceManager` fallback and raises `INotifyPropertyChanged` notifications when the culture changes. +- `LocalizeExtension` gives Avalonia views a live binding to a resource key. +- `LocalizationModule` registers one shared service for every platform host. + +Language selection, persisted preference, UI string migration, translation coverage, and right-to-left layout are separate feature concerns built on this foundation. + +## Resource layout + +The neutral English resource is: + +```text +GenHub/GenHub/Resources/Localization/Strings.resx +``` + +Add translations beside it using a valid culture name: + +```text +Strings.fr.resx +Strings.de.resx +Strings.ar.resx +Strings.pt-BR.resx +``` + +At build time, .NET creates a satellite assembly under the matching culture directory. GenHub discovers those directories at startup, so there is no manually maintained supported-language list. + +If a translated resource omits a key, `ResourceManager` follows the normal culture hierarchy and ultimately uses the value from `Strings.resx`. If the key does not exist in any resource, GenHub logs the miss and displays the key so the problem remains visible. + +## Resource keys + +Use dot-separated keys that identify the feature and UI purpose: + +```text +Settings.Appearance.Title +Settings.Appearance.Language.Label +GameProfiles.Create.Confirm +Downloads.Status.Queued +``` + +Keep keys stable after release. Add translator comments when context or placeholders are not obvious. Every translation of a formatted string must preserve the same numbered placeholders as the English resource. + +Do not localize log templates, protocol values, manifest identifiers, command-line arguments, or other developer-facing technical strings. + +## Avalonia views + +Reference the markup namespace and bind the property to a key: + +```xml + + + +``` + +The extension binds through the application-scoped localization service. When `SetCulture` succeeds, all localized indexer bindings are notified and refresh without recreating the view or restarting GenHub. + +## View models and services + +Inject `ILocalizationService` when text must be produced in code: + +```csharp +var title = localizationService.GetString("GameProfiles.Create.Title"); +var status = localizationService.GetString("Downloads.Status.Progress", completed, total); +``` + +Only request cultures returned by `AvailableCultures`, and check the operation result: + +```csharp +var result = localizationService.SetCulture(selectedCulture); +if (result.Failed) +{ + // Surface result.FirstError through the caller's normal error path. +} +``` + +Culture switching is synchronous because it performs no long-running I/O. Do not wrap it in `Task.Run`, block on a task, or introduce a reactive package solely for change notification. + +## Adding coverage + +Every localization change should test the behavior it introduces. At minimum: + +- a translated key resolves from the requested satellite assembly; +- an omitted translated key falls back to English; +- placeholders format correctly in the active culture; +- an unavailable culture fails without changing the current culture; +- a successful culture change refreshes live bindings. From f5178af4b9090ab3f6583efbfd2340c049b32d3c Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Tue, 25 Aug 2026 02:49:48 +0300 Subject: [PATCH 2/3] fix(localization): address CI and review findings --- .../Constants/LocalizationConstants.cs | 2 +- .../Interfaces/Common/ILocalizationService.cs | 4 +- .../Services/LocalizationServiceTests.cs | 181 ++++++++++++++++-- .../LocalizationModuleTests.cs | 17 +- .../Localization/TestStrings.fr.resx | 3 + .../Resources/Localization/TestStrings.resx | 6 + GenHub/GenHub/App.axaml.cs | 1 + .../GenHub/Common/Markup/LocalizeExtension.cs | 21 +- .../Services/LocalizationCultureUtilities.cs | 128 +++++++++++++ .../Common/Services/LocalizationService.cs | 122 ++---------- docs/dev/constants.md | 2 +- docs/dev/localization.md | 7 +- 12 files changed, 367 insertions(+), 127 deletions(-) create mode 100644 GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs diff --git a/GenHub/GenHub.Core/Constants/LocalizationConstants.cs b/GenHub/GenHub.Core/Constants/LocalizationConstants.cs index 9ec61dfa3..58b9b9530 100644 --- a/GenHub/GenHub.Core/Constants/LocalizationConstants.cs +++ b/GenHub/GenHub.Core/Constants/LocalizationConstants.cs @@ -13,7 +13,7 @@ public static class LocalizationConstants /// /// The property name used to notify bindings that all indexer values changed. /// - public const string IndexerPropertyName = "Item[]"; + public const string IndexerPropertyName = "Item"; /// /// The application resource key used to expose the localization service to XAML. diff --git a/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs b/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs index 5d62f4ce2..aedc00a6f 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs @@ -16,7 +16,7 @@ public interface ILocalizationService : INotifyPropertyChanged IReadOnlyList AvailableCultures { get; } /// - /// Gets the culture currently used for localized strings and culture-aware formatting. + /// Gets the culture used for resource lookup and formatting performed by . /// CultureInfo CurrentCulture { get; } @@ -33,7 +33,7 @@ public interface ILocalizationService : INotifyPropertyChanged /// The resource key to resolve. /// Optional format arguments. /// The localized value, its English fallback, or the key when no resource exists. - string GetString(string key, params object[] arguments); + string GetString(string key, params object?[] arguments); /// /// Changes the active culture when it has a deployed translation. diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs index e7c5ce760..189af6419 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs @@ -4,9 +4,12 @@ using System.IO; using System.Linq; using System.Resources; +using Avalonia; +using GenHub.Common.Markup; using GenHub.Common.Services; using GenHub.Core.Constants; using GenHub.Tests.Core.Collections; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -18,6 +21,41 @@ namespace GenHub.Tests.Core.Common.Services; [Collection(LocalizationCultureCollection.Name)] public sealed class LocalizationServiceTests : IDisposable { + private sealed class BindingTarget : AvaloniaObject + { + internal static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + internal string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + } + + private sealed class CountingLogger : ILogger + { + internal int WarningCount { get; private set; } + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + WarningCount++; + } + } + } + private const string TestResourceBaseName = "GenHub.Tests.Core.Resources.Localization.TestStrings"; private readonly CultureInfo? _originalDefaultCulture; @@ -36,18 +74,7 @@ public LocalizationServiceTests() _originalThreadCulture = CultureInfo.CurrentCulture; _originalThreadUiCulture = CultureInfo.CurrentUICulture; - var resourceAssembly = typeof(LocalizationServiceTests).Assembly; - var assemblyName = resourceAssembly.GetName().Name - ?? throw new InvalidOperationException("The test assembly name could not be resolved."); - var baseDirectory = Path.GetDirectoryName(resourceAssembly.Location) - ?? throw new InvalidOperationException("The test assembly directory could not be resolved."); - var resources = new LocalizationResources( - new ResourceManager(TestResourceBaseName, resourceAssembly), - $"{assemblyName}{LocalizationConstants.SatelliteAssemblySuffix}", - baseDirectory, - CultureInfo.GetCultureInfo(LocalizationConstants.DefaultCultureName)); - - _service = new LocalizationService(resources, NullLogger.Instance); + _service = CreateService(AppContext.BaseDirectory, NullLogger.Instance); } /// @@ -108,6 +135,44 @@ public void GetString_WithArguments_FormatsTranslatedValue() Assert.Equal("Bonjour, General!", _service.GetString("FormattedGreeting", "General")); } + /// + /// Verifies that null format arguments remain supported by the public contract. + /// + [Fact] + public void GetString_WithNullArgument_FormatsAsEmptyText() + { + Assert.Equal("Hello, !", _service.GetString("FormattedGreeting", (object?)null)); + } + + /// + /// Verifies that an invalid translated format string remains visible instead of throwing. + /// + [Fact] + public void GetString_InvalidFormatString_ReturnsUnformattedValue() + { + Assert.Equal("Hello, {0", _service.GetString("MalformedGreeting", "General")); + } + + /// + /// Verifies that the indexer delegates to resource lookup. + /// + [Fact] + public void Indexer_KnownKey_ReturnsLocalizedValue() + { + Assert.Equal("Hello", _service["Greeting"]); + } + + /// + /// Verifies that invalid lookup and culture arguments fail at the contract boundary. + /// + [Fact] + public void PublicMethods_InvalidArguments_ThrowArgumentExceptions() + { + Assert.Throws(() => _service.GetString(" ")); + Assert.Throws(() => _service.GetString("Greeting", (object?[])null!)); + Assert.Throws(() => _service.SetCulture(null!)); + } + /// /// Verifies that a completely unknown key remains visible for diagnostics. /// @@ -117,6 +182,27 @@ public void GetString_UnknownKey_ReturnsKey() Assert.Equal("Missing.Resource.Key", _service.GetString("Missing.Resource.Key")); } + /// + /// Verifies that repeated binding evaluations do not flood logs with the same missing key. + /// + [Fact] + public void GetString_RepeatedMissingKey_LogsOncePerCulture() + { + var logger = new CountingLogger(); + var service = CreateService(AppContext.BaseDirectory, logger); + + service.GetString("Missing.Resource.Key"); + service.GetString("Missing.Resource.Key"); + + Assert.Equal(1, logger.WarningCount); + + var result = service.SetCulture(CultureInfo.GetCultureInfo("fr")); + service.GetString("Missing.Resource.Key"); + + Assert.True(result.Success); + Assert.Equal(2, logger.WarningCount); + } + /// /// Verifies that changing culture refreshes both the culture and all indexer bindings. /// @@ -124,19 +210,40 @@ public void GetString_UnknownKey_ReturnsKey() public void SetCulture_AvailableCulture_RaisesLiveBindingNotifications() { var propertyNames = new List(); + var formattingCulture = CultureInfo.CurrentCulture; _service.PropertyChanged += (_, eventArgs) => propertyNames.Add(eventArgs.PropertyName); var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr")); Assert.True(result.Success); Assert.Equal("fr", _service.CurrentCulture.Name); - Assert.Equal("fr", CultureInfo.CurrentCulture.Name); Assert.Equal("fr", CultureInfo.CurrentUICulture.Name); + Assert.Equal("fr", CultureInfo.DefaultThreadCurrentUICulture?.Name); + Assert.Equal(formattingCulture, CultureInfo.CurrentCulture); Assert.Equal( [nameof(_service.CurrentCulture), LocalizationConstants.IndexerPropertyName], propertyNames); } + /// + /// Verifies that a dotted resource key resolves and refreshes through the Avalonia binding path. + /// + [Fact] + public void LocalizeExtension_DottedKeyBinding_RefreshesWhenCultureChanges() + { + var target = new BindingTarget(); + var extension = new LocalizeExtension("Settings.Appearance.Title"); + using (target.Bind(BindingTarget.ValueProperty, extension.CreateBinding(_service))) + { + Assert.Equal("Appearance", target.Value); + + var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr")); + + Assert.True(result.Success); + Assert.Equal("Apparence", target.Value); + } + } + /// /// Verifies that an unavailable culture returns a failure without changing state. /// @@ -151,4 +258,52 @@ public void SetCulture_UnavailableCulture_ReturnsFailureWithoutChangingCulture() Assert.Equal(originalCulture, _service.CurrentCulture); Assert.Contains("not available", result.FirstError, StringComparison.OrdinalIgnoreCase); } + + /// + /// Verifies that one invalid satellite cannot abort discovery of later valid cultures. + /// + [Fact] + public void AvailableCultures_InvalidSatellite_IgnoresItAndContinuesDiscovery() + { + var resourceAssemblyName = typeof(LocalizationServiceTests).Assembly.GetName().Name + ?? throw new InvalidOperationException("The test assembly name could not be resolved."); + var corruptCultureDirectory = Path.Combine(AppContext.BaseDirectory, "es-MX"); + var corruptSatellitePath = Path.Combine( + corruptCultureDirectory, + $"{resourceAssemblyName}{LocalizationConstants.SatelliteAssemblySuffix}"); + + Directory.CreateDirectory(corruptCultureDirectory); + File.WriteAllBytes(corruptSatellitePath, [0x00, 0x01, 0x02, 0x03]); + + try + { + var service = CreateService(AppContext.BaseDirectory, NullLogger.Instance); + var cultureNames = service.AvailableCultures.Select(culture => culture.Name).ToList(); + + Assert.DoesNotContain("es-MX", cultureNames); + Assert.Contains("fr", cultureNames); + } + finally + { + File.Delete(corruptSatellitePath); + if (!Directory.EnumerateFileSystemEntries(corruptCultureDirectory).Any()) + { + Directory.Delete(corruptCultureDirectory); + } + } + } + + private LocalizationService CreateService(string baseDirectory, ILogger logger) + { + var resourceAssembly = typeof(LocalizationServiceTests).Assembly; + var assemblyName = resourceAssembly.GetName().Name + ?? throw new InvalidOperationException("The test assembly name could not be resolved."); + var resources = new LocalizationResources( + new ResourceManager(TestResourceBaseName, resourceAssembly), + $"{assemblyName}{LocalizationConstants.SatelliteAssemblySuffix}", + baseDirectory, + CultureInfo.GetCultureInfo(LocalizationConstants.DefaultCultureName)); + + return new LocalizationService(resources, logger); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs index 0dc9f6c6f..b051fe537 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs @@ -1,5 +1,7 @@ +using System.Globalization; using GenHub.Core.Interfaces.Common; using GenHub.Infrastructure.DependencyInjection; +using GenHub.Tests.Core.Collections; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -8,8 +10,21 @@ namespace GenHub.Tests.Core.Infrastructure.DependencyInjection; /// /// Tests localization dependency injection registration. /// -public sealed class LocalizationModuleTests +[Collection(LocalizationCultureCollection.Name)] +public sealed class LocalizationModuleTests : IDisposable { + private readonly CultureInfo? _originalDefaultUiCulture = CultureInfo.DefaultThreadCurrentUICulture; + private readonly CultureInfo _originalThreadUiCulture = CultureInfo.CurrentUICulture; + + /// + /// Restores process-wide UI culture defaults changed by localization resolution. + /// + public void Dispose() + { + CultureInfo.CurrentUICulture = _originalThreadUiCulture; + CultureInfo.DefaultThreadCurrentUICulture = _originalDefaultUiCulture; + } + /// /// Verifies that localization resolves as one shared service with embedded English resources. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx index fbdf49f48..4e4b41438 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx @@ -18,4 +18,7 @@ Bonjour + + Apparence + diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx index 535afc9bb..a05b2b32e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx @@ -21,4 +21,10 @@ Hello + + Hello, {0 + + + Appearance + diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 221aa53fe..7497bb2a6 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -49,6 +49,7 @@ public App(IServiceProvider serviceProvider) /// public override void Initialize() { + Resources[LocalizationConstants.ResourceServiceKey] = _localizationService; AvaloniaXamlLoader.Load(this); Resources[LocalizationConstants.ResourceServiceKey] = _localizationService; } diff --git a/GenHub/GenHub/Common/Markup/LocalizeExtension.cs b/GenHub/GenHub/Common/Markup/LocalizeExtension.cs index 922271721..fa0d77820 100644 --- a/GenHub/GenHub/Common/Markup/LocalizeExtension.cs +++ b/GenHub/GenHub/Common/Markup/LocalizeExtension.cs @@ -1,6 +1,7 @@ using System; using Avalonia; using Avalonia.Data; +using Avalonia.Markup.Xaml; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; @@ -10,7 +11,7 @@ namespace GenHub.Common.Markup; /// Creates a live one-way binding to a localized resource key. /// /// The resource key to bind. -public sealed class LocalizeExtension(string key) +public sealed class LocalizeExtension(string key) : MarkupExtension { /// /// Gets the resource key resolved by the extension. @@ -22,9 +23,12 @@ public sealed class LocalizeExtension(string key) /// /// Provides a live binding when localization is initialized, or the key for design-time fallback. /// + /// The XAML service provider for the target object. /// A localization binding or the unresolved resource key. - public object ProvideValue() + public override object ProvideValue(IServiceProvider serviceProvider) { + ArgumentNullException.ThrowIfNull(serviceProvider); + if (Application.Current?.TryGetResource( LocalizationConstants.ResourceServiceKey, theme: null, @@ -34,9 +38,20 @@ public object ProvideValue() return Key; } + return CreateBinding(localizationService); + } + + /// + /// Creates the binding used to resolve and refresh a resource key. + /// + /// The source localization service. + /// A live one-way localization binding. + internal Binding CreateBinding(ILocalizationService localizationService) + { + ArgumentNullException.ThrowIfNull(localizationService); + return new Binding($"[{Key}]", BindingMode.OneWay) { - FallbackValue = Key, Source = localizationService, }; } diff --git a/GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs b/GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs new file mode 100644 index 000000000..351410b1e --- /dev/null +++ b/GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using Microsoft.Extensions.Logging; + +namespace GenHub.Common.Services; + +/// +/// Applies UI cultures and discovers deployed localization satellite assemblies. +/// +internal static class LocalizationCultureUtilities +{ + /// + /// Applies a culture to resource lookup without changing regional parsing or formatting behavior. + /// + /// The UI culture to apply. + /// The applied culture. + internal static CultureInfo ApplyUiCulture(CultureInfo culture) + { + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + return culture; + } + + /// + /// Discovers the neutral culture and valid deployed satellite resource cultures. + /// + /// The localization resource description. + /// The logger used for invalid deployment diagnostics. + /// The available cultures in deterministic order. + internal static IReadOnlyList DiscoverAvailableCultures( + LocalizationResources resources, + ILogger logger) + { + var cultures = new List { resources.DefaultCulture }; + var cultureNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + resources.DefaultCulture.Name, + }; + + try + { + var directories = Directory.GetDirectories(resources.BaseDirectory) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + + foreach (var directory in directories) + { + TryAddCulture(directory, resources, logger, cultures, cultureNames); + } + } + catch (DirectoryNotFoundException ex) + { + logger.LogWarning(ex, "Localization base directory was not found"); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Failed to scan localization satellite assemblies"); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access was denied while scanning localization satellite assemblies"); + } + + return cultures.AsReadOnly(); + } + + private static void TryAddCulture( + string directory, + LocalizationResources resources, + ILogger logger, + ICollection cultures, + ISet cultureNames) + { + var satelliteAssemblyPath = Path.Combine(directory, resources.SatelliteAssemblyFileName); + if (!File.Exists(satelliteAssemblyPath)) + { + return; + } + + var cultureName = Path.GetFileName(directory); + try + { + var culture = CultureInfo.GetCultureInfo(cultureName); + if (resources.ResourceManager.GetResourceSet( + culture, + createIfNotExists: true, + tryParents: false) is null) + { + logger.LogWarning( + "Ignoring satellite assembly without the GenHub string resource set for culture '{CultureName}'", + culture.Name); + return; + } + + if (cultureNames.Add(culture.Name)) + { + cultures.Add(culture); + } + } + catch (BadImageFormatException ex) + { + logger.LogWarning(ex, "Ignoring invalid localization satellite assembly for culture '{CultureName}'", cultureName); + } + catch (CultureNotFoundException ex) + { + logger.LogWarning(ex, "Ignoring localization directory with invalid culture name '{CultureName}'", cultureName); + } + catch (MissingManifestResourceException ex) + { + logger.LogWarning(ex, "Ignoring satellite assembly with missing localization resources for culture '{CultureName}'", cultureName); + } + catch (MissingSatelliteAssemblyException ex) + { + logger.LogWarning(ex, "Ignoring missing localization satellite assembly for culture '{CultureName}'", cultureName); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Ignoring unreadable localization satellite assembly for culture '{CultureName}'", cultureName); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access was denied to the localization satellite assembly for culture '{CultureName}'", cultureName); + } + } +} diff --git a/GenHub/GenHub/Common/Services/LocalizationService.cs b/GenHub/GenHub/Common/Services/LocalizationService.cs index c69e5e482..1f2539637 100644 --- a/GenHub/GenHub/Common/Services/LocalizationService.cs +++ b/GenHub/GenHub/Common/Services/LocalizationService.cs @@ -1,8 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; -using System.IO; using System.Linq; using System.Resources; using GenHub.Core.Constants; @@ -22,14 +22,18 @@ internal sealed class LocalizationService( private static readonly PropertyChangedEventArgs CurrentCultureChangedEventArgs = new(nameof(CurrentCulture)); private static readonly PropertyChangedEventArgs IndexerChangedEventArgs = new(LocalizationConstants.IndexerPropertyName); - private readonly IReadOnlyList _availableCultures = DiscoverAvailableCultures(resources, logger); + private readonly IReadOnlyList _availableCultures = + LocalizationCultureUtilities.DiscoverAvailableCultures(resources, logger); + private readonly object _cultureLock = new(); + private readonly ConcurrentDictionary<(string CultureName, string ResourceKey), byte> _missingResourceWarnings = new(); /// public IReadOnlyList AvailableCultures => _availableCultures; /// - public CultureInfo CurrentCulture { get; private set; } = resources.DefaultCulture; + public CultureInfo CurrentCulture { get; private set; } = + LocalizationCultureUtilities.ApplyUiCulture(resources.DefaultCulture); /// public string this[string key] => GetString(key); @@ -38,26 +42,26 @@ internal sealed class LocalizationService( public event PropertyChangedEventHandler? PropertyChanged; /// - public string GetString(string key, params object[] arguments) + public string GetString(string key, params object?[] arguments) { ArgumentException.ThrowIfNullOrWhiteSpace(key); ArgumentNullException.ThrowIfNull(arguments); - CultureInfo culture; - lock (_cultureLock) - { - culture = CurrentCulture; - } + var culture = CurrentCulture; try { var value = resources.ResourceManager.GetString(key, culture); if (value is null) { - logger.LogWarning( - "Localization resource '{ResourceKey}' was not found for culture '{CultureName}' or its English fallback", - key, - culture.Name); + if (_missingResourceWarnings.TryAdd((culture.Name, key), 0)) + { + logger.LogWarning( + "Localization resource '{ResourceKey}' was not found for culture '{CultureName}' or its English fallback", + key, + culture.Name); + } + return key; } @@ -107,7 +111,7 @@ public OperationResult SetCulture(CultureInfo culture) CurrentCulture.Name, availableCulture.Name, StringComparison.OrdinalIgnoreCase); - CurrentCulture = ApplyThreadCulture(availableCulture); + CurrentCulture = LocalizationCultureUtilities.ApplyUiCulture(availableCulture); } if (cultureChanged) @@ -118,94 +122,4 @@ public OperationResult SetCulture(CultureInfo culture) return OperationResult.CreateSuccess(); } - - private static CultureInfo ApplyThreadCulture(CultureInfo culture) - { - CultureInfo.CurrentCulture = culture; - CultureInfo.CurrentUICulture = culture; - CultureInfo.DefaultThreadCurrentCulture = culture; - CultureInfo.DefaultThreadCurrentUICulture = culture; - return culture; - } - - private static IReadOnlyList DiscoverAvailableCultures( - LocalizationResources localizationResources, - ILogger localizationLogger) - { - var cultures = new List { localizationResources.DefaultCulture }; - var cultureNames = new HashSet(StringComparer.OrdinalIgnoreCase) - { - localizationResources.DefaultCulture.Name, - }; - - try - { - var directories = Directory.GetDirectories(localizationResources.BaseDirectory) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); - - foreach (var directory in directories) - { - var satelliteAssemblyPath = Path.Combine(directory, localizationResources.SatelliteAssemblyFileName); - if (!File.Exists(satelliteAssemblyPath)) - { - continue; - } - - var cultureName = Path.GetFileName(directory); - try - { - var culture = CultureInfo.GetCultureInfo(cultureName); - if (localizationResources.ResourceManager.GetResourceSet( - culture, - createIfNotExists: true, - tryParents: false) is null) - { - localizationLogger.LogWarning( - "Ignoring satellite assembly without the GenHub string resource set for culture '{CultureName}'", - culture.Name); - continue; - } - - if (cultureNames.Add(culture.Name)) - { - cultures.Add(culture); - } - } - catch (CultureNotFoundException) - { - localizationLogger.LogWarning( - "Ignoring localization directory with invalid culture name '{CultureName}'", - cultureName); - } - catch (MissingManifestResourceException ex) - { - localizationLogger.LogWarning( - ex, - "Ignoring satellite assembly with missing localization resources for culture '{CultureName}'", - cultureName); - } - catch (MissingSatelliteAssemblyException ex) - { - localizationLogger.LogWarning( - ex, - "Ignoring missing localization satellite assembly for culture '{CultureName}'", - cultureName); - } - } - } - catch (DirectoryNotFoundException ex) - { - localizationLogger.LogWarning(ex, "Localization base directory was not found"); - } - catch (IOException ex) - { - localizationLogger.LogWarning(ex, "Failed to scan localization satellite assemblies"); - } - catch (UnauthorizedAccessException ex) - { - localizationLogger.LogWarning(ex, "Access was denied while scanning localization satellite assemblies"); - } - - return cultures.AsReadOnly(); - } } diff --git a/docs/dev/constants.md b/docs/dev/constants.md index ec49c161c..27ae0b4be 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -74,7 +74,7 @@ Constants used by the application localization foundation. | Constant | Value | Description | | --- | --- | --- | | `DefaultCultureName` | `"en"` | Neutral culture embedded in the main application assembly | -| `IndexerPropertyName` | `"Item[]"` | Change-notification name used to refresh all localized indexer bindings | +| `IndexerPropertyName` | `"Item"` | Avalonia change-notification name used to refresh localized indexer bindings | | `ResourceServiceKey` | `"LocalizationService"` | Application resource key used by the Avalonia markup extension | | `StringResourceBaseName` | `"GenHub.Resources.Localization.Strings"` | Fully qualified .NET resource base name | | `SatelliteAssemblySuffix` | `".resources.dll"` | Standard suffix used to identify satellite assemblies | diff --git a/docs/dev/localization.md b/docs/dev/localization.md index 0cc35da6b..fdaa93c7f 100644 --- a/docs/dev/localization.md +++ b/docs/dev/localization.md @@ -62,7 +62,7 @@ Reference the markup namespace and bind the property to a key: ``` -The extension binds through the application-scoped localization service. When `SetCulture` succeeds, all localized indexer bindings are notified and refresh without recreating the view or restarting GenHub. +The extension binds through the application-scoped localization service. When `SetCulture` changes the active culture, all localized indexer bindings are notified and refresh without recreating the view or restarting GenHub. ## View models and services @@ -85,6 +85,8 @@ if (result.Failed) Culture switching is synchronous because it performs no long-running I/O. Do not wrap it in `Task.Run`, block on a task, or introduce a reactive package solely for change notification. +The selected language is applied to `CurrentUICulture` and `DefaultThreadCurrentUICulture` for resource lookup. It does not replace `CurrentCulture`, so changing the UI language cannot silently alter unrelated regional number, date, parsing, or serialization behavior. Format arguments passed to `GetString` use the selected localization culture. + ## Adding coverage Every localization change should test the behavior it introduces. At minimum: @@ -93,4 +95,5 @@ Every localization change should test the behavior it introduces. At minimum: - an omitted translated key falls back to English; - placeholders format correctly in the active culture; - an unavailable culture fails without changing the current culture; -- a successful culture change refreshes live bindings. +- a successful culture change refreshes live bindings; +- an invalid satellite assembly is ignored without aborting discovery of other languages. From 549d5bd98026598587c19631f924f1e8f79343a8 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Mon, 31 Aug 2026 22:50:49 +0300 Subject: [PATCH 3/3] test(localization): cover application resource lifecycle --- GenHub/Directory.Packages.props | 1 + .../App/AppLifecycleTests.cs | 20 +++++++++ .../GenHub.Tests.Core/App/TestAppBuilder.cs | 44 +++++++++++++++++++ .../GenHub.Tests.Core.csproj | 1 + GenHub/GenHub/App.axaml.cs | 3 ++ 5 files changed, 69 insertions(+) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index b97095865..4d76f7123 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs index 6143238e6..0e3d4988b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs @@ -1,3 +1,7 @@ +using Avalonia.Data; +using Avalonia.Headless.XUnit; +using GenHub.Common.Markup; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; using Microsoft.Extensions.DependencyInjection; @@ -63,4 +67,20 @@ public void App_Constructor_WithValidServices_DoesNotThrow() var app = Activator.CreateInstance(appType, serviceProvider); Assert.NotNull(app); } + + /// + /// Verifies that application XAML loading exposes localization to markup extensions afterward. + /// + [AvaloniaFact] + public void App_Initialize_ExposesLocalizationServiceToMarkupExtensions() + { + var app = Assert.IsType(Avalonia.Application.Current); + Assert.Same( + TestAppBuilder.LocalizationService, + app.Resources[LocalizationConstants.ResourceServiceKey]); + + var extension = new LocalizeExtension("App.Name"); + var binding = Assert.IsType(extension.ProvideValue(Mock.Of())); + Assert.Same(TestAppBuilder.LocalizationService, binding.Source); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs new file mode 100644 index 000000000..5cd42a06d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs @@ -0,0 +1,44 @@ +using Avalonia; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameProfiles; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +[assembly: AvaloniaTestApplication(typeof(GenHub.Tests.Core.App.TestAppBuilder))] + +namespace GenHub.Tests.Core.App; + +/// +/// Configures the GenHub application for cross-platform headless lifecycle tests. +/// +internal static class TestAppBuilder +{ + private static readonly Mock LocalizationServiceMock = new(); + private static readonly IServiceProvider ServiceProvider = CreateServiceProvider(); + + /// + /// Gets the localization service registered in the headless application. + /// + internal static ILocalizationService LocalizationService => LocalizationServiceMock.Object; + + /// + /// Creates the Avalonia application builder used by headless tests. + /// + /// The configured application builder. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure(() => new global::GenHub.App(ServiceProvider)) + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); + + private static IServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + services.AddSingleton(LocalizationService); + services.AddSingleton(Mock.Of()); + + return services.BuildServiceProvider(); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj b/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj index 185df6dc3..44715d911 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj @@ -10,6 +10,7 @@ true + diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 7497bb2a6..a71e116e9 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -49,8 +49,11 @@ public App(IServiceProvider serviceProvider) /// public override void Initialize() { + // Make localization available while application XAML resources are loading. Resources[LocalizationConstants.ResourceServiceKey] = _localizationService; AvaloniaXamlLoader.Load(this); + + // App XAML replaces the resource dictionary, so restore the service for views loaded afterward. Resources[LocalizationConstants.ResourceServiceKey] = _localizationService; }