fix(r2-02): range-validation warnings on the resilience override form (01/S-6 authoring layer)
This commit is contained in:
@@ -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": 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": 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": 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": 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": 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] },
|
{ "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] },
|
||||||
|
|||||||
+14
@@ -33,6 +33,20 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@{ var _warnings = _m.Validate(); }
|
||||||
|
@if (_warnings.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-3" role="alert">
|
||||||
|
<strong>Out-of-range values will be clamped to a safe value on deploy:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var w in _warnings)
|
||||||
|
{
|
||||||
|
<li>@w</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<details class="mt-3">
|
<details class="mt-3">
|
||||||
<summary class="small text-muted">Raw JSON (advanced)</summary>
|
<summary class="small text-muted">Raw JSON (advanced)</summary>
|
||||||
<pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(_m.ToJson() ?? "(null — all tier defaults)")</pre>
|
<pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(_m.ToJson() ?? "(null — all tier defaults)")</pre>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
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;
|
public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Range-validate the per-capability overrides against <see cref="ResiliencePolicyRanges"/> and return
|
||||||
|
/// one human-readable message per violation (naming capability + field + legal range). This is
|
||||||
|
/// authoring-time <b>feedback</b>, not the enforcement layer — the parser's clamp is the authoritative
|
||||||
|
/// guard, so the form still emits. Mirrors the driver tag editors' <c>Validate()</c> idiom (01/S-6).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of range-violation messages; empty when every override is in range or blank.</returns>
|
||||||
|
public IReadOnlyList<string> Validate()
|
||||||
|
{
|
||||||
|
var msgs = new List<string>();
|
||||||
|
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 ReadOpts = new() { PropertyNameCaseInsensitive = true };
|
||||||
private static readonly JsonSerializerOptions WriteOpts = new()
|
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,6 +34,36 @@ public class ResilienceFormModelTests
|
|||||||
m.Policies["Read"].IsEmpty.ShouldBeTrue();
|
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]
|
[Fact]
|
||||||
public void Emitted_json_is_consumable_by_the_runtime_parser()
|
public void Emitted_json_is_consumable_by_the_runtime_parser()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user