Scaffold AvParser: Avalonia 12 shell with adaptive layout
Greenfield skeleton for a parser desktop app. The domain is deliberately a placeholder — IParser<TIn,TOut> plus two sample parsers — so the shell is runnable and verifiable end to end before real logic lands. Layers run one way: Core (no Avalonia, no IO) <- Infrastructure <- UI <- Desktop. UI is a class library rather than the exe so headless tests build real views without dragging in Program.cs, Serilog or the container. Adaptive layout is built from what Avalonia actually offers, since it has no AdaptiveTrigger or media queries: ResponsiveLayout observes Visual.Bounds and projects a breakpoint onto both an attached property and :compact/:medium/ :expanded pseudoclasses, with 24px hysteresis so dragging a window edge cannot make the layout flap. Pane state lives in the view model because a style setter loses to a local value permanently; styles own only the visual variance. Stack notes worth remembering: Avalonia.ReactiveUI is deprecated in favour of ReactiveUI.Avalonia, and ReactiveUI 24 runs on the Primitives engine (RxVoid, ISequencer, Signal<T>) and no longer self-initialises. Avalonia.Headless.XUnit 12.x requires xUnit v3. InvariantGlobalization must stay false or Semi.Avalonia throws in its static constructor. 102 tests across three projects, including headless guards for the two failures that are otherwise completely silent: a stylesheet whose selectors match nothing, and a light palette too low-contrast for cards to read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>AvParser.Core.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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}"));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AvParser.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IProgress{T}"/> that invokes its callback inline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Progress{T}"/> marshals through the captured synchronization context, which makes
|
||||
/// the delivery order untestable. This one reports on the calling thread.
|
||||
/// </remarks>
|
||||
internal sealed class SynchronousProgress<T>(Action<T> onReport) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => onReport(value);
|
||||
}
|
||||
Reference in New Issue
Block a user