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; /// /// Verifies the artifact-decode mirror () /// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null /// EquipmentId) bound to a GalaxyMxGateway driver in an Equipment-kind namespace — /// into the decoded EquipmentTags 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 Equipment (no Galaxy/DriverType exception — Galaxy is an ordinary Equipment-kind /// driver), so they agree on which tags qualify. /// public sealed class DeploymentArtifactAliasParityTests { /// 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 /// string.Empty. [Fact] 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"); // No AccessLevel in the blob → defaults to non-writable (read-only node). tag.Writable.ShouldBeFalse(); } /// The artifact decoder reads AccessLevel into EquipmentTagPlan.Writable: /// ReadWrite → true, Read → false. ConfigComposer emits the enum numerically (no string converter), /// so the numeric form (1 = ReadWrite) is the canonical wire shape, but the decoder also tolerates /// the string form ("ReadWrite") defensively — mirroring how the Kind gate accepts both. [Theory] [InlineData(1, true)] // numeric ReadWrite [InlineData(0, false)] // numeric Read [InlineData(2, false)] // future/unknown AccessLevel maps fail-safe to read-only (only ReadWrite==1 ⇒ writable) public void ParseComposition_maps_numeric_AccessLevel_to_Writable(int accessLevel, bool expectedWritable) { var blob = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, DriverInstances = new[] { new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, }, Tags = new object[] { new { TagId = "tag-1", DriverInstanceId = "drv", EquipmentId = "eq-1", Name = "Speed", FolderPath = (string?)null, DataType = "Float", AccessLevel = accessLevel, TagConfig = "{\"FullName\":\"40001\"}", }, }, }); var c = DeploymentArtifact.ParseComposition(blob); c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable); } /// The decoder also tolerates the string enum form ("ReadWrite"/"Read") in case a future /// serializer registers a string converter — byte-parity safety, mirroring the Kind gate. [Theory] [InlineData("ReadWrite", true)] [InlineData("Read", false)] public void ParseComposition_maps_string_AccessLevel_to_Writable(string accessLevel, bool expectedWritable) { var blob = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, DriverInstances = new[] { new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, }, Tags = new object[] { new { TagId = "tag-1", DriverInstanceId = "drv", EquipmentId = "eq-1", Name = "Speed", FolderPath = (string?)null, DataType = "Float", AccessLevel = accessLevel, TagConfig = "{\"FullName\":\"40001\"}", }, }, }); var c = DeploymentArtifact.ParseComposition(blob); c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable); } /// An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in /// EquipmentTags — byte-parity with the composer's pure ns.Kind == NamespaceKind.Equipment /// predicate. The gate keys off the namespace Kind alone, with no DriverType exception, so a /// non-Equipment namespace excludes the tag regardless of driver type. [Fact] public void ParseComposition_excludes_tag_in_non_equipment_namespace() { var blob = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-sim", Kind = 1 }, // NamespaceKind.Simulated (non-Equipment) }, DriverInstances = new[] { new { DriverInstanceId = "drv-sim", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sim" }, }, Tags = new object[] { new { TagId = "tag-x", DriverInstanceId = "drv-sim", EquipmentId = "eq-1", Name = "Source", FolderPath = (string?)null, DataType = "Int32", TagConfig = "{\"FullName\":\"40001\"}", }, }, }); var c = DeploymentArtifact.ParseComposition(blob); 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". // It also carries a native-alarm intent in TagConfig.alarm, so this draft proves the // optional Alarm field is parsed byte-identically on both producers (Phase B WS-2). 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\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", }; // 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); d.Writable.ShouldBe(x.Writable); d.Alarm.ShouldBe(x.Alarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality } 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 native-alarm intent in the Galaxy tag's TagConfig.alarm is parsed byte-identically on both // producers (Phase B WS-2). The Modbus tag has no alarm object ⇒ null Alarm on both sides. galaxyPlan.Alarm.ShouldNotBeNull(); galaxyPlan.Alarm!.AlarmType.ShouldBe("OffNormalAlarm"); galaxyPlan.Alarm.Severity.ShouldBe(700); composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Alarm.ShouldBe(galaxyPlan.Alarm); decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull(); // Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus // tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the // SequenceEqual above already proves they agree element-wise. galaxyPlan.Writable.ShouldBeFalse(); // AccessLevel = Read var modbusPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus"); modbusPlan.Writable.ShouldBeTrue(); // AccessLevel = ReadWrite composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Writable.ShouldBeFalse(); composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue(); } /// 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, // ConfigComposer serialises with no JsonStringEnumConverter, so the TagAccessLevel enum lands // as its numeric value (Read = 0, ReadWrite = 1) — exactly like Kind = (int)ns.Kind above. AccessLevel = (int)t.AccessLevel, t.TagConfig, }; }