Strings move into Localization/Strings.resx plus a Russian satellite. XAML uses
a {l:Loc Key} markup extension that yields a binding through the localizer's
indexer rather than a resolved string, so changing the language raises
PropertyChanged for the indexer and every caption in the app re-reads at once.
Resolving strings once at load would have been simpler and would have needed an
app restart to take effect.
Three places where this is more than a string swap:
Russian has three plural forms, so counted messages are assembled from .One /
.Few / .Many keys via Localizer.Plural instead of "{0} records" with an English
plural glued on. "1 запись", "3 записи", "7 записей".
Enum captions in pickers go through LocalizedOption<T>. A value converter would
resolve the caption once and never notice a language change; the wrapper keeps
identity on the enum value so the selection survives, while the label follows
the localizer. It needed a non-generic base for DataTemplate x:DataType, since
Avalonia 12 compiles bindings by default and cannot infer one for an open
generic.
Text originating in the domain would otherwise have stayed English under a
Russian UI — the screenshots showed exactly that. AvParser.Core still knows
nothing about languages: ParseError now carries a Code and Arguments, and the UI
translates Parse.Error.{Code} with a fallback to the English message. Parser
names work the same way (Parser.{id}.Name falling back to DisplayName), which
keeps "add a parser = one registration line" true — an untranslated parser shows
its own name rather than a missing-key marker.
Both .resx files are generated from one table so a key cannot exist in one and
be missing from the other, and the tests assert that, plus no blank translations
and identical {0} placeholder sets — a translation that drops a placeholder
throws at runtime rather than merely reading oddly. A headless test switches
language on a live shell and asserts the rendered text changes without the tree
being rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
83 lines
2.7 KiB
C#
83 lines
2.7 KiB
C#
using AvParser.Core.Proxies;
|
|
using AvParser.Core.Settings;
|
|
using AvParser.UI.Services;
|
|
using AvParser.UI.ViewModels;
|
|
using ReactiveUI.Primitives.Signals;
|
|
|
|
namespace AvParser.UI.HeadlessTests;
|
|
|
|
/// <summary>
|
|
/// Test doubles for the headless tests.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Intentionally duplicated from <c>AvParser.UI.Tests</c> rather than shared through a fourth
|
|
/// project: these are a few lines of trivial stand-ins, and a shared test-kit assembly would have
|
|
/// to opt out of the <c>tests/</c> conventions (self-executing xUnit exe) to build at all.
|
|
/// </remarks>
|
|
internal sealed class FakePage(string titleKey, string iconKey = "IconHome") : PageViewModel
|
|
{
|
|
public override string TitleKey { get; } = titleKey;
|
|
|
|
public override string IconKey { get; } = iconKey;
|
|
}
|
|
|
|
/// <inheritdoc cref="IThemeService" />
|
|
internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable
|
|
{
|
|
private readonly BehaviorSignal<AppTheme> _current = new(initial);
|
|
|
|
public AppTheme Current => _current.Value;
|
|
|
|
public IObservable<AppTheme> Changes => _current;
|
|
|
|
public void Apply(AppTheme theme)
|
|
{
|
|
if (theme != _current.Value)
|
|
{
|
|
_current.OnNext(theme);
|
|
}
|
|
}
|
|
|
|
public void Dispose() => _current.Dispose();
|
|
}
|
|
|
|
/// <summary>An editable proxy list held in memory.</summary>
|
|
internal sealed class FakeMutableProxySource : IMutableProxySource
|
|
{
|
|
public string Id => "fake-custom";
|
|
|
|
public string DisplayName => "Fake custom list";
|
|
|
|
public ProxySourceKind Kind => ProxySourceKind.Custom;
|
|
|
|
public List<ProxyEndpoint> Endpoints { get; } = [];
|
|
|
|
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<IReadOnlyList<ProxyEndpoint>>(Endpoints.ToArray());
|
|
|
|
public Task<int> AddAsync(IEnumerable<ProxyEndpoint> endpoints, CancellationToken cancellationToken = default)
|
|
{
|
|
Endpoints.AddRange(endpoints);
|
|
return Task.FromResult(Endpoints.Count);
|
|
}
|
|
|
|
public Task<bool> RemoveAsync(ProxyEndpoint endpoint, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(Endpoints.RemoveAll(e => e.Key == endpoint.Key) > 0);
|
|
|
|
public Task ClearAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
Endpoints.Clear();
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>A probe that says everything is dead. The view never calls it in these tests.</summary>
|
|
internal sealed class FakeProxyProbe : IProxyProbe
|
|
{
|
|
public Task<ProxyProbeResult> ProbeAsync(
|
|
ProxyEndpoint endpoint,
|
|
ProxyOptions options,
|
|
CancellationToken cancellationToken = default
|
|
) => Task.FromResult(ProxyProbeResult.Failure("not probed in tests"));
|
|
}
|