Files
av-parser/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs
T
Leonid Pershin eb5061ee23 Refactor media source handling and update collection options
- Updated `IMediaSourceCatalog` to support user-added media sources, allowing dynamic editing and management of sources.
- Removed the `UrlListSource` class as its functionality is now integrated into the new catalog structure.
- Enhanced `CollectOptions` to default `RequireProxy` to true, ensuring stricter handling of proxy requirements.
- Improved error handling in `ParseError` to include a `Subject` field for better context on failures.
- Adjusted dependency injection to reflect changes in media source management, removing old source registrations.
- Introduced background proxy checks to ensure a more robust proxy pool management during collection processes.

These changes streamline the media collection process and improve the overall user experience by providing clearer error reporting and more flexible source management.
2026-08-15 14:20:06 +03:00

253 lines
8.4 KiB
C#

using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
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;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public class CollectViewTests
{
private sealed class FakeUserSourceStore(IEnumerable<PatternSourceConfig>? seed = null) : IUserSourceStore
{
private readonly List<PatternSourceConfig> _configs = seed?.ToList() ?? [];
public event EventHandler? Changed;
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
public Task<PatternSourceConfig> AddAsync(
PatternSourceConfig config,
CancellationToken cancellationToken = default
)
{
_configs.RemoveAll(c => c.Id == config.Id);
_configs.Add(config);
Changed?.Invoke(this, EventArgs.Empty);
return Task.FromResult(config);
}
public Task<bool> UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default)
{
var index = _configs.FindIndex(c => c.Id == config.Id);
if (index < 0)
{
return Task.FromResult(false);
}
_configs[index] = config;
Changed?.Invoke(this, EventArgs.Empty);
return Task.FromResult(true);
}
public Task<bool> RemoveAsync(string id, CancellationToken cancellationToken = default)
{
var removed = _configs.RemoveAll(c => c.Id == id) > 0;
if (removed)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return Task.FromResult(removed);
}
}
private sealed class IdleRunner : ICollectRunner
{
public async IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
IMediaSource source,
MediaQuery query,
CollectOptions options,
IProgress<ParseProgress>? progress,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private static PatternSourceConfig Config(string id = "s1", bool allowDirect = false)
{
PatternSourceConfig.TryCreate(
"Test",
"https://imgtest.example/test1/",
6,
8,
IdAlphabet.Alphanumeric,
null,
".jpg",
allowDirect,
out var config,
id
);
return config!;
}
private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(
bool allowDirect,
bool withSource = true
)
{
// Whether a run may go without a proxy is the source's setting now, not the app's.
var store = new FakeUserSourceStore(withSource ? [Config(allowDirect: allowDirect)] : []);
var viewModel = new CollectViewModel(
new MediaSourceCatalog(store),
new FakeSettingsService(new AppSettings { LastSourceId = "s1" }),
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new IdleRunner(),
new FakeMediaStore(),
new FakeThumbnailCache(),
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(allowDirect: true);
view.GetVisualDescendants().OfType<ListBox>().ShouldNotBeEmpty();
}
[AvaloniaFact]
public void No_banner_is_shown_when_direct_connections_are_allowed()
{
var (view, viewModel, _) = ShowPage(allowDirect: true);
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
Banner(view).IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void A_blocked_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(allowDirect: false);
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(allowDirect: false);
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(allowDirect: false);
Dispatcher.UIThread.RunJobs();
var run = view.GetVisualDescendants()
.OfType<Button>()
.First(candidate => ReferenceEquals(candidate.Command, viewModel.CollectCommand));
run.IsEffectivelyEnabled.ShouldBeFalse();
}
[AvaloniaFact]
public void With_no_sources_the_empty_state_is_shown()
{
var (view, _, _) = ShowPage(allowDirect: true, withSource: false);
Dispatcher.UIThread.RunJobs();
var empty = view.FindControl<Border>("EmptyState").ShouldNotBeNull();
empty.IsEffectivelyVisible.ShouldBeTrue();
}
[AvaloniaFact]
public void The_log_panel_appears_as_soon_as_there_is_something_to_show()
{
// Rendered rather than asserted on the view model: a log nobody can see is the whole failure
// this guards, and an IsVisible binding that never fires breaks no view-model test.
var (view, viewModel, _) = ShowPage(allowDirect: true);
var panel = view.FindControl<Border>("LogPanel").ShouldNotBeNull();
panel.IsVisible.ShouldBeFalse();
viewModel.Log.Add(CollectLogEntryViewModel.Message(CollectLogLevel.Info, "Collect.Log.SourceStarted", "Test"));
Dispatcher.UIThread.RunJobs();
panel.IsEffectivelyVisible.ShouldBeTrue();
view.FindControl<ListBox>("CollectLog").ShouldNotBeNull().ItemCount.ShouldBe(1);
// A log that grows without bound pushes the collected list off the page.
panel.Bounds.Height.ShouldBeLessThanOrEqualTo(220);
// Lines have to be selectable in bulk and copyable, which is the point of a log you can
// paste into a bug report.
var list = view.FindControl<ListBox>("CollectLog").ShouldNotBeNull();
list.SelectionMode.HasFlag(SelectionMode.Multiple).ShouldBeTrue();
list.ContextMenu.ShouldNotBeNull().Items.Count.ShouldBe(2);
}
[AvaloniaFact]
public void Every_source_is_offered_with_a_tick_box()
{
var (view, viewModel, _) = ShowPage(allowDirect: true);
var list = view.FindControl<ListBox>("SourceList").ShouldNotBeNull();
list.ItemCount.ShouldBe(1);
viewModel.RunnableSources().ShouldHaveSingleItem();
view.GetVisualDescendants().OfType<CheckBox>().ShouldContain(box => box.IsChecked == true);
// The picker sits in the toolbar: it scrolls, it does not grow with the catalog.
list.Bounds.Height.ShouldBeLessThanOrEqualTo(120);
}
[AvaloniaFact]
public void Adding_a_source_reveals_the_editor()
{
var (view, viewModel, _) = ShowPage(allowDirect: true, withSource: false);
viewModel.AddSourceCommand.Execute().Subscribe();
Dispatcher.UIThread.RunJobs();
var editor = view.FindControl<Border>("SourceEditor").ShouldNotBeNull();
editor.IsEffectivelyVisible.ShouldBeTrue();
}
}