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>
155 lines
4.2 KiB
C#
155 lines
4.2 KiB
C#
using AvParser.Core.Parsing;
|
|
using AvParser.Core.Parsing.Samples;
|
|
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)
|
|
{
|
|
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser()]);
|
|
var settingsService = new FakeSettingsService(settings);
|
|
|
|
return (
|
|
new ParseViewModel(
|
|
catalog,
|
|
settingsService,
|
|
NullLogger<ParseViewModel>.Instance,
|
|
ImmediateSequencer.Instance
|
|
),
|
|
settingsService
|
|
);
|
|
}
|
|
|
|
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
|
|
|
|
[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");
|
|
}
|
|
|
|
[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);
|
|
|
|
page.ClearCommand.Execute().Subscribe(_ => { });
|
|
|
|
page.InputText.ShouldBeEmpty();
|
|
page.Records.ShouldBeEmpty();
|
|
page.StatusMessage.ShouldBeNull();
|
|
page.Progress.ShouldBe(0d);
|
|
}
|
|
}
|