Remove the demo text-parsing domain

The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.

Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.

Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.

Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 22:23:52 +03:00
co-authored by Claude Opus 5
parent fe62bcf53f
commit f8744c930a
33 changed files with 327 additions and 2109 deletions
@@ -0,0 +1,65 @@
using System.Reflection;
using System.Resources;
using System.Text.RegularExpressions;
using AvParser.UI.Localization;
namespace AvParser.UI.Tests;
/// <summary>
/// Every key the XAML asks for must actually exist.
/// </summary>
/// <remarks>
/// The parity test compares the two resource files against each other, so a key missing from
/// <b>both</b> passes it happily and then renders as <c>!Some.Key!</c> in the running app. That
/// happened during a rename: the XAML was repointed at new key names and the keys themselves were
/// never added. Only a screenshot caught it, which is too late and too manual.
/// </remarks>
public sealed partial class LocalizationCoverageTests
{
[GeneratedRegex(@"\{l:Loc\s+([A-Za-z0-9_.\-]+)\s*\}", RegexOptions.Compiled)]
private static partial Regex LocMarkup();
private static readonly ResourceManager Resources = new(
"AvParser.UI.Localization.Strings",
typeof(Localizer).GetTypeInfo().Assembly
);
/// <summary>Walks up to the repository root, which the test binary sits several levels below.</summary>
private static DirectoryInfo RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AvParser.slnx")))
{
directory = directory.Parent;
}
return directory ?? throw new InvalidOperationException("Could not find the repository root.");
}
[Fact]
public void Every_key_used_in_xaml_exists()
{
var views = Path.Combine(RepositoryRoot().FullName, "src", "AvParser.UI");
var files = Directory.GetFiles(views, "*.axaml", SearchOption.AllDirectories);
files.ShouldNotBeEmpty();
var missing = new SortedSet<string>(StringComparer.Ordinal);
foreach (var file in files)
{
foreach (Match match in LocMarkup().Matches(File.ReadAllText(file)))
{
var key = match.Groups[1].Value;
if (Resources.GetString(key, Localizer.Instance.Culture) is null)
{
missing.Add($"{key} ({Path.GetFileName(file)})");
}
}
}
missing.ShouldBeEmpty();
}
}
+4 -4
View File
@@ -128,9 +128,9 @@ public sealed class LocalizationTests : IDisposable
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Localizer.Instance.Plural("Parse.Count.Records", 1).ShouldBe("1 запись");
Localizer.Instance.Plural("Parse.Count.Records", 3).ShouldBe("3 записи");
Localizer.Instance.Plural("Parse.Count.Records", 7).ShouldBe("7 записей");
Localizer.Instance.Plural("Collect.Count.Images", 1).ShouldBe("1 изображение");
Localizer.Instance.Plural("Collect.Count.Images", 3).ShouldBe("3 изображения");
Localizer.Instance.Plural("Collect.Count.Images", 7).ShouldBe("7 изображений");
}
[Fact]
@@ -195,7 +195,7 @@ public sealed class LocalizationTests : IDisposable
[Fact]
public void A_fallback_is_used_when_a_key_is_absent() =>
Localizer.Instance.GetOrDefault("Parser.brand-new.Name", "Brand new").ShouldBe("Brand new");
Localizer.Instance.GetOrDefault("Source.brand-new.Name", "Brand new").ShouldBe("Brand new");
[Theory]
[InlineData(AppLanguage.English, "en")]
@@ -1,278 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ParseViewModelTests
{
private static (ParseViewModel Page, FakeSettingsService Settings) Build(
AppSettings? settings = null,
IProxyPool? proxyPool = null,
params ITextParser[] extraParsers
)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extraParsers]);
var settingsService = new FakeSettingsService(settings);
return (
new ParseViewModel(
catalog,
settingsService,
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new EmptyServiceProvider(),
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
),
settingsService
);
}
/// <summary>A container that resolves nothing; the page only reaches for navigation on click.</summary>
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
/// <summary>Stands in for the kind of parser the proxy gate exists for.</summary>
private sealed class NetworkParser : ITextParser
{
public string Id => "network";
public string DisplayName => "Network parser";
public string Description => "Fetches something";
public bool RequiresNetwork => true;
public bool CanParse(string input) => true;
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
/// <summary>
/// Waits until a command's gate opens.
/// </summary>
/// <remarks>
/// <c>Execute()</c> completing and <c>IsExecuting</c> going false are not the same instant:
/// the latter is published on the output scheduler. Commands gated on another command's
/// IsExecuting therefore need the gate observed, not assumed — asserting straight after the
/// await failed intermittently under load.
/// </remarks>
private static Task WhenExecutable<TParam, TResult>(ReactiveUI.ReactiveCommand<TParam, TResult> command) =>
command.CanExecute.Where(static can => can).Take(1).ToTask(TestContext.Current.CancellationToken);
[Fact]
public void Restores_the_last_used_parser()
{
var (page, _) = Build(new AppSettings { LastParserId = "key-value" });
page.SelectedParser.Id.ShouldBe("key-value");
}
[Fact]
public void Falls_back_to_the_default_parser_for_an_unknown_id()
{
var (page, _) = Build(new AppSettings { LastParserId = "removed-in-a-past-version" });
page.SelectedParser.Id.ShouldBe("delimited");
}
[Fact]
public void Remembers_the_selected_parser()
{
var (page, settings) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
settings.Current.LastParserId.ShouldBe("key-value");
}
[Fact]
public void A_local_parser_runs_even_with_no_proxy_at_all()
{
// Only network parsers are gated. Blocking a parser that works on pasted text would make
// the app unusable whenever the public lists are down, for no benefit at all.
var (page, _) = Build();
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Fact]
public void A_network_parser_is_blocked_while_nothing_is_live()
{
var (page, _) = Build(
new AppSettings { LastParserId = "network" },
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new NetworkParser()
);
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.IsBlockedWithoutProxy.ShouldBeTrue();
page.InputText = "anything";
canExecute.ShouldBeFalse();
}
[Fact]
public async Task A_network_parser_runs_once_a_proxy_answers()
{
var endpoint = new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080);
var pool = new ProxyPool(
[new FakeProxySource([endpoint])],
new FakeProxyProbe().Set(endpoint, alive: true),
new ProxyOptions()
);
var (page, _) = Build(new AppSettings { LastParserId = "network" }, pool, new NetworkParser());
page.IsBlockedWithoutProxy.ShouldBeTrue();
await pool.RefreshAsync(TestContext.Current.CancellationToken);
await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken);
page.RefreshProxyGate();
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Fact]
public void Allowing_direct_connections_lifts_the_gate()
{
var (page, _) = Build(
new AppSettings { LastParserId = "network", AllowDirectConnection = true },
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new NetworkParser()
);
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Fact]
public void Switching_away_from_a_network_parser_lifts_the_gate()
{
var (page, _) = Build(
new AppSettings { LastParserId = "network" },
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new NetworkParser()
);
page.SelectedParser = page.Parsers.Single(parser => parser.Id == "delimited");
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("id,name\n1,Ada", true)]
public void Parsing_requires_non_blank_input(string input, bool expected)
{
var (page, _) = Build();
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.InputText = input;
canExecute.ShouldBe(expected);
}
[Fact]
public async Task Parsing_fills_the_records_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(2);
page.Errors.ShouldBeEmpty();
page.Progress.ShouldBe(1d);
page.StatusMessage!.ShouldContain("2 records");
}
[Fact]
public async Task Bad_lines_land_in_the_errors_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
page.Errors.Count.ShouldBe(1);
page.StatusMessage!.ShouldContain("1 error");
}
[Fact]
public async Task A_second_run_replaces_the_previous_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
}
[Fact]
public async Task Cancelling_stops_the_run_and_says_so()
{
var (page, _) = Build();
page.InputText = string.Join(
'\n',
Enumerable.Range(0, 200_000).Select(i => i == 0 ? "id,name" : $"{i},row{i}")
);
var run = RunAsync(page);
page.CancelCommand.Execute().Subscribe(_ => { });
await run;
page.StatusMessage!.ShouldStartWith("Cancelled");
}
[Fact]
public void Loading_the_sample_matches_the_selected_parser()
{
var (page, _) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
page.LoadSampleCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldContain("host = localhost");
}
[Fact]
public async Task Clearing_empties_the_input_and_the_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
await WhenExecutable(page.ClearCommand);
await page.ClearCommand.Execute().ToTask(TestContext.Current.CancellationToken);
page.InputText.ShouldBeEmpty();
page.Records.ShouldBeEmpty();
page.StatusMessage.ShouldBeNull();
page.Progress.ShouldBe(0d);
}
}