diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 46e2ab54..4ac1e333 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -335,18 +335,22 @@ public static class Phase7Composer .OrderBy(t => t.EquipmentId, StringComparer.Ordinal) .ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly .ThenBy(t => t.Name, StringComparer.Ordinal) - .Select(t => new EquipmentTagPlan( - TagId: t.TagId, - EquipmentId: t.EquipmentId!, - DriverInstanceId: t.DriverInstanceId, - FolderPath: t.FolderPath ?? string.Empty, - Name: t.Name, - DataType: t.DataType, - FullName: ExtractTagFullName(t.TagConfig), - Writable: t.AccessLevel == TagAccessLevel.ReadWrite, - Alarm: ExtractTagAlarm(t.TagConfig), - IsHistorized: ExtractTagHistorize(t.TagConfig).IsHistorized, - HistorianTagname: ExtractTagHistorize(t.TagConfig).HistorianTagname)) + .Select(t => + { + var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig); + return new EquipmentTagPlan( + TagId: t.TagId, + EquipmentId: t.EquipmentId!, + DriverInstanceId: t.DriverInstanceId, + FolderPath: t.FolderPath ?? string.Empty, + Name: t.Name, + DataType: t.DataType, + FullName: ExtractTagFullName(t.TagConfig), + Writable: t.AccessLevel == TagAccessLevel.ReadWrite, + Alarm: ExtractTagAlarm(t.TagConfig), + IsHistorized: isHistorized, + HistorianTagname: historianTagname); + }) .ToList(); // Per-equipment tag base = the shared substring-before-first-dot across each equipment's 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 c8b92df8..115e631a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -437,6 +437,7 @@ public static class DeploymentArtifact // ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver). if (!equipmentNamespaces.Contains(nsId)) continue; + var (isHistorized, historianTagname) = ExtractTagHistorize(tagConfig); result.Add(new EquipmentTagPlan( TagId: tagId!, EquipmentId: equipmentId!, @@ -447,8 +448,8 @@ public static class DeploymentArtifact FullName: ExtractTagFullName(tagConfig), Writable: writable, Alarm: ExtractTagAlarm(tagConfig), - IsHistorized: ExtractTagHistorize(tagConfig).IsHistorized, - HistorianTagname: ExtractTagHistorize(tagConfig).HistorianTagname)); + IsHistorized: isHistorized, + HistorianTagname: historianTagname)); } result.Sort((a, b) => @@ -678,7 +679,8 @@ public static class DeploymentArtifact /// the isHistorized bool (absent / not a bool / non-object root / blank / malformed ⇒ /// false) and the optional historianTagname string override (absent / not a string / /// whitespace-or-empty ⇒ null, meaning the historian tagname defaults to the tag's FullName, - /// resolved later). The raw string value is used — not trimmed. Never throws. The live-edit side + /// resolved later). The raw string value is used — not trimmed — matching ExtractTagFullName / + /// ExtractTagAlarm. Never throws. The live-edit side /// (Phase7Composer.ExtractTagHistorize) MUST parse identically (byte-parity). private static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs index 3fe835e1..6404c6d1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs @@ -24,6 +24,8 @@ public class ExtractTagHistorizeTests [InlineData("[1,2]", false, null)] // Wrong type for isHistorized (string, not bool) ⇒ false. [InlineData("{\"isHistorized\":\"yes\"}", false, null)] + // Wrong type for historianTagname (number, not string) ⇒ tagname null, flag still honoured. + [InlineData("{\"isHistorized\":true,\"historianTagname\":123}", true, null)] public void ExtractTagHistorize_parses_or_returns_defaults(string? cfg, bool expectedHistorized, string? expectedTagname) { var (isHistorized, historianTagname) = Phase7Composer.ExtractTagHistorize(cfg); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs index fc1493b8..dec9b8f8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs @@ -93,12 +93,30 @@ public sealed class DeploymentArtifactHistorizeParityTests 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 }; + var tags = new[] { histDefaultTag, histOverrideTag, plainTag, notHistorizedNullNameTag }; var namespaces = new[] { ns }; // ---- Side 1: the live-edit composer ---- @@ -121,13 +139,14 @@ public sealed class DeploymentArtifactHistorizeParityTests 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(3); + 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. @@ -148,6 +167,14 @@ public sealed class DeploymentArtifactHistorizeParityTests 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