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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Collecting.Sources;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
@@ -16,9 +17,12 @@ public class CollectViewModelTests
|
||||
/// <summary>A runner that returns a canned stream instead of touching a network.</summary>
|
||||
private sealed class FakeRunner : ICollectRunner
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public List<ParseOutcome<CollectedItem>> Results { get; } = [];
|
||||
|
||||
public int Runs { get; private set; }
|
||||
/// <summary>Ids the runner was asked to run, one entry per call.</summary>
|
||||
public List<string> RunSourceIds { get; } = [];
|
||||
|
||||
public CollectOptions? LastOptions { get; private set; }
|
||||
|
||||
@@ -34,7 +38,11 @@ public class CollectViewModelTests
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
Runs++;
|
||||
lock (_gate)
|
||||
{
|
||||
RunSourceIds.Add(source.Id);
|
||||
}
|
||||
|
||||
LastOptions = options;
|
||||
LastQuery = query;
|
||||
|
||||
@@ -54,26 +62,48 @@ public class CollectViewModelTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSource(string id, string name, bool network) : IMediaSource
|
||||
/// <summary>In-memory source list standing in for the persisted store.</summary>
|
||||
private sealed class FakeUserSourceStore(IEnumerable<PatternSourceConfig>? seed = null) : IUserSourceStore
|
||||
{
|
||||
public string Id => id;
|
||||
private readonly List<PatternSourceConfig> _configs = seed?.ToList() ?? [];
|
||||
|
||||
public string DisplayName => name;
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public string Description => string.Empty;
|
||||
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
|
||||
|
||||
public bool RequiresNetwork => network;
|
||||
|
||||
public bool CanParse(MediaQuery input) => true;
|
||||
|
||||
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
|
||||
MediaQuery input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
public Task<PatternSourceConfig> AddAsync(
|
||||
PatternSourceConfig config,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +112,36 @@ public class CollectViewModelTests
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source that may run without a proxy, which is what most of these tests need: the gate is a
|
||||
/// per-source setting now, so a config that requires one would block every run.
|
||||
/// </summary>
|
||||
private static PatternSourceConfig Config(
|
||||
string id,
|
||||
string name = "Test",
|
||||
string url = "https://imgtest.example/test1/",
|
||||
bool allowDirect = true
|
||||
)
|
||||
{
|
||||
PatternSourceConfig.TryCreate(
|
||||
name,
|
||||
url,
|
||||
6,
|
||||
8,
|
||||
IdAlphabet.Alphanumeric,
|
||||
null,
|
||||
".jpg",
|
||||
allowDirect,
|
||||
out var config,
|
||||
id
|
||||
);
|
||||
|
||||
return config!;
|
||||
}
|
||||
|
||||
private static CollectedItem Item(string url, CollectStatus status, long length = 4096) =>
|
||||
new(
|
||||
new MediaCandidate(new Uri(url)) { SourceId = "url-list", Ordinal = 1 },
|
||||
new MediaCandidate(new Uri(url)) { SourceId = "s1", Ordinal = 1 },
|
||||
MediaBlob.Create(new string('a', 64), MediaKind.Png, length),
|
||||
status
|
||||
);
|
||||
@@ -92,24 +149,20 @@ public class CollectViewModelTests
|
||||
private static (CollectViewModel Page, FakeRunner Runner, FakeSettingsService Settings) Build(
|
||||
AppSettings? settings = null,
|
||||
IProxyPool? proxyPool = null,
|
||||
bool includeNetworkSource = false
|
||||
IEnumerable<PatternSourceConfig>? configs = null
|
||||
)
|
||||
{
|
||||
IMediaSource[] sources = includeNetworkSource
|
||||
? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)]
|
||||
: [new StubSource("url-list", "URL list", false)];
|
||||
|
||||
var catalog = new MediaSourceCatalog(sources, "url-list");
|
||||
var settingsService = new FakeSettingsService(settings);
|
||||
var store = new FakeUserSourceStore(configs ?? [Config("s1")]);
|
||||
var catalog = new MediaSourceCatalog(store);
|
||||
var settingsService = new FakeSettingsService(settings ?? new AppSettings());
|
||||
var runner = new FakeRunner();
|
||||
var store = new FakeMediaStore();
|
||||
|
||||
var page = new CollectViewModel(
|
||||
catalog,
|
||||
settingsService,
|
||||
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
runner,
|
||||
store,
|
||||
new FakeMediaStore(),
|
||||
new FakeThumbnailCache(),
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<CollectViewModel>.Instance,
|
||||
@@ -122,79 +175,142 @@ public class CollectViewModelTests
|
||||
private static Task RunAsync(CollectViewModel page) => page.CollectCommand.Execute().ToTask();
|
||||
|
||||
[Fact]
|
||||
public void The_page_opens_on_the_source_that_needs_no_proxy()
|
||||
public void An_empty_catalog_selects_nothing_and_offers_to_add_one()
|
||||
{
|
||||
// Otherwise the app lands behind the gate before the user has asked for anything.
|
||||
var (page, _, _) = Build(includeNetworkSource: true);
|
||||
var (page, _, _) = Build(configs: []);
|
||||
|
||||
page.SelectedSource.Id.ShouldBe("url-list");
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
page.SelectedSource.ShouldBeNull();
|
||||
page.HasSources.ShouldBeFalse();
|
||||
|
||||
var canExecute = true;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
canExecute.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_last_used_source_is_restored()
|
||||
{
|
||||
var (page, _, _) = Build(
|
||||
new AppSettings { LastSourceId = "own-service", AllowDirectConnection = true },
|
||||
includeNetworkSource: true
|
||||
new AppSettings { LastSourceId = "s2" },
|
||||
configs: [Config("s1", "Alpha"), Config("s2", "Beta")]
|
||||
);
|
||||
|
||||
page.SelectedSource.Id.ShouldBe("own-service");
|
||||
page.SelectedSource.ShouldNotBeNull().Id.ShouldBe("s2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_remembered_source_falls_back_instead_of_throwing()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "removed-in-a-past-version" });
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "gone" });
|
||||
|
||||
page.SelectedSource.Id.ShouldBe("url-list");
|
||||
page.SelectedSource.ShouldNotBeNull().Id.ShouldBe("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_a_source_remembers_it()
|
||||
{
|
||||
var (page, _, settings) = Build(new AppSettings { AllowDirectConnection = true }, includeNetworkSource: true);
|
||||
var (page, _, settings) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]);
|
||||
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "own-service");
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "s2");
|
||||
|
||||
settings.Current.LastSourceId.ShouldBe("own-service");
|
||||
settings.Current.LastSourceId.ShouldBe("s2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collecting_needs_something_to_collect()
|
||||
public void Collecting_needs_at_least_one_ticked_source()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
var canExecute = true;
|
||||
var canExecute = false;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
|
||||
canExecute.ShouldBeFalse();
|
||||
|
||||
page.InputText = "https://example.test/a.png";
|
||||
canExecute.ShouldBeTrue();
|
||||
|
||||
page.InputText = " ";
|
||||
page.Sources.Single().IsSelected = false;
|
||||
canExecute.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_endpoint_source_wants_an_address_not_pasted_text()
|
||||
public void An_unlimited_budget_does_not_block_the_button()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { AllowDirectConnection = true }, includeNetworkSource: true);
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "own-service");
|
||||
|
||||
var canExecute = true;
|
||||
// Zero used to mean "nothing to do"; it now means "until stopped", which is a running state,
|
||||
// not a disabled one.
|
||||
var (page, _, _) = Build();
|
||||
var canExecute = false;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
|
||||
page.InputText = "https://example.test/a.png";
|
||||
canExecute.ShouldBeFalse();
|
||||
page.AttemptBudget = 0;
|
||||
page.TargetCount = 0;
|
||||
|
||||
page.EndpointText = "not an address";
|
||||
canExecute.ShouldBeFalse();
|
||||
|
||||
page.EndpointText = "https://own.test/api/list";
|
||||
canExecute.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unlimited_budget_reaches_the_source_as_no_limit()
|
||||
{
|
||||
var (page, runner, settings) = Build();
|
||||
page.AttemptBudget = 0;
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
runner.LastQuery!.HasLimit.ShouldBeFalse();
|
||||
settings.Current.CollectAttemptBudget.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_ticked_source_runs_in_the_same_collection()
|
||||
{
|
||||
var (page, runner, _) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]);
|
||||
foreach (var source in page.Sources)
|
||||
{
|
||||
source.IsSelected = true;
|
||||
}
|
||||
|
||||
runner.Results.Add(ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)));
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
runner.RunSourceIds.Order().ShouldBe(["s1", "s2"]);
|
||||
page.Items.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ticked_set_is_remembered()
|
||||
{
|
||||
var (page, _, settings) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]);
|
||||
|
||||
page.Sources.Single(source => source.Id == "s2").IsSelected = true;
|
||||
|
||||
AppSettings.SplitSourceIds(settings.Current.CollectSourceIds).Order().ShouldBe(["s1", "s2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_remembered_ticked_set_is_restored()
|
||||
{
|
||||
var (page, _, _) = Build(
|
||||
new AppSettings { CollectSourceIds = "s2" },
|
||||
configs: [Config("s1", "Alpha"), Config("s2", "Beta")]
|
||||
);
|
||||
|
||||
page.RunnableSources().ShouldHaveSingleItem().Id.ShouldBe("s2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_log_records_what_happened_as_it_happens()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
runner.Results.AddRange([
|
||||
ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
|
||||
ParseOutcome<CollectedItem>.Failure(ParseError.Create(2, "TooLarge", "too big")),
|
||||
]);
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
page.ErrorCount.ShouldBe(1);
|
||||
page.Log.Select(entry => entry.Text).ShouldContain(text => text.Contains("https://a.test/1.png"));
|
||||
page.Log.Select(entry => entry.Text).ShouldContain("Larger than the size limit.");
|
||||
page.Log[^1].Text.ShouldStartWith("Run finished.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Results_land_in_the_list_and_the_summary_counts_them()
|
||||
{
|
||||
@@ -205,11 +321,10 @@ public class CollectViewModelTests
|
||||
ParseOutcome<CollectedItem>.Success(Item("https://a.test/3.png", CollectStatus.Skipped)),
|
||||
]);
|
||||
|
||||
page.InputText = "https://a.test/1.png";
|
||||
await RunAsync(page);
|
||||
|
||||
page.Items.Count.ShouldBe(3);
|
||||
page.Errors.ShouldBeEmpty();
|
||||
page.ErrorCount.ShouldBe(0);
|
||||
var summary = page.StatusMessage.ShouldNotBeNull();
|
||||
summary.ShouldContain("1 image");
|
||||
summary.ShouldContain("already held");
|
||||
@@ -225,11 +340,10 @@ public class CollectViewModelTests
|
||||
ParseOutcome<CollectedItem>.Failure(ParseError.Create(2, "TooLarge", "too big")),
|
||||
]);
|
||||
|
||||
page.InputText = "https://a.test/1.png";
|
||||
await RunAsync(page);
|
||||
|
||||
page.Items.ShouldHaveSingleItem();
|
||||
page.Errors.ShouldHaveSingleItem().Text.ShouldBe("Larger than the size limit.");
|
||||
page.Log.Single(entry => entry.IsError).Text.ShouldBe("Larger than the size limit.");
|
||||
page.StatusMessage!.ShouldContain("1 error");
|
||||
}
|
||||
|
||||
@@ -237,7 +351,6 @@ public class CollectViewModelTests
|
||||
public async Task The_force_refetch_switch_reaches_the_runner()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
page.InputText = "https://a.test/1.png";
|
||||
page.ForceRefetch = true;
|
||||
|
||||
await RunAsync(page);
|
||||
@@ -246,15 +359,33 @@ public class CollectViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_pasted_text_reaches_the_query()
|
||||
public async Task The_attempt_budget_becomes_the_query_limit()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
page.InputText = "https://a.test/1.png\nhttps://a.test/2.png";
|
||||
page.AttemptBudget = 250;
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
runner.LastQuery!.Text.ShouldContain("2.png");
|
||||
runner.LastQuery.Endpoint.ShouldBeNull();
|
||||
runner.LastQuery!.Limit.ShouldBe(250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Collecting_stops_once_the_target_is_reached()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
runner.Results.Add(
|
||||
ParseOutcome<CollectedItem>.Success(Item($"https://a.test/{i}.png", CollectStatus.Stored))
|
||||
);
|
||||
}
|
||||
|
||||
page.TargetCount = 2;
|
||||
await RunAsync(page);
|
||||
|
||||
page.Items.Count.ShouldBe(2);
|
||||
// Reaching the target is a clean finish, not a stop: the summary must not read "Stopped".
|
||||
page.StatusMessage.ShouldNotBeNull().ShouldNotContain("Stopped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -262,7 +393,6 @@ public class CollectViewModelTests
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
runner.Results.Add(ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)));
|
||||
page.InputText = "https://a.test/1.png";
|
||||
|
||||
await RunAsync(page);
|
||||
await RunAsync(page);
|
||||
@@ -270,39 +400,52 @@ public class CollectViewModelTests
|
||||
page.Items.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_local_source_runs_with_no_proxy_at_all()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_network_source_is_blocked_while_nothing_is_live()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, includeNetworkSource: true);
|
||||
var (page, _, _) = Build(configs: [Config("s1", allowDirect: false)]);
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
|
||||
var canExecute = true;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
page.EndpointText = "https://own.test/api/list";
|
||||
|
||||
canExecute.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowing_direct_connections_lifts_the_gate()
|
||||
public void A_source_allowed_to_go_direct_lifts_the_gate_for_itself()
|
||||
{
|
||||
var (page, _, _) = Build(
|
||||
new AppSettings { LastSourceId = "own-service", AllowDirectConnection = true },
|
||||
includeNetworkSource: true
|
||||
);
|
||||
var (page, _, _) = Build(configs: [Config("s1", allowDirect: true)]);
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_gated_source_in_the_run_blocks_the_whole_run()
|
||||
{
|
||||
// The permissive source cannot vouch for the strict one: the request the user did not want
|
||||
// leaving their own address would leave it anyway.
|
||||
var (page, _, _) = Build(
|
||||
configs: [Config("s1", "Open", allowDirect: true), Config("s2", "Strict", allowDirect: false)]
|
||||
);
|
||||
|
||||
page.Sources.Single(source => source.Id == "s2").IsSelected = true;
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_source_carries_its_own_proxy_policy_into_the_run()
|
||||
{
|
||||
var (page, runner, _) = Build(configs: [Config("s1", allowDirect: true)]);
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
// The gate is only half of it: the fetcher has to be told too, or a source that should be
|
||||
// blocked would simply go direct with nobody the wiser.
|
||||
runner.LastOptions.ShouldNotBeNull().RequireProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_network_source_runs_once_a_proxy_answers()
|
||||
{
|
||||
@@ -313,7 +456,7 @@ public class CollectViewModelTests
|
||||
new ProxyOptions()
|
||||
);
|
||||
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, pool, includeNetworkSource: true);
|
||||
var (page, _, _) = Build(proxyPool: pool, configs: [Config("s1", allowDirect: false)]);
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
|
||||
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
||||
@@ -324,21 +467,68 @@ public class CollectViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_away_from_a_network_source_lifts_the_gate()
|
||||
public async Task Adding_a_source_stores_it_and_selects_it()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, includeNetworkSource: true);
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
var (page, _, _) = Build(configs: []);
|
||||
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "url-list");
|
||||
page.AddSourceCommand.Execute().Subscribe();
|
||||
page.EditorName = "New one";
|
||||
page.EditorBaseUrl = "https://imgtest.example/test2/";
|
||||
page.EditorMinLength = 8;
|
||||
page.EditorMaxLength = 12;
|
||||
page.EditorAlphabet = page.AlphabetOptions.Single(o => o.Value == IdAlphabet.Digits);
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
await page.SaveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.IsEditorOpen.ShouldBeFalse();
|
||||
page.Sources.ShouldHaveSingleItem();
|
||||
page.SelectedSource.ShouldNotBeNull().Name.ShouldBe("New one");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_invalid_source_reports_an_error_and_is_not_added()
|
||||
{
|
||||
var (page, _, _) = Build(configs: []);
|
||||
|
||||
page.AddSourceCommand.Execute().Subscribe();
|
||||
page.EditorName = "Bad";
|
||||
page.EditorBaseUrl = "not a url";
|
||||
|
||||
await page.SaveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.EditorError.ShouldNotBeNull();
|
||||
page.IsEditorOpen.ShouldBeTrue();
|
||||
page.Sources.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editing_a_source_updates_it_in_place()
|
||||
{
|
||||
var (page, _, _) = Build(configs: [Config("s1", "Before")]);
|
||||
|
||||
page.EditSourceCommand.Execute().Subscribe();
|
||||
page.EditorName = "After";
|
||||
|
||||
await page.SaveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.Sources.ShouldHaveSingleItem().Name.ShouldBe("After");
|
||||
page.SelectedSource.ShouldNotBeNull().Id.ShouldBe("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_a_source_drops_it()
|
||||
{
|
||||
var (page, _, _) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]);
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "s2");
|
||||
|
||||
await page.RemoveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.Sources.ShouldHaveSingleItem().Id.ShouldBe("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purging_removes_only_the_selected_source()
|
||||
{
|
||||
// Scoped rather than emptying the store: content another source also holds must survive,
|
||||
// which is exactly what the index's reference count is for.
|
||||
var (page, _, _) = Build();
|
||||
|
||||
await page.PurgeCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
@@ -354,6 +544,35 @@ public class CollectViewModelTests
|
||||
page.StorageSummary.ShouldNotBeNull().ShouldContain("in the store");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Log_lines_copy_as_the_text_that_is_on_screen()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
runner.Results.Add(ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)));
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
var line = page.Log.First(entry => entry.IsSuccess).ToString();
|
||||
|
||||
line.ShouldContain("https://a.test/1.png");
|
||||
line.ShouldContain("Test"); // the source name, as shown in the chip
|
||||
line.ShouldStartWith(page.Log[0].TimeText[..2]); // a timestamp, not a bare message
|
||||
|
||||
var text = CollectLogEntryViewModel.ToText(page.Log);
|
||||
text.Split(Environment.NewLine).Length.ShouldBe(page.Log.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_twice_is_safe()
|
||||
{
|
||||
// The container disposes each page once per registration — its own type and PageViewModel.
|
||||
var (page, _, _) = Build();
|
||||
|
||||
page.Dispose();
|
||||
|
||||
Should.NotThrow(page.Dispose);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sizes_read_the_way_a_file_manager_shows_them()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
using AvParser.UI.ViewModels;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public class DashboardViewModelTests
|
||||
{
|
||||
private sealed class TestPaths : IAppPaths
|
||||
{
|
||||
public string DataDirectory => Path.Combine(Path.GetTempPath(), "AvParserTests");
|
||||
|
||||
public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
|
||||
|
||||
public string CustomProxiesFile => Path.Combine(DataDirectory, "proxies.custom.json");
|
||||
|
||||
public string ProxyStateFile => Path.Combine(DataDirectory, "proxies.state.json");
|
||||
|
||||
public string LogDirectory => Path.Combine(DataDirectory, "logs");
|
||||
}
|
||||
|
||||
private sealed class EmptyServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private sealed class EmptyUserSourceStore : IUserSourceStore
|
||||
{
|
||||
public event EventHandler? Changed
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public IReadOnlyList<AvParser.Core.Collecting.Sources.PatternSourceConfig> List() => [];
|
||||
|
||||
public Task<AvParser.Core.Collecting.Sources.PatternSourceConfig> AddAsync(
|
||||
AvParser.Core.Collecting.Sources.PatternSourceConfig config,
|
||||
CancellationToken cancellationToken = default
|
||||
) => Task.FromResult(config);
|
||||
|
||||
public Task<bool> UpdateAsync(
|
||||
AvParser.Core.Collecting.Sources.PatternSourceConfig config,
|
||||
CancellationToken cancellationToken = default
|
||||
) => Task.FromResult(false);
|
||||
|
||||
public Task<bool> RemoveAsync(string id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
}
|
||||
|
||||
private static DashboardViewModel Build(IProxyPool pool) =>
|
||||
new(
|
||||
new MediaSourceCatalog(new EmptyUserSourceStore()),
|
||||
new TestPaths(),
|
||||
pool,
|
||||
new EmptyServiceProvider(),
|
||||
ImmediateSequencer.Instance
|
||||
);
|
||||
|
||||
private static ProxyEndpoint Endpoint(string host) => new(ProxyProtocol.Http, host, 8080);
|
||||
|
||||
[Fact]
|
||||
public void An_empty_pool_reports_nothing_rather_than_zeroes_dressed_up_as_health()
|
||||
{
|
||||
using var page = Build(new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()));
|
||||
|
||||
page.ProxyTotal.ShouldBe(0);
|
||||
page.HasProxies.ShouldBeFalse();
|
||||
page.ProxyLatency.ShouldBeNull();
|
||||
page.ProxyRequests.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_pool_summary_counts_live_and_unchecked_entries()
|
||||
{
|
||||
var alive = Endpoint("1.2.3.4");
|
||||
var dead = Endpoint("5.6.7.8");
|
||||
var pool = new ProxyPool(
|
||||
[new FakeProxySource([alive, dead])],
|
||||
new FakeProxyProbe().Set(alive, alive: true),
|
||||
new ProxyOptions()
|
||||
);
|
||||
|
||||
using var page = Build(pool);
|
||||
|
||||
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
||||
page.RefreshProxyStats();
|
||||
|
||||
page.ProxyTotal.ShouldBe(2);
|
||||
page.HasProxies.ShouldBeTrue();
|
||||
// Nothing has been probed yet, so nothing is live however promising the list looks.
|
||||
page.ProxyLive.ShouldBe(0);
|
||||
page.ProxyUnchecked.ShouldBe(2);
|
||||
|
||||
await pool.SweepAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
page.RefreshProxyStats();
|
||||
|
||||
page.ProxyLive.ShouldBe(1);
|
||||
page.ProxyUnchecked.ShouldBe(0);
|
||||
page.ProxyLatency.ShouldNotBeNull().ShouldContain("20");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_requests_show_up_as_a_success_rate()
|
||||
{
|
||||
var endpoint = Endpoint("1.2.3.4");
|
||||
var pool = new ProxyPool(
|
||||
[new FakeProxySource([endpoint])],
|
||||
new FakeProxyProbe().Set(endpoint, alive: true),
|
||||
new ProxyOptions()
|
||||
);
|
||||
|
||||
using var page = Build(pool);
|
||||
|
||||
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
||||
await pool.SweepAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
using (var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken))
|
||||
{
|
||||
lease.ShouldNotBeNull().ReportSuccess(TimeSpan.FromMilliseconds(30));
|
||||
}
|
||||
|
||||
page.RefreshProxyStats();
|
||||
|
||||
page.ProxyRequests.ShouldNotBeNull().ShouldContain("1 of 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_twice_is_safe()
|
||||
{
|
||||
// The container disposes each page once per registration — its own type and PageViewModel.
|
||||
var page = Build(new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()));
|
||||
|
||||
page.Dispose();
|
||||
|
||||
Should.NotThrow(page.Dispose);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_summary_follows_the_pool_without_being_asked()
|
||||
{
|
||||
// The page has to react to a sweep it did not start; nobody presses refresh on a dashboard.
|
||||
var endpoint = Endpoint("1.2.3.4");
|
||||
var pool = new ProxyPool(
|
||||
[new FakeProxySource([endpoint])],
|
||||
new FakeProxyProbe().Set(endpoint, alive: true),
|
||||
new ProxyOptions()
|
||||
);
|
||||
|
||||
using var page = Build(pool);
|
||||
page.ProxyTotal.ShouldBe(0);
|
||||
|
||||
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
page.ProxyTotal.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -170,4 +170,18 @@ public class GalleryViewModelTests
|
||||
page.StatusMessage.ShouldNotBeNull().ShouldContain("Could not read the store");
|
||||
page.IsLoading.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_twice_is_safe()
|
||||
{
|
||||
// Not hypothetical: every page is registered under its own type and under PageViewModel, so
|
||||
// the container disposes it once per registration. The second call used to cancel an
|
||||
// already-disposed token source and bring the process down on every exit.
|
||||
var (page, _, _) = Build(Media("https://a.test/1.png"));
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.Dispose();
|
||||
|
||||
Should.NotThrow(page.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,4 +229,15 @@ public class ProxiesViewModelTests
|
||||
// and would keep rebuilding its rows in the background.
|
||||
Should.NotThrow(() => pool.Configure(new ProxyOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_twice_is_safe()
|
||||
{
|
||||
// The container disposes each page once per registration — its own type and PageViewModel.
|
||||
var (page, _, _) = Build("1.2.3.4");
|
||||
|
||||
page.Dispose();
|
||||
|
||||
Should.NotThrow(page.Dispose);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user