diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs index 61bafad2..feb93c79 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs @@ -1,6 +1,10 @@ +using System.Linq; using System.Text.Json; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; @@ -96,4 +100,164 @@ public sealed class DeploymentArtifactAliasParityTests c.EquipmentTags.ShouldBeEmpty(); } + + /// + /// The load-bearing direct byte-parity proof for the two equipment-tag producers: for the SAME + /// input draft, the live-edit composer () and the + /// artifact decoder () + /// must emit IDENTICAL EquipmentTags — element-wise equal on every field + /// (TagId, EquipmentId, DriverInstanceId, FolderPath, Name, DataType, FullName) AND in the same + /// ORDER. The draft holds a Galaxy equipment tag (EquipmentId set, GalaxyMxGateway driver in an + /// Equipment-kind namespace, TagConfig {"FullName":"DelmiaReceiver_001.DownloadPath"}) + /// PLUS a Modbus equipment tag, so the parity is proven both for Galaxy specifically and across + /// drivers — Galaxy is now an ordinary Equipment-kind driver with no exception clause on either + /// side. Both sides sort by EquipmentId → FolderPath → Name (Ordinal), so identical input yields + /// identical order. EquipmentTagPlan is a plain positional record (all string members), so + /// SequenceEqual is full value-and-order equality. + /// + [Fact] + public void Composer_and_artifact_agree_on_galaxy_equipment_tag() + { + // ---- The ONE input draft both producers consume ---- + // Equipment-kind namespace shared by both drivers (the qualifying gate on both sides). + var ns = new Namespace + { + NamespaceId = "ns-eq", + ClusterId = "c1", + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:eq", + }; + // Galaxy is a standard Equipment-kind driver now — no driver-type exception on either side. + var galaxyDriver = new DriverInstance + { + DriverInstanceId = "drv-galaxy", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Galaxy1", + DriverType = "GalaxyMxGateway", + DriverConfig = "{}", + }; + var modbusDriver = new DriverInstance + { + DriverInstanceId = "drv-modbus", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Modbus1", + DriverType = "Modbus", + DriverConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var galaxyEquip = new Equipment + { + EquipmentId = "eq-galaxy", + DriverInstanceId = "drv-galaxy", + UnsLineId = "line-1", + Name = "DelmiaReceiver", + MachineCode = "DELMIARECEIVER", + }; + var modbusEquip = new Equipment + { + EquipmentId = "eq-modbus", + DriverInstanceId = "drv-modbus", + UnsLineId = "line-1", + Name = "FillingPump", + MachineCode = "FILLINGPUMP", + }; + + // The Galaxy equipment tag — FullName is the Galaxy ref "tag_name.AttributeName". + var galaxyTag = new Tag + { + TagId = "tag-galaxy", + DriverInstanceId = "drv-galaxy", + EquipmentId = "eq-galaxy", + FolderPath = null, // coalesces to "" on both sides + Name = "DownloadPath", + DataType = "String", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}", + }; + // A non-Galaxy (Modbus) equipment tag — proves the parity holds across drivers, not just Galaxy. + var modbusTag = new Tag + { + TagId = "tag-modbus", + DriverInstanceId = "drv-modbus", + EquipmentId = "eq-modbus", + FolderPath = "registers", + Name = "Speed", + DataType = "Float", + AccessLevel = TagAccessLevel.ReadWrite, + TagConfig = "{\"FullName\":\"40001\"}", + }; + + var areas = new[] { area }; + var lines = new[] { line }; + var equipment = new[] { galaxyEquip, modbusEquip }; + var drivers = new[] { galaxyDriver, modbusDriver }; + var tags = new[] { galaxyTag, modbusTag }; + var namespaces = new[] { ns }; + + // ---- Side 1: the live-edit composer ---- + var composed = Phase7Composer.Compose( + areas, lines, equipment, drivers, Array.Empty(), tags, namespaces); + + // ---- Side 2: serialise the SAME draft to the artifact blob shape ConfigComposer emits + // (Pascal-case off the EF entities), then decode it. ---- + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, + }, + DriverInstances = new[] + { + new { galaxyDriver.DriverInstanceId, galaxyDriver.DriverType, galaxyDriver.DriverConfig, galaxyDriver.NamespaceId, galaxyDriver.ClusterId }, + new { modbusDriver.DriverInstanceId, modbusDriver.DriverType, modbusDriver.DriverConfig, modbusDriver.NamespaceId, modbusDriver.ClusterId }, + }, + Tags = new[] + { + ToSnapshot(galaxyTag), + ToSnapshot(modbusTag), + }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + // ---- The equality contract: element-wise equal on ALL fields + same order ---- + decoded.EquipmentTags.Count.ShouldBe(2); + // SequenceEqual = full value-and-order equality (EquipmentTagPlan is a positional record). + decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); + + // Spell the per-field contract out too (so a future divergence names the offending field + // rather than just "sequences differ"), and pin the Galaxy tag's wire-level FullName. + foreach (var (d, x) in decoded.EquipmentTags.Zip(composed.EquipmentTags)) + { + d.TagId.ShouldBe(x.TagId); + d.EquipmentId.ShouldBe(x.EquipmentId); + d.DriverInstanceId.ShouldBe(x.DriverInstanceId); + d.FolderPath.ShouldBe(x.FolderPath); + d.Name.ShouldBe(x.Name); + d.DataType.ShouldBe(x.DataType); + d.FullName.ShouldBe(x.FullName); + } + + var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy"); + galaxyPlan.FullName.ShouldBe("DelmiaReceiver_001.DownloadPath"); + galaxyPlan.EquipmentId.ShouldBe("eq-galaxy"); + galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy"); + galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides + } + + /// The full Pascal-case snapshot a EF entity serialises to in the + /// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields. + private static object ToSnapshot(Tag t) => new + { + t.TagId, + t.DriverInstanceId, + t.EquipmentId, + t.Name, + t.FolderPath, + t.DataType, + t.TagConfig, + }; }