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.Instance, ImmediateSequencer.Instance ), settingsService ); } /// A container that resolves nothing; the page only reaches for navigation on click. private sealed class EmptyServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; } /// Stands in for the kind of parser the proxy gate exists for. 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> ParseAsync( string input, IProgress? progress, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken ) { await Task.Yield(); yield break; } } private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask(); /// /// Waits until a command's gate opens. /// /// /// Execute() completing and IsExecuting 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. /// private static Task WhenExecutable(ReactiveUI.ReactiveCommand 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); } }