using System.Linq; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Proves follow-up E: the equipment's DriverInstanceId / DeviceId bindings and the /// resolved DeviceHost (parsed from the bound 's schemaless /// DeviceConfig JSON) round-trip with byte-parity through both /// producers: the live-edit composer () /// and the artifact decoder (). /// A secondary/follower node decoding a serialized artifact MUST see the same DeviceHost as the /// primary so it grafts FixedTree / partitions multi-device drivers identically. Both sides resolve /// the host through the shared (single source /// of truth + identical trim + lower-case normalization). /// public sealed class DeploymentArtifactDeviceHostParityTests { /// /// One draft exercising every branch: driver + device + host (with a mixed-case/whitespace host /// that must normalize identically on both sides); a driver-bound equipment with NO device /// (DeviceId null ⇒ DeviceHost null); a driver-less, device-less equipment (all three null); and a /// device whose config carries no HostAddress (DeviceId carried, DeviceHost null). The decoded /// EquipmentNodes must equal the composer's element-wise (positional-record value equality) /// and in the same order. /// [Fact] public void Composer_and_artifact_agree_on_equipment_node_device_host() { // eq-1: driver d1 + device dev1 (host needs trim + lower-case) var eq1 = NewEquipment("eq-1", driver: "d1", device: "dev1"); // eq-2: driver d1, NO device → DeviceHost null var eq2 = NewEquipment("eq-2", driver: "d1", device: null); // eq-3: driver-less + device-less → all three null var eq3 = NewEquipment("eq-3", driver: null, device: null); // eq-4: device dev-nohost whose config has no HostAddress → DeviceHost null var eq4 = NewEquipment("eq-4", driver: "d1", device: "dev-nohost"); var dev1 = NewDevice("dev1", "d1", "{\"HostAddress\":\" HOST-A:8193 \"}"); // → host-a:8193 var devNoHost = NewDevice("dev-nohost", "d1", "{\"Port\":502}"); // → null var equipment = new[] { eq1, eq2, eq3, eq4 }; var devices = new[] { dev1, devNoHost }; // ---- Side 1: the live-edit composer ---- var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), equipment, Array.Empty(), Array.Empty(), devices: devices); // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- var blob = JsonSerializer.SerializeToUtf8Bytes(new { Equipment = equipment.Select(ToEquipmentSnapshot).ToArray(), Devices = devices.Select(ToDeviceSnapshot).ToArray(), }); var decoded = DeploymentArtifact.ParseComposition(blob); // ---- Full byte-parity: every field, same order (positional-record value equality) ---- decoded.EquipmentNodes.Count.ShouldBe(4); decoded.EquipmentNodes.SequenceEqual(composed.EquipmentNodes).ShouldBeTrue(); // Spell out per-equipment so a divergence names the offending node. var d1Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-1"); d1Node.DriverInstanceId.ShouldBe("d1"); d1Node.DeviceId.ShouldBe("dev1"); d1Node.DeviceHost.ShouldBe("host-a:8193"); // trimmed + lower-cased on both sides var d2Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-2"); d2Node.DriverInstanceId.ShouldBe("d1"); d2Node.DeviceId.ShouldBeNull(); d2Node.DeviceHost.ShouldBeNull(); var d3Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-3"); d3Node.DriverInstanceId.ShouldBeNull(); d3Node.DeviceId.ShouldBeNull(); d3Node.DeviceHost.ShouldBeNull(); var d4Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-4"); d4Node.DriverInstanceId.ShouldBe("d1"); d4Node.DeviceId.ShouldBe("dev-nohost"); d4Node.DeviceHost.ShouldBeNull(); } private static Equipment NewEquipment(string id, string? driver, string? device) => new() { EquipmentId = id, DriverInstanceId = driver, DeviceId = device, UnsLineId = "line-1", Name = id, MachineCode = id.ToUpperInvariant(), }; private static Device NewDevice(string deviceId, string driverInstanceId, string deviceConfig) => new() { DeviceId = deviceId, DriverInstanceId = driverInstanceId, Name = deviceId, DeviceConfig = deviceConfig, }; /// The Pascal-case snapshot an EF entity serialises to in the /// artifact (matches ConfigComposer) — including the nullable DriverInstanceId / DeviceId /// the equipment-node decoder re-reads. private static object ToEquipmentSnapshot(Equipment e) => new { e.EquipmentId, e.Name, e.MachineCode, e.UnsLineId, e.DriverInstanceId, e.DeviceId, }; /// The Pascal-case snapshot a EF entity serialises to in the artifact — /// the decoder re-reads DeviceId + the raw DeviceConfig blob the host rides inside. private static object ToDeviceSnapshot(Device d) => new { d.DeviceId, d.DriverInstanceId, d.Name, d.DeviceConfig, }; }