diff --git a/.editorconfig b/.editorconfig index bd0de72..dcd945c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,8 +1,19 @@ root = true -# ------------------------------- -# General -# ------------------------------- +# ============================================================ +# Canonical .editorconfig for the NextIteration estate. +# +# Copied verbatim into every governed repo (STANDARD.md §5.2). +# Posture: a DELIBERATE ALLOW-LIST of style gates — there is no +# blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a +# future SDK ships never auto-gates the build. Every rule that IS +# gated below (`:warning`) is a hard build failure under +# `TreatWarningsAsErrors`; that is intentional — the build is what +# forces the code into the ordained style. Code-quality (CA) rules +# keep their `AnalysisLevel=latest` defaults. +# ============================================================ + +# ---------- All files ---------- [*] charset = utf-8 end_of_line = lf @@ -11,99 +22,136 @@ indent_style = space indent_size = 4 trim_trailing_whitespace = true -# ------------------------------- -# C# files -# ------------------------------- -[*.cs] - -indent_size = 4 - -# New lines & braces -csharp_new_line_before_open_brace = all -csharp_prefer_braces = true:warning - -# Using directives -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = true - -# var usage (Spectre-style: pragmatic) -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion - -# Expression-bodied members (used where clean) -csharp_style_expression_bodied_methods = when_on_single_line:suggestion -csharp_style_expression_bodied_constructors = false:suggestion -csharp_style_expression_bodied_operators = when_on_single_line:suggestion -csharp_style_expression_bodied_properties = when_on_single_line:suggestion - -# Pattern matching / modern C# -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion - -# Nullability helpers -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion - -# Readonly fields -dotnet_style_readonly_field = true:suggestion - -# ------------------------------- -# Naming -# ------------------------------- +[*.{csproj,props,targets}] +indent_size = 2 -# Private fields: _camelCase -dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields -dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_with_underscore +[*.{json,yml,yaml}] +indent_size = 2 -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private -# A const IS a field, so without this the rule demands `_nonceSize` for -# `private const int NonceSize` — PascalCase constants are correct .NET style and -# the codebase uses them throughout. Restricting the rule to instance fields keeps -# it aimed at what it was written for. Found when EnforceCodeStyleInBuild surfaced -# 76 IDE1006 violations, every one of them a constant. -dotnet_naming_symbols.private_fields.required_modifiers = - -dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case -dotnet_naming_style.camel_case_with_underscore.required_prefix = _ - -# Interfaces: IMyInterface -dotnet_naming_rule.interfaces_should_start_with_i.severity = suggestion -dotnet_naming_rule.interfaces_should_start_with_i.symbols = interfaces -dotnet_naming_rule.interfaces_should_start_with_i.style = interface_prefix +# Trailing whitespace is a hard line break in Markdown +[*.md] +trim_trailing_whitespace = false -dotnet_naming_symbols.interfaces.applicable_kinds = interface +# ---------- C# ---------- +[*.cs] +indent_size = 4 +# Braces & new lines +csharp_new_line_before_open_brace = all +csharp_prefer_braces = true:warning # braces always (IDE0011) + +# Namespaces — block-scoped estate-wide (IDE0160) +csharp_style_namespace_declarations = block_scoped:warning + +# using directives +csharp_using_directive_placement = outside_namespace:warning # IDE0065 +dotnet_sort_system_directives_first = true # feeds IDE0055 (gated below) +dotnet_separate_import_directive_groups = true # feeds IDE0055 — matches estate style + +# 'this.' qualification — never used in this estate +dotnet_style_qualification_for_field = false:warning # IDE0003 +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# Accessibility — always explicit +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning # IDE0040 + +# var — ordained: var everywhere it is legal (IDE0007) +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = true:warning + +# Expression-bodied members — single-line only; block-bodied constructors (IDE0021-0027) +csharp_style_expression_bodied_methods = when_on_single_line:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_operators = when_on_single_line:warning +csharp_style_expression_bodied_properties = when_on_single_line:warning + +# Pattern matching / null handling (IDE0019/0020/0029/0030/0031) +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_coalesce_expression = true:warning + +# readonly fields (IDE0044) — only flags never-reassigned fields, so genuinely mutable state is safe +dotnet_style_readonly_field = true:warning + +# Modern syntax — gated toward the modern form +dotnet_style_prefer_collection_expression = when_types_loosely_match:warning # IDE0300+ +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning # IDE0090 + +# Primary constructors — NOT forced. IDE0290 is one-directional (it can only push toward primary +# constructors), and forcing them onto service classes with real initialisation is a downgrade. +# Advisory only; existing class primary constructors are left as they are. +csharp_style_prefer_primary_constructors = false:suggestion # IDE0290 + +# Unused expression/assignment values — never nudge toward `_ =` discards (IDE0058/IDE0059). +# House rule: no discard solely to swallow a return value (see CLAUDE.md for the carve-outs). +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:silent + +# ---------- Naming (gated) ---------- +# Matches estate reality: _camelCase private fields (incl. static readonly), PascalCase const +# fields, PascalCase types/members, I-prefixed interfaces, T-prefixed type parameters, camelCase +# locals/parameters. `const_fields` is declared before `private_fields`: a const is a field, so +# both specs match it and precedence decides — const → PascalCase must win. + +# Styles +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_naming_style.camel_case_underscore.required_prefix = _ +dotnet_naming_style.camel_case_underscore.capitalization = camel_case +dotnet_naming_style.camel_case_plain.capitalization = camel_case dotnet_naming_style.interface_prefix.required_prefix = I dotnet_naming_style.interface_prefix.capitalization = pascal_case +dotnet_naming_style.type_param_prefix.required_prefix = T +dotnet_naming_style.type_param_prefix.capitalization = pascal_case -# ------------------------------- -# Analyzers -# ------------------------------- - -# Keep warnings visible but not painful -dotnet_analyzer_diagnostic.severity = warning - -# Unused usings -dotnet_diagnostic.IDE0005.severity = warning - -# Simplification -dotnet_diagnostic.IDE0007.severity = suggestion -dotnet_diagnostic.IDE0008.severity = suggestion - -# Documentation (Spectre.Console is pragmatic here) -dotnet_diagnostic.CS1591.severity = silent - -# ------------------------------- -# JSON / YAML -# ------------------------------- -[*.json] -indent_size = 2 - -[*.yml] -indent_size = 2 - -[*.yaml] -indent_size = 2 \ No newline at end of file +# Symbols +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate +dotnet_naming_symbols.non_field_members.applicable_kinds = property, method, event +dotnet_naming_symbols.const_fields.applicable_kinds = field +dotnet_naming_symbols.const_fields.required_modifiers = const +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected, internal, protected_internal +dotnet_naming_symbols.locals_and_params.applicable_kinds = parameter, local + +# Rules (all warning = gated) +dotnet_naming_rule.interfaces_i.severity = warning +dotnet_naming_rule.interfaces_i.symbols = interfaces +dotnet_naming_rule.interfaces_i.style = interface_prefix + +dotnet_naming_rule.type_params_t.severity = warning +dotnet_naming_rule.type_params_t.symbols = type_parameters +dotnet_naming_rule.type_params_t.style = type_param_prefix + +dotnet_naming_rule.types_pascal.severity = warning +dotnet_naming_rule.types_pascal.symbols = types +dotnet_naming_rule.types_pascal.style = pascal_case + +dotnet_naming_rule.members_pascal.severity = warning +dotnet_naming_rule.members_pascal.symbols = non_field_members +dotnet_naming_rule.members_pascal.style = pascal_case + +dotnet_naming_rule.const_pascal.severity = warning +dotnet_naming_rule.const_pascal.symbols = const_fields +dotnet_naming_rule.const_pascal.style = pascal_case + +dotnet_naming_rule.private_underscore.severity = warning +dotnet_naming_rule.private_underscore.symbols = private_fields +dotnet_naming_rule.private_underscore.style = camel_case_underscore + +dotnet_naming_rule.locals_camel.severity = warning +dotnet_naming_rule.locals_camel.symbols = locals_and_params +dotnet_naming_rule.locals_camel.style = camel_case_plain + +# ---------- Option-less / compiler severities ---------- +dotnet_diagnostic.IDE0005.severity = warning # unnecessary usings (needs GenerateDocumentationFile — set by §1.6) +dotnet_diagnostic.IDE0055.severity = warning # formatting: whitespace, using sort/groups +dotnet_diagnostic.CS1591.severity = warning # public members MUST carry XML docs (STANDARD §1.6, CLAUDE.md non-negotiable) + +# ---------- Deliberate exemptions (advisory, never gate) ---------- +dotnet_diagnostic.IDE0046.severity = suggestion # convert to conditional expression — nested ternaries hurt readability +dotnet_diagnostic.IDE0058.severity = suggestion # unused expression value — see the discard house rule above diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7177a68..662dd1a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,10 +49,26 @@ jobs: # Analyse source only. obj/ and bin/ hold generated and compiled # output — e.g. the xUnit auto-generated entry point — so findings # there are noise against code no human maintains. + # + # query-filters excludes the two audit queries that fire on every + # P/Invoke declaration and call site (cs/unmanaged-code, + # cs/call-to-unmanaged-code). Native-backend packages (Keychain, + # libsecret, DPAPI) exist to call unmanaged code, so these are pure + # noise there and non-native repos have no P/Invoke for them to hit. + # This excludes ONLY those two queries — every other + # security-and-quality query still runs on the interop files, so no + # real finding is lost (STANDARD.md 4.4). This repo has no P/Invoke, + # so the block matches nothing here; it is carried to keep the + # workflow identical to the template (STANDARD.md 3.0.1). config: | paths-ignore: - "**/obj/**" - "**/bin/**" + query-filters: + - exclude: + id: cs/unmanaged-code + - exclude: + id: cs/call-to-unmanaged-code # Explicit build rather than autobuild: these repos multi-target, and # autobuild has picked a single TFM in the past, silently analysing half diff --git a/CHANGELOG.md b/CHANGELOG.md index 597fbdc..de48c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Test-infrastructure and repository maintenance. The library's public API and -target frameworks are unchanged; the only consumer-visible change is a servicing -bump to the `net10.0` `Microsoft.Extensions.DependencyInjection.Abstractions` -floor. +_Nothing yet._ + +## [1.0.0] — 2026-08-21 + +First stable release. This is a milestone, not a rewrite: the public API and the +shipped target frameworks (`net8.0` and `net10.0`) are unchanged from 0.3.0. What +1.0.0 adds is a commitment — from here the public surface follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html) strictly, so a breaking +change to it requires a 2.0.0. The work in this cycle is the test-infrastructure, +repository maintenance and standards alignment below; the only consumer-visible +runtime change is a servicing bump to the `net10.0` +`Microsoft.Extensions.DependencyInjection.Abstractions` floor. ### Changed @@ -83,6 +91,26 @@ floor. - Adopted the canonical `.gitignore` and `.editorconfig`. The `.editorconfig` change scopes the private-field naming rule to instance fields — a `const` is a field, so the rule previously demanded `_nonceSize` for `private const int NonceSize`. +- Enabled `EnforceCodeStyleInBuild` and adopted the revised canonical + `.editorconfig` (`STANDARD.md` 1.2.1 and 5.2). The IDE style analyzers now run + in-build under `TreatWarningsAsErrors`, so the ordained house style — braces + always, block-scoped namespaces, `var` throughout, explicit accessibility, + collection expressions, the naming ruleset, and `CS1591` public-API docs — is + gated by the build rather than merely documented. The `.editorconfig` is now a + deliberate allow-list of named gates rather than a blanket severity, so a style + rule a future SDK ships never auto-fails the build. The clause had been blocked + on exactly that blanket-severity problem. Bringing the code to green under the + flag converted two collection initialisations in `SettingsStore` to collection + expressions and the test project's file-scoped namespaces to block-scoped — no + runtime behaviour changed, and all 64 tests (32 × `net8.0`/`net10.0`) still pass. + The test project also adds `IDE0005` to its `NoWarn` (`STANDARD.md` 2.7): that + rule only runs with `GenerateDocumentationFile` on, which the test project turns + off, so gating it would otherwise force the doc file back on. +- Added a CodeQL `query-filters` block excluding `cs/unmanaged-code` and + `cs/call-to-unmanaged-code` (`STANDARD.md` 4.4). This library has no P/Invoke, so + the filter matches nothing here; it is carried to keep `codeql.yml` aligned with + the canonical template (`STANDARD.md` 3.0.1), which non-native repos share + harmlessly. - Moved the build properties shared by both projects out of the individual csprojs and into the root `Directory.Build.props`. Both projects previously restated the same fifteen properties, which is fifteen chances for one copy to drift silently. @@ -209,7 +237,8 @@ floor. - `TreatWarningsAsErrors=true`, `AnalysisLevel=latest` — zero-warning public API. - Package icon, with the editable source vector kept under `design/icons/`. -[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/compare/v0.3.0...v1.0.0 [0.3.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/releases/tag/v0.3.0 [0.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/releases/tag/v0.2.0 [0.1.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/releases/tag/v0.1.1 diff --git a/Directory.Build.props b/Directory.Build.props index d216bf1..b27eef8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,16 +12,19 @@ GenerateDocumentationFile, because sample and fixture code has no XML docs and TreatWarningsAsErrors would fail the build over it). - EnforceCodeStyleInBuild is deliberately NOT here. STANDARD.md 1.2.1 is - blocked: TreatWarningsAsErrors promotes every advisory .editorconfig - preference to a hard failure, which produced 490 build errors in a sibling - repo. It needs a per-rule gate-versus-advisory decision first. + EnforceCodeStyleInBuild is on (STANDARD.md 1.2.1). It runs the IDE + analyzers in-build, so the canonical .editorconfig (5.2) actually gates + the house style rather than merely documenting it. The clause was blocked + while the blanket severity turned every advisory preference into a hard + failure (490 in a sibling repo); that blanket is gone and the .editorconfig + is now a deliberate allow-list of named gates, so the flag is enforceable. --> enable enable en latest + true true true true diff --git a/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj b/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj index f3070e7..1bb67ff 100644 --- a/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj +++ b/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj @@ -10,7 +10,7 @@ NextIteration.SpectreConsole.Settings - 0.3.0 + 1.0.0 Strongly-typed, JSON-persisted settings for CLI tools, with automatic or explicit persistence and ready-made Spectre.Console settings commands. true $(MSBuildThisFileDirectory)..\..\artifacts\packages diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs index 14c9118..029c49a 100644 --- a/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs @@ -87,9 +87,9 @@ private static async Task ReplaceAtomicallyAsync(string tempPath, string path) } } + // Unique per call so concurrent writers don't collide on a shared + // "{path}.tmp" name. private static string BuildTempPath(string finalPath) => - // Unique per call so concurrent writers don't collide on a shared - // "{path}.tmp" name. $"{finalPath}.{Guid.NewGuid():N}.tmp"; private static void TryDelete(string path) diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs index 2042ac6..189fcc1 100644 --- a/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs @@ -15,7 +15,7 @@ internal sealed class SettingsStore : ISettingsStore { private readonly object _gate = new(); private readonly Dictionary _descriptors; - private readonly Dictionary _entries = new(); + private readonly Dictionary _entries = []; private readonly List _registrations; public SettingsStore(IEnumerable descriptors) @@ -24,15 +24,16 @@ public SettingsStore(IEnumerable descriptors) var ordered = descriptors.ToList(); _descriptors = ordered.ToDictionary(d => d.SettingsType); - _registrations = ordered - .Select(d => new SettingsRegistration + _registrations = + [ + .. ordered.Select(d => new SettingsRegistration { Name = d.Name, SettingsType = d.SettingsType, FilePath = d.FilePath, PersistenceMode = d.PersistenceMode, - }) - .ToList(); + }), + ]; } public IReadOnlyList Registrations => _registrations; diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/CommandFlowTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/CommandFlowTests.cs index ff591a2..fe6eccd 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/CommandFlowTests.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/CommandFlowTests.cs @@ -4,108 +4,109 @@ using Xunit; -namespace NextIteration.SpectreConsole.Settings.Tests; - -/// -/// End-to-end tests of the settings command branch through a real -/// CommandApp, with scripted prompt input. -/// -public sealed class CommandFlowTests +namespace NextIteration.SpectreConsole.Settings.Tests { - private static string FileFor(string directory) => - Path.Combine(directory, typeof(T).Name + ".json"); - - private static void Register(string directory, IServiceCollection services) => - services.AddSettings(o => o.SettingsDirectory = directory); - - // Writes a non-default settings file so a reset has something to undo. - private static Task SeedChangedAsync(string directory) => - File.WriteAllTextAsync( - FileFor(directory), - "{\"Name\":\"changed\",\"Count\":99,\"Mode\":\"Second\"}", - TestContext.Current.CancellationToken); - - private static string LoadedName(string directory) - { - using var provider = new ServiceCollection() - .AddSettings(o => o.SettingsDirectory = directory) - .BuildServiceProvider(); - return provider.GetRequiredService().Name; - } - - [Fact] - public async Task List_RendersRegisteredClassAndValues() + /// + /// End-to-end tests of the settings command branch through a real + /// CommandApp, with scripted prompt input. + /// + public sealed class CommandFlowTests { - using var temp = new TempDir(); - await SeedChangedAsync(temp.Path); - - var result = await CliHarness.RunAsync( - services => Register(temp.Path, services), - ["settings", "list"]); - - Assert.Equal(0, result.ExitCode); - Assert.Contains("SampleSettings", result.Output, StringComparison.Ordinal); - Assert.Contains("changed", result.Output, StringComparison.Ordinal); - } - - [Fact] - public async Task Reset_Declined_DoesNotReset() - { - using var temp = new TempDir(); - await SeedChangedAsync(temp.Path); - - var result = await CliHarness.RunAsync( - services => Register(temp.Path, services), - ["settings", "reset", "SampleSettings"], - consoleInput: "n"); - - Assert.Equal(0, result.ExitCode); - Assert.Contains("cancelled", result.Output, StringComparison.OrdinalIgnoreCase); - // The file is untouched — still the seeded non-default value. - Assert.Equal("changed", LoadedName(temp.Path)); - } - - [Fact] - public async Task Reset_Confirmed_Resets() - { - using var temp = new TempDir(); - await SeedChangedAsync(temp.Path); - - var result = await CliHarness.RunAsync( - services => Register(temp.Path, services), - ["settings", "reset", "SampleSettings"], - consoleInput: "y"); - - Assert.Equal(0, result.ExitCode); - Assert.Equal("default-name", LoadedName(temp.Path)); - } - - [Fact] - public async Task Reset_Force_SkipsPromptAndResets() - { - using var temp = new TempDir(); - await SeedChangedAsync(temp.Path); - - // No console input pushed — if a prompt were shown it would hang/fail. - var result = await CliHarness.RunAsync( - services => Register(temp.Path, services), - ["settings", "reset", "SampleSettings", "--force"]); - - Assert.Equal(0, result.ExitCode); - Assert.Equal("default-name", LoadedName(temp.Path)); - } - - [Fact] - public async Task Reset_UnknownClass_ReportsAndListsAvailable() - { - using var temp = new TempDir(); - - var result = await CliHarness.RunAsync( - services => Register(temp.Path, services), - ["settings", "reset", "NoSuchSettings", "--force"]); - - Assert.Equal(1, result.ExitCode); - Assert.Contains("Unknown settings class", result.Output, StringComparison.Ordinal); - Assert.Contains("SampleSettings", result.Output, StringComparison.Ordinal); + private static string FileFor(string directory) => + Path.Combine(directory, typeof(T).Name + ".json"); + + private static void Register(string directory, IServiceCollection services) => + services.AddSettings(o => o.SettingsDirectory = directory); + + // Writes a non-default settings file so a reset has something to undo. + private static Task SeedChangedAsync(string directory) => + File.WriteAllTextAsync( + FileFor(directory), + "{\"Name\":\"changed\",\"Count\":99,\"Mode\":\"Second\"}", + TestContext.Current.CancellationToken); + + private static string LoadedName(string directory) + { + using var provider = new ServiceCollection() + .AddSettings(o => o.SettingsDirectory = directory) + .BuildServiceProvider(); + return provider.GetRequiredService().Name; + } + + [Fact] + public async Task List_RendersRegisteredClassAndValues() + { + using var temp = new TempDir(); + await SeedChangedAsync(temp.Path); + + var result = await CliHarness.RunAsync( + services => Register(temp.Path, services), + ["settings", "list"]); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("SampleSettings", result.Output, StringComparison.Ordinal); + Assert.Contains("changed", result.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Reset_Declined_DoesNotReset() + { + using var temp = new TempDir(); + await SeedChangedAsync(temp.Path); + + var result = await CliHarness.RunAsync( + services => Register(temp.Path, services), + ["settings", "reset", "SampleSettings"], + consoleInput: "n"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("cancelled", result.Output, StringComparison.OrdinalIgnoreCase); + // The file is untouched — still the seeded non-default value. + Assert.Equal("changed", LoadedName(temp.Path)); + } + + [Fact] + public async Task Reset_Confirmed_Resets() + { + using var temp = new TempDir(); + await SeedChangedAsync(temp.Path); + + var result = await CliHarness.RunAsync( + services => Register(temp.Path, services), + ["settings", "reset", "SampleSettings"], + consoleInput: "y"); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("default-name", LoadedName(temp.Path)); + } + + [Fact] + public async Task Reset_Force_SkipsPromptAndResets() + { + using var temp = new TempDir(); + await SeedChangedAsync(temp.Path); + + // No console input pushed — if a prompt were shown it would hang/fail. + var result = await CliHarness.RunAsync( + services => Register(temp.Path, services), + ["settings", "reset", "SampleSettings", "--force"]); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("default-name", LoadedName(temp.Path)); + } + + [Fact] + public async Task Reset_UnknownClass_ReportsAndListsAvailable() + { + using var temp = new TempDir(); + + var result = await CliHarness.RunAsync( + services => Register(temp.Path, services), + ["settings", "reset", "NoSuchSettings", "--force"]); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("Unknown settings class", result.Output, StringComparison.Ordinal); + Assert.Contains("SampleSettings", result.Output, StringComparison.Ordinal); + } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CliHarness.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CliHarness.cs index d77d680..6ccaf6d 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CliHarness.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CliHarness.cs @@ -6,82 +6,83 @@ using Spectre.Console.Cli; using Spectre.Console.Testing; -namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; - -/// Captured result of running the CLI in a test. -internal sealed record CliResult(int ExitCode, string Output); - -/// -/// Drives the real settings command branch end-to-end: builds a -/// over a DI container (via ), -/// redirects the static to a -/// so prompt input can be scripted and output captured, runs the given args, -/// then restores the console. -/// -/// -/// The commands write to the static AnsiConsole rather than an injected -/// console, so the swap is on the global. Each run restores it in a -/// finally; only this harness touches the global, and xUnit serialises -/// tests within a class, so there's no cross-test bleed. -/// -internal static class CliHarness +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure { - public static async Task RunAsync( - Action configureServices, - string[] args, - params string[] consoleInput) + /// Captured result of running the CLI in a test. + internal sealed record CliResult(int ExitCode, string Output); + + /// + /// Drives the real settings command branch end-to-end: builds a + /// over a DI container (via ), + /// redirects the static to a + /// so prompt input can be scripted and output captured, runs the given args, + /// then restores the console. + /// + /// + /// The commands write to the static AnsiConsole rather than an injected + /// console, so the swap is on the global. Each run restores it in a + /// finally; only this harness touches the global, and xUnit serialises + /// tests within a class, so there's no cross-test bleed. + /// + internal static class CliHarness { - var services = new ServiceCollection(); - configureServices(services); + public static async Task RunAsync( + Action configureServices, + string[] args, + params string[] consoleInput) + { + var services = new ServiceCollection(); + configureServices(services); - var app = new CommandApp(new TypeRegistrar(services)); - app.Configure(config => config.AddSettingsBranch()); + var app = new CommandApp(new TypeRegistrar(services)); + app.Configure(config => config.AddSettingsBranch()); - var console = new TestConsole().Interactive(); - foreach (var line in consoleInput) - { - console.Input.PushTextWithEnter(line); - } + var console = new TestConsole().Interactive(); + foreach (var line in consoleInput) + { + console.Input.PushTextWithEnter(line); + } - var original = AnsiConsole.Console; - AnsiConsole.Console = console; - try - { - var exitCode = await app.RunAsync(args).ConfigureAwait(false); - return new CliResult(exitCode, console.Output); - } - finally - { - AnsiConsole.Console = original; + var original = AnsiConsole.Console; + AnsiConsole.Console = console; + try + { + var exitCode = await app.RunAsync(args).ConfigureAwait(false); + return new CliResult(exitCode, console.Output); + } + finally + { + AnsiConsole.Console = original; + } } } -} - -/// -/// Standard Spectre.Console.Cli adapter over -/// , so DI-constructed commands (which take an -/// ISettingsStore) resolve in tests exactly as they would in a host app. -/// -internal sealed class TypeRegistrar(IServiceCollection builder) : ITypeRegistrar -{ - public ITypeResolver Build() => new TypeResolver(builder.BuildServiceProvider()); - public void Register(Type service, Type implementation) => builder.AddSingleton(service, implementation); + /// + /// Standard Spectre.Console.Cli adapter over + /// , so DI-constructed commands (which take an + /// ISettingsStore) resolve in tests exactly as they would in a host app. + /// + internal sealed class TypeRegistrar(IServiceCollection builder) : ITypeRegistrar + { + public ITypeResolver Build() => new TypeResolver(builder.BuildServiceProvider()); - public void RegisterInstance(Type service, object implementation) => builder.AddSingleton(service, implementation); + public void Register(Type service, Type implementation) => builder.AddSingleton(service, implementation); - public void RegisterLazy(Type service, Func factory) => builder.AddSingleton(service, _ => factory()); -} + public void RegisterInstance(Type service, object implementation) => builder.AddSingleton(service, implementation); -internal sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDisposable -{ - public object? Resolve(Type? type) => type is null ? null : provider.GetService(type); + public void RegisterLazy(Type service, Func factory) => builder.AddSingleton(service, _ => factory()); + } - public void Dispose() + internal sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDisposable { - if (provider is IDisposable disposable) + public object? Resolve(Type? type) => type is null ? null : provider.GetService(type); + + public void Dispose() { - disposable.Dispose(); + if (provider is IDisposable disposable) + { + disposable.Dispose(); + } } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs index 1bae07e..e5266f1 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs @@ -1,30 +1,31 @@ using NextIteration.SpectreConsole.Settings.Persistence; -namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; - -/// -/// Test double for that counts writes (and can -/// optionally fail), letting debounce / explicit / -/// error-surfacing behaviour be asserted deterministically without touching -/// disk. -/// -internal sealed class CountingPersister : ISettingsPersister +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure { - private int _writeCount; - private readonly bool _throwOnWrite; - - public CountingPersister(bool throwOnWrite = false) + /// + /// Test double for that counts writes (and can + /// optionally fail), letting debounce / explicit / + /// error-surfacing behaviour be asserted deterministically without touching + /// disk. + /// + internal sealed class CountingPersister : ISettingsPersister { - _throwOnWrite = throwOnWrite; - } + private int _writeCount; + private readonly bool _throwOnWrite; - public int WriteCount => Volatile.Read(ref _writeCount); + public CountingPersister(bool throwOnWrite = false) + { + _throwOnWrite = throwOnWrite; + } - public Task PersistAsync(SettingsBase settings, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _writeCount); - return _throwOnWrite - ? Task.FromException(new InvalidOperationException("write failed")) - : Task.CompletedTask; + public int WriteCount => Volatile.Read(ref _writeCount); + + public Task PersistAsync(SettingsBase settings, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _writeCount); + return _throwOnWrite + ? Task.FromException(new InvalidOperationException("write failed")) + : Task.CompletedTask; + } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs index f373360..4c4565b 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs @@ -1,66 +1,67 @@ -namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; - -/// Mode enum exercised by the sample settings (round-trips as a string). -public enum SampleMode -{ - First, - Second, -} - -/// -/// Representative consumer settings class used across the test suite: a -/// string, an int, and an enum, each backed by a field and calling -/// OnPropertyChanged() from its setter. -/// -public sealed class SampleSettings : SettingsBase +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure { - private string _name = "default-name"; - private int _count = 1; - private SampleMode _mode = SampleMode.First; + /// Mode enum exercised by the sample settings (round-trips as a string). + public enum SampleMode + { + First, + Second, + } - public string Name + /// + /// Representative consumer settings class used across the test suite: a + /// string, an int, and an enum, each backed by a field and calling + /// OnPropertyChanged() from its setter. + /// + public sealed class SampleSettings : SettingsBase { - get => _name; - set + private string _name = "default-name"; + private int _count = 1; + private SampleMode _mode = SampleMode.First; + + public string Name { - _name = value; - OnPropertyChanged(); + get => _name; + set + { + _name = value; + OnPropertyChanged(); + } } - } - public int Count - { - get => _count; - set + public int Count { - _count = value; - OnPropertyChanged(); + get => _count; + set + { + _count = value; + OnPropertyChanged(); + } } - } - public SampleMode Mode - { - get => _mode; - set + public SampleMode Mode { - _mode = value; - OnPropertyChanged(); + get => _mode; + set + { + _mode = value; + OnPropertyChanged(); + } } } -} -/// A second settings class, so multi-class and reset-all behaviour can be tested. -public sealed class SecondarySettings : SettingsBase -{ - private bool _enabled = true; - - public bool Enabled + /// A second settings class, so multi-class and reset-all behaviour can be tested. + public sealed class SecondarySettings : SettingsBase { - get => _enabled; - set + private bool _enabled = true; + + public bool Enabled { - _enabled = value; - OnPropertyChanged(); + get => _enabled; + set + { + _enabled = value; + OnPropertyChanged(); + } } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs index 39b795c..7e31ee8 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs @@ -1,34 +1,35 @@ -namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; - -/// -/// Throwaway directory under the system temp path. Tests that need a settings -/// directory (or any disk scratch space) should wrap this in using so -/// the directory is recursively removed when the test ends regardless of -/// pass/fail. -/// -internal sealed class TempDir : IDisposable +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure { - public string Path { get; } = - System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ni.scs.tests." + Guid.NewGuid().ToString("N")); - - public TempDir() + /// + /// Throwaway directory under the system temp path. Tests that need a settings + /// directory (or any disk scratch space) should wrap this in using so + /// the directory is recursively removed when the test ends regardless of + /// pass/fail. + /// + internal sealed class TempDir : IDisposable { - Directory.CreateDirectory(Path); - } + public string Path { get; } = + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ni.scs.tests." + Guid.NewGuid().ToString("N")); - public void Dispose() - { - try + public TempDir() { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } + Directory.CreateDirectory(Path); } - catch + + public void Dispose() { - // Best-effort cleanup. Stray scratch dirs in %TEMP% aren't a - // problem — the OS reclaims temp eventually. + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch + { + // Best-effort cleanup. Stray scratch dirs in %TEMP% aren't a + // problem — the OS reclaims temp eventually. + } } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/Wait.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/Wait.cs index 862fcc1..009c663 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/Wait.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/Wait.cs @@ -1,80 +1,81 @@ using System.Diagnostics; -namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; - -/// -/// Waits for an observable effect to appear, rather than sleeping for a fixed -/// interval and hoping it was long enough. -/// -/// -/// Automatic persistence is debounced and fire-and-forget: nothing the caller can -/// await signals that the write finished, so a test can only observe its effect. -/// A fixed sleep has to be sized for the slowest machine that will ever run it, -/// and still fails spuriously when a loaded CI agent overruns the guess. Polling -/// returns as soon as the effect lands and only spends the whole budget on the -/// failure path, where the wait is a genuine timeout rather than dead time. -/// -/// Negative assertions ("nothing was written") can't be polled — there is no -/// effect to wait for — so those keep an explicit delay. That direction is safe: -/// too short a wait can only let a bug through, never fail a correct build. -/// -internal static class Wait +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure { - private static readonly TimeSpan _pollInterval = TimeSpan.FromMilliseconds(10); - - /// - /// Generous upper bound — reached only when the effect never happens, so it - /// costs nothing on a passing run and gives a loaded agent ample headroom. - /// - private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); - /// - /// Polls until it holds, or fails the test with - /// in the message once the budget runs out. + /// Waits for an observable effect to appear, rather than sleeping for a fixed + /// interval and hoping it was long enough. /// - public static async Task UntilAsync( - Func condition, - string expectation, - CancellationToken cancellationToken) + /// + /// Automatic persistence is debounced and fire-and-forget: nothing the caller can + /// await signals that the write finished, so a test can only observe its effect. + /// A fixed sleep has to be sized for the slowest machine that will ever run it, + /// and still fails spuriously when a loaded CI agent overruns the guess. Polling + /// returns as soon as the effect lands and only spends the whole budget on the + /// failure path, where the wait is a genuine timeout rather than dead time. + /// + /// Negative assertions ("nothing was written") can't be polled — there is no + /// effect to wait for — so those keep an explicit delay. That direction is safe: + /// too short a wait can only let a bug through, never fail a correct build. + /// + internal static class Wait { - var elapsed = Stopwatch.StartNew(); + private static readonly TimeSpan _pollInterval = TimeSpan.FromMilliseconds(10); - while (!condition()) + /// + /// Generous upper bound — reached only when the effect never happens, so it + /// costs nothing on a passing run and gives a loaded agent ample headroom. + /// + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); + + /// + /// Polls until it holds, or fails the test with + /// in the message once the budget runs out. + /// + public static async Task UntilAsync( + Func condition, + string expectation, + CancellationToken cancellationToken) { - if (elapsed.Elapsed > _timeout) + var elapsed = Stopwatch.StartNew(); + + while (!condition()) { - throw new TimeoutException( - $"Timed out after {_timeout.TotalSeconds:0}s waiting for {expectation}."); - } + if (elapsed.Elapsed > _timeout) + { + throw new TimeoutException( + $"Timed out after {_timeout.TotalSeconds:0}s waiting for {expectation}."); + } - await Task.Delay(_pollInterval, cancellationToken).ConfigureAwait(false); + await Task.Delay(_pollInterval, cancellationToken).ConfigureAwait(false); + } } - } - /// - /// Polls until exists and contains - /// . - /// - public static Task UntilFileContainsAsync( - string path, - string substring, - CancellationToken cancellationToken) => - UntilAsync( - () => TryReadAllText(path)?.Contains(substring, StringComparison.Ordinal) == true, - $"\"{substring}\" to appear in {path}", - cancellationToken); + /// + /// Polls until exists and contains + /// . + /// + public static Task UntilFileContainsAsync( + string path, + string substring, + CancellationToken cancellationToken) => + UntilAsync( + () => TryReadAllText(path)?.Contains(substring, StringComparison.Ordinal) == true, + $"\"{substring}\" to appear in {path}", + cancellationToken); - // Writes land via a temp file and a rename, so a read can arrive mid-swap. - // A missing or momentarily unreadable file just means "not yet" — poll again. - private static string? TryReadAllText(string path) - { - try - { - return File.Exists(path) ? File.ReadAllText(path) : null; - } - catch (IOException) + // Writes land via a temp file and a rename, so a read can arrive mid-swap. + // A missing or momentarily unreadable file just means "not yet" — poll again. + private static string? TryReadAllText(string path) { - return null; + try + { + return File.Exists(path) ? File.ReadAllText(path) : null; + } + catch (IOException) + { + return null; + } } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/ListSettingsFormattingTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/ListSettingsFormattingTests.cs index eff71e5..7a4615b 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/ListSettingsFormattingTests.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/ListSettingsFormattingTests.cs @@ -2,38 +2,39 @@ using Xunit; -namespace NextIteration.SpectreConsole.Settings.Tests; - -public sealed class ListSettingsFormattingTests +namespace NextIteration.SpectreConsole.Settings.Tests { - private sealed class Nested + public sealed class ListSettingsFormattingTests { - public string Name { get; set; } = "x"; - public int Age { get; set; } = 3; + private sealed class Nested + { + public string Name { get; set; } = "x"; + public int Age { get; set; } = 3; + } + + private sealed class Holder + { + public string Text { get; set; } = "hello"; + public bool Flag { get; set; } = true; + public Nested Complex { get; set; } = new(); + public List Items { get; set; } = ["a", "b"]; + } + + private static string Format(string propertyName) => + ListSettingsCommand.FormatValue(typeof(Holder).GetProperty(propertyName)!, new Holder()); + + [Fact] + public void Scalar_String_RendersPlainly() => Assert.Equal("hello", Format(nameof(Holder.Text))); + + [Fact] + public void Scalar_Bool_RendersPlainly() => Assert.Equal("True", Format(nameof(Holder.Flag))); + + [Fact] + public void ComplexObject_RendersCompactJson() => + Assert.Equal("{\"Name\":\"x\",\"Age\":3}", Format(nameof(Holder.Complex))); + + [Fact] + public void Collection_RendersCompactJson() => + Assert.Equal("[\"a\",\"b\"]", Format(nameof(Holder.Items))); } - - private sealed class Holder - { - public string Text { get; set; } = "hello"; - public bool Flag { get; set; } = true; - public Nested Complex { get; set; } = new(); - public List Items { get; set; } = ["a", "b"]; - } - - private static string Format(string propertyName) => - ListSettingsCommand.FormatValue(typeof(Holder).GetProperty(propertyName)!, new Holder()); - - [Fact] - public void Scalar_String_RendersPlainly() => Assert.Equal("hello", Format(nameof(Holder.Text))); - - [Fact] - public void Scalar_Bool_RendersPlainly() => Assert.Equal("True", Format(nameof(Holder.Flag))); - - [Fact] - public void ComplexObject_RendersCompactJson() => - Assert.Equal("{\"Name\":\"x\",\"Age\":3}", Format(nameof(Holder.Complex))); - - [Fact] - public void Collection_RendersCompactJson() => - Assert.Equal("[\"a\",\"b\"]", Format(nameof(Holder.Items))); } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj b/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj index d3429cd..9134a86 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj @@ -26,8 +26,16 @@ CA2007 (ConfigureAwait) doesn't apply in test contexts; there is no SynchronizationContext to recapture. + + IDE0005 (remove unnecessary usings) only runs in-build when + GenerateDocumentationFile is true, which this project sets to + false. Once EnforceCodeStyleInBuild is on (STANDARD.md 1.2.1) the + canonical .editorconfig gates IDE0005 as a warning, so a test + project would hard-error demanding the doc file be re-enabled. + Suppressing it here resolves that conflict (STANDARD.md 2.7); the + shipping project still gates it, with the doc file on. --> - $(NoWarn);CA1707;CA1515;CA2007 + $(NoWarn);CA1707;CA1515;CA2007;IDE0005 diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs index 7cce4ab..619e8a7 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs @@ -3,59 +3,60 @@ using Xunit; -namespace NextIteration.SpectreConsole.Settings.Tests.Persistence; - -public sealed class AtomicFileTests +namespace NextIteration.SpectreConsole.Settings.Tests.Persistence { - [Fact] - public async Task WriteAllTextAsync_WritesExpectedContent() + public sealed class AtomicFileTests { - using var temp = new TempDir(); - var target = Path.Combine(temp.Path, "file.txt"); + [Fact] + public async Task WriteAllTextAsync_WritesExpectedContent() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); - await AtomicFile.WriteAllTextAsync(target, "hello", TestContext.Current.CancellationToken); + await AtomicFile.WriteAllTextAsync(target, "hello", TestContext.Current.CancellationToken); - Assert.Equal("hello", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken)); - } + Assert.Equal("hello", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken)); + } - [Fact] - public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess() - { - using var temp = new TempDir(); - var target = Path.Combine(temp.Path, "file.txt"); + [Fact] + public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); - await AtomicFile.WriteAllTextAsync(target, "hello", TestContext.Current.CancellationToken); + await AtomicFile.WriteAllTextAsync(target, "hello", TestContext.Current.CancellationToken); - var file = Assert.Single(Directory.GetFiles(temp.Path)); - Assert.Equal(target, file); - } + var file = Assert.Single(Directory.GetFiles(temp.Path)); + Assert.Equal(target, file); + } - [Fact] - public async Task WriteAllTextAsync_OverwritesExisting() - { - using var temp = new TempDir(); - var target = Path.Combine(temp.Path, "file.txt"); - await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken); + [Fact] + public async Task WriteAllTextAsync_OverwritesExisting() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); + await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken); - await AtomicFile.WriteAllTextAsync(target, "replaced", TestContext.Current.CancellationToken); + await AtomicFile.WriteAllTextAsync(target, "replaced", TestContext.Current.CancellationToken); - Assert.Equal("replaced", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken)); - } + Assert.Equal("replaced", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken)); + } - [Fact] - public async Task WriteAllTextAsync_ConcurrentWriters_OneWinsNoStragglers() - { - using var temp = new TempDir(); - var target = Path.Combine(temp.Path, "file.txt"); + [Fact] + public async Task WriteAllTextAsync_ConcurrentWriters_OneWinsNoStragglers() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); - await Task.WhenAll( - AtomicFile.WriteAllTextAsync(target, "writer-a", TestContext.Current.CancellationToken), - AtomicFile.WriteAllTextAsync(target, "writer-b", TestContext.Current.CancellationToken)); + await Task.WhenAll( + AtomicFile.WriteAllTextAsync(target, "writer-a", TestContext.Current.CancellationToken), + AtomicFile.WriteAllTextAsync(target, "writer-b", TestContext.Current.CancellationToken)); - var final = await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken); - Assert.True(final is "writer-a" or "writer-b", $"expected one writer to win, got: {final}"); + var final = await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken); + Assert.True(final is "writer-a" or "writer-b", $"expected one writer to win, got: {final}"); - // Unique temp names mean no leftover ".tmp" files. - Assert.Single(Directory.GetFiles(temp.Path)); + // Unique temp names mean no leftover ".tmp" files. + Assert.Single(Directory.GetFiles(temp.Path)); + } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs index 1ff3962..bbe5813 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs @@ -2,139 +2,140 @@ using Xunit; -namespace NextIteration.SpectreConsole.Settings.Tests; - -public sealed class SettingsBaseTests +namespace NextIteration.SpectreConsole.Settings.Tests { - private static readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(40); - - // Only used for negative assertions — "no write happened" and "no *second* - // write happened" have no effect to wait for, so they need an explicit quiet - // window. Positive assertions poll via Wait instead of sleeping. - private static readonly TimeSpan _quiet = TimeSpan.FromMilliseconds(400); - - [Fact] - public void OnPropertyChanged_WhenUnbound_DoesNotThrow() - { - var settings = new SampleSettings(); - - // No Bind() has happened (mirrors the deserializer setting properties - // during load) — the change must be a silent no-op, not a crash. - var ex = Record.Exception(() => settings.Name = "changed"); - - Assert.Null(ex); - } - - [Fact] - public async Task Automatic_SingleChange_PersistsOnce() - { - var settings = new SampleSettings(); - var persister = new CountingPersister(); - settings.Bind(persister, PersistenceMode.Automatic, static _ => { }, _debounce); - - settings.Name = "changed"; - await Wait.UntilAsync( - () => persister.WriteCount > 0, - "the debounced write to land", - TestContext.Current.CancellationToken); - - Assert.Equal(1, persister.WriteCount); - } - - [Fact] - public async Task Automatic_BurstOfChanges_CoalescesIntoSingleWrite() + public sealed class SettingsBaseTests { - var settings = new SampleSettings(); - var persister = new CountingPersister(); - settings.Bind(persister, PersistenceMode.Automatic, static _ => { }, _debounce); - - // Three synchronous mutations in one call stack must collapse to one - // disk write — each change supersedes the previous pending write. - settings.Name = "a"; - settings.Count = 2; - settings.Mode = SampleMode.Second; - - await Wait.UntilAsync( - () => persister.WriteCount > 0, - "the coalesced write to land", - TestContext.Current.CancellationToken); - - // Broken coalescing would schedule three independent writes; give the - // other two long enough to land before concluding only one happened. - await Task.Delay(_quiet, TestContext.Current.CancellationToken); - - Assert.Equal(1, persister.WriteCount); - } - - [Fact] - public async Task Explicit_PropertyChange_DoesNotPersist() - { - var settings = new SampleSettings(); - var persister = new CountingPersister(); - settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); - - settings.Name = "changed"; - await Task.Delay(_quiet, TestContext.Current.CancellationToken); - - Assert.Equal(0, persister.WriteCount); - } - - [Fact] - public async Task Explicit_SaveAsync_Persists() - { - var settings = new SampleSettings(); - var persister = new CountingPersister(); - settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); - - settings.Name = "changed"; - await settings.SaveAsync(TestContext.Current.CancellationToken); - - Assert.Equal(1, persister.WriteCount); - } - - [Fact] - public async Task Save_FireAndForget_EventuallyPersists() - { - var settings = new SampleSettings(); - var persister = new CountingPersister(); - settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); - - settings.Save(); - await Wait.UntilAsync( - () => persister.WriteCount > 0, - "the fire-and-forget save to land", - TestContext.Current.CancellationToken); - - Assert.Equal(1, persister.WriteCount); - } - - [Fact] - public async Task Automatic_FailedWrite_SurfacesToErrorHandler() - { - Exception? captured = null; - var settings = new SampleSettings(); - var persister = new CountingPersister(throwOnWrite: true); - settings.Bind(persister, PersistenceMode.Automatic, ex => captured = ex, _debounce); - - settings.Name = "boom"; - await Wait.UntilAsync( - () => captured is not null, - "the failed write to reach the error handler", - TestContext.Current.CancellationToken); - - Assert.NotNull(captured); - Assert.IsType(captured); - } - - [Fact] - public async Task SaveAsync_FailedWrite_PropagatesToAwaiter() - { - var settings = new SampleSettings(); - var persister = new CountingPersister(throwOnWrite: true); - settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); - - // SaveAsync is awaitable, so the error reaches the caller directly - // rather than the fire-and-forget handler. - await Assert.ThrowsAsync(() => settings.SaveAsync(TestContext.Current.CancellationToken)); + private static readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(40); + + // Only used for negative assertions — "no write happened" and "no *second* + // write happened" have no effect to wait for, so they need an explicit quiet + // window. Positive assertions poll via Wait instead of sleeping. + private static readonly TimeSpan _quiet = TimeSpan.FromMilliseconds(400); + + [Fact] + public void OnPropertyChanged_WhenUnbound_DoesNotThrow() + { + var settings = new SampleSettings(); + + // No Bind() has happened (mirrors the deserializer setting properties + // during load) — the change must be a silent no-op, not a crash. + var ex = Record.Exception(() => settings.Name = "changed"); + + Assert.Null(ex); + } + + [Fact] + public async Task Automatic_SingleChange_PersistsOnce() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Automatic, static _ => { }, _debounce); + + settings.Name = "changed"; + await Wait.UntilAsync( + () => persister.WriteCount > 0, + "the debounced write to land", + TestContext.Current.CancellationToken); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Automatic_BurstOfChanges_CoalescesIntoSingleWrite() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Automatic, static _ => { }, _debounce); + + // Three synchronous mutations in one call stack must collapse to one + // disk write — each change supersedes the previous pending write. + settings.Name = "a"; + settings.Count = 2; + settings.Mode = SampleMode.Second; + + await Wait.UntilAsync( + () => persister.WriteCount > 0, + "the coalesced write to land", + TestContext.Current.CancellationToken); + + // Broken coalescing would schedule three independent writes; give the + // other two long enough to land before concluding only one happened. + await Task.Delay(_quiet, TestContext.Current.CancellationToken); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Explicit_PropertyChange_DoesNotPersist() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + settings.Name = "changed"; + await Task.Delay(_quiet, TestContext.Current.CancellationToken); + + Assert.Equal(0, persister.WriteCount); + } + + [Fact] + public async Task Explicit_SaveAsync_Persists() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + settings.Name = "changed"; + await settings.SaveAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Save_FireAndForget_EventuallyPersists() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + settings.Save(); + await Wait.UntilAsync( + () => persister.WriteCount > 0, + "the fire-and-forget save to land", + TestContext.Current.CancellationToken); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Automatic_FailedWrite_SurfacesToErrorHandler() + { + Exception? captured = null; + var settings = new SampleSettings(); + var persister = new CountingPersister(throwOnWrite: true); + settings.Bind(persister, PersistenceMode.Automatic, ex => captured = ex, _debounce); + + settings.Name = "boom"; + await Wait.UntilAsync( + () => captured is not null, + "the failed write to reach the error handler", + TestContext.Current.CancellationToken); + + Assert.NotNull(captured); + Assert.IsType(captured); + } + + [Fact] + public async Task SaveAsync_FailedWrite_PropagatesToAwaiter() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(throwOnWrite: true); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + // SaveAsync is awaitable, so the error reaches the caller directly + // rather than the fire-and-forget handler. + await Assert.ThrowsAsync(() => settings.SaveAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs index 232acfe..970094d 100644 --- a/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs @@ -4,226 +4,227 @@ using Xunit; -namespace NextIteration.SpectreConsole.Settings.Tests; - -public sealed class SettingsStoreTests +namespace NextIteration.SpectreConsole.Settings.Tests { - private static readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(40); - - // Negative assertions only — see the note on Wait. Positive ones poll. - private static readonly TimeSpan _quiet = TimeSpan.FromMilliseconds(400); - - private static ServiceProvider BuildProvider(string directory, PersistenceMode mode = PersistenceMode.Automatic) => - new ServiceCollection() - .AddSettings(options => - { - options.SettingsDirectory = directory; - options.PersistenceMode = mode; - options.DebounceInterval = _debounce; - }) - .BuildServiceProvider(); - - private static string FileFor(string directory) => - Path.Combine(directory, typeof(T).Name + ".json"); - - [Fact] - public void AddSettings_WithoutSettingsDirectory_Throws() + public sealed class SettingsStoreTests { - var services = new ServiceCollection(); + private static readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(40); + + // Negative assertions only — see the note on Wait. Positive ones poll. + private static readonly TimeSpan _quiet = TimeSpan.FromMilliseconds(400); + + private static ServiceProvider BuildProvider(string directory, PersistenceMode mode = PersistenceMode.Automatic) => + new ServiceCollection() + .AddSettings(options => + { + options.SettingsDirectory = directory; + options.PersistenceMode = mode; + options.DebounceInterval = _debounce; + }) + .BuildServiceProvider(); + + private static string FileFor(string directory) => + Path.Combine(directory, typeof(T).Name + ".json"); + + [Fact] + public void AddSettings_WithoutSettingsDirectory_Throws() + { + var services = new ServiceCollection(); - var ex = Assert.Throws( - () => services.AddSettings(_ => { })); + var ex = Assert.Throws( + () => services.AddSettings(_ => { })); - Assert.Contains(nameof(SettingsOptions.SettingsDirectory), ex.Message, StringComparison.Ordinal); - } - - [Fact] - public void MissingFile_ResolvesDefaults_WithoutCreatingFile() - { - using var temp = new TempDir(); - using var provider = BuildProvider(temp.Path); + Assert.Contains(nameof(SettingsOptions.SettingsDirectory), ex.Message, StringComparison.Ordinal); + } - var settings = provider.GetRequiredService(); + [Fact] + public void MissingFile_ResolvesDefaults_WithoutCreatingFile() + { + using var temp = new TempDir(); + using var provider = BuildProvider(temp.Path); - Assert.Equal("default-name", settings.Name); - Assert.Equal(1, settings.Count); - Assert.Equal(SampleMode.First, settings.Mode); - // Loading a missing file must not write one — only a mutation does. - Assert.False(File.Exists(FileFor(temp.Path))); - } + var settings = provider.GetRequiredService(); - [Fact] - public void Resolved_Instance_IsSameAsStoreInstance() - { - using var temp = new TempDir(); - using var provider = BuildProvider(temp.Path); + Assert.Equal("default-name", settings.Name); + Assert.Equal(1, settings.Count); + Assert.Equal(SampleMode.First, settings.Mode); + // Loading a missing file must not write one — only a mutation does. + Assert.False(File.Exists(FileFor(temp.Path))); + } - var injected = provider.GetRequiredService(); - var fromStore = provider.GetRequiredService().GetInstance(typeof(SampleSettings)); + [Fact] + public void Resolved_Instance_IsSameAsStoreInstance() + { + using var temp = new TempDir(); + using var provider = BuildProvider(temp.Path); - Assert.Same(injected, fromStore); - } + var injected = provider.GetRequiredService(); + var fromStore = provider.GetRequiredService().GetInstance(typeof(SampleSettings)); - [Fact] - public void Registration_FileIsNamedAfterClass() - { - using var temp = new TempDir(); - using var provider = BuildProvider(temp.Path); + Assert.Same(injected, fromStore); + } - var registration = Assert.Single(provider.GetRequiredService().Registrations); + [Fact] + public void Registration_FileIsNamedAfterClass() + { + using var temp = new TempDir(); + using var provider = BuildProvider(temp.Path); - Assert.Equal("SampleSettings", registration.Name); - Assert.Equal(FileFor(temp.Path), registration.FilePath); - } + var registration = Assert.Single(provider.GetRequiredService().Registrations); - [Fact] - public async Task Automatic_Mutation_PersistsAndRoundTrips() - { - using var temp = new TempDir(); + Assert.Equal("SampleSettings", registration.Name); + Assert.Equal(FileFor(temp.Path), registration.FilePath); + } - await using (var provider = BuildProvider(temp.Path)) + [Fact] + public async Task Automatic_Mutation_PersistsAndRoundTrips() { - var settings = provider.GetRequiredService(); - settings.Name = "persisted"; - settings.Mode = SampleMode.Second; + using var temp = new TempDir(); - // The debounced write is fire-and-forget with nothing to await, and - // disposing the provider does not flush it — so wait for the value to - // actually reach disk before tearing this scope down and reloading. - await Wait.UntilFileContainsAsync( - FileFor(temp.Path), - "persisted", - TestContext.Current.CancellationToken); + await using (var provider = BuildProvider(temp.Path)) + { + var settings = provider.GetRequiredService(); + settings.Name = "persisted"; + settings.Mode = SampleMode.Second; + + // The debounced write is fire-and-forget with nothing to await, and + // disposing the provider does not flush it — so wait for the value to + // actually reach disk before tearing this scope down and reloading. + await Wait.UntilFileContainsAsync( + FileFor(temp.Path), + "persisted", + TestContext.Current.CancellationToken); + } + + // A fresh provider over the same directory must observe the writes. + await using var reloadedProvider = BuildProvider(temp.Path); + var reloaded = reloadedProvider.GetRequiredService(); + + Assert.Equal("persisted", reloaded.Name); + Assert.Equal(SampleMode.Second, reloaded.Mode); } - // A fresh provider over the same directory must observe the writes. - await using var reloadedProvider = BuildProvider(temp.Path); - var reloaded = reloadedProvider.GetRequiredService(); + [Fact] + public async Task Explicit_DoesNotWriteUntilSave() + { + using var temp = new TempDir(); + var file = FileFor(temp.Path); - Assert.Equal("persisted", reloaded.Name); - Assert.Equal(SampleMode.Second, reloaded.Mode); - } + await using var provider = BuildProvider(temp.Path, PersistenceMode.Explicit); + var settings = provider.GetRequiredService(); - [Fact] - public async Task Explicit_DoesNotWriteUntilSave() - { - using var temp = new TempDir(); - var file = FileFor(temp.Path); + settings.Name = "changed"; + await Task.Delay(_quiet, TestContext.Current.CancellationToken); + Assert.False(File.Exists(file)); - await using var provider = BuildProvider(temp.Path, PersistenceMode.Explicit); - var settings = provider.GetRequiredService(); + await settings.SaveAsync(TestContext.Current.CancellationToken); + Assert.True(File.Exists(file)); + } - settings.Name = "changed"; - await Task.Delay(_quiet, TestContext.Current.CancellationToken); - Assert.False(File.Exists(file)); + [Fact] + public async Task TolerantDeserialization_MissingDefaulted_UnknownIgnored() + { + using var temp = new TempDir(); - await settings.SaveAsync(TestContext.Current.CancellationToken); - Assert.True(File.Exists(file)); - } + // Case-insensitive key, one known property, no Count/Mode, plus an + // unknown property the schema has never heard of. + await File.WriteAllTextAsync( + FileFor(temp.Path), + """{ "name": "fromfile", "removedLegacyProperty": 42 }""", + TestContext.Current.CancellationToken); - [Fact] - public async Task TolerantDeserialization_MissingDefaulted_UnknownIgnored() - { - using var temp = new TempDir(); + await using var provider = BuildProvider(temp.Path); + var settings = provider.GetRequiredService(); - // Case-insensitive key, one known property, no Count/Mode, plus an - // unknown property the schema has never heard of. - await File.WriteAllTextAsync( - FileFor(temp.Path), - """{ "name": "fromfile", "removedLegacyProperty": 42 }""", - TestContext.Current.CancellationToken); + Assert.Equal("fromfile", settings.Name); // present (case-insensitive) + Assert.Equal(1, settings.Count); // missing -> default + Assert.Equal(SampleMode.First, settings.Mode); // missing -> default + } - await using var provider = BuildProvider(temp.Path); - var settings = provider.GetRequiredService(); + [Fact] + public async Task CorruptFile_BacksUpAndFallsBackToDefaults() + { + using var temp = new TempDir(); + var file = FileFor(temp.Path); + const string corrupt = "{ this is not valid json "; + await File.WriteAllTextAsync(file, corrupt, TestContext.Current.CancellationToken); + + Exception? surfaced = null; + await using var provider = new ServiceCollection() + .AddSettings(o => + { + o.SettingsDirectory = temp.Path; + o.ErrorHandler = ex => surfaced = ex; // no-op stderr, capture instead + }) + .BuildServiceProvider(); - Assert.Equal("fromfile", settings.Name); // present (case-insensitive) - Assert.Equal(1, settings.Count); // missing -> default - Assert.Equal(SampleMode.First, settings.Mode); // missing -> default - } + var settings = provider.GetRequiredService(); - [Fact] - public async Task CorruptFile_BacksUpAndFallsBackToDefaults() - { - using var temp = new TempDir(); - var file = FileFor(temp.Path); - const string corrupt = "{ this is not valid json "; - await File.WriteAllTextAsync(file, corrupt, TestContext.Current.CancellationToken); - - Exception? surfaced = null; - await using var provider = new ServiceCollection() - .AddSettings(o => - { - o.SettingsDirectory = temp.Path; - o.ErrorHandler = ex => surfaced = ex; // no-op stderr, capture instead - }) - .BuildServiceProvider(); - - var settings = provider.GetRequiredService(); - - // Falls back to defaults rather than throwing on startup... - Assert.Equal("default-name", settings.Name); - // ...the parse error is surfaced... - Assert.IsType(surfaced); - // ...and the unreadable content is preserved as a sidecar. - var backup = file + ".bak"; - Assert.True(File.Exists(backup)); - Assert.Equal(corrupt, await File.ReadAllTextAsync(backup, TestContext.Current.CancellationToken)); - } + // Falls back to defaults rather than throwing on startup... + Assert.Equal("default-name", settings.Name); + // ...the parse error is surfaced... + Assert.IsType(surfaced); + // ...and the unreadable content is preserved as a sidecar. + var backup = file + ".bak"; + Assert.True(File.Exists(backup)); + Assert.Equal(corrupt, await File.ReadAllTextAsync(backup, TestContext.Current.CancellationToken)); + } - [Fact] - public async Task ResetAsync_RestoresDefaults_InPlaceAndOnDisk() - { - using var temp = new TempDir(); + [Fact] + public async Task ResetAsync_RestoresDefaults_InPlaceAndOnDisk() + { + using var temp = new TempDir(); - await using var provider = BuildProvider(temp.Path); - var store = provider.GetRequiredService(); - var settings = provider.GetRequiredService(); + await using var provider = BuildProvider(temp.Path); + var store = provider.GetRequiredService(); + var settings = provider.GetRequiredService(); - settings.Name = "changed"; - settings.Count = 99; - await settings.SaveAsync(TestContext.Current.CancellationToken); + settings.Name = "changed"; + settings.Count = 99; + await settings.SaveAsync(TestContext.Current.CancellationToken); - await store.ResetAsync(typeof(SampleSettings), TestContext.Current.CancellationToken); + await store.ResetAsync(typeof(SampleSettings), TestContext.Current.CancellationToken); - // The live instance the consumer holds is reset in place. - Assert.Equal("default-name", settings.Name); - Assert.Equal(1, settings.Count); + // The live instance the consumer holds is reset in place. + Assert.Equal("default-name", settings.Name); + Assert.Equal(1, settings.Count); - // And the defaults are persisted: a fresh load sees them too. - await using var reloadedProvider = BuildProvider(temp.Path); - var reloaded = reloadedProvider.GetRequiredService(); - Assert.Equal("default-name", reloaded.Name); - Assert.Equal(1, reloaded.Count); - } + // And the defaults are persisted: a fresh load sees them too. + await using var reloadedProvider = BuildProvider(temp.Path); + var reloaded = reloadedProvider.GetRequiredService(); + Assert.Equal("default-name", reloaded.Name); + Assert.Equal(1, reloaded.Count); + } - [Fact] - public async Task ResetAllAsync_RestoresEveryRegisteredClass() - { - using var temp = new TempDir(); + [Fact] + public async Task ResetAllAsync_RestoresEveryRegisteredClass() + { + using var temp = new TempDir(); - await using var provider = new ServiceCollection() - .AddSettings(o => o.SettingsDirectory = temp.Path) - .AddSettings(o => o.SettingsDirectory = temp.Path) - .BuildServiceProvider(); + await using var provider = new ServiceCollection() + .AddSettings(o => o.SettingsDirectory = temp.Path) + .AddSettings(o => o.SettingsDirectory = temp.Path) + .BuildServiceProvider(); - var sample = provider.GetRequiredService(); - var secondary = provider.GetRequiredService(); - sample.Name = "changed"; - secondary.Enabled = false; + var sample = provider.GetRequiredService(); + var secondary = provider.GetRequiredService(); + sample.Name = "changed"; + secondary.Enabled = false; - await provider.GetRequiredService().ResetAllAsync(TestContext.Current.CancellationToken); + await provider.GetRequiredService().ResetAllAsync(TestContext.Current.CancellationToken); - Assert.Equal("default-name", sample.Name); - Assert.True(secondary.Enabled); - } + Assert.Equal("default-name", sample.Name); + Assert.True(secondary.Enabled); + } - [Fact] - public async Task ResetAsync_UnregisteredType_Throws() - { - using var temp = new TempDir(); - await using var provider = BuildProvider(temp.Path); - var store = provider.GetRequiredService(); + [Fact] + public async Task ResetAsync_UnregisteredType_Throws() + { + using var temp = new TempDir(); + await using var provider = BuildProvider(temp.Path); + var store = provider.GetRequiredService(); - await Assert.ThrowsAsync(() => store.ResetAsync(typeof(SecondarySettings), TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => store.ResetAsync(typeof(SecondarySettings), TestContext.Current.CancellationToken)); + } } }