Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs
T
Joseph Doherty cbb882293b refactor(drivers): delete the inert driver-tier recycle machinery (#522)
The tier system was documented, operator-authorable, and inert. Deleted rather than activated,
because its premise is gone rather than merely unused.

"Tier C" meant a driver running out-of-process behind an IDriverSupervisor that could restart its
Host without tearing down the OPC UA session. No such process exists anywhere: Galaxy reaches
MXAccess over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process
Galaxy.Host/Proxy/Shared projects) and FOCAS has run in-process since its managed wire client
landed 2026-04-24. Consistently, IDriverSupervisor had ZERO implementations and there was nothing
for one to implement against.

The issue understated the inertness. It says the Tier-C-only protections never engaged, implying
the Tier A/B parts did. They did not: nothing constructs MemoryTracking, MemoryRecycle or
ScheduledRecycleScheduler outside their own unit tests, so the whole Core/Stability recycle layer
was dead — meaning option (a), "pass real tiers", was never a flag flip. It would have meant
writing the wiring that never existed AND arming it.

Deleted: MemoryTracking, MemoryRecycle, ScheduledRecycleScheduler, IDriverSupervisor, the
vestigial DriverTypeRegistry (referenced only by its own tests), and the RecycleIntervalSeconds
knob — which the AdminUI let an operator author and the parser validated while it configured
nothing.

DriverTier itself SURVIVES and is load-bearing: DriverResilienceOptions.GetTierDefaults supplies
the real per-capability timeout/retry/breaker policies via DriverFactoryRegistry.GetTier and
DriverCapabilityInvokerFactory. Only the isolation-and-recycle layer above it is gone.

Deliberately NOT deleted:

- WedgeDetector came along in the same directory and is equally dead in production, but it is
  tier-agnostic and is not recycle machinery — it only shares the folder. Restored rather than
  swept up in a decision that was not about it.
- IDriver.GetMemoryFootprint() and FlushOptionalCachesAsync() lose their only consumer here.
  Removing them touches all 12 drivers and every test stub, so they are documented as
  consumerless and filed as #525 instead of buried in this diff.

Compatibility: a deployed ResilienceConfig blob still carrying "recycleIntervalSeconds" parses
cleanly (unknown keys are ignored — guarded by a new test, because a blob that suddenly failed to
parse would fall back to tier defaults and silently discard the operator's real overrides), and
the AdminUI's preserve-unknown-keys bag keeps the key rather than rewriting stored config on an
unrelated edit.

The 01/U-6 knob-inertness guard carried an explicit carve-out admitting RecycleIntervalSeconds was
dormant and out of scope; that carve-out is now gone, so the expected set is literally what the
test's name claims.

Note: Host.IntegrationTests has 2 failures (DriverProbeRegistrationTests.is_idempotent,
PrimaryGateFailoverTests) — verified pre-existing by reproducing both on clean master dc9d947b.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-30 04:44:04 -04:00

154 lines
5.5 KiB
C#

using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
public class ResilienceFormModelTests
{
// ---- 04/C-7: non-lossy round-trip ----
[Fact]
public void Unknown_top_level_key_survives_round_trip()
{
// bulkheadMaxConcurrent doubles as the 01/U-6 continuity proof: the deleted knob's key survives.
var json = """{"bulkheadMaxConcurrent":16,"capabilityPolicies":{"Read":{"timeoutSeconds":5}}}""";
var back = ResilienceFormModel.FromJson(json).ToJson();
back.ShouldNotBeNull();
var obj = JsonNode.Parse(back)!.AsObject();
obj["bulkheadMaxConcurrent"]!.GetValue<int>().ShouldBe(16);
obj["capabilityPolicies"]!["Read"]!["timeoutSeconds"]!.GetValue<int>().ShouldBe(5);
}
[Fact]
public void Unknown_capability_entry_survives_round_trip()
{
var json = """{"capabilityPolicies":{"Read":{"timeoutSeconds":5},"FutureCap":{"timeoutSeconds":9}}}""";
var back = ResilienceFormModel.FromJson(json).ToJson();
back.ShouldNotBeNull();
var obj = JsonNode.Parse(back)!.AsObject();
obj["capabilityPolicies"]!["FutureCap"]!["timeoutSeconds"]!.GetValue<int>().ShouldBe(9);
obj["capabilityPolicies"]!["Read"]!["timeoutSeconds"]!.GetValue<int>().ShouldBe(5);
}
[Fact]
public void Unknown_per_policy_field_survives_round_trip()
{
var json = """{"capabilityPolicies":{"Read":{"timeoutSeconds":5,"futureField":7}}}""";
var back = ResilienceFormModel.FromJson(json).ToJson();
back.ShouldNotBeNull();
var read = JsonNode.Parse(back)!.AsObject()["capabilityPolicies"]!["Read"]!.AsObject();
read["timeoutSeconds"]!.GetValue<int>().ShouldBe(5);
read["futureField"]!.GetValue<int>().ShouldBe(7);
}
[Fact]
public void Malformed_json_sets_ParseFailed_and_ToJson_returns_original_text()
{
const string json = "{ not json";
var m = ResilienceFormModel.FromJson(json);
m.ParseFailed.ShouldBeTrue();
m.ToJson().ShouldBe(json); // never overwrite what it couldn't read
}
[Fact]
public void Blank_form_serializes_to_null()
=> new ResilienceFormModel().ToJson().ShouldBeNull();
[Fact]
public void Partial_override_round_trips()
{
var m = new ResilienceFormModel();
m.Policies["Read"].TimeoutSeconds = 5;
m.Policies["Read"].RetryCount = 5;
var json = m.ToJson();
json.ShouldNotBeNull();
var back = ResilienceFormModel.FromJson(json);
back.Policies["Read"].TimeoutSeconds.ShouldBe(5);
back.Policies["Write"].IsEmpty.ShouldBeTrue();
}
/// <summary>
/// The removed <c>recycleIntervalSeconds</c> field (Gitea #522) must be PRESERVED in a stored blob
/// rather than stripped, via the model's preserve-unknown-keys contract. Opening a driver's
/// resilience form and saving an unrelated change must not silently rewrite the operator's stored
/// config — dropping the key is a migration decision, not a side effect of a page visit.
/// </summary>
[Fact]
public void The_removed_recycle_key_survives_a_load_save_as_an_unknown_key()
{
const string stored = """
{ "recycleIntervalSeconds": 3600, "capabilityPolicies": { "Read": { "retryCount": 5 } } }
""";
var m = ResilienceFormModel.FromJson(stored);
m.Policies["Read"].TimeoutSeconds = 9; // an unrelated edit
var json = m.ToJson();
json.ShouldNotBeNull();
json.ShouldContain("recycleIntervalSeconds");
json.ShouldContain("3600");
}
[Fact]
public void Malformed_json_yields_empty_model()
{
var m = ResilienceFormModel.FromJson("{ not valid json");
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()
{
var m = new ResilienceFormModel();
m.Policies["Read"].TimeoutSeconds = 7;
var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag);
diag.ShouldBeNull();
opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
}
}