feat(opcua): remove SystemPlatform-mirror GalaxyTags contract end-to-end (composer+applier+artifact, byte-parity)

This commit is contained in:
Joseph Doherty
2026-06-12 21:45:19 -04:00
parent 5dfb797817
commit 95be607a07
13 changed files with 167 additions and 431 deletions
@@ -7,19 +7,65 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the artifact-decode mirror (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
/// admits a Galaxy alias tag — an equipment-scoped tag (non-null <c>EquipmentId</c>) bound to a
/// <c>GalaxyMxGateway</c> driver in a <c>SystemPlatform</c>-kind namespace — into the decoded
/// <c>EquipmentTags</c> with byte-parity to the live-edit composer path: same FullName, EquipmentId,
/// DriverInstanceId, Name, DataType. The composer broadens the same filter by DriverType, so both
/// data-contract sites must agree on which tags qualify.
/// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null
/// <c>EquipmentId</c>) bound to a <c>GalaxyMxGateway</c> driver in an <c>Equipment</c>-kind namespace —
/// into the decoded <c>EquipmentTags</c> with byte-parity to the live-edit composer path: same FullName,
/// EquipmentId, DriverInstanceId, Name, DataType. Both data-contract sites gate purely on the namespace
/// Kind being <c>Equipment</c> (no Galaxy/DriverType exception — the SystemPlatform-mirror contract is
/// retired), so they agree on which tags qualify.
/// </summary>
public sealed class DeploymentArtifactAliasParityTests
{
/// <summary>An artifact JSON blob with a GalaxyMxGateway driver in a SystemPlatform (Kind=1)
/// namespace and one equipment-scoped alias tag (EquipmentId set, FolderPath null, FullName = the
/// Galaxy ref). Decode must surface the alias in EquipmentTags carrying its driver-side FullName.</summary>
/// <summary>An artifact JSON blob with a GalaxyMxGateway driver in an Equipment (Kind=0) namespace and
/// one equipment-scoped tag (EquipmentId set, FolderPath null, FullName = the Galaxy ref). Decode must
/// surface the tag in EquipmentTags carrying its driver-side FullName, coalescing the null FolderPath to
/// <c>string.Empty</c>.</summary>
[Fact]
public void ParseComposition_admits_galaxy_alias_tag_in_equipment_tags()
public void ParseComposition_admits_galaxy_equipment_tag_in_equipment_tags()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-eq" },
},
Tags = new object[]
{
new
{
TagId = "tag-galaxy",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
Name = "TestChangingInt",
FolderPath = (string?)null,
DataType = "Int32",
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var tag = c.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-galaxy");
tag.EquipmentId.ShouldBe("eq-1");
tag.DriverInstanceId.ShouldBe("drv-galaxy");
tag.Name.ShouldBe("TestChangingInt");
tag.DataType.ShouldBe("Int32");
tag.FolderPath.ShouldBe(string.Empty);
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
}
/// <summary>An equipment-scoped GalaxyMxGateway tag in a SystemPlatform-kind namespace must NOT surface
/// in EquipmentTags — byte-parity with the composer's pure <c>ns.Kind == NamespaceKind.Equipment</c>
/// predicate. The retired SystemPlatform-mirror contract no longer carried a DriverType exception, so a
/// non-Equipment namespace excludes the tag regardless of driver type.</summary>
[Fact]
public void ParseComposition_excludes_galaxy_tag_in_non_equipment_namespace()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
@@ -32,54 +78,11 @@ public sealed class DeploymentArtifactAliasParityTests
new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-sp" },
},
Tags = new object[]
{
new
{
TagId = "tag-alias",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
Name = "TestChangingInt",
FolderPath = (string?)null,
DataType = "Int32",
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var alias = c.EquipmentTags.ShouldHaveSingleItem();
alias.TagId.ShouldBe("tag-alias");
alias.EquipmentId.ShouldBe("eq-1");
alias.DriverInstanceId.ShouldBe("drv-galaxy");
alias.Name.ShouldBe("TestChangingInt");
alias.DataType.ShouldBe("Int32");
alias.FolderPath.ShouldBe(string.Empty);
alias.FullName.ShouldBe("TestMachine_020.TestChangingInt");
}
/// <summary>An equipment-scoped tag bound to a non-Galaxy driver in a SystemPlatform namespace is
/// NOT a Galaxy alias and must stay excluded from EquipmentTags — the broadened clause keys on the
/// GalaxyMxGateway DriverType, not on the namespace kind, so the contract narrows correctly.</summary>
[Fact]
public void ParseComposition_excludes_non_galaxy_systemplatform_equipment_tag()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sp" },
},
Tags = new object[]
{
new
{
TagId = "tag-x",
DriverInstanceId = "drv-modbus",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
Name = "Source",
FolderPath = (string?)null,
@@ -193,9 +193,10 @@ public sealed class DeploymentArtifactTests
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
/// from the tag's TagConfig blob — the equipment-signal mirror of the Galaxy-tag path. A
/// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must
/// still route to GalaxyTags.
/// from the tag's TagConfig blob. A tag in a non-Equipment (SystemPlatform) namespace with a
/// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure
/// <c>ns.Kind == NamespaceKind.Equipment</c> predicate (the SystemPlatform-mirror contract is
/// retired, so such a tag routes nowhere).
/// </summary>
[Fact]
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
@@ -247,8 +248,9 @@ public sealed class DeploymentArtifactTests
tag.DataType.ShouldBe("Float");
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
// The Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags.
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
// The SystemPlatform tag (null EquipmentId, non-Equipment namespace) does NOT leak into
// EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate.
c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-gx");
}
/// <summary>
@@ -387,15 +389,18 @@ public sealed class DeploymentArtifactTests
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
},
// Galaxy points are ordinary equipment tags now — Equipment-kind namespaces with non-null
// EquipmentId, so the cluster-scoped decode filters them via EquipmentTags (by their driver's
// cluster), exactly as it filtered the retired GalaxyTags.
Namespaces = new[]
{
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 },
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 },
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
},
Tags = new[]
{
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
},
};
@@ -406,18 +411,18 @@ public sealed class DeploymentArtifactTests
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
main.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
main.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
siteA.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
siteA.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
}
[Fact]
public void ParseComposition_scoped_unknown_node_is_empty()
{
var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
comp.GalaxyTags.ShouldBeEmpty();
comp.EquipmentTags.ShouldBeEmpty();
comp.DriverInstancePlans.ShouldBeEmpty();
}
@@ -102,10 +102,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
/// shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver +
/// one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the
/// SITE-A tag (<c>t-sa</c> → variable <c>F.S1</c>) and NOT MAIN's (<c>t-main</c> →
/// <c>F.M1</c>); the mirror holds for the MAIN node. Without the production scoping edit,
/// the unscoped parse would materialise BOTH variables on every node.
/// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped
/// rebuild for the SITE-A node must surface the SITE-A tag (<c>t-sa</c> → folder-scoped
/// variable <c>eq-sa/F/S1</c>) and NOT MAIN's (<c>t-main</c> → <c>eq-main/F/M1</c>); the
/// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse
/// would materialise BOTH variables on every node.
/// </summary>
[Fact]
public void Rebuild_materialises_only_the_nodes_cluster()
@@ -125,10 +126,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
// t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1".
sinkA.Calls.ShouldContain("EV:F.S1");
// t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1")folder-scoped variable "eq-sa/F/S1".
sinkA.Calls.ShouldContain("EV:eq-sa/F/S1");
// t-main (MAIN cluster) must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:F.M1");
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
// --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
var dbM = NewInMemoryDbFactory();
@@ -145,15 +146,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
sinkM.Calls.ShouldContain("EV:F.M1");
sinkM.Calls.ShouldNotContain("EV:F.S1");
sinkM.Calls.ShouldContain("EV:eq-main/F/M1");
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1");
}
/// <summary>
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one SystemPlatform
/// namespace + Galaxy driver + Galaxy tag per cluster. Used by
/// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one Equipment namespace +
/// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now).
/// Used by <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// </summary>
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
@@ -165,6 +166,21 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
},
UnsAreas = new[]
{
new { UnsAreaId = "area-main", ClusterId = "MAIN", Name = "main-area" },
new { UnsAreaId = "area-sa", ClusterId = "SITE-A", Name = "sa-area" },
},
UnsLines = new[]
{
new { UnsLineId = "line-main", UnsAreaId = "area-main", Name = "main-line" },
new { UnsLineId = "line-sa", UnsAreaId = "area-sa", Name = "sa-line" },
},
Equipment = new[]
{
new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" },
new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
@@ -172,13 +188,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
},
Namespaces = new[]
{
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, // NamespaceKind.SystemPlatform
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, // NamespaceKind.Equipment
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
},
Tags = new[]
{
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
},
ScriptedAlarms = Array.Empty<object>(),
});