From 57c224c65ab2c12338a7823e8f77343ac4e9884b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:00:43 -0400 Subject: [PATCH] fix(r2-02): range-validation warnings on the resilience override form (01/S-6 authoring layer) --- ...lience-config-hardening-plan.md.tasks.json | 2 +- .../Drivers/DriverResilienceSection.razor | 14 ++++++++ .../Shared/Drivers/ResilienceFormModel.cs | 33 +++++++++++++++++++ .../ResilienceFormModelTests.cs | 30 +++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json index 7fc196b3..84181ada 100644 --- a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json @@ -6,7 +6,7 @@ { "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "completed", "blockedBy": [0, 1] }, { "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "completed", "blockedBy": [0, 1] }, { "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "completed", "blockedBy": [2, 3] }, - { "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "pending", "blockedBy": [1] }, + { "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] }, { "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "pending", "blockedBy": [2] }, { "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "pending", "blockedBy": [6] }, { "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "pending", "blockedBy": [7] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor index 4a05503f..799957db 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor @@ -33,6 +33,20 @@ + @{ var _warnings = _m.Validate(); } + @if (_warnings.Count > 0) + { + + } +
Raw JSON (advanced)
@(_m.ToJson() ?? "(null — all tier defaults)")
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs index 6d70c4d0..851d7a6d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; @@ -39,6 +40,38 @@ public sealed class ResilienceFormModel public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null; } + /// + /// Range-validate the per-capability overrides against and return + /// one human-readable message per violation (naming capability + field + legal range). This is + /// authoring-time feedback, not the enforcement layer — the parser's clamp is the authoritative + /// guard, so the form still emits. Mirrors the driver tag editors' Validate() idiom (01/S-6). + /// + /// A list of range-violation messages; empty when every override is in range or blank. + public IReadOnlyList Validate() + { + var msgs = new List(); + foreach (var (cap, row) in Policies) + { + if (row.TimeoutSeconds is { } t && + (t < ResiliencePolicyRanges.MinTimeoutSeconds || t > ResiliencePolicyRanges.MaxTimeoutSeconds)) + msgs.Add($"{cap} timeout must be between {ResiliencePolicyRanges.MinTimeoutSeconds} and " + + $"{ResiliencePolicyRanges.MaxTimeoutSeconds} seconds (got {t})."); + + if (row.RetryCount is { } r && + (r < ResiliencePolicyRanges.MinRetryCount || r > ResiliencePolicyRanges.MaxRetryCount)) + msgs.Add($"{cap} retries must be between {ResiliencePolicyRanges.MinRetryCount} and " + + $"{ResiliencePolicyRanges.MaxRetryCount} (got {r})."); + + if (row.BreakerFailureThreshold is { } b && + (b < ResiliencePolicyRanges.MinBreakerFailureThreshold || b == 1 || + b > ResiliencePolicyRanges.MaxBreakerFailureThreshold)) + msgs.Add($"{cap} breaker threshold must be 0 (off) or between " + + $"{ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold} and " + + $"{ResiliencePolicyRanges.MaxBreakerFailureThreshold} (got {b})."); + } + return msgs; + } + private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions WriteOpts = new() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs index cb95310a..60773454 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs @@ -34,6 +34,36 @@ public class ResilienceFormModelTests m.Policies["Read"].IsEmpty.ShouldBeTrue(); } + [Fact] + public void Validate_flags_out_of_range_values_with_capability_and_range() + { + var m = new ResilienceFormModel(); + m.Policies["Read"].TimeoutSeconds = 0; // < MinTimeoutSeconds + m.Policies["Read"].RetryCount = -1; // < MinRetryCount + m.Policies["Read"].BreakerFailureThreshold = 1; // below Polly MinimumThroughput floor + + var msgs = m.Validate(); + + msgs.Count.ShouldBe(3); + msgs.ShouldContain(s => s.Contains("Read") && s.Contains("timeout")); + msgs.ShouldContain(s => s.Contains("Read") && s.Contains("retries")); + msgs.ShouldContain(s => s.Contains("Read") && s.Contains("breaker")); + } + + [Fact] + public void Validate_passes_in_range_and_blank() + { + var blank = new ResilienceFormModel(); + blank.Validate().ShouldBeEmpty(); + + var inRange = new ResilienceFormModel(); + inRange.Policies["Read"].TimeoutSeconds = 5; + inRange.Policies["Read"].RetryCount = 3; + inRange.Policies["Read"].BreakerFailureThreshold = 4; + inRange.Policies["Write"].BreakerFailureThreshold = 0; // 0 = breaker off, legal + inRange.Validate().ShouldBeEmpty(); + } + [Fact] public void Emitted_json_is_consumable_by_the_runtime_parser() {