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
1 change: 1 addition & 0 deletions GenHub/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.2.7" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.2.7" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.2.7" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.2.7" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="Markdown.Avalonia" Version="11.0.2" />
Expand Down
32 changes: 32 additions & 0 deletions GenHub/GenHub.Core/Constants/LocalizationConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace GenHub.Core.Constants;

/// <summary>
/// Constants used by the application localization infrastructure.
/// </summary>
public static class LocalizationConstants
{
/// <summary>
/// The neutral culture embedded in the main application assembly.
/// </summary>
public const string DefaultCultureName = "en";

/// <summary>
/// The property name used to notify bindings that all indexer values changed.
/// </summary>
public const string IndexerPropertyName = "Item";

/// <summary>
/// The application resource key used to expose the localization service to XAML.
/// </summary>
public const string ResourceServiceKey = "LocalizationService";

/// <summary>
/// The fully qualified base name of the application's string resources.
/// </summary>
public const string StringResourceBaseName = "GenHub.Resources.Localization.Strings";

/// <summary>
/// The suffix used by .NET satellite resource assemblies.
/// </summary>
public const string SatelliteAssemblySuffix = ".resources.dll";
}
44 changes: 44 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using GenHub.Core.Models.Results;

namespace GenHub.Core.Interfaces.Common;

/// <summary>
/// Provides localized application strings and runtime culture switching.
/// </summary>
public interface ILocalizationService : INotifyPropertyChanged
{
/// <summary>
/// Gets the cultures backed by the neutral resource or a deployed satellite assembly.
/// </summary>
IReadOnlyList<CultureInfo> AvailableCultures { get; }

/// <summary>
/// Gets the culture used for resource lookup and formatting performed by <see cref="GetString"/>.
/// </summary>
CultureInfo CurrentCulture { get; }

/// <summary>
/// Gets a localized string by resource key.
/// </summary>
/// <param name="key">The resource key to resolve.</param>
/// <returns>The localized value, its English fallback, or the key when no resource exists.</returns>
string this[string key] { get; }

/// <summary>
/// Gets and optionally formats a localized string.
/// </summary>
/// <param name="key">The resource key to resolve.</param>
/// <param name="arguments">Optional format arguments.</param>
/// <returns>The localized value, its English fallback, or the key when no resource exists.</returns>
string GetString(string key, params object?[] arguments);

/// <summary>
/// Changes the active culture when it has a deployed translation.
/// </summary>
/// <param name="culture">The culture to activate.</param>
/// <returns>A result indicating whether the requested culture was available and applied.</returns>
OperationResult SetCulture(CultureInfo culture);
}
22 changes: 22 additions & 0 deletions GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,10 +52,12 @@ public void App_Constructor_WithValidServices_DoesNotThrow()
var services = new ServiceCollection();
var mockUserSettingsService = new Mock<IUserSettingsService>();
var mockConfigurationProvider = new Mock<IConfigurationProviderService>();
var mockLocalizationService = new Mock<ILocalizationService>();
var mockProfileLauncherFacade = new Mock<IProfileLauncherFacade>();

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();
Expand All @@ -61,4 +67,20 @@ public void App_Constructor_WithValidServices_DoesNotThrow()
var app = Activator.CreateInstance(appType, serviceProvider);
Assert.NotNull(app);
}

/// <summary>
/// Verifies that application XAML loading exposes localization to markup extensions afterward.
/// </summary>
[AvaloniaFact]
public void App_Initialize_ExposesLocalizationServiceToMarkupExtensions()
{
var app = Assert.IsType<global::GenHub.App>(Avalonia.Application.Current);
Assert.Same(
TestAppBuilder.LocalizationService,
app.Resources[LocalizationConstants.ResourceServiceKey]);

var extension = new LocalizeExtension("App.Name");
var binding = Assert.IsType<Binding>(extension.ProvideValue(Mock.Of<IServiceProvider>()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The test does not exercise the resolution path it claims to verify. LocalizeExtension.ProvideValue(IServiceProvider) calls Application.Current?.TryGetResource(...) (LocalizeExtension.cs:32) — it never touches serviceProvider — so passing Mock.Of<IServiceProvider>() proves nothing about the pre-AvaloniaXamlLoader.Load registration on App.axaml.cs:53. The assertion only confirms the binding's Source is the same mock service that was just placed into app.Resources by the post-Load statement (App.axaml.cs:57), which would pass identically if the pre-Load assignment at line 53 were removed. To actually verify the App-level XAML resolution path that justifies keeping line 53, exercise the extension against a real IServiceProvider resolved from AvaloniaRuntimeXamlLoader/AvaloniaXamlLoader while App.axaml is being parsed (e.g. parse a small XAML fragment containing {localization:Localize App.Name} and assert the resolved Binding.Source matches the registered service).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Assert.Same(TestAppBuilder.LocalizationService, binding.Source);
}
}
44 changes: 44 additions & 0 deletions GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs
Original file line number Diff line number Diff line change
@@ -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))]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Assembly-level [AvaloniaTestApplication] is now competing with the production GenHub/GenHub/Properties/AssemblyInfo.cs (or any other test assembly that might add its own). Only one such attribute can win per process; if another test assembly in this repo (or a future one) introduces its own TestAppBuilder or registers an AvaloniaTestApplication differently, xUnit will silently use whichever initializer runs first and App_Initialize_ExposesLocalizationServiceToMarkupExtensions will lose its App instance. Centralize the test app builder (e.g. shared partial class or a clearly-named single-purpose file) and guard the attribute with a comment explaining that no other test assembly may register AvaloniaTestApplication.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


namespace GenHub.Tests.Core.App;

/// <summary>
/// Configures the GenHub application for cross-platform headless lifecycle tests.
/// </summary>
internal static class TestAppBuilder
{
private static readonly Mock<ILocalizationService> LocalizationServiceMock = new();
private static readonly IServiceProvider ServiceProvider = CreateServiceProvider();

/// <summary>
/// Gets the localization service registered in the headless application.
/// </summary>
internal static ILocalizationService LocalizationService => LocalizationServiceMock.Object;

/// <summary>
/// Creates the Avalonia application builder used by headless tests.
/// </summary>
/// <returns>The configured application builder.</returns>
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<IUserSettingsService>());
services.AddSingleton(Mock.Of<IConfigurationProviderService>());
services.AddSingleton(LocalizationService);
services.AddSingleton(Mock.Of<IProfileLauncherFacade>());

return services.BuildServiceProvider();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace GenHub.Tests.Core.Collections;

/// <summary>
/// Prevents culture-mutating localization tests from running beside unrelated tests.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class LocalizationCultureCollection
{
/// <summary>
/// The collection name used by culture-mutating tests.
/// </summary>
public const string Name = "Localization culture";
}
Loading