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? seed = null) : IUserSourceStore { private readonly List _configs = seed?.ToList() ?? []; public event EventHandler? Changed; public IReadOnlyList List() => [.. _configs]; public Task 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 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 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> RunAsync( IMediaSource source, MediaQuery query, CollectOptions options, IProgress? 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.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("ProxyGateBanner").ShouldNotBeNull(); [AvaloniaFact] public void The_page_renders() { var (view, _, _) = ShowPage(allowDirect: true); view.GetVisualDescendants().OfType().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