Files
av-parser/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs
T
Leonid PershinandClaude Opus 5 fe62bcf53f Add collector settings and per-source purge
Every limit the fetcher was using was a constant. They are settings now, and
CollectOptions became the single place policy lives: AppSettings.ToCollectOptions
clamps them, and the HTTP layer's FetchOptions is projected from that. One
clamping site rather than two sets of ceilings drifting apart.

Clamping rather than validating, for the reason the proxy options already do it:
a hand-edited file must not stop the app from starting. A MaxItemBytes edited to
zero would otherwise refuse everything, and a zeroed concurrency would deadlock
the run outright - so both are pulled into range instead. An empty format filter
is read as "everything", because switching every format off is far more likely
to be a slip than an instruction to collect nothing.

The media root has an ordering problem - it is a setting that decides the paths
the container is built from - so the file is read once before the container
exists rather than making every path lazy for one value.

Purge is scoped to a source and lives on the Collect page, where the source is
already chosen. Content another source also holds survives, which is what the
index's reference count was for.

The showcase hint says out loud what a hard link means: editing the browsable
copy edits the original, and deleting it frees nothing until the last name goes.
That is surprising enough to belong in the UI rather than only in the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:52:43 +03:00

173 lines
5.6 KiB
C#

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 FakeMediaStore(),
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();
}
}