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; /// /// Proves the Phase C HistoryRead intent (isHistorized + optional historianTagname), /// which rides inside the raw TagConfig JSON blob, round-trips with byte-parity through both /// equipment-tag producers: the live-edit composer () and the /// artifact decoder (). /// The artifact serializer re-parses the SAME TagConfig string both sides emit, so no /// ConfigComposer change is needed — the flags are already carried in the blob. /// public sealed class DeploymentArtifactHistorizeParityTests { /// /// One draft consumed by both producers: a historized equipment tag with no explicit historian /// tagname (defaults to FullName later, so HistorianTagname is null on both sides), plus a /// historized equipment tag WITH an explicit historianTagname override, plus a plain /// non-historized tag. The decoded EquipmentTags must equal the composer's element-wise /// (positional-record value equality) and in the same order, proving IsHistorized / /// HistorianTagname are derived byte-identically on both seams. /// [Fact] public void Composer_and_artifact_agree_on_historized_equipment_tags() { var ns = new Namespace { NamespaceId = "ns-eq", ClusterId = "c1", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", }; var driver = 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 equip = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "line-1", Name = "FillingPump", MachineCode = "FILLINGPUMP", }; // Historized, no explicit tagname → HistorianTagname null (defaults to FullName later). var histDefaultTag = new Tag { TagId = "tag-hist-default", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Flow", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\",\"isHistorized\":true}", }; // Historized WITH an explicit historian-tagname override. var histOverrideTag = new Tag { TagId = "tag-hist-override", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Pressure", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40002\",\"isHistorized\":true,\"historianTagname\":\"Plant.Line1.Pressure\"}", }; // Plain tag — not historized. var plainTag = new Tag { TagId = "tag-plain", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"FullName\":\"40003\"}", }; // Well-formed but non-historized: explicit isHistorized:false + a JSON-null historianTagname // (not a string token → falls through the ValueKind==String guard → tagname null). // This exercises the artifact-side private ExtractTagHistorize through the real round-trip // (it can't be unit-tested directly because it's private). A truly malformed TagConfig string // would cause ExtractTagFullName to return "" and break the SequenceEqual on other fields, so // we use a well-formed blob and note that the malformed/throws path is already covered by // the composer unit test in ExtractTagHistorizeTests. var notHistorizedNullNameTag = new Tag { TagId = "tag-not-hist-nullname", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Temp", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40004\",\"isHistorized\":false,\"historianTagname\":null}", }; var areas = new[] { area }; var lines = new[] { line }; var equipment = new[] { equip }; var drivers = new[] { driver }; var tags = new[] { histDefaultTag, histOverrideTag, plainTag, notHistorizedNullNameTag }; 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, then decode it ---- var blob = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, }, DriverInstances = new[] { new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, }, Tags = new[] { ToSnapshot(histDefaultTag), ToSnapshot(histOverrideTag), ToSnapshot(plainTag), ToSnapshot(notHistorizedNullNameTag), }, }); var decoded = DeploymentArtifact.ParseComposition(blob); // ---- Full byte-parity: every field, same order (positional-record value equality) ---- decoded.EquipmentTags.Count.ShouldBe(4); decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); // Spell out the Phase C fields per-tag so a divergence names the offending tag. var histDefault = decoded.EquipmentTags.Single(t => t.TagId == "tag-hist-default"); histDefault.IsHistorized.ShouldBeTrue(); histDefault.HistorianTagname.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-hist-default").IsHistorized.ShouldBeTrue(); composed.EquipmentTags.Single(t => t.TagId == "tag-hist-default").HistorianTagname.ShouldBeNull(); var histOverride = decoded.EquipmentTags.Single(t => t.TagId == "tag-hist-override"); histOverride.IsHistorized.ShouldBeTrue(); histOverride.HistorianTagname.ShouldBe("Plant.Line1.Pressure"); composed.EquipmentTags.Single(t => t.TagId == "tag-hist-override").IsHistorized.ShouldBeTrue(); composed.EquipmentTags.Single(t => t.TagId == "tag-hist-override").HistorianTagname.ShouldBe("Plant.Line1.Pressure"); var plain = decoded.EquipmentTags.Single(t => t.TagId == "tag-plain"); plain.IsHistorized.ShouldBeFalse(); plain.HistorianTagname.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-plain").IsHistorized.ShouldBeFalse(); composed.EquipmentTags.Single(t => t.TagId == "tag-plain").HistorianTagname.ShouldBeNull(); // 4th tag: explicit isHistorized:false + JSON-null historianTagname ⇒ (false, null) on both sides. // Exercises the artifact-side private ExtractTagHistorize's null-JSON-token guard path. var notHistorizedNullName = decoded.EquipmentTags.Single(t => t.TagId == "tag-not-hist-nullname"); notHistorizedNullName.IsHistorized.ShouldBeFalse(); notHistorizedNullName.HistorianTagname.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-not-hist-nullname").IsHistorized.ShouldBeFalse(); composed.EquipmentTags.Single(t => t.TagId == "tag-not-hist-nullname").HistorianTagname.ShouldBeNull(); } /// The Pascal-case snapshot a EF entity serialises to in the artifact /// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw /// TagConfig blob the Phase C flags ride inside. private static object ToSnapshot(Tag t) => new { t.TagId, t.DriverInstanceId, t.EquipmentId, t.Name, t.FolderPath, t.DataType, AccessLevel = (int)t.AccessLevel, t.TagConfig, }; }