diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
index ceee9a94..25de396a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
@@ -178,11 +178,26 @@ public sealed class DriverProbeHandshakeE2eTests
// ControlLogix instead returns ErrorNotFound, which the probe classifies as
// reachable). Whether the `@raw_cpu_type` system-tag fallback is valid on a real
// ControlLogix is a hardware-gated follow-up (AbCipDriverOptions.cs flags it deferred).
+ // ⚠️ The config must use the v3 `RawTags` shape — `[{RawPath, TagConfig, WriteIdempotent,
+ // DeviceName}]`, where the Logix tag path lives inside the TagConfig blob as `tagPath`. This
+ // test passed the retired v2 `Tags: [{DeviceHostAddress, TagPath}]` shape until 2026-08-01, so
+ // AbCipDriverOptions.RawTags bound EMPTY, the probe fell through to its `@raw_cpu_type` system-tag
+ // fallback, and ab_server answered ErrorBadParam. Which is to say it spent that whole time
+ // exercising the one path the comment above says it does not exercise — and reporting it as a
+ // failure of the fixture rather than of the config shape.
var result = await new AbCipDriverProbe().ProbeAsync(
$"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
- $"\"Tags\":[{{\"DeviceHostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\",\"TagPath\":\"TestDINT\"}}]}}",
+ $"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," +
+ $"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," +
+ $"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
- result.Message!.ShouldContain("CIP session OK");
+
+ // EXACT, not ShouldContain. The probe's two green messages are "CIP session OK" (the tag was
+ // read) and "CIP session OK (controller reachable; probe tag not found)" (the fallback), and
+ // `ShouldContain` matches both — so it would have gone green again the moment the config shape
+ // was merely well-formed enough to reach the controller, whether or not the authored tag was
+ // ever resolved. Reading the authored tag is the entire point of this test.
+ result.Message.ShouldBe("CIP session OK");
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
index 61d4a148..085f6d31 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs
@@ -14,29 +14,20 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// admin path — not only the driver path — or every Test Connect button returns
/// "No probe registered for driver type X".
///
+///
+/// ⚠️ The expected set is derived from , not hand-written.
+/// It used to be a literal array kept "in sync" by comment, and it drifted the moment `Sql`
+/// (9b5a9687) and `Mqtt` (f79d13e2) shipped: the count assertion below went red
+/// reporting 12 where 10 expected — a true statement that named neither driver and read like
+/// the probes were wrong rather than the list. Worse is the direction it could NOT catch: a new
+/// driver type registered with no probe passed silently, because a hand-written list grows
+/// only when someone remembers to grow it, and this test exists precisely for the case where nobody
+/// did. Deriving from the constants makes the guard bidirectional and self-maintaining.
+///
public sealed class DriverProbeRegistrationTests
{
- // The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey.
- // Keep in sync with the IDriverProbe registrations in
- // src/Server/.../Host/Drivers/DriverFactoryBootstrap.AddOtOpcUaDriverProbes (the routed
- // *DriverPage.razor flow was retired in v3 Batch 2 — the driver-type keys now live in the /raw
- // modals, but the probe set is the authority for what the AdminUI can Test-connect).
- private static readonly string[] AdminUiDriverTypeKeys =
- [
- "Modbus",
- "AbCip",
- "AbLegacy",
- "S7",
- "TwinCat", // page key; probe reports "TwinCAT" — must resolve case-insensitively
- "Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
- "OpcUaClient",
- "GalaxyMxGateway",
- "Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
- "MTConnect", // MTConnectProbe — read-only Agent driver; probe issues a bare GET /probe
- ];
-
[Fact]
- public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_AdminUI_driver_type()
+ public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_driver_type()
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
@@ -48,8 +39,50 @@ public sealed class DriverProbeRegistrationTests
// (case-insensitive) and would throw on a duplicate key, crashing the singleton.
var byType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
- foreach (var key in AdminUiDriverTypeKeys)
- byType.ContainsKey(key).ShouldBeTrue($"No IDriverProbe registered for AdminUI driver type '{key}'.");
+ var missing = DriverTypeNames.All.Where(t => !byType.ContainsKey(t)).ToList();
+ missing.ShouldBeEmpty(
+ "Driver types with no IDriverProbe — Test Connect returns 'No probe registered for driver "
+ + $"type X' for each: {string.Join(", ", missing)}");
+ }
+
+ [Fact]
+ public void No_probe_is_registered_for_a_driver_type_that_does_not_exist()
+ {
+ // The other direction. A probe whose DriverType matches no DriverTypeNames constant can never
+ // be reached — AdminOperationsActor looks probes up by the authored DriverType — so it is a
+ // typo or a leftover from a renamed driver, and it reads as coverage while providing none.
+ var services = new ServiceCollection();
+ services.AddOtOpcUaDriverProbes();
+
+ using var sp = services.BuildServiceProvider();
+
+ var orphans = sp.GetServices()
+ .Select(p => p.DriverType)
+ .Where(t => !DriverTypeNames.All.Contains(t, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+
+ orphans.ShouldBeEmpty($"Probes for unknown driver types: {string.Join(", ", orphans)}");
+ }
+
+ ///
+ /// Two driver types are spelled differently by the constant and by the AdminUI's own key
+ /// (TwinCAT/TwinCat, FOCAS/Focas), so the lookup must fold case. The
+ /// derived tests above cannot cover this — they compare the constants against themselves.
+ ///
+ [Theory]
+ [InlineData("TwinCat", DriverTypeNames.TwinCAT)]
+ [InlineData("Focas", DriverTypeNames.FOCAS)]
+ [InlineData("modbus", DriverTypeNames.Modbus)]
+ public void A_probe_resolves_by_a_case_drifted_driver_type_key(string authoredKey, string canonical)
+ {
+ var services = new ServiceCollection();
+ services.AddOtOpcUaDriverProbes();
+
+ using var sp = services.BuildServiceProvider();
+ var byType = sp.GetServices().ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
+
+ byType.ShouldContainKey(authoredKey);
+ byType[authoredKey].DriverType.ShouldBe(canonical);
}
[Fact]
@@ -67,6 +100,6 @@ public sealed class DriverProbeRegistrationTests
var distinctTypes = probes.Select(p => p.DriverType).Distinct(StringComparer.OrdinalIgnoreCase).Count();
probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup.");
- distinctTypes.ShouldBe(AdminUiDriverTypeKeys.Length);
+ distinctTypes.ShouldBe(DriverTypeNames.All.Count);
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
index 9e2558b5..1d68ce50 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
@@ -423,6 +423,21 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
ab.WithOtOpcUaClusterBootstrap(sp);
ab.WithOtOpcUaControlPlaneSingletons();
ab.WithOtOpcUaRuntimeActors();
+
+ // Production fidelity, per-cluster mesh Phase 6 (`a2c5d57f`). RedundancyStateActor moved OUT
+ // of WithOtOpcUaControlPlaneSingletons and into its own cluster-role-scoped registration,
+ // which Program.cs makes from its DRIVER branch — a driver-only site pair has no admin node
+ // to elect its Primary. The harness models a fused admin,driver node but was never updated,
+ // so from that commit until now NO node here spawned the singleton: nothing published
+ // `redundancy-state`, every node sat at "role unknown" forever, and PrimaryGateFailoverTests
+ // polled for 45s and timed out.
+ //
+ // This is the second time this exact shape has bitten the harness — see the AddOtOpcUaRuntime
+ // note above, where calling only the Akka-spawn half left IDriverHealthPublisher on its Null
+ // default. When Program.cs grows an actor registration, this list has to grow with it; the
+ // harness's whole value is that it is wired the way a real node is.
+ ab.WithOtOpcUaClusterRedundancySingleton(
+ sp.GetRequiredService>().Value);
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);