Third step: the collector becomes wireable. Both catalogs coexist for exactly this one step, so ParseViewModel and every existing test stay green while the new domain is proven. IMediaSource reuses the closed-generic trick ITextParser used, and for the same reason - the container cannot resolve an open generic as IEnumerable<T>, so adding a source stays a one-line registration. Its input is a MediaQuery rather than text, because a source that walks a paginated listing needs an endpoint and a cursor, not a string. Sources discover; they do not download. That split is why UrlListSource lives in the domain with no network at all, and why everything hard about fetching lives in one place instead of once per source. The catalog takes an explicit default id. Left to alphabetical order the landing source would be the network one, so the app would open behind the proxy gate before the user had asked for anything. The runner decouples discovery from downloading with a bounded channel - a listing of two hundred thousand items must not materialise because the workers are slower than the source - and owns its workers, waiting for them even when cancelled. Without that a stopped run keeps writing to the store after the page has said it stopped. The own-service listing is read leniently: the service on the other end is the user's own and should not have to be rewritten to match a schema we invented, so both a bare array of addresses and an object with items and a cursor work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
6.2 KiB
C#
199 lines
6.2 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|