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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>AvParser.UI.HeadlessTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\AvParser.UI\AvParser.UI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Headless" />
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" />
|
||||
<PackageReference Include="Semi.Avalonia" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="ReactiveUI.Primitives" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,41 @@
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Services;
|
||||
using AvParser.UI.ViewModels;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
/// <summary>
|
||||
/// Test doubles for the headless tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally duplicated from <c>AvParser.UI.Tests</c> rather than shared through a fourth
|
||||
/// project: these are a few lines of trivial stand-ins, and a shared test-kit assembly would have
|
||||
/// to opt out of the <c>tests/</c> conventions (self-executing xUnit exe) to build at all.
|
||||
/// </remarks>
|
||||
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
|
||||
{
|
||||
public override string Title { get; } = title;
|
||||
|
||||
public override string IconKey { get; } = iconKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IThemeService" />
|
||||
internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable
|
||||
{
|
||||
private readonly BehaviorSignal<AppTheme> _current = new(initial);
|
||||
|
||||
public AppTheme Current => _current.Value;
|
||||
|
||||
public IObservable<AppTheme> Changes => _current;
|
||||
|
||||
public void Apply(AppTheme theme)
|
||||
{
|
||||
if (theme != _current.Value)
|
||||
{
|
||||
_current.OnNext(theme);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _current.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using AvParser.UI.Responsive;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
public class ResponsiveTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(320, Breakpoint.Compact)]
|
||||
[InlineData(719, Breakpoint.Compact)]
|
||||
[InlineData(720, Breakpoint.Medium)]
|
||||
[InlineData(1000, Breakpoint.Medium)]
|
||||
[InlineData(1100, Breakpoint.Expanded)]
|
||||
[InlineData(1920, Breakpoint.Expanded)]
|
||||
public void Classify_maps_width_to_a_breakpoint(double width, Breakpoint expected) =>
|
||||
ResponsiveLayout.Classify(width).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Hysteresis_widens_whichever_band_we_are_already_in()
|
||||
{
|
||||
// Sitting just under the medium threshold, a nudge upward must not flip the layout...
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.MediumMinWidth + 10, Breakpoint.Compact)
|
||||
.ShouldBe(Breakpoint.Compact);
|
||||
// ...but a decisive move past the deadband must.
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.MediumMinWidth + ResponsiveLayout.Hysteresis, Breakpoint.Compact)
|
||||
.ShouldBe(Breakpoint.Medium);
|
||||
|
||||
// Symmetrically on the way down from expanded.
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.ExpandedMinWidth - 10, Breakpoint.Expanded)
|
||||
.ShouldBe(Breakpoint.Expanded);
|
||||
ResponsiveLayout
|
||||
.Classify(ResponsiveLayout.ExpandedMinWidth - ResponsiveLayout.Hysteresis - 1, Breakpoint.Expanded)
|
||||
.ShouldBe(Breakpoint.Medium);
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(500, Breakpoint.Compact, ":compact")]
|
||||
[InlineData(900, Breakpoint.Medium, ":medium")]
|
||||
[InlineData(1400, Breakpoint.Expanded, ":expanded")]
|
||||
public void Laying_out_a_control_sets_the_breakpoint_and_its_pseudoclass(
|
||||
double width,
|
||||
Breakpoint expected,
|
||||
string pseudoClass
|
||||
)
|
||||
{
|
||||
var host = new Border();
|
||||
ResponsiveLayout.SetIsEnabled(host, true);
|
||||
|
||||
// Measure/Arrange directly rather than resizing a Window: the headless window manager's
|
||||
// resize path is the flakiest thing available, and this is what actually drives Bounds.
|
||||
host.Measure(new Size(width, 800));
|
||||
host.Arrange(new Rect(0, 0, width, 800));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
ResponsiveLayout.GetBreakpoint(host).ShouldBe(expected);
|
||||
host.Classes.Contains(pseudoClass).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Only_one_breakpoint_pseudoclass_is_active_at_a_time()
|
||||
{
|
||||
var host = new Border();
|
||||
ResponsiveLayout.SetIsEnabled(host, true);
|
||||
|
||||
host.Measure(new Size(400, 600));
|
||||
host.Arrange(new Rect(0, 0, 400, 600));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
host.Classes.Contains(":compact").ShouldBeTrue();
|
||||
host.Classes.Contains(":medium").ShouldBeFalse();
|
||||
host.Classes.Contains(":expanded").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Disabling_the_behaviour_stops_further_updates()
|
||||
{
|
||||
var host = new Border();
|
||||
ResponsiveLayout.SetIsEnabled(host, true);
|
||||
host.Measure(new Size(400, 600));
|
||||
host.Arrange(new Rect(0, 0, 400, 600));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
ResponsiveLayout.SetIsEnabled(host, false);
|
||||
host.Measure(new Size(1400, 600));
|
||||
host.Arrange(new Rect(0, 0, 1400, 600));
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
ResponsiveLayout.GetBreakpoint(host).ShouldBe(Breakpoint.Compact);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Responsive;
|
||||
using AvParser.UI.Services;
|
||||
using AvParser.UI.ViewModels;
|
||||
using AvParser.UI.Views;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
public class ShellViewTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the shell inside a window of the requested width.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A real <see cref="Window"/> is required — a detached control never builds its visual tree,
|
||||
/// so there would be no <see cref="SplitView"/> to assert on. The width is set on the window
|
||||
/// rather than by calling Measure/Arrange by hand: a manual arrange is undone by the window's
|
||||
/// own next layout pass, which made these assertions depend on pump ordering.
|
||||
/// </remarks>
|
||||
private static (ShellView View, ShellViewModel ViewModel, Window Window) ShowShell(double width)
|
||||
{
|
||||
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second", "IconDocument")]);
|
||||
|
||||
// ImmediateSequencer, not the real main-thread one: derived properties then settle within
|
||||
// the same layout pass, so a single RunJobs() is enough for the bindings to catch up.
|
||||
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
|
||||
var view = new ShellView { DataContext = viewModel };
|
||||
var window = new Window
|
||||
{
|
||||
Width = width,
|
||||
Height = 800,
|
||||
Content = view,
|
||||
};
|
||||
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
return (view, viewModel, window);
|
||||
}
|
||||
|
||||
private static void Resize(Window window, double width)
|
||||
{
|
||||
window.Width = width;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
private static SplitView NavPaneOf(Visual view) => view.GetVisualDescendants().OfType<SplitView>().Single();
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(500, SplitViewDisplayMode.Overlay)]
|
||||
[InlineData(900, SplitViewDisplayMode.CompactInline)]
|
||||
[InlineData(1400, SplitViewDisplayMode.Inline)]
|
||||
public void The_navigation_pane_adapts_to_the_shell_width(double width, SplitViewDisplayMode expected)
|
||||
{
|
||||
var (view, _, _) = ShowShell(width);
|
||||
|
||||
NavPaneOf(view).DisplayMode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(500, Breakpoint.Compact)]
|
||||
[InlineData(900, Breakpoint.Medium)]
|
||||
[InlineData(1400, Breakpoint.Expanded)]
|
||||
public void The_shell_hands_its_breakpoint_to_the_view_model(double width, Breakpoint expected)
|
||||
{
|
||||
var (_, viewModel, _) = ShowShell(width);
|
||||
|
||||
viewModel.Breakpoint.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Narrowing_the_shell_closes_the_pane_and_widening_reopens_it()
|
||||
{
|
||||
var (_, viewModel, window) = ShowShell(1400);
|
||||
viewModel.IsPaneOpen.ShouldBeTrue();
|
||||
|
||||
Resize(window, 480);
|
||||
viewModel.IsPaneOpen.ShouldBeFalse();
|
||||
|
||||
Resize(window, 1400);
|
||||
viewModel.IsPaneOpen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void The_rail_lists_every_registered_page()
|
||||
{
|
||||
var (view, _, _) = ShowShell(1400);
|
||||
|
||||
view.GetVisualDescendants().OfType<ListBox>().Single().ItemCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Selecting_a_rail_entry_swaps_the_hosted_page()
|
||||
{
|
||||
var (view, viewModel, _) = ShowShell(1400);
|
||||
var second = viewModel.Pages[1];
|
||||
|
||||
viewModel.SelectedPage = second;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var host = view.GetVisualDescendants().OfType<TransitioningContentControl>().Single();
|
||||
host.Content.ShouldBeSameAs(second);
|
||||
viewModel.Title.ShouldBe("Second");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guards against the shell stylesheet silently matching nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Avalonia type selectors match the exact type, so <c>UserControl.shell</c> does not match
|
||||
/// <see cref="ShellView"/> (which derives from <c>ReactiveUserControl<T></c>). That
|
||||
/// failure is completely silent — the app renders, just with default metrics — so it needs an
|
||||
/// explicit assertion on a value only the stylesheet can produce.
|
||||
/// </remarks>
|
||||
[AvaloniaFact]
|
||||
public void The_shell_stylesheet_is_actually_applied()
|
||||
{
|
||||
var (view, _, _) = ShowShell(1400);
|
||||
|
||||
// 248 comes from the NavPaneWidth token; SplitView's own default is 320.
|
||||
NavPaneOf(view).OpenPaneLength.ShouldBe(248d);
|
||||
NavPaneOf(view).CompactPaneLength.ShouldBe(56d);
|
||||
|
||||
var pageHost = view.GetVisualDescendants().OfType<Border>().Single(b => b.Name == "PageHost");
|
||||
pageHost.Padding.ShouldBe(new Thickness(24));
|
||||
|
||||
var paneToggle = view.GetVisualDescendants().OfType<Button>().Single(b => b.Name == "PaneToggle");
|
||||
paneToggle.IsVisible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void The_hamburger_reappears_once_the_pane_stops_being_inline()
|
||||
{
|
||||
var (view, _, _) = ShowShell(520);
|
||||
|
||||
var paneToggle = view.GetVisualDescendants().OfType<Button>().Single(b => b.Name == "PaneToggle");
|
||||
paneToggle.IsVisible.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Labels_collapse_away_on_the_icon_rail()
|
||||
{
|
||||
var (view, _, _) = ShowShell(900);
|
||||
|
||||
var labels = view.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.Where(t => t.Classes.Contains("navLabel"))
|
||||
.ToList();
|
||||
|
||||
labels.ShouldNotBeEmpty();
|
||||
labels.ShouldAllBe(label => !label.IsVisible);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void The_theme_tokens_actually_change_with_the_variant()
|
||||
{
|
||||
var application = Application.Current.ShouldNotBeNull();
|
||||
|
||||
application.TryFindResource("AppSurfaceBrush", ThemeVariant.Light, out var light).ShouldBeTrue();
|
||||
application.TryFindResource("AppSurfaceBrush", ThemeVariant.Dark, out var dark).ShouldBeTrue();
|
||||
|
||||
// If this fails, Styles/Index.axaml was not loaded by the test app and every other style
|
||||
// assertion in this assembly is meaningless.
|
||||
dark!.ToString().ShouldNotBe(light!.ToString());
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Theme_service_maps_the_app_theme_onto_an_avalonia_variant()
|
||||
{
|
||||
ThemeService.ToVariant(AppTheme.Light).ShouldBe(ThemeVariant.Light);
|
||||
ThemeService.ToVariant(AppTheme.Dark).ShouldBe(ThemeVariant.Dark);
|
||||
ThemeService.ToVariant(AppTheme.System).ShouldBe(ThemeVariant.Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
public class StyleTests
|
||||
{
|
||||
private static T Show<T>(T control, ThemeVariant variant)
|
||||
where T : Control
|
||||
{
|
||||
Application.Current!.RequestedThemeVariant = variant;
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Width = 400,
|
||||
Height = 200,
|
||||
Content = control,
|
||||
};
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
private static Color TokenColor(string key, ThemeVariant variant)
|
||||
{
|
||||
Application.Current!.TryFindResource(key, variant, out var value).ShouldBeTrue();
|
||||
return value.ShouldBeOfType<SolidColorBrush>().Color;
|
||||
}
|
||||
|
||||
private static Color? RenderedBackground(Control control) =>
|
||||
(
|
||||
control.GetVisualDescendants().OfType<ContentPresenter>().FirstOrDefault()?.Background
|
||||
?? control.GetValue(TemplatedControl.BackgroundProperty)
|
||||
)
|
||||
is SolidColorBrush brush
|
||||
? brush.Color
|
||||
: null;
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Light")]
|
||||
[InlineData("Dark")]
|
||||
public void The_primary_button_is_filled_with_the_accent_colour(string variantName)
|
||||
{
|
||||
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
||||
var button = Show(new Button { Classes = { "primary" }, Content = "Go" }, variant);
|
||||
|
||||
// If this fails the button paints itself white-on-white and simply disappears.
|
||||
RenderedBackground(button).ShouldBe(TokenColor("AppAccentBrush", variant));
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Light")]
|
||||
[InlineData("Dark")]
|
||||
public void A_card_is_distinguishable_from_the_page_behind_it(string variantName)
|
||||
{
|
||||
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
||||
|
||||
var page = TokenColor("AppSurfaceBrush", variant);
|
||||
var card = TokenColor("AppSurfaceRaisedBrush", variant);
|
||||
var border = TokenColor("AppBorderBrush", variant);
|
||||
|
||||
// Summed across three channels, 24 is roughly a 3% per-channel step — the point at which
|
||||
// a card stops being a rectangle you have to squint for. The first light palette here
|
||||
// scored 33 and was still visually indistinguishable, so the bar is deliberately high.
|
||||
Distance(page, card).ShouldBeGreaterThan(30);
|
||||
Distance(page, border).ShouldBeGreaterThan(24);
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Light")]
|
||||
[InlineData("Dark")]
|
||||
public void The_card_style_reaches_a_bare_border(string variantName)
|
||||
{
|
||||
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
||||
var card = Show(new Border { Classes = { "card" } }, variant);
|
||||
|
||||
card.BorderThickness.ShouldBe(new Thickness(1));
|
||||
card.Padding.ShouldBe(new Thickness(16));
|
||||
|
||||
// The value a DynamicResource actually resolved to for this element's variant — not what
|
||||
// TryFindResource can dig out when asked for a variant explicitly.
|
||||
(card.Background as SolidColorBrush)?.Color.ShouldBe(TokenColor("AppSurfaceRaisedBrush", variant));
|
||||
}
|
||||
|
||||
private static int Distance(Color a, Color b) => Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Markup.Xaml.Styling;
|
||||
using AvParser.UI.HeadlessTests;
|
||||
using ReactiveUI.Avalonia;
|
||||
using Semi.Avalonia;
|
||||
|
||||
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
|
||||
// The headless platform is one dispatcher per assembly; running collections in parallel produces
|
||||
// intermittent "call from invalid thread" failures rather than useful signal.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
/// <summary>Boots a headless Avalonia application for <c>[AvaloniaFact]</c> tests.</summary>
|
||||
public static class TestAppBuilder
|
||||
{
|
||||
/// <summary>Called by Avalonia.Headless.XUnit once per test assembly.</summary>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder
|
||||
.Configure<HeadlessTestApp>()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions())
|
||||
.UseReactiveUI(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal application that loads the real styles but never touches the DI container.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not <c>AvParser.Desktop.App</c>: these tests should exercise the views and the
|
||||
/// stylesheet, not Serilog, the settings file or the composition root. Styles are added in code
|
||||
/// rather than XAML so the test project needs no Avalonia XAML compilation of its own.
|
||||
/// </remarks>
|
||||
public sealed class HeadlessTestApp : Application
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Initialize()
|
||||
{
|
||||
Styles.Add(new SemiTheme());
|
||||
|
||||
var index = new Uri("avares://AvParser.UI/Styles/Index.axaml");
|
||||
Styles.Add(new StyleInclude(index) { Source = index });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using AvParser.UI;
|
||||
using AvParser.UI.ViewModels;
|
||||
using AvParser.UI.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
public class ViewLocatorTests
|
||||
{
|
||||
private static ViewLocator Locator(Action<IServiceCollection>? configure = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
configure?.Invoke(services);
|
||||
return new ViewLocator(services.BuildServiceProvider());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_view_models_only()
|
||||
{
|
||||
var locator = Locator();
|
||||
|
||||
locator.Match(new FakePage("x")).ShouldBeTrue();
|
||||
locator.Match("a string").ShouldBeFalse();
|
||||
locator.Match(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Resolves_a_view_by_naming_convention()
|
||||
{
|
||||
var locator = Locator();
|
||||
var viewModel = new AboutViewModel(new TestPaths());
|
||||
|
||||
var view = locator.Build(viewModel);
|
||||
|
||||
view.ShouldBeOfType<AboutView>();
|
||||
view.DataContext.ShouldBeSameAs(viewModel);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Prefers_a_registered_view_over_activation()
|
||||
{
|
||||
var registered = new AboutView();
|
||||
var locator = Locator(services => services.AddSingleton(registered));
|
||||
|
||||
var view = locator.Build(new AboutViewModel(new TestPaths()));
|
||||
|
||||
view.ShouldBeSameAs(registered);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Reports_a_missing_view_instead_of_throwing()
|
||||
{
|
||||
var locator = Locator();
|
||||
|
||||
// "FakePage" matches neither half of the convention, so the locator must report a miss
|
||||
// rather than resolving the view model as its own view and failing to activate it.
|
||||
var view = locator.Build(new FakePage("x"));
|
||||
|
||||
view.ShouldBeOfType<TextBlock>().Text!.ShouldContain("View not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builds_a_placeholder_for_a_null_view_model() => Locator().Build(null).ShouldBeOfType<TextBlock>();
|
||||
|
||||
private sealed class TestPaths : Infrastructure.Storage.IAppPaths
|
||||
{
|
||||
public string DataDirectory => Path.Combine(Path.GetTempPath(), "AvParserTests");
|
||||
|
||||
public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
|
||||
|
||||
public string LogDirectory => Path.Combine(DataDirectory, "logs");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>AvParser.UI.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\AvParser.UI\AvParser.UI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ReactiveUI.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Subscribe(Action<T>) and the operator set live here; without it every test file
|
||||
needs the same using just to call .Subscribe on an IObservable. -->
|
||||
<Using Include="ReactiveUI.Primitives" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using AvParser.UI.ViewModels;
|
||||
|
||||
namespace AvParser.UI.Tests.Fakes;
|
||||
|
||||
/// <summary>A navigation destination with no behaviour, for exercising the shell and the stack.</summary>
|
||||
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
|
||||
{
|
||||
public override string Title { get; } = title;
|
||||
|
||||
public override string IconKey { get; } = iconKey;
|
||||
}
|
||||
|
||||
/// <summary>A second page type, so <c>NavigateTo<TPage>()</c> has something to discriminate on.</summary>
|
||||
internal sealed class OtherFakePage : PageViewModel
|
||||
{
|
||||
public override string Title => "Other";
|
||||
|
||||
public override string IconKey => "IconInfo";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using AvParser.Core.Settings;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
|
||||
namespace AvParser.UI.Tests.Fakes;
|
||||
|
||||
/// <summary>In-memory settings, so tests never touch the developer's real profile.</summary>
|
||||
internal sealed class FakeSettingsService(AppSettings? initial = null) : ISettingsService, IDisposable
|
||||
{
|
||||
private readonly BehaviorSignal<AppSettings> _current = new(initial ?? new AppSettings());
|
||||
|
||||
public AppSettings Current => _current.Value;
|
||||
|
||||
public IObservable<AppSettings> Changes => _current;
|
||||
|
||||
/// <summary>How many times <see cref="FlushAsync"/> was called.</summary>
|
||||
public int FlushCount { get; private set; }
|
||||
|
||||
/// <summary>Every value the settings have taken, oldest first.</summary>
|
||||
public List<AppSettings> History { get; } = [];
|
||||
|
||||
public void Update(Func<AppSettings, AppSettings> mutate)
|
||||
{
|
||||
var next = mutate(_current.Value);
|
||||
History.Add(next);
|
||||
_current.OnNext(next);
|
||||
}
|
||||
|
||||
public Task FlushAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
FlushCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _current.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Services;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
|
||||
namespace AvParser.UI.Tests.Fakes;
|
||||
|
||||
/// <summary>Theme service with no Avalonia <c>Application</c> behind it.</summary>
|
||||
internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable
|
||||
{
|
||||
private readonly BehaviorSignal<AppTheme> _current = new(initial);
|
||||
|
||||
public AppTheme Current => _current.Value;
|
||||
|
||||
public IObservable<AppTheme> Changes => _current;
|
||||
|
||||
public void Apply(AppTheme theme)
|
||||
{
|
||||
if (theme == _current.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_current.OnNext(theme);
|
||||
}
|
||||
|
||||
public void Dispose() => _current.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
using AvParser.UI.ViewModels;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public class NavigationServiceTests
|
||||
{
|
||||
private static (NavigationService Service, FakePage First, FakePage Second, OtherFakePage Third) Build()
|
||||
{
|
||||
var first = new FakePage("First");
|
||||
var second = new FakePage("Second");
|
||||
var third = new OtherFakePage();
|
||||
return (new NavigationService([first, second, third]), first, second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Starts_on_the_first_registered_page()
|
||||
{
|
||||
var (service, first, _, _) = Build();
|
||||
|
||||
service.Current.ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_an_empty_page_set() =>
|
||||
Should.Throw<ArgumentException>(() => new NavigationService(Array.Empty<PageViewModel>()));
|
||||
|
||||
[Fact]
|
||||
public void Navigating_pushes_the_previous_page()
|
||||
{
|
||||
var (service, first, second, _) = Build();
|
||||
|
||||
service.NavigateTo(second);
|
||||
|
||||
service.Current.ShouldBeSameAs(second);
|
||||
service.GoBack();
|
||||
service.Current.ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigating_to_the_current_page_is_a_no_op()
|
||||
{
|
||||
var (service, first, _, _) = Build();
|
||||
|
||||
service.NavigateTo(first);
|
||||
service.GoBack();
|
||||
|
||||
// Nothing was pushed, so GoBack had nothing to pop.
|
||||
service.Current.ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoBack_on_an_empty_stack_does_nothing()
|
||||
{
|
||||
var (service, first, _, _) = Build();
|
||||
|
||||
service.GoBack();
|
||||
|
||||
service.Current.ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanGoBack_tracks_the_stack()
|
||||
{
|
||||
var (service, _, second, _) = Build();
|
||||
var observed = new List<bool>();
|
||||
using var subscription = service.CanGoBack.Subscribe(observed.Add);
|
||||
|
||||
service.NavigateTo(second);
|
||||
service.GoBack();
|
||||
|
||||
observed.ShouldBe([false, true, false]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigates_by_type()
|
||||
{
|
||||
var (service, _, _, third) = Build();
|
||||
|
||||
service.NavigateTo<OtherFakePage>();
|
||||
|
||||
service.Current.ShouldBeSameAs(third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_when_navigating_to_an_unregistered_type()
|
||||
{
|
||||
var service = new NavigationService([new FakePage("Only")]);
|
||||
|
||||
Should
|
||||
.Throw<InvalidOperationException>(service.NavigateTo<OtherFakePage>)
|
||||
.Message.ShouldContain("OtherFakePage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentChanges_replays_the_present_value()
|
||||
{
|
||||
var (service, first, second, _) = Build();
|
||||
service.NavigateTo(second);
|
||||
|
||||
PageViewModel? seen = null;
|
||||
using var subscription = service.CurrentChanges.Subscribe(page =>
|
||||
{
|
||||
seen ??= page;
|
||||
});
|
||||
|
||||
seen.ShouldBeSameAs(second);
|
||||
seen.ShouldNotBeSameAs(first);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ReactiveUI.Builder;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
/// <summary>Initialises ReactiveUI once for the whole test assembly.</summary>
|
||||
/// <remarks>
|
||||
/// ReactiveUI 24 no longer self-initialises: <c>WhenAnyValue</c> throws
|
||||
/// <see cref="InvalidOperationException"/> until the builder has run. In the app that happens
|
||||
/// inside <c>AppBuilder.UseReactiveUI()</c>; here there is no Avalonia app, so a module
|
||||
/// initialiser does it before any test constructs a view model.
|
||||
/// </remarks>
|
||||
internal static class ReactiveUiBootstrap
|
||||
{
|
||||
[ModuleInitializer]
|
||||
internal static void Initialize() =>
|
||||
RxAppBuilder
|
||||
.CreateReactiveUIBuilder()
|
||||
// No dispatcher exists in these tests, so anything ReactiveUI marshals internally
|
||||
// must run inline rather than being queued onto a thread that never pumps.
|
||||
.WithMainThreadScheduler(ImmediateSequencer.Instance)
|
||||
.WithTaskPoolScheduler(ImmediateSequencer.Instance)
|
||||
.WithCoreServices()
|
||||
.BuildApp();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Avalonia.Controls;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Responsive;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
using AvParser.UI.ViewModels;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public class ShellViewModelTests
|
||||
{
|
||||
private static (ShellViewModel Shell, NavigationService Navigation, FakeThemeService Theme) Build(
|
||||
AppTheme theme = AppTheme.System
|
||||
)
|
||||
{
|
||||
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second"), new OtherFakePage()]);
|
||||
var themeService = new FakeThemeService(theme);
|
||||
|
||||
// ImmediateSequencer makes every derived property settle before the next line runs,
|
||||
// which is what lets these read as plain synchronous assertions.
|
||||
return (new ShellViewModel(navigation, themeService, ImmediateSequencer.Instance), navigation, themeService);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Breakpoint.Expanded, SplitViewDisplayMode.Inline)]
|
||||
[InlineData(Breakpoint.Medium, SplitViewDisplayMode.CompactInline)]
|
||||
[InlineData(Breakpoint.Compact, SplitViewDisplayMode.Overlay)]
|
||||
public void Breakpoint_selects_the_pane_display_mode(Breakpoint breakpoint, SplitViewDisplayMode expected)
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
|
||||
shell.Breakpoint = breakpoint;
|
||||
|
||||
shell.PaneDisplayMode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Breakpoint.Expanded, true)]
|
||||
[InlineData(Breakpoint.Medium, false)]
|
||||
[InlineData(Breakpoint.Compact, false)]
|
||||
public void Crossing_a_breakpoint_resets_the_pane(Breakpoint breakpoint, bool expectedOpen)
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
|
||||
shell.Breakpoint = breakpoint;
|
||||
|
||||
shell.IsPaneOpen.ShouldBe(expectedOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_manual_toggle_survives_until_the_next_breakpoint_change()
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
shell.Breakpoint = Breakpoint.Compact;
|
||||
|
||||
shell.TogglePaneCommand.Execute().Subscribe(_ => { });
|
||||
shell.IsPaneOpen.ShouldBeTrue();
|
||||
|
||||
shell.Breakpoint = Breakpoint.Expanded;
|
||||
shell.IsPaneOpen.ShouldBeTrue();
|
||||
|
||||
shell.Breakpoint = Breakpoint.Compact;
|
||||
shell.IsPaneOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_a_destination_dismisses_the_compact_drawer()
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
shell.Breakpoint = Breakpoint.Compact;
|
||||
shell.TogglePaneCommand.Execute().Subscribe(_ => { });
|
||||
shell.IsPaneOpen.ShouldBeTrue();
|
||||
|
||||
shell.SelectedPage = shell.Pages[1];
|
||||
|
||||
shell.IsPaneOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_a_destination_leaves_the_expanded_sidebar_open()
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
shell.Breakpoint = Breakpoint.Expanded;
|
||||
|
||||
shell.SelectedPage = shell.Pages[1];
|
||||
|
||||
shell.IsPaneOpen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_page_navigates_to_it()
|
||||
{
|
||||
var (shell, navigation, _) = Build();
|
||||
|
||||
shell.SelectedPage = shell.Pages[2];
|
||||
|
||||
navigation.Current.ShouldBeSameAs(shell.Pages[2]);
|
||||
shell.CurrentPage.ShouldBeSameAs(shell.Pages[2]);
|
||||
shell.Title.ShouldBe("Other");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigating_from_elsewhere_updates_the_rail_selection()
|
||||
{
|
||||
var (shell, navigation, _) = Build();
|
||||
|
||||
navigation.NavigateTo<OtherFakePage>();
|
||||
|
||||
shell.SelectedPage.ShouldBeSameAs(navigation.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Back_is_disabled_until_something_is_on_the_stack()
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
|
||||
shell.CanGoBack.ShouldBeFalse();
|
||||
|
||||
shell.SelectedPage = shell.Pages[1];
|
||||
|
||||
shell.CanGoBack.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Back_returns_to_the_previous_page()
|
||||
{
|
||||
var (shell, _, _) = Build();
|
||||
var first = shell.Pages[0];
|
||||
shell.SelectedPage = shell.Pages[1];
|
||||
|
||||
shell.GoBackCommand.Execute().Subscribe(_ => { });
|
||||
|
||||
shell.CurrentPage.ShouldBeSameAs(first);
|
||||
shell.SelectedPage.ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Dark, AppTheme.Light)]
|
||||
[InlineData(AppTheme.Light, AppTheme.Dark)]
|
||||
[InlineData(AppTheme.System, AppTheme.Dark)]
|
||||
public void Toggling_the_theme_flips_between_light_and_dark(AppTheme start, AppTheme expected)
|
||||
{
|
||||
var (shell, _, theme) = Build(start);
|
||||
|
||||
shell.ToggleThemeCommand.Execute().Subscribe(_ => { });
|
||||
|
||||
theme.Current.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_theme_button_shows_the_theme_it_would_switch_to()
|
||||
{
|
||||
var (shell, _, theme) = Build(AppTheme.Light);
|
||||
|
||||
shell.ThemeIconKey.ShouldBe("IconMoon");
|
||||
|
||||
theme.Apply(AppTheme.Dark);
|
||||
|
||||
shell.ThemeIconKey.ShouldBe("IconSun");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project>
|
||||
<!-- MSBuild stops at the nearest Directory.Build.props, so the repo-root one must be
|
||||
imported explicitly or the test projects would lose the shared settings entirely. -->
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- xUnit v3 test projects are self-executing. -->
|
||||
<OutputType>Exe</OutputType>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<!-- CA1707: Test_names_read_better_with_underscores.
|
||||
CA2007: ConfigureAwait is noise in tests.
|
||||
CA1861: inline arrays as test data are the point.
|
||||
CA1859: tests deliberately hold interface types to exercise default interface members. -->
|
||||
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1859</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user