Add the Collect page

The collector becomes usable. The page is a close copy of the parse page's
shape - same proxy gate, same batched flush to the observable collection, same
truncation cap that reports rather than truncates silently - because that shape
was built for streaming outcomes and the collector produces exactly those.

Two differences that are not cosmetic. The batch drops from 512 to 64: items
arrive at network speed, roughly one a second, and a batch of five hundred would
mean the list never visibly moved. And progress is explicitly indeterminate
until a source finishes listing, because a paginated listing genuinely does not
know its total until the last page - a bar pretending otherwise would be lying.

The input swaps shape with the source: an endpoint source wants one address, a
pasted-list source wants many lines. The proxy gate is unchanged in substance -
only network sources are gated, and the banner keeps its x:Name because the
headless tests find it that way.

Rendering the page caught a defect the tests could not: IsVisible sat on the
caption inside the header border rather than on the border, so hiding the text
left its padding and divider behind as an empty bar above the input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 21:39:21 +03:00
co-authored by Claude Opus 5
parent 6909884851
commit 70fb3a1df3
12 changed files with 1774 additions and 1 deletions
@@ -0,0 +1,171 @@
using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Collecting;
using AvParser.Core.Parsing;
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 CollectViewTests
{
private sealed class StubSource(string id, string name, bool network) : IMediaSource
{
public string Id => id;
public string DisplayName => name;
public string Description => "A source";
public bool RequiresNetwork => network;
public bool CanParse(MediaQuery input) => true;
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
MediaQuery input,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
private sealed class IdleRunner : ICollectRunner
{
public async IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
IMediaSource source,
MediaQuery query,
CollectOptions options,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(bool networkSource)
{
IMediaSource[] sources = networkSource
? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)]
: [new StubSource("url-list", "URL list", false)];
var viewModel = new CollectViewModel(
new MediaSourceCatalog(sources, networkSource ? "own-service" : "url-list"),
new FakeSettingsService(new AppSettings { LastSourceId = networkSource ? "own-service" : "url-list" }),
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new IdleRunner(),
new EmptyServiceProvider(),
NullLogger<CollectViewModel>.Instance,
ImmediateSequencer.Instance
);
var view = new CollectView { 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(CollectView view) => view.FindControl<Border>("ProxyGateBanner").ShouldNotBeNull();
[AvaloniaFact]
public void The_page_renders()
{
var (view, _, _) = ShowPage(networkSource: false);
view.GetVisualDescendants().OfType<ListBox>().ShouldNotBeEmpty();
}
[AvaloniaFact]
public void No_banner_is_shown_for_a_source_that_needs_no_network()
{
var (view, viewModel, _) = ShowPage(networkSource: false);
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
Banner(view).IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void A_blocked_network_source_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(networkSource: true);
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(networkSource: true);
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_collector()
{
var (view, viewModel, _) = ShowPage(networkSource: true);
viewModel.EndpointText = "https://own.test/api/list";
Dispatcher.UIThread.RunJobs();
var run = view.GetVisualDescendants()
.OfType<Button>()
.First(candidate => ReferenceEquals(candidate.Command, viewModel.CollectCommand));
run.IsEffectivelyEnabled.ShouldBeFalse();
}
[AvaloniaFact]
public void An_endpoint_source_shows_an_address_box_rather_than_a_paste_box()
{
var (view, _, _) = ShowPage(networkSource: true);
Dispatcher.UIThread.RunJobs();
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
boxes.ShouldHaveSingleItem();
boxes[0].PlaceholderText.ShouldNotBeNull().ShouldContain("api/list");
}
[AvaloniaFact]
public void A_pasted_list_source_shows_the_paste_box()
{
var (view, _, _) = ShowPage(networkSource: false);
Dispatcher.UIThread.RunJobs();
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
boxes.ShouldHaveSingleItem();
boxes[0].AcceptsReturn.ShouldBeTrue();
}
}