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:
co-authored by
Claude Opus 5
parent
fe62bcf53f
commit
f8744c930a
@@ -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}"));
|
||||
}
|
||||
@@ -56,7 +56,6 @@ public sealed class JsonSettingsServiceTests : IDisposable
|
||||
"""
|
||||
{
|
||||
"theme": "System",
|
||||
"lastParserId": "delimited",
|
||||
"windowWidth": 1280,
|
||||
"windowHeight": 800,
|
||||
"windowMaximized": false,
|
||||
@@ -76,7 +75,6 @@ public sealed class JsonSettingsServiceTests : IDisposable
|
||||
settings.Language.ShouldBe(AppLanguage.System);
|
||||
|
||||
// And the values the file did carry must survive.
|
||||
settings.LastParserId.ShouldBe("delimited");
|
||||
settings.MinimumLogLevel.ShouldBe("Information");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class LocalizationViewTests : IDisposable
|
||||
|
||||
private static (ShellView View, ShellViewModel ViewModel) ShowShell()
|
||||
{
|
||||
var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Parse")]);
|
||||
var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Collect")]);
|
||||
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
|
||||
var view = new ShellView { DataContext = viewModel };
|
||||
|
||||
@@ -49,14 +49,14 @@ public sealed class LocalizationViewTests : IDisposable
|
||||
var (view, _) = ShowShell();
|
||||
|
||||
var english = RailLabels(view);
|
||||
english.ShouldBe(["Dashboard", "Parse"]);
|
||||
english.ShouldBe(["Dashboard", "Collect"]);
|
||||
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
// The same controls, not a rebuilt tree: this is the whole point of binding through the
|
||||
// localizer's indexer rather than resolving strings once at load.
|
||||
RailLabels(view).ShouldBe(["Обзор", "Разбор"]);
|
||||
RailLabels(view).ShouldBe(["Обзор", "Сбор"]);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Parsing.Samples;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.ViewModels;
|
||||
using AvParser.UI.Views;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
public class ParseViewTests
|
||||
{
|
||||
private static (ParseView View, ParseViewModel ViewModel, Window Window) ShowPage(params ITextParser[] extra)
|
||||
{
|
||||
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extra]);
|
||||
var lastParser = extra.Length > 0 ? extra[0].Id : null;
|
||||
|
||||
var viewModel = new ParseViewModel(
|
||||
catalog,
|
||||
new FakeSettingsService(new AppSettings { LastParserId = lastParser }),
|
||||
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<ParseViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
);
|
||||
|
||||
var view = new ParseView { DataContext = viewModel };
|
||||
var window = new Window
|
||||
{
|
||||
Width = 1400,
|
||||
Height = 900,
|
||||
Content = view,
|
||||
};
|
||||
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
return (view, viewModel, window);
|
||||
}
|
||||
|
||||
private static Border Banner(ParseView view) => view.FindControl<Border>("ProxyGateBanner").ShouldNotBeNull();
|
||||
|
||||
[AvaloniaFact]
|
||||
public void No_banner_is_shown_for_a_parser_that_needs_no_network()
|
||||
{
|
||||
var (view, viewModel, _) = ShowPage();
|
||||
|
||||
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
Banner(view).IsVisible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void A_blocked_network_parser_puts_the_banner_on_screen()
|
||||
{
|
||||
// Rendered rather than asserted on the view model: an IsVisible binding that never fires
|
||||
// leaves the page silently unhelpful, which is exactly the failure this guards.
|
||||
var (view, viewModel, _) = ShowPage(new NetworkParser());
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
viewModel.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
Banner(view).IsEffectivelyVisible.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void The_banner_offers_a_way_to_the_proxies_page()
|
||||
{
|
||||
var (view, viewModel, _) = ShowPage(new NetworkParser());
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var button = Banner(view).GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
|
||||
|
||||
button.Command.ShouldBeSameAs(viewModel.GoToProxiesCommand);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void A_blocked_page_will_not_run_the_parser()
|
||||
{
|
||||
var (view, viewModel, _) = ShowPage(new NetworkParser());
|
||||
viewModel.InputText = "anything";
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var run = view.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(candidate => ReferenceEquals(candidate.Command, viewModel.ParseCommand));
|
||||
|
||||
run.IsEffectivelyEnabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed class EmptyServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user