From 6265e2cb6666671b8db685ae034078597dcf396b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:53:13 +0200 Subject: [PATCH 01/19] Extract settings normalization authority --- src/SightAdapt/SettingsNormalizer.cs | 443 +++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 src/SightAdapt/SettingsNormalizer.cs diff --git a/src/SightAdapt/SettingsNormalizer.cs b/src/SightAdapt/SettingsNormalizer.cs new file mode 100644 index 00000000..33757e3f --- /dev/null +++ b/src/SightAdapt/SettingsNormalizer.cs @@ -0,0 +1,443 @@ +namespace SightAdapt; + +internal static class SettingsNormalizer +{ + public static bool Normalize( + SightAdaptSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var schemaChanged = + settings.SchemaVersion != + SightAdaptSettings.CurrentSchemaVersion; + settings.SchemaVersion = + SightAdaptSettings.CurrentSchemaVersion; + settings.EnsureCollections(); + + var context = + new SettingsNormalizationContext(settings); + CanonicalizeBuiltInProfiles(context); + NormalizeCustomProfiles(context); + NormalizeApplicationOverlayScopes(context); + NormalizeApplications(context); + RepairProfileReferences(context); + context.Commit(settings); + + return schemaChanged || context.Changed; + } + + private static void CanonicalizeBuiltInProfiles( + SettingsNormalizationContext context) + { + var exactInvert = TakeProfile( + context.RemainingProfiles, + VisualProfile.DefaultInvertId); + + if (exactInvert is null) + { + exactInvert = + VisualProfile.CreateDefaultInvert(); + context.MarkChanged(); + } + + if (VisualProfileDefaults + .CanonicalizeExactInvert(exactInvert)) + { + context.MarkChanged(); + } + + context.AddProfile(exactInvert); + + var softInvert = TakeProfile( + context.RemainingProfiles, + VisualProfile.DefaultSoftInvertId); + + if (softInvert is null) + { + softInvert = + VisualProfile.CreateDefaultSoftInvert(); + context.MarkChanged(); + } + + if (VisualProfileDefaults + .CanonicalizeSoftInvert(softInvert)) + { + context.MarkChanged(); + } + + context.AddProfile(softInvert); + } + + private static void NormalizeCustomProfiles( + SettingsNormalizationContext context) + { + foreach (var profile in + context.RemainingProfiles) + { + NormalizeCustomProfileIdentity( + context, + profile); + NormalizeCustomProfileName( + context, + profile); + + if (VisualProfileDefaults + .NormalizeTuningForTransform(profile)) + { + context.MarkChanged(); + } + + context.AddProfile(profile); + } + } + + private static void NormalizeCustomProfileIdentity( + SettingsNormalizationContext context, + VisualProfile profile) + { + var normalizedId = + (profile.Id ?? string.Empty).Trim(); + + if (string.IsNullOrWhiteSpace( + normalizedId) || + VisualProfilePolicy.IsBuiltInId( + normalizedId) || + context.ProfileIds.Contains( + normalizedId)) + { + normalizedId = + VisualProfilePolicy + .CreateUserProfileId( + context.ProfileIds); + context.MarkChanged(); + } + + if (!string.Equals( + profile.Id, + normalizedId, + StringComparison.Ordinal)) + { + profile.Id = normalizedId; + context.MarkChanged(); + } + + var normalizedTransformId = + (profile.TransformId ?? string.Empty) + .Trim() + .ToLowerInvariant(); + + if (!VisualProfilePolicy + .IsSupportedTransformId( + normalizedTransformId)) + { + normalizedTransformId = + SoftInvertVisualTransform.TransformId; + context.MarkChanged(); + } + + if (!string.Equals( + profile.TransformId, + normalizedTransformId, + StringComparison.Ordinal)) + { + profile.TransformId = + normalizedTransformId; + context.MarkChanged(); + } + } + + private static void NormalizeCustomProfileName( + SettingsNormalizationContext context, + VisualProfile profile) + { + var normalizedName = + VisualProfilePolicy + .NormalizeNameOrFallback( + profile.Name, + VisualProfilePolicy + .CustomProfileBaseName); + + if (context.Profiles.Any( + candidate => string.Equals( + candidate.Name, + normalizedName, + StringComparison.OrdinalIgnoreCase))) + { + normalizedName = + VisualProfilePolicy + .CreateUniqueName( + context.Profiles, + normalizedName); + context.MarkChanged(); + } + + if (!string.Equals( + profile.Name, + normalizedName, + StringComparison.Ordinal)) + { + profile.Name = normalizedName; + context.MarkChanged(); + } + } + + private static void NormalizeApplicationOverlayScopes( + SettingsNormalizationContext context) + { + foreach (var application in + context.SourceApplications) + { + var persistedId = + application.OverlayScopeId ?? + string.Empty; + OverlayScopePolicy.TryParseId( + persistedId, + out var scope); + var canonicalId = + OverlayScopePolicy.ToId(scope); + + if (string.Equals( + persistedId, + canonicalId, + StringComparison.Ordinal)) + { + continue; + } + + application.OverlayScopeId = canonicalId; + context.MarkChanged(); + } + } + + private static void NormalizeApplications( + SettingsNormalizationContext context) + { + foreach (var application in + context.SourceApplications) + { + if (NormalizeApplicationStrings( + application)) + { + context.MarkChanged(); + } + + MigrateLegacyEffect( + context, + application); + + if (string.IsNullOrWhiteSpace( + application.ExecutablePath)) + { + context.MarkChanged(); + continue; + } + + if (string.IsNullOrWhiteSpace( + application.ExecutableName)) + { + application.ExecutableName = + Path.GetFileName( + application.ExecutablePath) ?? + string.Empty; + context.MarkChanged(); + } + + if (string.IsNullOrWhiteSpace( + application.ExecutableName) || + !context.ExecutablePaths.Add( + application.ExecutablePath)) + { + context.MarkChanged(); + continue; + } + + if (string.IsNullOrWhiteSpace( + application.DisplayName)) + { + application.DisplayName = + Path.GetFileNameWithoutExtension( + application.ExecutableName) ?? + string.Empty; + context.MarkChanged(); + } + + context.Applications.Add(application); + } + } + + private static void RepairProfileReferences( + SettingsNormalizationContext context) + { + foreach (var application in + context.Applications) + { + if (!string.IsNullOrWhiteSpace( + application.VisualProfileId) && + context.ProfileIds.Contains( + application.VisualProfileId)) + { + continue; + } + + application.VisualProfileId = + VisualProfilePolicy + .MissingReferenceFallbackProfileId; + context.MarkChanged(); + } + } + + private static void MigrateLegacyEffect( + SettingsNormalizationContext context, + ApplicationProfile application) + { + if (!string.IsNullOrWhiteSpace( + application.LegacyEffect)) + { + application.VisualProfileId = + VisualProfile.DefaultInvertId; + application.LegacyEffect = null; + context.MarkChanged(); + } + else if (application.LegacyEffect is not null) + { + application.LegacyEffect = null; + context.MarkChanged(); + } + } + + private static VisualProfile? TakeProfile( + List profiles, + string profileId) + { + var profile = profiles.FirstOrDefault( + candidate => string.Equals( + candidate.Id, + profileId, + StringComparison.OrdinalIgnoreCase)); + + if (profile is not null) + { + profiles.Remove(profile); + } + + return profile; + } + + private static bool NormalizeApplicationStrings( + ApplicationProfile application) + { + var displayName = + (application.DisplayName ?? + string.Empty).Trim(); + var executableName = + (application.ExecutableName ?? + string.Empty).Trim(); + var executablePath = + (application.ExecutablePath ?? + string.Empty).Trim(); + var visualProfileId = + (application.VisualProfileId ?? + string.Empty).Trim(); + + var changed = !string.Equals( + application.DisplayName, + displayName, + StringComparison.Ordinal) || + !string.Equals( + application.ExecutableName, + executableName, + StringComparison.Ordinal) || + !string.Equals( + application.ExecutablePath, + executablePath, + StringComparison.Ordinal) || + !string.Equals( + application.VisualProfileId, + visualProfileId, + StringComparison.Ordinal); + + application.DisplayName = displayName; + application.ExecutableName = executableName; + application.ExecutablePath = executablePath; + application.VisualProfileId = + visualProfileId; + return changed; + } + + private sealed class SettingsNormalizationContext + { + private readonly List + _originalProfiles; + private readonly List + _originalApplications; + + public SettingsNormalizationContext( + SightAdaptSettings settings) + { + _originalProfiles = + settings.VisualProfiles; + _originalApplications = + settings.Applications; + RemainingProfiles = + settings.VisualProfiles + .OfType() + .ToList(); + SourceApplications = + settings.Applications + .OfType() + .ToList(); + + Changed = + RemainingProfiles.Count != + settings.VisualProfiles.Count || + SourceApplications.Count != + settings.Applications.Count; + } + + public bool Changed { get; private set; } + + public List + RemainingProfiles { get; } + + public List + SourceApplications { get; } + + public List Profiles { get; } = []; + + public List + Applications { get; } = []; + + public HashSet ProfileIds { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public HashSet ExecutablePaths { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public void AddProfile( + VisualProfile profile) + { + Profiles.Add(profile); + ProfileIds.Add(profile.Id); + } + + public void MarkChanged() + { + Changed = true; + } + + public void Commit( + SightAdaptSettings settings) + { + if (!_originalProfiles + .SequenceEqual(Profiles) || + !_originalApplications + .SequenceEqual(Applications)) + { + Changed = true; + } + + settings.VisualProfiles = Profiles; + settings.Applications = Applications; + } + } +} From 7ba66c49bfcc13587fb6a17e50aedf5b7b269677 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:53:34 +0200 Subject: [PATCH 02/19] Limit settings store to persistence --- src/SightAdapt/SettingsStore.cs | 458 ++------------------------------ 1 file changed, 21 insertions(+), 437 deletions(-) diff --git a/src/SightAdapt/SettingsStore.cs b/src/SightAdapt/SettingsStore.cs index e7d8c682..72ad46c3 100644 --- a/src/SightAdapt/SettingsStore.cs +++ b/src/SightAdapt/SettingsStore.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text; using System.Text.Json; @@ -61,7 +62,7 @@ public SightAdaptSettings Load() new SightAdaptSettings(); SettingsWereMigrated = - Normalize(settings); + SettingsNormalizer.Normalize(settings); return settings; } catch (JsonException exception) @@ -91,7 +92,7 @@ public void Save(SightAdaptSettings settings) { ArgumentNullException.ThrowIfNull(settings); - Normalize(settings); + SettingsNormalizer.Normalize(settings); var directory = Path.GetDirectoryName(SettingsPath) ?? @@ -101,6 +102,7 @@ public void Save(SightAdaptSettings settings) var temporaryPath = SettingsPath + ".tmp"; + Exception? primaryException = null; try { @@ -117,451 +119,33 @@ public void Save(SightAdaptSettings settings) SettingsPath, true); } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } - } - - internal static bool Normalize( - SightAdaptSettings settings) - { - ArgumentNullException.ThrowIfNull(settings); - - var schemaChanged = - settings.SchemaVersion != - SightAdaptSettings.CurrentSchemaVersion; - settings.SchemaVersion = - SightAdaptSettings.CurrentSchemaVersion; - settings.EnsureCollections(); - - var context = - new SettingsNormalizationContext(settings); - CanonicalizeBuiltInProfiles(context); - NormalizeCustomProfiles(context); - NormalizeApplicationOverlayScopes(context); - NormalizeApplications(context); - RepairProfileReferences(context); - context.Commit(settings); - - return schemaChanged || context.Changed; - } - - private static void CanonicalizeBuiltInProfiles( - SettingsNormalizationContext context) - { - var exactInvert = TakeProfile( - context.RemainingProfiles, - VisualProfile.DefaultInvertId); - - if (exactInvert is null) + catch (Exception exception) { - exactInvert = - VisualProfile.CreateDefaultInvert(); - context.MarkChanged(); + primaryException = exception; + throw; } - - if (VisualProfileDefaults - .CanonicalizeExactInvert(exactInvert)) - { - context.MarkChanged(); - } - - context.AddProfile(exactInvert); - - var softInvert = TakeProfile( - context.RemainingProfiles, - VisualProfile.DefaultSoftInvertId); - - if (softInvert is null) - { - softInvert = - VisualProfile.CreateDefaultSoftInvert(); - context.MarkChanged(); - } - - if (VisualProfileDefaults - .CanonicalizeSoftInvert(softInvert)) - { - context.MarkChanged(); - } - - context.AddProfile(softInvert); - } - - private static void NormalizeCustomProfiles( - SettingsNormalizationContext context) - { - foreach (var profile in - context.RemainingProfiles) - { - NormalizeCustomProfileIdentity( - context, - profile); - NormalizeCustomProfileName( - context, - profile); - - if (VisualProfileDefaults - .NormalizeTuningForTransform(profile)) - { - context.MarkChanged(); - } - - context.AddProfile(profile); - } - } - - private static void NormalizeCustomProfileIdentity( - SettingsNormalizationContext context, - VisualProfile profile) - { - var normalizedId = - (profile.Id ?? string.Empty).Trim(); - - if (string.IsNullOrWhiteSpace( - normalizedId) || - VisualProfilePolicy.IsBuiltInId( - normalizedId) || - context.ProfileIds.Contains( - normalizedId)) - { - normalizedId = - VisualProfilePolicy - .CreateUserProfileId( - context.ProfileIds); - context.MarkChanged(); - } - - if (!string.Equals( - profile.Id, - normalizedId, - StringComparison.Ordinal)) - { - profile.Id = normalizedId; - context.MarkChanged(); - } - - var normalizedTransformId = - (profile.TransformId ?? string.Empty) - .Trim() - .ToLowerInvariant(); - - if (!VisualProfilePolicy - .IsSupportedTransformId( - normalizedTransformId)) - { - normalizedTransformId = - SoftInvertVisualTransform.TransformId; - context.MarkChanged(); - } - - if (!string.Equals( - profile.TransformId, - normalizedTransformId, - StringComparison.Ordinal)) - { - profile.TransformId = - normalizedTransformId; - context.MarkChanged(); - } - } - - private static void NormalizeCustomProfileName( - SettingsNormalizationContext context, - VisualProfile profile) - { - var normalizedName = - VisualProfilePolicy - .NormalizeNameOrFallback( - profile.Name, - VisualProfilePolicy - .CustomProfileBaseName); - - if (context.Profiles.Any( - candidate => string.Equals( - candidate.Name, - normalizedName, - StringComparison.OrdinalIgnoreCase))) - { - normalizedName = - VisualProfilePolicy - .CreateUniqueName( - context.Profiles, - normalizedName); - context.MarkChanged(); - } - - if (!string.Equals( - profile.Name, - normalizedName, - StringComparison.Ordinal)) - { - profile.Name = normalizedName; - context.MarkChanged(); - } - } - - private static void NormalizeApplicationOverlayScopes( - SettingsNormalizationContext context) - { - foreach (var application in - context.SourceApplications) - { - var persistedId = - application.OverlayScopeId ?? - string.Empty; - OverlayScopePolicy.TryParseId( - persistedId, - out var scope); - var canonicalId = - OverlayScopePolicy.ToId(scope); - - if (string.Equals( - persistedId, - canonicalId, - StringComparison.Ordinal)) - { - continue; - } - - application.OverlayScopeId = canonicalId; - context.MarkChanged(); - } - } - - private static void NormalizeApplications( - SettingsNormalizationContext context) - { - foreach (var application in - context.SourceApplications) + finally { - if (NormalizeApplicationStrings( - application)) - { - context.MarkChanged(); - } - - MigrateLegacyEffect( - context, - application); - - if (string.IsNullOrWhiteSpace( - application.ExecutablePath)) + try { - context.MarkChanged(); - continue; + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } } - - if (string.IsNullOrWhiteSpace( - application.ExecutableName)) - { - application.ExecutableName = - Path.GetFileName( - application.ExecutablePath) ?? - string.Empty; - context.MarkChanged(); - } - - if (string.IsNullOrWhiteSpace( - application.ExecutableName) || - !context.ExecutablePaths.Add( - application.ExecutablePath)) + catch (Exception cleanupException) + when (primaryException is not null) { - context.MarkChanged(); - continue; + Debug.WriteLine( + "SightAdapt could not remove the temporary settings " + + $"file after a save failure: {cleanupException}"); } - - if (string.IsNullOrWhiteSpace( - application.DisplayName)) - { - application.DisplayName = - Path.GetFileNameWithoutExtension( - application.ExecutableName) ?? - string.Empty; - context.MarkChanged(); - } - - context.Applications.Add(application); - } - } - - private static void RepairProfileReferences( - SettingsNormalizationContext context) - { - foreach (var application in - context.Applications) - { - if (!string.IsNullOrWhiteSpace( - application.VisualProfileId) && - context.ProfileIds.Contains( - application.VisualProfileId)) - { - continue; - } - - application.VisualProfileId = - VisualProfilePolicy - .MissingReferenceFallbackProfileId; - context.MarkChanged(); - } - } - - private static void MigrateLegacyEffect( - SettingsNormalizationContext context, - ApplicationProfile application) - { - if (!string.IsNullOrWhiteSpace( - application.LegacyEffect)) - { - application.VisualProfileId = - VisualProfile.DefaultInvertId; - application.LegacyEffect = null; - context.MarkChanged(); - } - else if (application.LegacyEffect is not null) - { - application.LegacyEffect = null; - context.MarkChanged(); - } - } - - private static VisualProfile? TakeProfile( - List profiles, - string profileId) - { - var profile = profiles.FirstOrDefault( - candidate => string.Equals( - candidate.Id, - profileId, - StringComparison.OrdinalIgnoreCase)); - - if (profile is not null) - { - profiles.Remove(profile); } - - return profile; - } - - private static bool NormalizeApplicationStrings( - ApplicationProfile application) - { - var displayName = - (application.DisplayName ?? - string.Empty).Trim(); - var executableName = - (application.ExecutableName ?? - string.Empty).Trim(); - var executablePath = - (application.ExecutablePath ?? - string.Empty).Trim(); - var visualProfileId = - (application.VisualProfileId ?? - string.Empty).Trim(); - - var changed = !string.Equals( - application.DisplayName, - displayName, - StringComparison.Ordinal) || - !string.Equals( - application.ExecutableName, - executableName, - StringComparison.Ordinal) || - !string.Equals( - application.ExecutablePath, - executablePath, - StringComparison.Ordinal) || - !string.Equals( - application.VisualProfileId, - visualProfileId, - StringComparison.Ordinal); - - application.DisplayName = displayName; - application.ExecutableName = executableName; - application.ExecutablePath = executablePath; - application.VisualProfileId = - visualProfileId; - return changed; } - private sealed class SettingsNormalizationContext + internal static bool Normalize( + SightAdaptSettings settings) { - private readonly List - _originalProfiles; - private readonly List - _originalApplications; - - public SettingsNormalizationContext( - SightAdaptSettings settings) - { - _originalProfiles = - settings.VisualProfiles; - _originalApplications = - settings.Applications; - RemainingProfiles = - settings.VisualProfiles - .OfType() - .ToList(); - SourceApplications = - settings.Applications - .OfType() - .ToList(); - - Changed = - RemainingProfiles.Count != - settings.VisualProfiles.Count || - SourceApplications.Count != - settings.Applications.Count; - } - - public bool Changed { get; private set; } - - public List - RemainingProfiles { get; } - - public List - SourceApplications { get; } - - public List Profiles { get; } = []; - - public List - Applications { get; } = []; - - public HashSet ProfileIds { get; } = - new(StringComparer.OrdinalIgnoreCase); - - public HashSet ExecutablePaths { get; } = - new(StringComparer.OrdinalIgnoreCase); - - public void AddProfile( - VisualProfile profile) - { - Profiles.Add(profile); - ProfileIds.Add(profile.Id); - } - - public void MarkChanged() - { - Changed = true; - } - - public void Commit( - SightAdaptSettings settings) - { - if (!_originalProfiles - .SequenceEqual(Profiles) || - !_originalApplications - .SequenceEqual(Applications)) - { - Changed = true; - } - - settings.VisualProfiles = Profiles; - settings.Applications = Applications; - } + return SettingsNormalizer.Normalize(settings); } } From 47ce95cfb0e239d2324075be715f0ebac3729bb4 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:54:16 +0200 Subject: [PATCH 03/19] Define read-only settings contract --- src/SightAdapt/ApplicationProfile.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/SightAdapt/ApplicationProfile.cs b/src/SightAdapt/ApplicationProfile.cs index 704f7a25..694e306b 100644 --- a/src/SightAdapt/ApplicationProfile.cs +++ b/src/SightAdapt/ApplicationProfile.cs @@ -2,7 +2,18 @@ namespace SightAdapt; -internal sealed class SightAdaptSettings +internal interface IReadOnlySightAdaptSettings +{ + int SchemaVersion { get; } + + bool AutomaticMode { get; } + + IReadOnlyList Applications { get; } + + IReadOnlyList VisualProfiles { get; } +} + +internal sealed class SightAdaptSettings : IReadOnlySightAdaptSettings { public const int CurrentSchemaVersion = 4; @@ -18,6 +29,12 @@ internal sealed class SightAdaptSettings VisualProfile.CreateDefaultSoftInvert(), ]; + IReadOnlyList + IReadOnlySightAdaptSettings.Applications => Applications; + + IReadOnlyList + IReadOnlySightAdaptSettings.VisualProfiles => VisualProfiles; + public SightAdaptSettings CreateWorkingCopy() { EnsureCollections(); From d4843069568416c73f05bc1833d10248e2224cd0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:54:24 +0200 Subject: [PATCH 04/19] Add explicit settings validation exception --- src/SightAdapt/SettingsValidationException.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/SightAdapt/SettingsValidationException.cs diff --git a/src/SightAdapt/SettingsValidationException.cs b/src/SightAdapt/SettingsValidationException.cs new file mode 100644 index 00000000..2976a662 --- /dev/null +++ b/src/SightAdapt/SettingsValidationException.cs @@ -0,0 +1,9 @@ +namespace SightAdapt; + +internal sealed class SettingsValidationException : Exception +{ + public SettingsValidationException(string message) + : base(message) + { + } +} From 8846350273754d1e4f84c54cc6151ee700df74e4 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:54:53 +0200 Subject: [PATCH 05/19] Protect committed settings transactions --- src/SightAdapt/SettingsCoordinator.cs | 115 ++++++++++++++++++++------ 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/src/SightAdapt/SettingsCoordinator.cs b/src/SightAdapt/SettingsCoordinator.cs index 8d02a14e..ef6385d1 100644 --- a/src/SightAdapt/SettingsCoordinator.cs +++ b/src/SightAdapt/SettingsCoordinator.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + namespace SightAdapt; internal sealed record SettingsCommitResult( @@ -6,15 +9,42 @@ internal sealed record SettingsCommitResult( { public static SettingsCommitResult Success() => new(true, null); - public static SettingsCommitResult Failure(string message) => new(false, message); + public static SettingsCommitResult Failure(string message) => + new(false, message); } -internal sealed record SettingsCommitResult( - bool Succeeded, - T Value, - string? ErrorMessage) +internal sealed class SettingsCommitResult { - public static SettingsCommitResult Success(T value) => new(true, value, null); + private readonly T _value; + + private SettingsCommitResult( + bool succeeded, + T value, + string? errorMessage) + { + Succeeded = succeeded; + _value = value; + ErrorMessage = errorMessage; + } + + public bool Succeeded { get; } + + public string? ErrorMessage { get; } + + public T Value => Succeeded + ? _value + : throw new InvalidOperationException( + "A failed settings commit does not have a value."); + + public bool TryGetValue( + [MaybeNullWhen(false)] out T value) + { + value = _value; + return Succeeded; + } + + public static SettingsCommitResult Success(T value) => + new(true, value, null); public static SettingsCommitResult Failure(string message) => new(false, default!, message); @@ -23,14 +53,21 @@ public static SettingsCommitResult Failure(string message) => internal sealed class SettingsCoordinator { private readonly SettingsStore _store; + private readonly SightAdaptSettings _current; + private readonly Action _reportUnexpectedError; - public SettingsCoordinator(SettingsStore? store = null) + public SettingsCoordinator( + SettingsStore? store = null, + Action? reportUnexpectedError = null) { _store = store ?? new SettingsStore(); - Current = _store.Load(); + _reportUnexpectedError = + reportUnexpectedError ?? ReportUnexpectedError; + _current = _store.Load(); } - public SightAdaptSettings Current { get; } + public IReadOnlySightAdaptSettings Current => + _current.CreateWorkingCopy(); public string SettingsPath => _store.SettingsPath; @@ -40,7 +77,8 @@ public SettingsCoordinator(SettingsStore? store = null) public event EventHandler? Changed; - public SettingsCommitResult Commit(Action mutation) + public SettingsCommitResult Commit( + Action mutation) { ArgumentNullException.ThrowIfNull(mutation); @@ -53,7 +91,8 @@ public SettingsCommitResult Commit(Action mutation) return result.Succeeded ? SettingsCommitResult.Success() : SettingsCommitResult.Failure( - result.ErrorMessage ?? "Settings could not be changed."); + result.ErrorMessage ?? + "Settings could not be changed."); } public SettingsCommitResult Commit( @@ -61,7 +100,7 @@ public SettingsCommitResult Commit( { ArgumentNullException.ThrowIfNull(mutation); - var candidate = Current.CreateWorkingCopy(); + var candidate = _current.CreateWorkingCopy(); T value; try @@ -69,45 +108,69 @@ public SettingsCommitResult Commit( value = mutation(candidate); _store.Save(candidate); } - catch (Exception exception) when (IsExpectedError(exception)) + catch (Exception exception) + when (IsExpectedError(exception)) + { + return SettingsCommitResult.Failure( + FormatError(exception)); + } + catch (Exception exception) { - return SettingsCommitResult.Failure(FormatError(exception)); + _reportUnexpectedError(exception); + throw; } - Current.ReplaceWith(candidate); + _current.ReplaceWith(candidate); Changed?.Invoke(this, EventArgs.Empty); return SettingsCommitResult.Success(value); } public SettingsCommitResult PersistCurrent() { - var candidate = Current.CreateWorkingCopy(); + var candidate = _current.CreateWorkingCopy(); try { _store.Save(candidate); } - catch (Exception exception) when (IsExpectedError(exception)) + catch (Exception exception) + when (IsExpectedError(exception)) { - return SettingsCommitResult.Failure(FormatError(exception)); + return SettingsCommitResult.Failure( + FormatError(exception)); + } + catch (Exception exception) + { + _reportUnexpectedError(exception); + throw; } - Current.ReplaceWith(candidate); + _current.ReplaceWith(candidate); return SettingsCommitResult.Success(); } - private static bool IsExpectedError(Exception exception) + private static bool IsExpectedError( + Exception exception) { - return exception is ArgumentException or - InvalidOperationException or + return exception is SettingsValidationException or IOException or UnauthorizedAccessException; } - private static string FormatError(Exception exception) + private static string FormatError( + Exception exception) + { + return exception is IOException or + UnauthorizedAccessException + ? $"Settings could not be saved: {exception.Message}" + : exception.Message; + } + + private static void ReportUnexpectedError( + Exception exception) { - return exception is IOException or UnauthorizedAccessException - ? $"Settings could not be saved: {exception.Message}" - : exception.Message; + Debug.WriteLine( + $"Unexpected SightAdapt settings transaction failure: " + + $"{exception}"); } } From 765f90adc345c7bbd1c403681fa7b6b49e77e74f Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:55:07 +0200 Subject: [PATCH 06/19] Use read-only settings in profile resolution --- src/SightAdapt/ProfileResolver.cs | 40 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/SightAdapt/ProfileResolver.cs b/src/SightAdapt/ProfileResolver.cs index dceb41aa..1b9e7f9d 100644 --- a/src/SightAdapt/ProfileResolver.cs +++ b/src/SightAdapt/ProfileResolver.cs @@ -3,18 +3,18 @@ namespace SightAdapt; internal static class ProfileResolver { public static ApplicationProfile? FindAssignment( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, ApplicationIdentity identity) { ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(identity); - return settings.Applications?.FirstOrDefault(profile => + return settings.Applications.FirstOrDefault(profile => profile is not null && profile.Matches(identity)); } public static ApplicationProfile? FindAssignmentByExecutablePath( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string? executablePath) { ArgumentNullException.ThrowIfNull(settings); @@ -25,7 +25,7 @@ internal static class ProfileResolver } var normalizedPath = executablePath.Trim(); - return settings.Applications?.FirstOrDefault(profile => + return settings.Applications.FirstOrDefault(profile => profile is not null && string.Equals( profile.ExecutablePath, normalizedPath, @@ -33,28 +33,29 @@ internal static class ProfileResolver } public static ApplicationProfile RequireAssignmentByExecutablePath( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string executablePath) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); return FindAssignmentByExecutablePath( settings, executablePath) ?? - throw new InvalidOperationException( + throw new SettingsValidationException( "The selected application assignment no longer exists."); } public static ApplicationProfile? FindEnabledAssignment( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, ApplicationIdentity identity) { - return FindAssignment(settings, identity) is { Enabled: true } assignment - ? assignment - : null; + return FindAssignment(settings, identity) is + { Enabled: true } assignment + ? assignment + : null; } public static VisualProfile? FindVisualProfile( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string? profileId) { ArgumentNullException.ThrowIfNull(settings); @@ -64,7 +65,7 @@ public static ApplicationProfile RequireAssignmentByExecutablePath( return null; } - return settings.VisualProfiles?.FirstOrDefault(candidate => + return settings.VisualProfiles.FirstOrDefault(candidate => candidate is not null && string.Equals( candidate.Id, profileId.Trim(), @@ -72,17 +73,17 @@ public static ApplicationProfile RequireAssignmentByExecutablePath( } public static VisualProfile RequireVisualProfile( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string profileId) { ArgumentException.ThrowIfNullOrWhiteSpace(profileId); return FindVisualProfile(settings, profileId) ?? - throw new InvalidOperationException( + throw new SettingsValidationException( "The selected visual profile no longer exists."); } public static string ResolveVisualProfileName( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string? profileId, string fallback) { @@ -96,15 +97,18 @@ public static string ResolveVisualProfileName( } public static VisualProfile ResolveVisualProfile( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, ApplicationProfile? assignment) { ArgumentNullException.ThrowIfNull(settings); - return FindVisualProfile(settings, assignment?.VisualProfileId) + return FindVisualProfile( + settings, + assignment?.VisualProfileId) ?? FindVisualProfile( settings, - VisualProfilePolicy.MissingReferenceFallbackProfileId) + VisualProfilePolicy + .MissingReferenceFallbackProfileId) ?? VisualProfile.CreateDefaultInvert(); } } From 2248e8346ec0b54a3736bdc26e84602933577c61 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:56:14 +0200 Subject: [PATCH 07/19] Use explicit application settings validation --- .../ApplicationProfileManagementService.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/SightAdapt/ApplicationProfileManagementService.cs b/src/SightAdapt/ApplicationProfileManagementService.cs index 4e2db181..626a78f2 100644 --- a/src/SightAdapt/ApplicationProfileManagementService.cs +++ b/src/SightAdapt/ApplicationProfileManagementService.cs @@ -54,7 +54,7 @@ public static void AssignVisualProfile( ProfileResolver.FindVisualProfile( settings, visualProfileId) ?? - throw new InvalidOperationException( + throw new SettingsValidationException( $"The visual profile " + $"'{visualProfileId}' does not exist."); @@ -109,7 +109,7 @@ public static int ReassignVisualProfile( ProfileResolver.FindVisualProfile( settings, targetProfileId) ?? - throw new InvalidOperationException( + throw new SettingsValidationException( $"The fallback visual profile " + $"'{targetProfileId}' does not exist."); @@ -132,11 +132,10 @@ assignment is not null && } public static int CountAssignments( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string visualProfileId) { ArgumentNullException.ThrowIfNull(settings); - settings.EnsureCollections(); return settings.Applications.Count( assignment => @@ -180,8 +179,8 @@ private static ApplicationProfileToggleResult private static ( ApplicationProfile Profile, bool WasCreated) GetOrCreate( - SightAdaptSettings settings, - ApplicationIdentity identity) + SightAdaptSettings settings, + ApplicationIdentity identity) { var existing = ProfileResolver.FindAssignment( @@ -233,7 +232,7 @@ private static void EnsureMember( { if (!settings.Applications.Contains(profile)) { - throw new InvalidOperationException( + throw new SettingsValidationException( "The application assignment is not part " + "of the current settings."); } From a25ec13973362c4319345539cfa9c63b4c1860b8 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:56:39 +0200 Subject: [PATCH 08/19] Use explicit visual profile validation --- src/SightAdapt/VisualProfileManagementService.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/SightAdapt/VisualProfileManagementService.cs b/src/SightAdapt/VisualProfileManagementService.cs index 1745d6af..f22c2976 100644 --- a/src/SightAdapt/VisualProfileManagementService.cs +++ b/src/SightAdapt/VisualProfileManagementService.cs @@ -32,7 +32,7 @@ public static VisualProfile Duplicate( if (!source.SupportsTuning) { - throw new InvalidOperationException( + throw new SettingsValidationException( "Only editable visual profiles can be duplicated."); } @@ -79,7 +79,7 @@ public static void UpdateTuning( if (!profile.SupportsTuning) { - throw new InvalidOperationException( + throw new SettingsValidationException( "Only editable visual profiles can be tuned."); } @@ -109,7 +109,7 @@ public static int Delete( if (fallback is null || ReferenceEquals(fallback, profile)) { - throw new InvalidOperationException( + throw new SettingsValidationException( "A valid fallback visual profile is " + "required before deletion."); } @@ -126,12 +126,11 @@ public static int Delete( } public static int CountAssignments( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, VisualProfile profile) { ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(profile); - settings.EnsureCollections(); return ApplicationProfileManagementService .CountAssignments( @@ -148,11 +147,10 @@ public static bool IsBuiltIn( } public static string CreateAvailableName( - SightAdaptSettings settings, + IReadOnlySightAdaptSettings settings, string baseName) { ArgumentNullException.ThrowIfNull(settings); - settings.EnsureCollections(); return VisualProfilePolicy.CreateUniqueName( settings.VisualProfiles, @@ -191,7 +189,7 @@ private static void EnsureMember( { if (!settings.VisualProfiles.Contains(profile)) { - throw new InvalidOperationException( + throw new SettingsValidationException( "The visual profile is not part " + "of the current settings."); } @@ -203,7 +201,7 @@ private static void EnsureUserDefined( { if (IsBuiltIn(profile)) { - throw new InvalidOperationException( + throw new SettingsValidationException( $"Built-in visual profiles cannot be " + $"{operation}."); } From 7020566cc3669a92b4883a3a8cd1223e451fee22 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:57:10 +0200 Subject: [PATCH 09/19] Classify profile name validation explicitly --- src/SightAdapt/VisualProfilePolicy.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/SightAdapt/VisualProfilePolicy.cs b/src/SightAdapt/VisualProfilePolicy.cs index 8f0b9a32..846259a4 100644 --- a/src/SightAdapt/VisualProfilePolicy.cs +++ b/src/SightAdapt/VisualProfilePolicy.cs @@ -111,18 +111,16 @@ public static string ValidateUserName( if (string.IsNullOrWhiteSpace( normalizedName)) { - throw new ArgumentException( - "The visual profile name cannot be empty.", - nameof(name)); + throw new SettingsValidationException( + "The visual profile name cannot be empty."); } if (normalizedName.Length > MaximumNameLength) { - throw new ArgumentException( + throw new SettingsValidationException( $"The visual profile name cannot exceed " + - $"{MaximumNameLength} characters.", - nameof(name)); + $"{MaximumNameLength} characters."); } if (NameExists( @@ -130,7 +128,7 @@ public static string ValidateUserName( normalizedName, exceptProfile)) { - throw new InvalidOperationException( + throw new SettingsValidationException( $"A visual profile named " + $"'{normalizedName}' already exists."); } From 3d35fb9749403ba17ed36b8da73c2a1f2de3e6e4 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:57:27 +0200 Subject: [PATCH 10/19] Publish defensive settings snapshots --- src/SightAdapt/SettingsCoordinator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SightAdapt/SettingsCoordinator.cs b/src/SightAdapt/SettingsCoordinator.cs index ef6385d1..b51a4042 100644 --- a/src/SightAdapt/SettingsCoordinator.cs +++ b/src/SightAdapt/SettingsCoordinator.cs @@ -66,7 +66,7 @@ public SettingsCoordinator( _current = _store.Load(); } - public IReadOnlySightAdaptSettings Current => + public SightAdaptSettings Current => _current.CreateWorkingCopy(); public string SettingsPath => _store.SettingsPath; From 2e075aca9774e8a8b43fbd2e677c9cf014facc29 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:58:04 +0200 Subject: [PATCH 11/19] Test settings snapshot and failure boundaries --- .../SettingsCoordinatorTests.cs | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs b/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs index 34e41c55..c691c590 100644 --- a/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs +++ b/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs @@ -38,6 +38,42 @@ public void SuccessfulCommitPublishesPersistedSnapshot() fileExistedWhenPublished); } + [TestMethod] + public void CurrentReturnsDefensiveSnapshot() + { + using var temporaryDirectory = + new TemporaryDirectory(); + var coordinator = + new SettingsCoordinator( + new SettingsStore(Path.Combine( + temporaryDirectory.Path, + "settings.json"))); + var identity = new ApplicationIdentity( + "Reader", + "reader.exe", + @"C:\Apps\reader.exe"); + var result = coordinator.Commit(settings => + ApplicationProfileManagementService.AddOrEnable( + settings, + identity)); + Assert.IsTrue(result.Succeeded); + + var changedEvents = 0; + coordinator.Changed += (_, _) => changedEvents++; + var snapshot = coordinator.Current; + snapshot.AutomaticMode = false; + snapshot.Applications[0].Enabled = false; + snapshot.Applications.Clear(); + snapshot.VisualProfiles.Clear(); + + var current = coordinator.Current; + Assert.IsTrue(current.AutomaticMode); + Assert.AreEqual(1, current.Applications.Count); + Assert.IsTrue(current.Applications[0].Enabled); + Assert.AreEqual(2, current.VisualProfiles.Count); + Assert.AreEqual(0, changedEvents); + } + [TestMethod] public void FailedPersistenceDoesNotPublishCandidateState() { @@ -101,6 +137,58 @@ public void FailedDomainMutationDoesNotPublishPartialChanges() coordinator.Current.Applications.Count); } + [TestMethod] + public void ValidationFailureDoesNotExposeGenericValue() + { + using var temporaryDirectory = + new TemporaryDirectory(); + var coordinator = + new SettingsCoordinator( + new SettingsStore(Path.Combine( + temporaryDirectory.Path, + "settings.json"))); + + var result = coordinator.Commit(_ => + throw new SettingsValidationException( + "The requested change is invalid.")); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual( + "The requested change is invalid.", + result.ErrorMessage); + Assert.IsFalse(result.TryGetValue(out _)); + Assert.ThrowsException( + () => _ = result.Value); + } + + [TestMethod] + public void UnexpectedMutationFailureIsReportedAndRethrown() + { + using var temporaryDirectory = + new TemporaryDirectory(); + Exception? reported = null; + var coordinator = + new SettingsCoordinator( + new SettingsStore(Path.Combine( + temporaryDirectory.Path, + "settings.json")), + exception => reported = exception); + var changedEvents = 0; + coordinator.Changed += (_, _) => changedEvents++; + + var thrown = Assert.ThrowsException( + () => coordinator.Commit(settings => + { + settings.AutomaticMode = false; + throw new InvalidOperationException( + "programming failure"); + })); + + Assert.AreSame(thrown, reported); + Assert.IsTrue(coordinator.Current.AutomaticMode); + Assert.AreEqual(0, changedEvents); + } + [TestMethod] public void CommitReturnsValueFromPublishedCandidate() { @@ -119,10 +207,12 @@ public void CommitReturnsValueFromPublishedCandidate() "Reader").Id); Assert.IsTrue(result.Succeeded); + Assert.IsTrue(result.TryGetValue(out var profileId)); + Assert.AreEqual(result.Value, profileId); Assert.IsTrue( coordinator.Current.VisualProfiles.Any( profile => - profile.Id == result.Value)); + profile.Id == profileId)); } private sealed class TemporaryDirectory : From 6473cc2affdd87690c08b7528e36b1935df60d67 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:58:59 +0200 Subject: [PATCH 12/19] Preserve validation exception compatibility --- src/SightAdapt/SettingsValidationException.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SightAdapt/SettingsValidationException.cs b/src/SightAdapt/SettingsValidationException.cs index 2976a662..a6b188af 100644 --- a/src/SightAdapt/SettingsValidationException.cs +++ b/src/SightAdapt/SettingsValidationException.cs @@ -1,6 +1,7 @@ namespace SightAdapt; -internal sealed class SettingsValidationException : Exception +internal sealed class SettingsValidationException : + InvalidOperationException { public SettingsValidationException(string message) : base(message) From b5c5ce1471ac287bfa19550268636f6753f5eb54 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:59:22 +0200 Subject: [PATCH 13/19] Track settings normalization mutation authority --- tests/SightAdapt.Tests/ArchitectureComplianceTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/SightAdapt.Tests/ArchitectureComplianceTests.cs b/tests/SightAdapt.Tests/ArchitectureComplianceTests.cs index 47c1df1c..027c2774 100644 --- a/tests/SightAdapt.Tests/ArchitectureComplianceTests.cs +++ b/tests/SightAdapt.Tests/ArchitectureComplianceTests.cs @@ -15,12 +15,12 @@ public void ApplicationAssignmentWritesStayInAuthorities() @"(?m)^(?!\s*string\?\s+VisualProfileId\s*=)\s*.*\bVisualProfileId\s*=", "ApplicationProfile.cs", "ApplicationProfileManagementService.cs", - "SettingsStore.cs", + "SettingsNormalizer.cs", "OverlayController.cs"); AssertPatternRestrictedTo( @"\.Applications\.(Add|Remove)\(", "ApplicationProfileManagementService.cs", - "SettingsStore.cs"); + "SettingsNormalizer.cs"); } [TestMethod] @@ -160,4 +160,4 @@ private static string RepositoryRoot "The SightAdapt repository root could not be located."); } } -} \ No newline at end of file +} From 588076e8458cad1a268d5b2c6e198deca179cb23 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:59:58 +0200 Subject: [PATCH 14/19] Document settings transaction authorities --- docs/ARCHITECTURE.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1023b924..c9047c3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,22 +37,26 @@ CreateWorkingCopy Domain-service mutation ↓ SettingsStore.Save -(normalize and atomic replacement) + ↓ +SettingsNormalizer.Normalize + ↓ +atomic file replacement ↓ Current.ReplaceWith ↓ one synchronous Changed event ``` -A failed mutation or failed write does not replace committed settings and does not publish a settings change. +A failed mutation or failed write does not replace committed settings and does not publish a settings change. `SettingsCoordinator.Current` returns a defensive snapshot, so consumers cannot mutate the committed in-memory object outside a transaction. ## Authorities | Concern | Authority | |---|---| -| Settings transaction | `SettingsCoordinator` | +| Settings transaction and published snapshots | `SettingsCoordinator` | +| Settings JSON persistence and atomic replacement | `SettingsStore` | +| Migration, scope canonicalization, normalization, recovery, and reference repair | `SettingsNormalizer` | | Runtime use-case orchestration | `RuntimeCoordinator` | -| Migration, scope canonicalization, normalization, recovery, and reference repair | `SettingsStore.Normalize` | | Application assignment mutations and overlay scope | `ApplicationProfileManagementService` | | Visual-profile lifecycle and tuning | `VisualProfileManagementService` | | Automatic-mode mutation | `AutomaticModeManagementService` | @@ -72,7 +76,7 @@ A failed mutation or failed write does not replace committed settings and does n | Data or rule | Source of truth | |---|---| -| Persisted automatic mode, applications, assignments, scopes, and profiles | `SightAdaptSettings` committed through `SettingsCoordinator.Current` | +| Persisted automatic mode, applications, assignments, scopes, and profiles | `SightAdaptSettings` committed through `SettingsCoordinator` | | Runtime mode, target, active profile, suppression, and message | `ApplicationStateController.Current` | | Actual overlay resource and target | `OverlayController` and active `MagnifierOverlay` | | Per-application overlay scope | `ApplicationProfile.OverlayScopeId` | @@ -126,7 +130,7 @@ The current backend uses the same rectangle for the magnifier source and overlay ## Architecture test strategy -Architecture checks are behavior-first. Transaction publication, failed persistence, emergency ordering, runtime state transitions, transform catalog consistency, overlay-scope recovery, grid commits, menu roles, preview caching, and profile-manager refresh behavior are exercised through executable tests. +Architecture checks are behavior-first. Transaction publication, defensive settings snapshots, failed persistence, expected and unexpected transaction failures, emergency ordering, runtime state transitions, transform catalog consistency, overlay-scope recovery, grid commits, menu roles, preview caching, and profile-manager refresh behavior are exercised through executable tests. Source inspection is retained only for exhaustive negative rules that cannot be proven by a finite runtime scenario: From ec47d84eb1248eab6f1498ccca67c84c9753befe Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:00:18 +0200 Subject: [PATCH 15/19] Advance version to 0.5.0.32 --- src/SightAdapt/SightAdapt.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SightAdapt/SightAdapt.csproj b/src/SightAdapt/SightAdapt.csproj index 6402b546..5a9e204a 100644 --- a/src/SightAdapt/SightAdapt.csproj +++ b/src/SightAdapt/SightAdapt.csproj @@ -16,10 +16,10 @@ Copyright © $(Company) MIT https://github.com/KeyffMS/SightAdapt - 0.5.0.31 + 0.5.0.32 0.5.0.0 - 0.5.0.31 - 0.5.0.31 + 0.5.0.32 + 0.5.0.32 x64 x64 10.0.19041.0 From 884e162a14a2bf475f3d0864c56a42629ff1a544 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:05:57 +0200 Subject: [PATCH 16/19] Assert explicit application validation failures --- tests/SightAdapt.Tests/ApplicationProfileManagementTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/SightAdapt.Tests/ApplicationProfileManagementTests.cs b/tests/SightAdapt.Tests/ApplicationProfileManagementTests.cs index 247b0802..ac15982e 100644 --- a/tests/SightAdapt.Tests/ApplicationProfileManagementTests.cs +++ b/tests/SightAdapt.Tests/ApplicationProfileManagementTests.cs @@ -34,7 +34,7 @@ public void AssignVisualProfileRequiresExistingProfile() custom.Id); Assert.AreEqual(custom.Id, assignment.VisualProfileId); - Assert.ThrowsException(() => + Assert.ThrowsException(() => ApplicationProfileManagementService.AssignVisualProfile( settings, assignment, @@ -66,9 +66,9 @@ public void DetachedAssignmentCannotBeMutatedOrRemoved() var settings = new SightAdaptSettings(); var detached = new ApplicationProfile(); - Assert.ThrowsException(() => + Assert.ThrowsException(() => ApplicationProfileManagementService.SetEnabled(settings, detached, false)); - Assert.ThrowsException(() => + Assert.ThrowsException(() => ApplicationProfileManagementService.Remove(settings, detached)); } From c308d990e64742baf2786f8c5ed9fef666d57e61 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:06:13 +0200 Subject: [PATCH 17/19] Assert explicit resolver validation failures --- tests/SightAdapt.Tests/ProfileResolverLookupTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/SightAdapt.Tests/ProfileResolverLookupTests.cs b/tests/SightAdapt.Tests/ProfileResolverLookupTests.cs index b05c8c55..5ad0a0d4 100644 --- a/tests/SightAdapt.Tests/ProfileResolverLookupTests.cs +++ b/tests/SightAdapt.Tests/ProfileResolverLookupTests.cs @@ -33,7 +33,7 @@ public void RequiredAssignmentReportsMissingSelection() ProfileResolver.FindAssignmentByExecutablePath( settings, "C:\\Apps\\Missing.exe")); - Assert.ThrowsException(() => + Assert.ThrowsException(() => ProfileResolver.RequireAssignmentByExecutablePath( settings, "C:\\Apps\\Missing.exe")); @@ -56,7 +56,7 @@ public void RequiredVisualProfileUsesCanonicalCaseInsensitiveLookup() [TestMethod] public void RequiredVisualProfileReportsMissingSelection() { - Assert.ThrowsException(() => + Assert.ThrowsException(() => ProfileResolver.RequireVisualProfile( CreateSettings(), "missing-profile")); @@ -94,4 +94,4 @@ private static SightAdaptSettings CreateSettings() }); return settings; } -} \ No newline at end of file +} From 1e0351b5d26d1ee2d57ebd721b21c426a5af9138 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:06:29 +0200 Subject: [PATCH 18/19] Assert explicit tuning validation failures --- tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs b/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs index 18e76182..08efffa4 100644 --- a/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs +++ b/tests/SightAdapt.Tests/VisualProfileTuningAuthorityTests.cs @@ -36,12 +36,12 @@ public void UpdateTuningRejectsDetachedAndExactInvertProfiles() var exact = settings.VisualProfiles.Single( profile => profile.Id == VisualProfile.DefaultInvertId); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.UpdateTuning( settings, detached, detached.CreateWorkingCopy())); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.UpdateTuning( settings, exact, From 6a7968bc510403dcfdb967b3b7b14f8ad64193c6 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:07:32 +0200 Subject: [PATCH 19/19] Assert explicit visual profile validation failures --- .../VisualProfileManagementTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/SightAdapt.Tests/VisualProfileManagementTests.cs b/tests/SightAdapt.Tests/VisualProfileManagementTests.cs index 3022479e..fbfd7a47 100644 --- a/tests/SightAdapt.Tests/VisualProfileManagementTests.cs +++ b/tests/SightAdapt.Tests/VisualProfileManagementTests.cs @@ -53,9 +53,9 @@ public void CreateAndRenameRejectDuplicateNamesCaseInsensitively() var settings = new SightAdaptSettings(); var custom = VisualProfileManagementService.Create(settings, "Reader"); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Create(settings, "reader")); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Rename(settings, custom, "soft invert")); } @@ -66,9 +66,9 @@ public void BuiltInProfilesCannotBeRenamedOrDeleted() var builtIn = settings.VisualProfiles.Single(profile => profile.Id == VisualProfile.DefaultSoftInvertId); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Rename(settings, builtIn, "Changed")); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Delete(settings, builtIn)); } @@ -80,11 +80,11 @@ public void DetachedProfilesCannotBeMutatedThroughLifecycleAuthority() detached.Id = "user-detached"; detached.Name = "Detached"; - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Duplicate(settings, detached, "Copy")); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Rename(settings, detached, "Renamed")); - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Delete(settings, detached)); } @@ -117,7 +117,7 @@ public void DeleteRejectsMissingFallbackWithoutChangingSettings() CreateApplication("C:\\Apps\\Reader.exe", custom.Id), ]; - Assert.ThrowsException(() => + Assert.ThrowsException(() => VisualProfileManagementService.Delete(settings, custom, "missing")); Assert.IsTrue(settings.VisualProfiles.Contains(custom));