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:
Leonid Pershin
2026-08-15 14:20:06 +03:00
parent a4a0ea9a6b
commit eb5061ee23
63 changed files with 5165 additions and 1557 deletions
@@ -0,0 +1,110 @@
using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
namespace AvParser.Core.Tests.Collecting;
public class MediaSourceCatalogTests
{
private sealed class FakeStore(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.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 static PatternSourceConfig Config(string id, string name)
{
PatternSourceConfig.TryCreate(
name,
"https://h/x/",
6,
8,
IdAlphabet.Digits,
null,
null,
allowDirectConnection: false,
out var config,
id
);
return config!;
}
[Fact]
public void An_empty_store_is_a_valid_empty_catalog()
{
using var catalog = new MediaSourceCatalog(new FakeStore());
catalog.Sources.ShouldBeEmpty();
catalog.Find("anything").ShouldBeNull();
}
[Fact]
public void Sources_are_materialised_from_the_stored_configs()
{
using var catalog = new MediaSourceCatalog(new FakeStore([Config("s1", "Beta"), Config("s2", "Alpha")]));
// Ordered by display name.
catalog.Sources.Select(s => s.Id).ShouldBe(["s2", "s1"]);
catalog.Find("s1").ShouldNotBeNull().DisplayName.ShouldBe("Beta");
}
[Fact]
public async Task Adding_through_the_catalog_rebuilds_and_signals()
{
var store = new FakeStore();
using var catalog = new MediaSourceCatalog(store);
var changed = 0;
catalog.Changed += (_, _) => changed++;
await catalog.AddAsync(Config("s1", "One"), TestContext.Current.CancellationToken);
changed.ShouldBe(1);
catalog.Sources.ShouldHaveSingleItem().Id.ShouldBe("s1");
}
[Fact]
public async Task Removing_through_the_catalog_drops_the_source()
{
using var catalog = new MediaSourceCatalog(new FakeStore([Config("s1", "One")]));
await catalog.RemoveAsync("s1", TestContext.Current.CancellationToken);
catalog.Sources.ShouldBeEmpty();
}
}
@@ -0,0 +1,283 @@
using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
namespace AvParser.Core.Tests.Collecting;
public class PatternMediaSourceTests
{
private static PatternSourceConfig Config(
int min = 6,
int max = 8,
IdAlphabet alphabet = IdAlphabet.Alphanumeric,
string? custom = null,
string? extension = ".jpg",
string url = "https://imgtest.example/test1/",
bool allowDirect = false
)
{
PatternSourceConfig.TryCreate("Test", url, min, max, alphabet, custom, extension, allowDirect, out var config);
return config!;
}
private static async Task<List<MediaCandidate>> Collect(IMediaSource source, int limit)
{
var list = new List<MediaCandidate>();
await foreach (var outcome in source.ParseAsync(new MediaQuery(Limit: limit), null, CancellationToken.None))
{
if (outcome.IsSuccess)
{
list.Add(outcome.Value!);
}
}
return list;
}
[Fact]
public void A_pattern_source_needs_the_network()
{
new PatternMediaSource(Config()).RequiresNetwork.ShouldBeTrue();
}
[Fact]
public async Task Generated_ids_stay_within_the_length_range_and_alphabet()
{
var source = new PatternMediaSource(Config(min: 4, max: 6, alphabet: IdAlphabet.Digits, extension: null));
var candidates = await Collect(source, 300);
candidates.Count.ShouldBe(300);
foreach (var candidate in candidates)
{
var id = candidate.ExternalId.ShouldNotBeNull();
id.Length.ShouldBeInRange(4, 6);
id.ShouldAllBe(c => char.IsAsciiDigit(c));
}
}
[Fact]
public async Task Candidates_hang_the_id_off_the_base_url_with_the_extension()
{
var source = new PatternMediaSource(Config(min: 6, max: 6, alphabet: IdAlphabet.HexLower, extension: ".png"));
var candidate = (await Collect(source, 1)).ShouldHaveSingleItem();
candidate.SourceId.ShouldBe(source.Id);
candidate.Url.AbsoluteUri.ShouldBe($"https://imgtest.example/test1/{candidate.ExternalId}.png");
}
[Fact]
public async Task An_unset_extension_still_lands_on_a_jpg_address()
{
var source = new PatternMediaSource(Config(min: 6, max: 6, alphabet: IdAlphabet.HexLower, extension: null));
var candidate = (await Collect(source, 1)).ShouldHaveSingleItem();
candidate.Url.AbsoluteUri.ShouldBe($"https://imgtest.example/test1/{candidate.ExternalId}.jpg");
}
[Fact]
public async Task The_attempt_budget_bounds_how_many_are_generated()
{
var source = new PatternMediaSource(Config(min: 6, max: 8, alphabet: IdAlphabet.Alphanumeric));
(await Collect(source, 40)).Count.ShouldBe(40);
}
[Fact]
public async Task A_small_space_is_never_exceeded_however_large_the_budget()
{
// Digits of length 2 is a hundred ids; asking for a thousand must not loop forever.
var source = new PatternMediaSource(Config(min: 2, max: 2, alphabet: IdAlphabet.Digits, extension: null));
var candidates = await Collect(source, 1000);
candidates.Count.ShouldBeLessThanOrEqualTo(100);
candidates.Select(c => c.ExternalId).Distinct().Count().ShouldBe(candidates.Count);
}
[Fact]
public async Task No_budget_means_the_source_keeps_going_until_it_is_stopped()
{
// A limit of zero used to mean "one candidate", which made an unlimited run collect nothing.
var source = new PatternMediaSource(Config(min: 8, max: 8, alphabet: IdAlphabet.Alphanumeric));
using var stop = new CancellationTokenSource();
var seen = 0;
try
{
await foreach (var outcome in source.ParseAsync(new MediaQuery(), null, stop.Token))
{
outcome.IsSuccess.ShouldBeTrue();
if (++seen >= 5_000)
{
await stop.CancelAsync();
}
}
}
catch (OperationCanceledException)
{
// The only way an unbounded run ends.
}
seen.ShouldBe(5_000);
}
[Fact]
public async Task A_small_space_still_bounds_an_unlimited_run()
{
// Two digits is a hundred ids: with no budget the run must end at the space, not spin on
// collisions for ever.
var source = new PatternMediaSource(Config(min: 2, max: 2, alphabet: IdAlphabet.Digits, extension: null));
var candidates = await Collect(source, 0);
// Not exactly a hundred: the draw is random, so the last few ids may be given up on rather
// than waited for. What matters is that the run ends and never repeats itself.
candidates.Count.ShouldBeInRange(90, 100);
candidates.Select(c => c.ExternalId).Distinct().Count().ShouldBe(candidates.Count);
}
[Fact]
public async Task Generated_ids_are_unique_within_a_run()
{
var source = new PatternMediaSource(Config(min: 8, max: 8, alphabet: IdAlphabet.Alphanumeric, extension: null));
var candidates = await Collect(source, 500);
candidates.Select(c => c.ExternalId).Distinct().Count().ShouldBe(500);
}
}
public class PatternAlphabetTests
{
[Theory]
[InlineData(IdAlphabet.LettersLower, "abcdefghijklmnopqrstuvwxyz")]
[InlineData(IdAlphabet.Digits, "0123456789")]
[InlineData(IdAlphabet.HexLower, "0123456789abcdef")]
public void Named_sets_resolve_to_their_characters(IdAlphabet alphabet, string expected)
{
PatternAlphabet.Resolve(alphabet).ShouldBe(expected);
}
[Fact]
public void Letters_and_alphanumeric_combine_the_sets()
{
PatternAlphabet.Resolve(IdAlphabet.Letters).Length.ShouldBe(52);
PatternAlphabet.Resolve(IdAlphabet.Alphanumeric).Length.ShouldBe(62);
}
[Fact]
public void A_custom_set_is_de_duplicated_and_stripped_of_separators()
{
// Repeats would skew the draw; a slash would break the "one path segment" assumption.
PatternAlphabet.Resolve(IdAlphabet.Custom, "aab/c c").ShouldBe("abc");
}
[Fact]
public void An_empty_custom_set_resolves_to_nothing()
{
PatternAlphabet.Resolve(IdAlphabet.Custom, " ").ShouldBeEmpty();
}
}
public class PatternSourceConfigTests
{
[Fact]
public void A_valid_config_normalises_the_base_url_and_extension()
{
var error = PatternSourceConfig.TryCreate(
" My source ",
"https://imgtest.example/test1",
6,
8,
IdAlphabet.Alphanumeric,
null,
"jpg",
allowDirectConnection: false,
out var config
);
error.ShouldBe(PatternConfigError.None);
config.ShouldNotBeNull();
config.Name.ShouldBe("My source");
config.BaseUrl.AbsoluteUri.ShouldBe("https://imgtest.example/test1/");
config.Extension.ShouldBe(".jpg");
config.Id.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public void A_blank_extension_becomes_the_default_one()
{
// The editor shows ".jpg" as the placeholder, so an untouched field means "the usual one",
// not "no suffix" — the latter costs a whole run of 404s to discover.
var error = PatternSourceConfig.TryCreate(
"N",
"https://h/x/",
6,
8,
IdAlphabet.Digits,
null,
" ",
allowDirectConnection: false,
out var config
);
error.ShouldBe(PatternConfigError.None);
config.ShouldNotBeNull().Extension.ShouldBe(PatternSourceConfig.DefaultExtension);
}
[Fact]
public void A_supplied_id_is_kept()
{
PatternSourceConfig.TryCreate(
"N",
"https://h/x/",
6,
8,
IdAlphabet.Digits,
null,
null,
allowDirectConnection: false,
out var config,
"fixed-id"
);
config.ShouldNotBeNull().Id.ShouldBe("fixed-id");
}
[Theory]
[InlineData("", "https://h/x/", 6, 8, IdAlphabet.Digits, PatternConfigError.NameRequired)]
[InlineData("N", "not-a-url", 6, 8, IdAlphabet.Digits, PatternConfigError.BaseUrlInvalid)]
[InlineData("N", "ftp://h/x/", 6, 8, IdAlphabet.Digits, PatternConfigError.BaseUrlInvalid)]
[InlineData("N", "https://h/x/", 0, 8, IdAlphabet.Digits, PatternConfigError.LengthRangeInvalid)]
[InlineData("N", "https://h/x/", 8, 6, IdAlphabet.Digits, PatternConfigError.LengthRangeInvalid)]
[InlineData("N", "https://h/x/", 6, 65, IdAlphabet.Digits, PatternConfigError.LengthRangeInvalid)]
[InlineData("N", "https://h/x/", 6, 8, IdAlphabet.Custom, PatternConfigError.AlphabetEmpty)]
public void Invalid_inputs_are_rejected_with_a_reason(
string name,
string url,
int min,
int max,
IdAlphabet alphabet,
PatternConfigError expected
)
{
var error = PatternSourceConfig.TryCreate(
name,
url,
min,
max,
alphabet,
null,
null,
allowDirectConnection: false,
out var config
);
error.ShouldBe(expected);
config.ShouldBeNull();
}
}
@@ -1,198 +0,0 @@
using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
using AvParser.Core.Parsing;
namespace AvParser.Core.Tests.Collecting;
public class UrlListSourceTests
{
private static readonly IMediaSource Source = new UrlListSource();
private static async Task<List<ParseOutcome<MediaCandidate>>> RunAsync(string text, int limit = 0)
{
var results = new List<ParseOutcome<MediaCandidate>>();
await foreach (
var outcome in Source.ParseAsync(
new MediaQuery(text, Limit: limit),
null,
TestContext.Current.CancellationToken
)
)
{
results.Add(outcome);
}
return results;
}
[Fact]
public async Task Each_line_becomes_a_candidate()
{
var results = await RunAsync("https://example.test/a.png\nhttps://example.test/b.gif");
results.Count.ShouldBe(2);
results.ShouldAllBe(r => r.IsSuccess);
results[0].Value!.Url.AbsoluteUri.ShouldBe("https://example.test/a.png");
results[1].Value!.Ordinal.ShouldBe(2);
}
[Fact]
public async Task Blank_lines_and_comments_are_ignored()
{
var results = await RunAsync(
"""
# my list
https://example.test/a.png
# indented comment
https://example.test/b.png
"""
);
results.Count.ShouldBe(2);
}
[Fact]
public async Task Whitespace_around_an_address_is_forgiven()
{
var results = await RunAsync(" https://example.test/a.png ");
results.ShouldHaveSingleItem().IsSuccess.ShouldBeTrue();
}
[Fact]
public async Task A_bad_line_is_named_rather_than_dropped()
{
// In a paste of two hundred addresses a silently skipped typo is unfindable.
var results = await RunAsync("https://example.test/a.png\nnot an address\nhttps://example.test/b.png");
results.Count.ShouldBe(3);
results[1].IsSuccess.ShouldBeFalse();
results[1].Error!.Code.ShouldBe("NotAnAddress");
results[1].Error!.Arguments.ShouldContain("not an address");
}
[Theory]
[InlineData("file:///etc/passwd")]
[InlineData("data:image/png;base64,AAAA")]
[InlineData("ftp://example.test/a.png")]
[InlineData("javascript:alert(1)")]
public async Task Only_http_addresses_are_accepted(string line)
{
// A pasted list is as likely to have come from somewhere else as to have been typed.
var results = await RunAsync(line);
results.ShouldHaveSingleItem().IsSuccess.ShouldBeFalse();
}
[Fact]
public async Task A_limit_stops_the_listing_early()
{
var text = string.Join('\n', Enumerable.Range(0, 50).Select(i => $"https://example.test/{i}.png"));
var results = await RunAsync(text, limit: 5);
results.Count(r => r.IsSuccess).ShouldBe(5);
}
[Fact]
public void The_source_needs_no_network_of_its_own()
{
// It discovers nothing; downloading what it read is the fetcher's job and is gated there.
Source.RequiresNetwork.ShouldBeFalse();
}
[Theory]
[InlineData("https://example.test/a.png", true)]
[InlineData("# only a comment", false)]
[InlineData("", false)]
[InlineData("nonsense", false)]
public void CanParse_answers_without_doing_any_work(string text, bool expected) =>
Source.CanParse(new MediaQuery(text)).ShouldBe(expected);
[Fact]
public async Task Cancellation_is_honoured()
{
using var cancellation = new CancellationTokenSource();
await cancellation.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(async () =>
{
await foreach (var _ in Source.ParseAsync(new MediaQuery("https://a.test/x.png"), null, cancellation.Token))
{
// Draining is the point; the first move must already throw.
}
});
}
}
public class MediaSourceCatalogTests
{
[Fact]
public void Sources_are_ordered_by_display_name()
{
var catalog = new MediaSourceCatalog([new FakeSource("z", "Zebra"), new FakeSource("a", "Aardvark")]);
catalog.Sources.Select(s => s.Id).ShouldBe(["a", "z"]);
}
[Fact]
public void The_named_default_wins_over_alphabetical_order()
{
// Otherwise the landing page depends on a display name, and would open on the network
// source — behind the proxy gate — before the user has asked for anything.
var catalog = new MediaSourceCatalog([new FakeSource("a", "Aardvark"), new FakeSource("z", "Zebra")], "z");
catalog.DefaultSource.Id.ShouldBe("z");
}
[Fact]
public void An_unknown_default_falls_back_rather_than_throwing()
{
var catalog = new MediaSourceCatalog([new FakeSource("a", "Aardvark")], "removed-in-a-past-version");
catalog.DefaultSource.Id.ShouldBe("a");
}
[Fact]
public void Lookup_ignores_case_and_reports_a_miss()
{
IMediaSourceCatalog catalog = new MediaSourceCatalog([new FakeSource("url-list", "URL list")]);
catalog.Find("URL-LIST").ShouldNotBeNull();
catalog.Find("nope").ShouldBeNull();
catalog.FindOrDefault("nope").Id.ShouldBe("url-list");
}
[Fact]
public void An_empty_registration_is_rejected() =>
Should.Throw<ArgumentException>(() => new MediaSourceCatalog([]));
[Fact]
public void Two_sources_sharing_an_id_are_rejected() =>
Should.Throw<ArgumentException>(() =>
new MediaSourceCatalog([new FakeSource("same", "One"), new FakeSource("same", "Two")])
);
private sealed class FakeSource(string id, string name) : IMediaSource
{
public string Id => id;
public string DisplayName => name;
public string Description => string.Empty;
public bool CanParse(MediaQuery input) => true;
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
MediaQuery input,
IProgress<ParseProgress>? progress,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
}
@@ -102,4 +102,57 @@ public class ProxyPoolWarmUpTests
(await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(0);
pool.LiveCount.ShouldBe(0);
}
[Fact]
public async Task The_top_up_checks_what_the_warm_up_skipped()
{
// The warm-up leaves almost everything unknown by design; without this pass the pool looks
// — and behaves — as if it held two proxies rather than the fifty that answer.
var pool = Build(out var probe, out _, [.. Enumerable.Range(0, 50).Select(index => $"h{index}")]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
probe.DefaultAlive = true;
await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken);
var afterWarmUp = probe.ProbeCount;
afterWarmUp.ShouldBeLessThan(50);
var found = await pool.TopUpAsync(4, cancellationToken: TestContext.Current.CancellationToken);
found.ShouldBe(50 - afterWarmUp);
pool.LiveCount.ShouldBe(50);
pool.Entries.ShouldAllBe(entry => entry.Health == ProxyHealthState.Alive);
}
[Fact]
public async Task The_top_up_does_not_re_probe_what_is_already_known()
{
var pool = Build(out var probe, out _, "a", "b");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
probe.DefaultAlive = true;
await pool.TopUpAsync(2, cancellationToken: TestContext.Current.CancellationToken);
var first = probe.ProbeCount;
// Everything has a verdict now, so a second pass has nothing to do — re-probing would just
// be a sweep, and a sweep is something the user asks for.
(await pool.TopUpAsync(2, cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(0);
probe.ProbeCount.ShouldBe(first);
}
[Fact]
public async Task A_stopped_top_up_keeps_what_it_learned()
{
var pool = Build(out var probe, out _, [.. Enumerable.Range(0, 20).Select(index => $"h{index}")]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
probe.DefaultAlive = true;
using var stop = new CancellationTokenSource();
await stop.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => pool.TopUpAsync(2, null, stop.Token));
// Cancelled before anything was probed, so nothing is claimed to be live either.
pool.LiveCount.ShouldBe(0);
}
}
@@ -138,8 +138,7 @@ public sealed class CollectRunnerTests : IAsyncLifetime
NullLogger<MediaStore>.Instance
);
// Direct is allowed here: these tests are about the runner, not the proxy gate.
_settings = new FixedSettings(new AppSettings { AllowDirectConnection = true });
_settings = new FixedSettings(new AppSettings());
_fetcher = new ScriptedFetcher(_blobs);
_throttle = new HostThrottle(4, TimeSpan.Zero, NullLogger<CollectRunnerTests>.Instance);
_runner = new CollectRunner(_fetcher, _store, _throttle, NullLogger<CollectRunner>.Instance);
@@ -354,66 +353,3 @@ public sealed class CollectRunnerTests : IAsyncLifetime
}
}
}
public class OwnServiceListingTests
{
private static readonly Uri Endpoint = new("https://own.test/api/list");
[Fact]
public void An_object_with_items_is_read()
{
var page = OwnServiceSource.ReadPage(
"""
{ "items": [ { "url": "https://own.test/a.png", "id": "42", "name": "kitten",
"published": "2026-08-13T10:00:00Z", "size": 4096, "tags": ["cats"] } ],
"next": "page2" }
""",
Endpoint
);
var item = page.Items.ShouldHaveSingleItem();
item.Url.AbsoluteUri.ShouldBe("https://own.test/a.png");
item.ExternalId.ShouldBe("42");
item.SuggestedName.ShouldBe("kitten");
item.ExpectedLength.ShouldBe(4096);
item.Tags.ShouldBe(["cats"]);
page.Next.ShouldBe("page2");
}
[Fact]
public void A_bare_array_of_addresses_is_read()
{
// The service on the other end is the user's own; it should not have to be rewritten to
// match a schema we invented.
var page = OwnServiceSource.ReadPage("""["https://own.test/a.png", "https://own.test/b.gif"]""", Endpoint);
page.Items.Count.ShouldBe(2);
page.Next.ShouldBeNull();
}
[Fact]
public void Relative_addresses_resolve_against_the_endpoint()
{
var page = OwnServiceSource.ReadPage("""{"items":[{"url":"/files/a.png"}]}""", Endpoint);
page.Items.ShouldHaveSingleItem().Url.AbsoluteUri.ShouldBe("https://own.test/files/a.png");
}
[Fact]
public void Entries_without_a_usable_address_are_dropped()
{
var page = OwnServiceSource.ReadPage(
"""{"items":[{"name":"no url"}, {"url":"data:image/png;base64,AA"}, {"url":"https://own.test/ok.png"}]}""",
Endpoint
);
page.Items.ShouldHaveSingleItem().Url.AbsoluteUri.ShouldBe("https://own.test/ok.png");
}
[Fact]
public void An_empty_listing_is_not_an_error()
{
OwnServiceSource.ReadPage("""{"items":[]}""", Endpoint).Items.ShouldBeEmpty();
OwnServiceSource.ReadPage("[]", Endpoint).Items.ShouldBeEmpty();
}
}
@@ -0,0 +1,124 @@
using AvParser.Core.Collecting.Sources;
using AvParser.Infrastructure.Collecting;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests.Collecting;
public sealed class JsonUserSourceStoreTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
"AvParserTests",
Guid.NewGuid().ToString("N")
);
private JsonUserSourceStore Create() => new(new AppPaths(_directory), NullLogger<JsonUserSourceStore>.Instance);
private static PatternSourceConfig Config(string id, string name = "Test", bool allowDirect = false)
{
PatternSourceConfig.TryCreate(
name,
"https://imgtest.example/test1/",
6,
8,
IdAlphabet.Alphanumeric,
null,
".jpg",
allowDirect,
out var config,
id
);
return config!;
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
[Fact]
public void An_absent_file_reads_as_an_empty_list()
{
using var store = Create();
store.List().ShouldBeEmpty();
}
[Fact]
public async Task Added_sources_survive_a_reload()
{
using (var store = Create())
{
await store.AddAsync(Config("s1", "Kept"), TestContext.Current.CancellationToken);
}
// A brand-new instance reads from disk rather than from the in-memory cache.
using var reopened = Create();
var config = reopened.List().ShouldHaveSingleItem();
config.Id.ShouldBe("s1");
config.Name.ShouldBe("Kept");
config.BaseUrl.AbsoluteUri.ShouldBe("https://imgtest.example/test1/");
config.Extension.ShouldBe(".jpg");
}
[Fact]
public async Task Updating_replaces_the_matching_config()
{
using var store = Create();
await store.AddAsync(Config("s1", "Before"), TestContext.Current.CancellationToken);
var updated = Config("s1", "After");
(await store.UpdateAsync(updated, TestContext.Current.CancellationToken)).ShouldBeTrue();
store.List().ShouldHaveSingleItem().Name.ShouldBe("After");
}
[Fact]
public async Task Updating_an_unknown_id_changes_nothing()
{
using var store = Create();
(await store.UpdateAsync(Config("ghost"), TestContext.Current.CancellationToken)).ShouldBeFalse();
store.List().ShouldBeEmpty();
}
[Fact]
public async Task Removing_takes_the_config_out()
{
using var store = Create();
await store.AddAsync(Config("s1"), TestContext.Current.CancellationToken);
(await store.RemoveAsync("s1", TestContext.Current.CancellationToken)).ShouldBeTrue();
store.List().ShouldBeEmpty();
}
[Fact]
public async Task Mutations_raise_the_changed_event()
{
using var store = Create();
var changed = 0;
store.Changed += (_, _) => changed++;
await store.AddAsync(Config("s1"), TestContext.Current.CancellationToken);
await store.RemoveAsync("s1", TestContext.Current.CancellationToken);
changed.ShouldBe(2);
}
[Fact]
public async Task A_corrupt_file_reads_as_an_empty_list()
{
Directory.CreateDirectory(_directory);
IAppPaths paths = new AppPaths(_directory);
await File.WriteAllTextAsync(paths.UserSourcesFile, "{ not json ]", TestContext.Current.CancellationToken);
using var store = Create();
store.List().ShouldBeEmpty();
}
}
@@ -156,10 +156,13 @@ public sealed class JsonSettingsServiceTests : IDisposable
}
[Fact]
public void The_proxy_gate_setting_reaches_the_collector()
public void The_collector_defaults_to_proxy_only()
{
new AppSettings(AllowDirectConnection: false).ToCollectOptions().RequireProxy.ShouldBeTrue();
new AppSettings(AllowDirectConnection: true).ToCollectOptions().RequireProxy.ShouldBeFalse();
// Whether a source may go direct is that source's own setting now; what the app-wide
// options must never do is default to the permissive answer.
new AppSettings()
.ToCollectOptions()
.RequireProxy.ShouldBeTrue();
}
[Fact]
@@ -92,6 +92,58 @@ public class ProxyPoolLoaderTests
(await loader.EnsureLoadedAsync()).Total.ShouldBe(0);
}
[Fact]
public async Task What_the_warm_up_skipped_is_checked_in_the_background()
{
// The warm-up stops at the target, which on a real feed leaves thousands unknown. Without
// this pass the pool reports ten live out of a few thousand and the rest were never asked.
var source = new CountingSource();
var pool = new ProxyPool(
[source],
new AliveProbe(),
new ProxyOptions { MinimumLiveProxies = 1, ProbeConcurrency = 1 }
);
using var loader = new ProxyPoolLoader(pool, new MemoryStateStore(), NullLogger<ProxyPoolLoader>.Instance);
var result = await loader.EnsureLoadedAsync();
// One live was enough to finish starting up; the other is still unknown at this point.
result.Live.ShouldBe(1);
await loader.TopUp.ShouldNotBeNull();
pool.LiveCount.ShouldBe(2);
loader.IsToppingUp.ShouldBeFalse();
}
[Fact]
public async Task The_background_check_is_skipped_when_the_user_asked_for_lazy_probing()
{
// Lazy means "check a proxy when you hand it out"; sweeping the list behind the user's back
// is exactly what they switched off.
var pool = new ProxyPool(
[new CountingSource()],
new AliveProbe(),
new ProxyOptions { HealthCheck = ProxyHealthCheck.Lazy }
);
using var loader = new ProxyPoolLoader(pool, new MemoryStateStore(), NullLogger<ProxyPoolLoader>.Instance);
await loader.EnsureLoadedAsync();
loader.TopUp.ShouldBeNull();
}
[Fact]
public async Task Disposing_twice_is_safe()
{
var loader = Build(out _, out _);
await loader.EnsureLoadedAsync();
loader.Dispose();
Should.NotThrow(loader.Dispose);
}
private sealed class MemoryStateStore : IProxyStateStore
{
public Dictionary<string, ProxyStateRecord> State { get; } = new(StringComparer.Ordinal);
@@ -150,4 +202,13 @@ public class ProxyPoolLoaderTests
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Failure("not used"));
}
private sealed class AliveProbe : IProxyProbe
{
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Success(TimeSpan.FromMilliseconds(15)));
}
}
@@ -1,41 +1,63 @@
using System.Runtime.CompilerServices;
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 StubSource(string id, string name, bool network) : IMediaSource
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 => "A source";
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);
}
}
@@ -46,7 +68,7 @@ public class CollectViewTests
MediaQuery query,
CollectOptions options,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
@@ -59,15 +81,34 @@ public class CollectViewTests
public object? GetService(Type serviceType) => null;
}
private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(bool networkSource)
private static PatternSourceConfig Config(string id = "s1", bool allowDirect = false)
{
IMediaSource[] sources = networkSource
? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)]
: [new StubSource("url-list", "URL list", 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(sources, networkSource ? "own-service" : "url-list"),
new FakeSettingsService(new AppSettings { LastSourceId = networkSource ? "own-service" : "url-list" }),
new MediaSourceCatalog(store),
new FakeSettingsService(new AppSettings { LastSourceId = "s1" }),
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new IdleRunner(),
new FakeMediaStore(),
@@ -96,26 +137,26 @@ public class CollectViewTests
[AvaloniaFact]
public void The_page_renders()
{
var (view, _, _) = ShowPage(networkSource: false);
var (view, _, _) = ShowPage(allowDirect: true);
view.GetVisualDescendants().OfType<ListBox>().ShouldNotBeEmpty();
}
[AvaloniaFact]
public void No_banner_is_shown_for_a_source_that_needs_no_network()
public void No_banner_is_shown_when_direct_connections_are_allowed()
{
var (view, viewModel, _) = ShowPage(networkSource: false);
var (view, viewModel, _) = ShowPage(allowDirect: true);
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
Banner(view).IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void A_blocked_network_source_puts_the_banner_on_screen()
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(networkSource: true);
var (view, viewModel, _) = ShowPage(allowDirect: false);
Dispatcher.UIThread.RunJobs();
viewModel.IsBlockedWithoutProxy.ShouldBeTrue();
@@ -125,7 +166,7 @@ public class CollectViewTests
[AvaloniaFact]
public void The_banner_offers_a_way_to_the_proxies_page()
{
var (view, viewModel, _) = ShowPage(networkSource: true);
var (view, viewModel, _) = ShowPage(allowDirect: false);
Dispatcher.UIThread.RunJobs();
var button = Banner(view).GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
@@ -136,8 +177,7 @@ public class CollectViewTests
[AvaloniaFact]
public void A_blocked_page_will_not_run_the_collector()
{
var (view, viewModel, _) = ShowPage(networkSource: true);
viewModel.EndpointText = "https://own.test/api/list";
var (view, viewModel, _) = ShowPage(allowDirect: false);
Dispatcher.UIThread.RunJobs();
var run = view.GetVisualDescendants()
@@ -148,26 +188,65 @@ public class CollectViewTests
}
[AvaloniaFact]
public void An_endpoint_source_shows_an_address_box_rather_than_a_paste_box()
public void With_no_sources_the_empty_state_is_shown()
{
var (view, _, _) = ShowPage(networkSource: true);
var (view, _, _) = ShowPage(allowDirect: true, withSource: false);
Dispatcher.UIThread.RunJobs();
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
boxes.ShouldHaveSingleItem();
boxes[0].PlaceholderText.ShouldNotBeNull().ShouldContain("api/list");
var empty = view.FindControl<Border>("EmptyState").ShouldNotBeNull();
empty.IsEffectivelyVisible.ShouldBeTrue();
}
[AvaloniaFact]
public void A_pasted_list_source_shows_the_paste_box()
public void The_log_panel_appears_as_soon_as_there_is_something_to_show()
{
var (view, _, _) = ShowPage(networkSource: false);
// 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();
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
panel.IsEffectivelyVisible.ShouldBeTrue();
view.FindControl<ListBox>("CollectLog").ShouldNotBeNull().ItemCount.ShouldBe(1);
boxes.ShouldHaveSingleItem();
boxes[0].AcceptsReturn.ShouldBeTrue();
// 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();
}
}
+2
View File
@@ -122,6 +122,8 @@ internal sealed class FakeProxyPoolLoader : IProxyPoolLoader
{
public bool IsLoaded => true;
public bool IsToppingUp => false;
public Task<ProxyPoolLoadResult> EnsureLoadedAsync() => Task.FromResult(new ProxyPoolLoadResult(0, 0, 0));
public Task SaveStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+309 -90
View File
@@ -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);
}
}