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>
200 lines
6.1 KiB
C#
200 lines
6.1 KiB
C#
using AvParser.Core.Proxies;
|
|
using AvParser.UI.Tests.Fakes;
|
|
using AvParser.UI.ViewModels;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ReactiveUI.Primitives.Concurrency;
|
|
|
|
namespace AvParser.UI.Tests;
|
|
|
|
public class ProxiesViewModelTests
|
|
{
|
|
private static (ProxiesViewModel Page, ProxyPool Pool, FakeMutableProxySource Custom) Build(params string[] hosts)
|
|
{
|
|
var custom = new FakeMutableProxySource();
|
|
var feed = new FakeProxySource(hosts.Select(host => Endpoint(host)));
|
|
var pool = new ProxyPool(
|
|
[feed, custom],
|
|
new FakeProxyProbe(),
|
|
new ProxyOptions(),
|
|
timeProvider: null,
|
|
random: new Random(1)
|
|
);
|
|
|
|
var page = new ProxiesViewModel(
|
|
pool,
|
|
custom,
|
|
NullLogger<ProxiesViewModel>.Instance,
|
|
ImmediateSequencer.Instance
|
|
);
|
|
|
|
return (page, pool, custom);
|
|
}
|
|
|
|
private static ProxyEndpoint Endpoint(string host, ProxyProtocol protocol = ProxyProtocol.Http) =>
|
|
new(protocol, host, 8080);
|
|
|
|
/// <summary>Runs a command to completion under the ambient test cancellation token.</summary>
|
|
private static Task Run<TResult>(ReactiveUI.ReactiveCommand<RxVoid, TResult> command) =>
|
|
command.Execute().ToTask(TestContext.Current.CancellationToken);
|
|
|
|
[Fact]
|
|
public void Starts_empty()
|
|
{
|
|
var (page, _, _) = Build();
|
|
|
|
page.Proxies.ShouldBeEmpty();
|
|
page.TotalCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refreshing_fills_the_list()
|
|
{
|
|
var (page, _, _) = Build("a", "b");
|
|
|
|
await Run(page.RefreshCommand);
|
|
|
|
page.Proxies.Count.ShouldBe(2);
|
|
page.TotalCount.ShouldBe(2);
|
|
page.StatusMessage.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_search_box_filters_by_address()
|
|
{
|
|
var (page, _, _) = Build("10.0.0.1", "10.0.0.2");
|
|
await Run(page.RefreshCommand);
|
|
|
|
page.SearchText = "10.0.0.2";
|
|
await Task.Delay(250, TestContext.Current.CancellationToken);
|
|
|
|
page.Proxies.ShouldHaveSingleItem().Address.ShouldContain("10.0.0.2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_protocol_filter_narrows_the_list()
|
|
{
|
|
var custom = new FakeMutableProxySource();
|
|
var feed = new FakeProxySource([Endpoint("http-one"), Endpoint("socks-one", ProxyProtocol.Socks5)]);
|
|
var pool = new ProxyPool([feed, custom], new FakeProxyProbe(), new ProxyOptions());
|
|
var page = new ProxiesViewModel(
|
|
pool,
|
|
custom,
|
|
NullLogger<ProxiesViewModel>.Instance,
|
|
ImmediateSequencer.Instance
|
|
);
|
|
|
|
await Run(page.RefreshCommand);
|
|
page.ProtocolFilter = page.ProtocolFilters.Single(option => option.Value == ProxyProtocolFilter.Socks5);
|
|
await Task.Delay(250, TestContext.Current.CancellationToken);
|
|
|
|
page.Proxies.ShouldHaveSingleItem().Protocol.ShouldBe("SOCKS5");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Adding_custom_proxies_reports_what_it_could_not_parse()
|
|
{
|
|
var (page, _, custom) = Build();
|
|
page.NewProxies = "1.2.3.4:8080\nnot-a-proxy";
|
|
|
|
await Run(page.AddCustomCommand);
|
|
|
|
custom.Endpoints.Count.ShouldBe(1);
|
|
page.StatusMessage!.ShouldContain("not-a-proxy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_input_box_is_cleared_only_when_something_was_added()
|
|
{
|
|
var (page, _, _) = Build();
|
|
|
|
page.NewProxies = "not-a-proxy";
|
|
await Run(page.AddCustomCommand);
|
|
page.NewProxies.ShouldBe("not-a-proxy");
|
|
|
|
page.NewProxies = "1.2.3.4:8080";
|
|
await Run(page.AddCustomCommand);
|
|
page.NewProxies.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Removing_is_offered_only_for_custom_entries()
|
|
{
|
|
var (page, _, _) = Build("feed-one");
|
|
await Run(page.RefreshCommand);
|
|
|
|
var canRemove = true;
|
|
using var subscription = page.RemoveSelectedCommand.CanExecute.Subscribe(value => canRemove = value);
|
|
|
|
page.SelectedProxy = page.Proxies.Single();
|
|
|
|
// Feed entries are republished upstream; removing one locally would be undone on the
|
|
// next refresh, so the command stays disabled.
|
|
canRemove.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_custom_entry_can_be_removed()
|
|
{
|
|
var (page, _, custom) = Build();
|
|
page.NewProxies = "1.2.3.4:8080";
|
|
await Run(page.AddCustomCommand);
|
|
|
|
page.SelectedProxy = page.Proxies.Single();
|
|
page.SelectedProxy.IsCustom.ShouldBeTrue();
|
|
|
|
await Run(page.RemoveSelectedCommand);
|
|
|
|
custom.Endpoints.ShouldBeEmpty();
|
|
page.Proxies.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sweeping_updates_the_alive_count()
|
|
{
|
|
var custom = new FakeMutableProxySource();
|
|
var feed = new FakeProxySource([Endpoint("good"), Endpoint("bad")]);
|
|
var probe = new FakeProxyProbe();
|
|
probe.Set(Endpoint("good"), alive: true);
|
|
|
|
var pool = new ProxyPool([feed, custom], probe, new ProxyOptions());
|
|
var page = new ProxiesViewModel(
|
|
pool,
|
|
custom,
|
|
NullLogger<ProxiesViewModel>.Instance,
|
|
ImmediateSequencer.Instance
|
|
);
|
|
|
|
await Run(page.RefreshCommand);
|
|
await Run(page.SweepCommand);
|
|
|
|
page.AliveCount.ShouldBe(1);
|
|
page.IsSweeping.ShouldBeFalse();
|
|
page.StatusMessage!.ShouldContain("1 of 2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clearing_empties_the_custom_list()
|
|
{
|
|
var (page, _, custom) = Build();
|
|
page.NewProxies = "1.2.3.4:8080\n5.6.7.8:1080";
|
|
await Run(page.AddCustomCommand);
|
|
|
|
await Run(page.ClearCustomCommand);
|
|
|
|
custom.Endpoints.ShouldBeEmpty();
|
|
page.Proxies.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Disposing_detaches_from_the_pool()
|
|
{
|
|
var (page, pool, _) = Build("a");
|
|
|
|
page.Dispose();
|
|
|
|
// The pool is a singleton; a page that stayed subscribed would be kept alive forever
|
|
// and would keep rebuilding its rows in the background.
|
|
Should.NotThrow(() => pool.Configure(new ProxyOptions()));
|
|
}
|
|
}
|