From 9c5a091395592cf957bed02cc5da694ef8b3801a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 10:17:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(vtags):=20decode=20VirtualTag=20Historize?= =?UTF-8?q?=20from=20artifact,=20byte-parity=20with=20composer=20(H5b,=20s?= =?UTF-8?q?tillpending=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Drivers/DeploymentArtifact.cs | 11 +- ...tArtifactVirtualTagHistorizeParityTests.cs | 172 ++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index a99f5c22..5e5bd575 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -523,6 +523,14 @@ public static class DeploymentArtifact var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : string.Empty; + // Historize: the artifact carries a Pascal-case "Historize" bool (ConfigComposer serialises + // the whole VirtualTag entity with DefaultIgnoreCondition.Never). Robust parse — default + // false; only honoured when the JSON value is an actual boolean — so absent/non-bool ⇒ false, + // byte-parity with Phase7Composer's entity-default-false behaviour. + var historize = el.TryGetProperty("Historize", out var hEl) + && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) + && hEl.GetBoolean(); + // Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting // refs, so both Expression and DependencyRefs are machine-specific — byte-parity with // Phase7Composer.Compose. @@ -536,7 +544,8 @@ public static class DeploymentArtifact Name: name!, DataType: dataType ?? "BaseDataType", Expression: expanded, - DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded))); + DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded), + Historize: historize)); } result.Sort((a, b) => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs new file mode 100644 index 00000000..6410bd64 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs @@ -0,0 +1,172 @@ +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; + +/// +/// Byte-parity tests for the VirtualTag Historize flag across the artifact-decode path +/// (DeploymentArtifact.BuildEquipmentVirtualTagPlans) and the live compose seam +/// (Phase7Composer.Compose) — H5b. The artifact JSON already carries a Pascal-case +/// "Historize" bool (ConfigComposer serialises the whole VirtualTag entity with +/// DefaultIgnoreCondition.Never); the decode just had to read it. Both sides default to +/// false when the flag is unset/absent/non-bool, so the decoded plans must equal the +/// composer's element-wise (the record has value equality including Historize). Mirrors +/// DeploymentArtifactScriptedAlarmParityTests. +/// +public sealed class DeploymentArtifactVirtualTagHistorizeParityTests +{ + private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot); + + [Fact] + public void ParseComposition_virtual_tag_historize_is_byte_parity_with_composer() + { + var ns = new Namespace + { + NamespaceId = "ns-eq", + ClusterId = "c1", + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:eq", + }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-1", + 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-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" }; + var tag = new Tag + { + TagId = "tag-1", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + Name = "Source", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_001.Source\",\"DataType\":\"Int32\"}", + }; + var script = new Script + { + ScriptId = "s-1", + Name = "passthru", + SourceCode = "return ctx.GetTag(\"TestMachine_001.Source\").Value;", + SourceHash = "hash-1", + }; + var vtHist = new VirtualTag { VirtualTagId = "vt-hist", EquipmentId = "eq-1", Name = "Historized", DataType = "Int32", ScriptId = "s-1", Historize = true }; + var vtPlain = new VirtualTag { VirtualTagId = "vt-plain", EquipmentId = "eq-1", Name = "Plain", DataType = "Int32", ScriptId = "s-1", Historize = false }; + + var composed = Phase7Composer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + new[] { tag }, new[] { ns }, + virtualTags: new[] { vtHist, vtPlain }, + scripts: new[] { script }); + + // The artifact blob the write side (ConfigComposer) emits: the FULL VirtualTag entity + // serialised Pascal-case off EF, including the Historize bool. + var blob = BlobOf(new + { + Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-1", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + Name = "Source", + FolderPath = (string?)null, + DataType = "Int32", + TagConfig = "{\"FullName\":\"TestMachine_001.Source\",\"DataType\":\"Int32\"}", + }, + }, + Scripts = new[] + { + new { ScriptId = "s-1", SourceCode = "return ctx.GetTag(\"TestMachine_001.Source\").Value;" }, + }, + VirtualTags = new[] + { + ToSnapshot(vtHist), + ToSnapshot(vtPlain), + }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + decoded.EquipmentVirtualTags.Count.ShouldBe(2); + decoded.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-hist").Historize.ShouldBeTrue(); + decoded.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-plain").Historize.ShouldBeFalse(); + + // Byte-parity: element-wise value equality (record equality includes Historize). + decoded.EquipmentVirtualTags.SequenceEqual(composed.EquipmentVirtualTags).ShouldBeTrue(); + } + + [Fact] + public void ParseComposition_absent_historize_defaults_false() + { + // A VirtualTag object with NO "Historize" property at all (older artifact / unset) decodes + // to false — same default the composer uses for an unset entity column. + var blob = BlobOf(new + { + Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-1", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + Name = "Source", + FolderPath = (string?)null, + DataType = "Int32", + TagConfig = "{\"FullName\":\"TestMachine_001.Source\"}", + }, + }, + Scripts = new[] + { + new { ScriptId = "s-1", SourceCode = "return ctx.GetTag(\"TestMachine_001.Source\").Value;" }, + }, + VirtualTags = new[] + { + new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "NoHistorize", DataType = "Int32", ScriptId = "s-1" }, + }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + decoded.EquipmentVirtualTags.ShouldHaveSingleItem().Historize.ShouldBeFalse(); + } + + // The Pascal-case snapshot a VirtualTag EF entity serialises to (matches ConfigComposer's + // DefaultIgnoreCondition.Never whole-entity serialisation — includes Historize). + private static object ToSnapshot(VirtualTag v) => new + { + v.VirtualTagId, + v.EquipmentId, + v.Name, + v.DataType, + v.ScriptId, + v.ChangeTriggered, + v.TimerIntervalMs, + v.Historize, + v.Enabled, + }; +}