The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.
Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.
Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.
Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
7.6 KiB
C#
226 lines
7.6 KiB
C#
using AvParser.Core.Collecting;
|
|
using AvParser.Core.Proxies;
|
|
using AvParser.Core.Settings;
|
|
using AvParser.Infrastructure.Settings;
|
|
using AvParser.Infrastructure.Storage;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace AvParser.Infrastructure.Tests;
|
|
|
|
public sealed class JsonSettingsServiceTests : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"AvParserTests",
|
|
Guid.NewGuid().ToString("N")
|
|
);
|
|
|
|
private JsonSettingsService Create() => new(new AppPaths(_directory), NullLogger<JsonSettingsService>.Instance);
|
|
|
|
private void WriteSettings(string json)
|
|
{
|
|
Directory.CreateDirectory(_directory);
|
|
File.WriteAllText(Path.Combine(_directory, "settings.json"), json);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
{
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void An_absent_file_yields_the_defaults()
|
|
{
|
|
using var service = Create();
|
|
|
|
service.Current.ShouldBe(new AppSettings());
|
|
}
|
|
|
|
/// <summary>
|
|
/// A settings file written before a setting existed must not zero that setting.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is a regression test with a real bug behind it. Defaults used to live on property
|
|
/// initialisers, which the source-generated deserialiser does not run — so an older file came
|
|
/// back with <c>default(T)</c> everywhere, switching the proxy feed off, clearing the protocol
|
|
/// filter and zeroing the probe timeout. The app then loaded an empty pool and looked as if
|
|
/// the network had failed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_file_from_an_older_version_keeps_the_defaults_for_settings_it_predates()
|
|
{
|
|
WriteSettings(
|
|
"""
|
|
{
|
|
"theme": "System",
|
|
"windowWidth": 1280,
|
|
"windowHeight": 800,
|
|
"windowMaximized": false,
|
|
"minimumLogLevel": "Information"
|
|
}
|
|
"""
|
|
);
|
|
|
|
using var service = Create();
|
|
var settings = service.Current;
|
|
|
|
settings.ProxyUseFeed.ShouldBeTrue();
|
|
settings.ProxyProtocols.ShouldBe(ProxyProtocolFilter.All);
|
|
settings.ProxyProbeTimeoutSeconds.ShouldBe(8);
|
|
settings.ProxyProbeConcurrency.ShouldBe(64);
|
|
settings.ProxyProbeUrl.ShouldNotBeNullOrEmpty();
|
|
settings.Language.ShouldBe(AppLanguage.System);
|
|
|
|
// And the values the file did carry must survive.
|
|
settings.MinimumLogLevel.ShouldBe("Information");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same guarantee, for the collector settings added afterwards.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The failure this prevents is worse here than it was for the proxy pool: a zeroed
|
|
/// <c>MaxItemBytes</c> would refuse everything, and a zeroed concurrency would deadlock the
|
|
/// run outright.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_file_that_predates_the_collector_keeps_the_collector_defaults()
|
|
{
|
|
WriteSettings("""{ "theme": "Dark", "proxyMinimumLive": 25 }""");
|
|
|
|
using var service = Create();
|
|
var settings = service.Current;
|
|
|
|
settings.MaxConcurrentDownloads.ShouldBe(4);
|
|
settings.MaxConcurrentPerHost.ShouldBe(2);
|
|
settings.HostDelayMs.ShouldBe(250);
|
|
settings.MaxItemBytes.ShouldBe(33_554_432);
|
|
settings.MinItemBytes.ShouldBe(1024);
|
|
settings.MaxRedirects.ShouldBe(5);
|
|
settings.ConnectTimeoutSeconds.ShouldBe(15);
|
|
settings.HeaderTimeoutSeconds.ShouldBe(30);
|
|
settings.IdleTimeoutSeconds.ShouldBe(20);
|
|
settings.AllowedMediaKinds.ShouldBe(MediaKindFilter.All);
|
|
settings.ShowcaseMode.ShouldBe(ShowcaseMode.HardLink);
|
|
settings.CollectUserAgent.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
// And what the file did carry survives.
|
|
settings.ProxyMinimumLive.ShouldBe(25);
|
|
}
|
|
|
|
[Fact]
|
|
public void Collect_options_from_an_older_file_are_usable()
|
|
{
|
|
WriteSettings("""{ "theme": "Dark" }""");
|
|
|
|
using var service = Create();
|
|
var options = service.Current.ToCollectOptions();
|
|
|
|
options.MaxConcurrentDownloads.ShouldBeGreaterThan(0);
|
|
options.MaxItemBytes.ShouldBeGreaterThan(0);
|
|
options.AllowedKinds.ShouldBe(MediaKindFilter.All);
|
|
options.IdleTimeout.ShouldBeGreaterThan(TimeSpan.Zero);
|
|
}
|
|
|
|
[Fact]
|
|
public void Collector_values_out_of_range_are_clamped_rather_than_thrown()
|
|
{
|
|
var options = new AppSettings(
|
|
MaxConcurrentDownloads: 0,
|
|
MaxConcurrentPerHost: -5,
|
|
MaxItemBytes: 0,
|
|
MaxRedirects: 9999,
|
|
IdleTimeoutSeconds: 0,
|
|
CollectUserAgent: " "
|
|
).ToCollectOptions();
|
|
|
|
options.MaxConcurrentDownloads.ShouldBe(1);
|
|
options.MaxConcurrentPerHost.ShouldBe(1);
|
|
options.MaxItemBytes.ShouldBe(1024);
|
|
options.MaxRedirects.ShouldBe(20);
|
|
options.IdleTimeout.ShouldBe(TimeSpan.FromSeconds(1));
|
|
options.UserAgent.ShouldBe("AvParser/0.1");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_empty_media_filter_is_treated_as_everything()
|
|
{
|
|
// Switching every format off is far more likely to be an accident than an instruction to
|
|
// collect nothing at all.
|
|
new AppSettings(AllowedMediaKinds: MediaKindFilter.None)
|
|
.ToCollectOptions()
|
|
.AllowedKinds.ShouldBe(MediaKindFilter.All);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_proxy_gate_setting_reaches_the_collector()
|
|
{
|
|
new AppSettings(AllowDirectConnection: false).ToCollectOptions().RequireProxy.ShouldBeTrue();
|
|
new AppSettings(AllowDirectConnection: true).ToCollectOptions().RequireProxy.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Options_built_from_an_older_file_still_consult_the_feed()
|
|
{
|
|
WriteSettings("""{ "theme": "Dark" }""");
|
|
|
|
using var service = Create();
|
|
var options = service.Current.ToProxyOptions();
|
|
|
|
options.UseFeed.ShouldBeTrue();
|
|
options.Protocols.ShouldBe(ProxyProtocolFilter.All);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_empty_protocol_filter_is_treated_as_all()
|
|
{
|
|
// Nothing in the UI can produce None, but a hand-edited file can — and a pool that
|
|
// silently matches nothing is the least useful possible reading of it.
|
|
var options = new AppSettings(ProxyProtocols: ProxyProtocolFilter.None).ToProxyOptions();
|
|
|
|
options.Protocols.ShouldBe(ProxyProtocolFilter.All);
|
|
}
|
|
|
|
[Fact]
|
|
public void Out_of_range_values_are_clamped_rather_than_thrown()
|
|
{
|
|
var options = new AppSettings(
|
|
ProxyProbeTimeoutSeconds: 0,
|
|
ProxyProbeConcurrency: 100_000,
|
|
ProxyProbeUrl: "not a url"
|
|
).ToProxyOptions();
|
|
|
|
options.ProbeTimeout.ShouldBe(TimeSpan.FromSeconds(1));
|
|
options.ProbeConcurrency.ShouldBe(512);
|
|
options.ProbeUrl.ShouldBe(new ProxyOptions().ProbeUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_corrupt_file_falls_back_to_the_defaults_instead_of_failing_to_start()
|
|
{
|
|
WriteSettings("{ this is not json");
|
|
|
|
using var service = Create();
|
|
|
|
service.Current.ShouldBe(new AppSettings());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Updates_round_trip_through_the_file()
|
|
{
|
|
using (var service = Create())
|
|
{
|
|
service.Update(current => current with { Theme = AppTheme.Dark, ProxyUseFeed = false });
|
|
await service.FlushAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
using var reopened = Create();
|
|
|
|
reopened.Current.Theme.ShouldBe(AppTheme.Dark);
|
|
reopened.Current.ProxyUseFeed.ShouldBeFalse();
|
|
}
|
|
}
|