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
@@ -1,114 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class DelimitedTextParserTests
{
private readonly DelimitedTextParser _parser = new();
[Fact]
public async Task Parses_header_and_rows()
{
var (records, errors) = await _parser.CollectAsync("id,name\n1,Ada\n2,Grace");
errors.ShouldBeEmpty();
records.Count.ShouldBe(2);
records[0].Field("id").ShouldBe("1");
records[0].Field("name").ShouldBe("Ada");
records[1].LineNumber.ShouldBe(3);
}
[Theory]
[InlineData("a;b\n1;2")]
[InlineData("a\tb\n1\t2")]
[InlineData("a|b\n1|2")]
public async Task Detects_the_delimiter_from_the_header(string input)
{
var (records, errors) = await _parser.CollectAsync(input);
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Fields.Count.ShouldBe(2);
}
[Fact]
public async Task Reports_a_field_count_mismatch_without_aborting()
{
var (records, errors) = await _parser.CollectAsync("id,name\n1\n2,Grace");
// The bad line becomes an error; the good line after it still parses.
errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2);
records.ShouldHaveSingleItem().Field("name").ShouldBe("Grace");
}
[Fact]
public async Task Skips_blank_lines_and_comments()
{
var (records, errors) = await _parser.CollectAsync("# a comment\nid,name\n\n1,Ada\n");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada");
}
[Fact]
public async Task Trims_surrounding_whitespace()
{
var (records, _) = await _parser.CollectAsync("id , name\n 1 , Ada ");
records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada");
}
[Fact]
public async Task Reports_an_error_when_there_is_no_header()
{
var (records, errors) = await _parser.CollectAsync("\n\n");
records.ShouldBeEmpty();
errors.ShouldHaveSingleItem().Message.ShouldContain("header");
}
[Fact]
public async Task Reports_progress_reaching_completion()
{
var reports = new List<ParseProgress>();
// Not Progress<T>: it posts to the captured synchronization context, so the reports would
// arrive after the assertions. A direct IProgress<T> keeps the test deterministic.
await foreach (
var _ in _parser.ParseAsync(
ParserTestExtensions.DelimitedDocument(900),
new SynchronousProgress<ParseProgress>(reports.Add),
TestContext.Current.CancellationToken
)
) { }
reports.ShouldNotBeEmpty();
reports[^1].Fraction.ShouldBe(1d);
}
[Fact]
public async Task Honours_cancellation()
{
using var cancellation = new CancellationTokenSource();
var act = async () =>
{
await foreach (
var _ in _parser.ParseAsync(ParserTestExtensions.DelimitedDocument(20_000), null, cancellation.Token)
)
{
await cancellation.CancelAsync();
}
};
await act.ShouldThrowAsync<OperationCanceledException>();
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("no delimiters here", false)]
[InlineData("a,b", true)]
public void CanParse_checks_for_a_delimiter(string input, bool expected) =>
_parser.CanParse(input).ShouldBe(expected);
}
@@ -1,64 +0,0 @@
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class KeyValueTextParserTests
{
private readonly KeyValueTextParser _parser = new();
[Theory]
[InlineData("host = localhost")]
[InlineData("host: localhost")]
public async Task Accepts_both_separators(string input)
{
var (records, errors) = await _parser.CollectAsync(input);
errors.ShouldBeEmpty();
var record = records.ShouldHaveSingleItem();
record.Field("Key").ShouldBe("host");
record.Field("Value").ShouldBe("localhost");
}
[Fact]
public async Task Splits_on_the_first_separator_only()
{
var (records, _) = await _parser.CollectAsync("url = https://example.com:8080/path");
records.ShouldHaveSingleItem().Field("Value").ShouldBe("https://example.com:8080/path");
}
[Fact]
public async Task Reports_lines_without_a_separator()
{
var (records, errors) = await _parser.CollectAsync("host = localhost\ngarbage\nport = 80");
records.Count.ShouldBe(2);
errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2);
}
[Fact]
public async Task Reports_an_empty_key()
{
var (_, errors) = await _parser.CollectAsync("= orphan");
errors.ShouldHaveSingleItem().Message.ShouldContain("separator");
}
[Fact]
public async Task Skips_comments_and_blank_lines()
{
var (records, errors) = await _parser.CollectAsync("# comment\n\nhost = localhost\n");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("Key").ShouldBe("host");
}
[Fact]
public async Task Allows_an_empty_value()
{
var (records, errors) = await _parser.CollectAsync("host =");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("Value").ShouldBe(string.Empty);
}
}
@@ -1,37 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class ParserCatalogTests
{
private static IParserCatalog Catalog() => new ParserCatalog([new KeyValueTextParser(), new DelimitedTextParser()]);
[Fact]
public void Orders_parsers_by_display_name_regardless_of_registration_order() =>
Catalog().Parsers.Select(p => p.Id).ShouldBe(["delimited", "key-value"]);
[Fact]
public void Finds_a_parser_by_id_ignoring_case() => Catalog().Find("KEY-VALUE")!.Id.ShouldBe("key-value");
[Fact]
public void Returns_null_for_an_unknown_id() => Catalog().Find("nope").ShouldBeNull();
[Fact]
public void Falls_back_to_the_default_for_an_unknown_id()
{
var catalog = Catalog();
catalog.FindOrDefault("nope").ShouldBeSameAs(catalog.DefaultParser);
catalog.FindOrDefault(null).ShouldBeSameAs(catalog.DefaultParser);
}
[Fact]
public void Rejects_an_empty_registration() => Should.Throw<ArgumentException>(() => new ParserCatalog([]));
[Fact]
public void Rejects_duplicate_ids() =>
Should
.Throw<ArgumentException>(() => new ParserCatalog([new DelimitedTextParser(), new DelimitedTextParser()]))
.Message.ShouldContain("Duplicate");
}
@@ -1,47 +0,0 @@
using AvParser.Core.Parsing;
namespace AvParser.Core.Tests;
/// <summary>Collection helpers so the tests read as assertions rather than as loops.</summary>
internal static class ParserTestExtensions
{
/// <summary>
/// Drains a parse into memory, using the ambient test cancellation token.
/// </summary>
/// <remarks>
/// Deliberately takes no <see cref="CancellationToken"/>: every call site would otherwise have
/// to pass <c>TestContext.Current.CancellationToken</c> to satisfy xUnit1051. Cancellation
/// behaviour is covered by driving <see cref="IParser{TInput,TOutput}.ParseAsync"/> directly.
/// </remarks>
internal static async Task<(List<ParsedRecord> Records, List<ParseError> Errors)> CollectAsync(
this ITextParser parser,
string input,
IProgress<ParseProgress>? progress = null
)
{
var records = new List<ParsedRecord>();
var errors = new List<ParseError>();
await foreach (var outcome in parser.ParseAsync(input, progress, TestContext.Current.CancellationToken))
{
if (outcome.IsSuccess)
{
records.Add(outcome.Value!);
}
else
{
errors.Add(outcome.Error);
}
}
return (records, errors);
}
/// <summary>Reads a field by name, failing the test if it is absent.</summary>
internal static string Field(this ParsedRecord record, string name) =>
record[name] ?? throw new InvalidOperationException($"Field '{name}' is missing.");
/// <summary>Builds a delimited document with a header plus <paramref name="rows"/> data rows.</summary>
internal static string DelimitedDocument(int rows) =>
string.Join('\n', Enumerable.Range(0, rows + 1).Select(i => i == 0 ? "id,name" : $"{i},row{i}"));
}