Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs
Joseph Doherty bd6568bcbd Phase 6.1 Stream B.4 — wire ScheduledRecycleHostedService into bootstrap
Task #125 / #137. The hosted service + scheduler classes already shipped;
this commit connects them to the published-generation driver list so a
Tier C driver with `RecycleIntervalSeconds` in its `ResilienceConfig`
actually gets an armed scheduler at bootstrap.

Wiring:

- `DriverFactoryRegistry.Register` gains an optional `DriverTier`
  parameter (default Tier.A). Existing call sites unchanged —
  `GalaxyProxyDriverFactoryExtensions.Register` explicitly passes
  Tier.C so the bootstrapper can identify out-of-process drivers
  without a per-driver-type allow-list.
- `DriverResilienceOptions` + parser grow `RecycleIntervalSeconds`.
  Tier A/B values are rejected with a diagnostic (decision #74 —
  recycling an in-process driver would kill every OPC UA session).
  Non-positive values are rejected the same way.
- `DriverInstanceBootstrapper` auto-arms a `ScheduledRecycleScheduler`
  after a successful driver register when: (1) the registered tier is
  C, (2) the row's ResilienceConfig carries a positive recycle interval,
  (3) DI has an `IDriverSupervisor` keyed by that `DriverInstanceId`.
  Missing supervisor → warn + skip (no crash). That keeps the wiring
  harmless by default: no driver ships a supervisor today, so the
  hosted service runs with zero schedulers out of the box.
- `Program.cs` registers `ScheduledRecycleHostedService` as singleton
  (shared with `DriverInstanceBootstrapper`) + hosted service (drives
  the tick loop). Constructor changes on the bootstrapper ripple into
  DI resolution automatically.

Tests: 4 new parser tests covering RecycleIntervalSeconds on Tier C
happy path, null default, Tier A/B rejection, non-positive rejection.
Existing 283 Server.Tests + 200 Core.Tests all still green.

No behavioural change for existing deployments: Galaxy driver + any
future Tier C driver gain the opt-in automatically; Tier A/B drivers
(FOCAS, Modbus, S7, AB CIP, AB Legacy, TwinCAT) are structurally
excluded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:58:13 -04:00

209 lines
6.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class DriverResilienceOptionsParserTests
{
[Fact]
public void NullJson_ReturnsPureTierDefaults()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag);
diag.ShouldBeNull();
options.Tier.ShouldBe(DriverTier.A);
options.Resolve(DriverCapability.Read).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
[Fact]
public void WhitespaceJson_ReturnsDefaults()
{
DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag);
diag.ShouldBeNull();
}
[Fact]
public void MalformedJson_FallsBack_WithDiagnostic()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("malformed");
options.Tier.ShouldBe(DriverTier.A);
options.Resolve(DriverCapability.Read).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
[Fact]
public void EmptyObject_ReturnsDefaults()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag);
diag.ShouldBeNull();
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
[Fact]
public void ReadOverride_MergedIntoTierDefaults()
{
var json = """
{
"capabilityPolicies": {
"Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldBeNull();
var read = options.Resolve(DriverCapability.Read);
read.TimeoutSeconds.ShouldBe(5);
read.RetryCount.ShouldBe(7);
read.BreakerFailureThreshold.ShouldBe(2);
// Other capabilities untouched
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
[Fact]
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
{
var json = """
{
"capabilityPolicies": {
"Read": { "retryCount": 10 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
var read = options.Resolve(DriverCapability.Read);
var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
read.RetryCount.ShouldBe(10);
read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default");
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
}
[Fact]
public void BulkheadOverrides_AreHonored()
{
var json = """
{ "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 }
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out _);
options.BulkheadMaxConcurrent.ShouldBe(100);
options.BulkheadMaxQueue.ShouldBe(500);
}
[Fact]
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
{
var json = """
{
"capabilityPolicies": {
"InventedCapability": { "timeoutSeconds": 99 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("InventedCapability");
// Known capabilities untouched.
options.Resolve(DriverCapability.Read).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
[Fact]
public void PropertyNames_AreCaseInsensitive()
{
var json = """
{ "BULKHEADMAXCONCURRENT": 42 }
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
options.BulkheadMaxConcurrent.ShouldBe(42);
}
[Fact]
public void CapabilityName_IsCaseInsensitive()
{
var json = """
{ "capabilityPolicies": { "read": { "retryCount": 99 } } }
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldBeNull();
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
}
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
[InlineData(DriverTier.C)]
public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier)
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag);
diag.ShouldBeNull();
options.Tier.ShouldBe(tier);
foreach (var cap in Enum.GetValues<DriverCapability>())
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
}
[Fact]
public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(
DriverTier.C, "{\"recycleIntervalSeconds\":3600}", out var diag);
diag.ShouldBeNull();
options.RecycleIntervalSeconds.ShouldBe(3600);
}
[Fact]
public void RecycleIntervalSeconds_Null_DefaultsToNull()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, "{}", out _);
options.RecycleIntervalSeconds.ShouldBeNull();
}
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
public void RecycleIntervalSeconds_OnTierAorB_Rejected_With_Diagnostic(DriverTier tier)
{
// Decision #74 — in-process drivers must not scheduled-recycle because it would
// tear down every OPC UA session. The parser surfaces a diagnostic rather than
// silently honouring the value.
var options = DriverResilienceOptionsParser.ParseOrDefaults(
tier, "{\"recycleIntervalSeconds\":3600}", out var diag);
options.RecycleIntervalSeconds.ShouldBeNull();
diag.ShouldContain("Tier C only");
}
[Fact]
public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(
DriverTier.C, "{\"recycleIntervalSeconds\":0}", out var diag);
options.RecycleIntervalSeconds.ShouldBeNull();
diag.ShouldContain("must be positive");
}
}