Files
av-parser/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs
T
Leonid PershinandClaude Opus 5 f8744c930a Remove the demo text-parsing domain
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>
2026-08-13 22:23:52 +03:00

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();
}
}