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