test+docs(v3-batch4-wp5): 2-node dual-namespace harness tests + address-space docs
Tests: - OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire, offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse and read; UNS variable Organizes-references its raw node; single-source fan-out parity (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId -> GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds. - Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline. Docs (dual-namespace reality): - CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph. - docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf). - docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples. - docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath. - docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
+75
-3
@@ -29,9 +29,11 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// real <c>AddressSpaceComposer</c> over the SAME seeded config (loaded back from the deploy DB) and pins
|
||||
/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the
|
||||
/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out).
|
||||
/// <para><i>Note:</i> the composer is what WP1 lights up; the artifact-decode seam
|
||||
/// (<c>DeploymentArtifact.ParseComposition</c>) is migrated by WP3, so the dual-tree assertion runs through
|
||||
/// the composer directly rather than the full deploy → artifact round-trip.</para>
|
||||
/// <para><i>Note:</i> the composer is what WP1 lights up. WP3 migrated the artifact-decode seam
|
||||
/// (<c>DeploymentArtifact.ParseComposition</c>) to carry both realms too, so
|
||||
/// <see cref="Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster"/> (added in WP5) pins
|
||||
/// the dual tree through the FULL deploy → persisted-artifact → decode round-trip a driver node applies, and
|
||||
/// additionally asserts the dual-namespace deploy seals across the redundant 2-node cluster.</para>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
|
||||
@@ -142,6 +144,76 @@ public sealed class EquipmentNamespaceMaterializationTests
|
||||
composition.EquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>BATCH-4 harness round-trip + redundancy: deploying the seeded dual-namespace config through the
|
||||
/// REAL deploy pipeline (AdminOperations → ConfigPublishCoordinator → BOTH DriverHostActors) seals on the
|
||||
/// 2-node cluster, AND the persisted artifact round-trips BOTH realms via
|
||||
/// <see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/> — the raw tag as a Raw-realm
|
||||
/// Variable keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
|
||||
/// Organizes/fan-out backing path). This is the harness-level dual-namespace materialization proof: the
|
||||
/// second namespace neither breaks redundant sealing nor is lost across artifact serialization. (WP3 migrated
|
||||
/// the artifact-decode seam to carry both realms, so — unlike the composer-direct test above — this exercises
|
||||
/// the full deploy → persisted-artifact → decode path a driver node actually applies.)</summary>
|
||||
[Fact]
|
||||
public async Task Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
await harness.SeedDefaultClusterAsync("c1");
|
||||
await SeedUnsHierarchyAsync(harness);
|
||||
|
||||
await using var scope = harness.NodeA.Services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
|
||||
|
||||
var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct);
|
||||
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
|
||||
var deploymentId = result.DeploymentId!.Value.Value;
|
||||
|
||||
// Redundancy non-interference: the dual-namespace deploy seals after BOTH DriverHostActors apply — the
|
||||
// second namespace does not break the redundant seal path (the ServiceLevel / primary-gate machinery
|
||||
// is orthogonal to the address-space namespace count; see FailoverDuringDeploy / PrimaryGateFailover).
|
||||
var artifact = Array.Empty<byte>();
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
var d = await db.Deployments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct);
|
||||
if (d is { Status: DeploymentStatus.Sealed, ArtifactBlob.Length: > 0 })
|
||||
{
|
||||
artifact = d.ArtifactBlob;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, TimeSpan.FromSeconds(20));
|
||||
|
||||
await using (var db = await CreateDbAsync(harness))
|
||||
{
|
||||
var nodeStates = await db.NodeDeploymentStates.AsNoTracking()
|
||||
.Where(s => s.DeploymentId == deploymentId)
|
||||
.ToListAsync(Ct);
|
||||
nodeStates.Count.ShouldBe(2, "both cluster nodes record a per-node deployment state");
|
||||
nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied);
|
||||
}
|
||||
|
||||
// Round-trip the PERSISTED artifact through the artifact-decode seam and pin the dual tree.
|
||||
var composition = DeploymentArtifact.ParseComposition(artifact);
|
||||
|
||||
// Raw subtree: the seeded raw tag survives as a Raw-realm Variable keyed by its RawPath.
|
||||
var rawTag = composition.RawTags.ShouldHaveSingleItem();
|
||||
rawTag.TagId.ShouldBe("tag-speed");
|
||||
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||
|
||||
// UNS subtree: the UnsTagReference survives as a UNS-realm Variable carrying the backing RawPath (the
|
||||
// Organizes UNS→Raw target + the fan-out source).
|
||||
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
|
||||
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
unsVar.EquipmentId.ShouldBe(EquipmentId);
|
||||
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||
|
||||
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
|
||||
composition.EquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness)
|
||||
{
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
|
||||
Reference in New Issue
Block a user