From 2e14fe1f24df09e0ffff966fb337a835124df0b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:35:47 -0400 Subject: [PATCH 01/24] test(runtime): golden TagConfig corpus + compose/decode parity characterization (R2-11) --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../Drivers/TagConfigCorpusParityTests.cs | 111 ++++++++++++++++++ .../Drivers/TagConfigGoldenCorpus.cs | 83 +++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 46bbe08a..d952ba71 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -2,7 +2,7 @@ "planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md", "lastUpdated": "2026-07-12", "tasks": [ - { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "pending", "blockedBy": [] }, + { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] }, { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "pending", "blockedBy": [] }, { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] }, { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] }, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs new file mode 100644 index 00000000..562c27a4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs @@ -0,0 +1,111 @@ +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; + +/// +/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per +/// blob, serialises the SAME draft to the artifact blob shape, +/// decodes it, and asserts the decoded list equals the composer's +/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse +/// the identical raw TagConfig string, this proves the four byte-parity parse sites agree TODAY, +/// and is the permanent regression guard held green through every subsequent seam swap +/// (TagConfigIntent.Parse consolidation). +/// +public sealed class TagConfigCorpusParityTests +{ + [Fact] + public void Composer_and_artifact_agree_over_the_whole_TagConfig_corpus() + { + 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 = "Machine_001", + MachineCode = "MACHINE_001", + }; + + var tags = TagConfigGoldenCorpus.Blobs + .Select((blob, i) => new Tag + { + TagId = $"tag-{i:D2}", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + FolderPath = null, + Name = $"Sig{i:D2}", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = blob, + }) + .ToArray(); + + // ---- Side 1: the live-edit composer ---- + var composed = AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), tags, new[] { ns }); + + // ---- 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 = tags.Select(ToSnapshot).ToArray(), + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + decoded.EquipmentTags.Count.ShouldBe(tags.Length); + // Full byte-parity: every field, same order (positional-record value equality). A field-by-field + // spell-out per blob so a divergence names the offending corpus entry. + for (var i = 0; i < composed.EquipmentTags.Count; i++) + { + var c = composed.EquipmentTags[i]; + var d = decoded.EquipmentTags.Single(t => t.TagId == c.TagId); + d.ShouldBe(c, customMessage: $"corpus blob #{c.TagId} diverged: '{tags.Single(t => t.TagId == c.TagId).TagConfig}'"); + } + decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); + } + + 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, + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs new file mode 100644 index 00000000..a76bed77 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs @@ -0,0 +1,83 @@ +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The shared golden corpus of TagConfig JSON blobs used to characterise the cross-driver +/// platform-intent parse (FullName / alarm / isHistorized / historianTagname / isArray / arrayLength). +/// Every entry is a NON-NULL string (the DB CK_Tag_TagConfig_IsJson constraint guarantees a +/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test +/// () can round-trip each through both equipment-tag producers +/// and assert byte-parity field-by-field. The null-input divergence (composer returns the raw +/// null, artifact coalesces to "") is a documented seam handled only at the +/// TagConfigIntent.Parse unit level, not through the parity round-trip. +/// Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace, +/// alarm object with each field absent/wrong-type, historizeToAveva true/false/non-bool, +/// historianTagname whitespace, isArray/arrayLength combinations (incl. negative/overflow/non-number/ +/// length-without-flag/zero), unknown keys, and mixed platform+driver keys. +/// +public static class TagConfigGoldenCorpus +{ + /// The corpus blobs, index-ordered. The parity test builds one equipment tag per blob. + public static readonly IReadOnlyList Blobs = new[] + { + // 0 happy path + "{\"FullName\":\"X.Y\"}", + // 1 blank (whitespace) — FullName falls back to the raw (blank) blob on both seams + " ", + // 2 non-object root + "[1,2]", + // 3 malformed JSON + "not json {", + // 4 FullName absent (driver keys only) — FullName = raw blob + "{\"region\":\"HoldingRegisters\",\"address\":5}", + // 5 FullName present but non-string — FullName = raw blob + "{\"FullName\":123}", + // 6 FullName whitespace value (not trimmed) + "{\"FullName\":\" \"}", + // 7 alarm empty object → defaults (AlarmCondition, 500, historize null) + "{\"FullName\":\"A\",\"alarm\":{}}", + // 8 alarm fully specified + "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + // 9 alarm non-object → no alarm + "{\"FullName\":\"A\",\"alarm\":\"oops\"}", + // 10 alarm historizeToAveva true + "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", + // 11 alarm historizeToAveva false + "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", + // 12 alarm historizeToAveva non-bool → null + "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", + // 13 alarm severity non-number → 500 + "{\"FullName\":\"A\",\"alarm\":{\"severity\":\"high\"}}", + // 14 alarm alarmType non-string → AlarmCondition + "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":123}}", + // 15 isHistorized true + "{\"FullName\":\"A\",\"isHistorized\":true}", + // 16 isHistorized true + explicit tagname + "{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", + // 17 isHistorized false + JSON-null tagname + "{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", + // 18 historianTagname whitespace → null + "{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", + // 19 isHistorized non-bool → false + "{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", + // 20 isArray true + length + "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16}", + // 21 isArray true, no length + "{\"FullName\":\"A\",\"isArray\":true}", + // 22 isArray false + length → scalar + "{\"FullName\":\"A\",\"isArray\":false,\"arrayLength\":16}", + // 23 isArray true, length 0 → true, 0 + "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":0}", + // 24 arrayLength non-number → true, null + "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}", + // 25 arrayLength negative → true, null + "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":-1}", + // 26 arrayLength float → true, null + "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16.5}", + // 27 arrayLength overflow (uint.MaxValue + 1) → true, null + "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}", + // 28 unknown + mixed platform/driver keys + "{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5,\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}", + // 29 all intents combined + "{\"FullName\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}", + }; +} From 353f04b70c4e4e05cb30d5e8b2d60dcb5366715f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:37:21 -0400 Subject: [PATCH 02/24] test(core,config): FullName-semantics characterization for walker + draft gate (R2-11) --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- ...DraftValidatorGalaxyFullNameCorpusTests.cs | 60 +++++++++++++++++++ .../EquipmentNodeWalkerFullNameCorpusTests.cs | 36 +++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index d952ba71..19647b38 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -3,7 +3,7 @@ "lastUpdated": "2026-07-12", "tasks": [ { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] }, - { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "pending", "blockedBy": [] }, + { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] }, { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] }, { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] }, { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] }, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs new file mode 100644 index 00000000..e4af659e --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs @@ -0,0 +1,60 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's explicit-FullName +/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to +/// TagConfigIntent.Parse(...).ExplicitFullName. Distinct from the walker/composer raw-blob +/// fallback: GalaxyTagMissingReference fires whenever the TagConfig has no usable +/// explicit string FullName — i.e. absent / non-string / whitespace / non-object / +/// malformed — and does NOT fire only when a non-blank string FullName is present. Must be green on +/// the unmodified tree and stay green through the swap. +/// +public sealed class DraftValidatorGalaxyFullNameCorpusTests +{ + [Theory] + // Present, non-blank string FullName → rule does NOT fire + [InlineData("{\"FullName\":\"X.Y\"}", false)] + [InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", false)] + // Absent / non-string / whitespace / non-object / malformed → rule FIRES + [InlineData("{}", true)] + [InlineData("{\"region\":\"HoldingRegisters\"}", true)] + [InlineData("{\"FullName\":123}", true)] + [InlineData("{\"FullName\":\" \"}", true)] + [InlineData("[1,2]", true)] + [InlineData("not json {", true)] + public void Galaxy_missing_reference_fires_iff_no_explicit_string_FullName(string tagConfig, bool shouldFire) + { + var draft = new DraftSnapshot + { + GenerationId = 1, + ClusterId = "c", + DriverInstances = + [ + new DriverInstance + { + DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", + Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", + }, + ], + Tags = + [ + new Tag + { + TagId = "tag-galaxytag", DriverInstanceId = "d-galaxy", EquipmentId = "eq-1", + Name = "galaxytag", FolderPath = null, DataType = "Float", + AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig, + }, + ], + }; + + var fired = DraftValidator.Validate(draft) + .Any(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag"); + fired.ShouldBe(shouldFire); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs new file mode 100644 index 00000000..ae74919d --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs @@ -0,0 +1,36 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa; + +/// +/// Characterization net (R2-11) pinning 's +/// raw-blob-fallback semantics across the golden TagConfig corpus BEFORE the parser is delegated to +/// TagConfigIntent.Parse. The walker's FullName copy falls back to the raw blob whenever the +/// input is not a JSON object carrying a string FullName (blank / non-object / malformed / +/// absent / non-string). Whitespace + non-string-property values are returned verbatim (not trimmed). +/// Must be green on the unmodified tree and stay green through the swap. +/// +public sealed class EquipmentNodeWalkerFullNameCorpusTests +{ + [Theory] + // object with a string FullName → the FullName string (verbatim, incl. whitespace) + [InlineData("{\"FullName\":\"X.Y\"}", "X.Y")] + [InlineData("{\"FullName\":\" \"}", " ")] + [InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5}", "A.B")] + // blank → raw blob (IsNullOrWhiteSpace short-circuit) + [InlineData(" ", " ")] + // non-object root → raw blob + [InlineData("[1,2]", "[1,2]")] + // malformed JSON → raw blob + [InlineData("not json {", "not json {")] + // object, FullName absent → raw blob + [InlineData("{\"region\":\"HoldingRegisters\",\"address\":5}", "{\"region\":\"HoldingRegisters\",\"address\":5}")] + // object, FullName present but non-string → raw blob + [InlineData("{\"FullName\":123}", "{\"FullName\":123}")] + public void ExtractFullName_falls_back_to_raw_blob_except_for_string_FullName(string tagConfig, string expected) + { + EquipmentNodeWalker.ExtractFullName(tagConfig).ShouldBe(expected); + } +} From cd3f1270eeb438d1105812b0a81dcc32f0631885 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:39:07 -0400 Subject: [PATCH 03/24] =?UTF-8?q?feat(commons):=20TagConfigIntent.Parse=20?= =?UTF-8?q?=E2=80=94=20single=20home=20for=20the=20byte-parity=20TagConfig?= =?UTF-8?q?=20intents=20(R2-11,=2001/C-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../Types/TagConfigIntent.cs | 129 ++++++++++++++++++ .../TagConfigIntentTests.cs | 128 +++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 19647b38..6bbe0462 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -4,7 +4,7 @@ "tasks": [ { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] }, { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] }, - { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] }, + { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] }, { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] }, { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] }, { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs new file mode 100644 index 00000000..ba81fcf2 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs @@ -0,0 +1,129 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// The cross-driver platform intents parsed from a Tag's schemaless TagConfig JSON. +/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam +/// (AddressSpaceComposer), the artifact-decode seam (DeploymentArtifact), the draft +/// gate (DraftValidator), and the walker (EquipmentNodeWalker). One +/// per blob. Never throws. +/// Two deliberate variations are carried on the record so every seam can be served from one +/// parse: +/// +/// is the driver-side wire reference: the top-level +/// FullName string when present, else the RAW blob (an equipment tag's TagConfig JSON +/// is its wire reference for the six protocol drivers). Parse(null) ⇒ +/// FullName == "". +/// is the FullName property ONLY when +/// present-and-string, else — the DraftValidator Galaxy-rule semantics +/// (it wants the explicit reference, not the raw-blob fallback). +/// +/// +/// Top-level FullName string, else the raw blob (wire-reference fallback). +/// The FullName property when present-and-string, else . +/// The optional native-alarm intent parsed from the alarm object; ⇒ plain value variable. +/// isHistorized — true only when the value is a bool true. +/// Non-whitespace historianTagname string override, else (not trimmed). +/// isArray bool. +/// arrayLength honoured ONLY when and a JSON number fitting ; else . +public sealed record TagConfigIntent( + string FullName, + string? ExplicitFullName, + TagAlarmIntent? Alarm, + bool IsHistorized, + string? HistorianTagname, + bool IsArray, + uint? ArrayLength) +{ + /// + /// Parse a Tag's schemaless TagConfig JSON into the cross-driver platform intents. + /// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with + /// = the raw blob (or "" for null). Semantics are ported verbatim from the + /// historical AddressSpaceComposer.Extract* statics (the canonical copy). + /// + /// The tag's raw TagConfig JSON (nullable/blank tolerated). + /// The parsed intents; never . + public static TagConfigIntent Parse(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) + return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null); + + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return new TagConfigIntent(tagConfig, null, null, false, null, false, null); + + var explicitFullName = + root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String + ? fn.GetString() + : null; + var fullName = explicitFullName ?? tagConfig; + + var (isHistorized, historianTagname) = ParseHistorize(root); + var (isArray, arrayLength) = ParseArray(root); + + return new TagConfigIntent( + fullName, explicitFullName, ParseAlarm(root), + isHistorized, historianTagname, isArray, arrayLength); + } + catch (JsonException) + { + return new TagConfigIntent(tagConfig, null, null, false, null, false, null); + } + } + + private static TagAlarmIntent? ParseAlarm(JsonElement root) + { + if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null; + var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String + ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition"; + var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number + && sEl.TryGetInt32(out var sv) ? sv : 500; + // historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the + // durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on). + bool? historize = a.TryGetProperty("historizeToAveva", out var hEl) + && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False + ? hEl.GetBoolean() + : null; + return new TagAlarmIntent(type, sev, historize); + } + + private static (bool IsHistorized, string? HistorianTagname) ParseHistorize(JsonElement root) + { + var isHistorized = root.TryGetProperty("isHistorized", out var hEl) + && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) + && hEl.GetBoolean(); + string? tagname = null; + if (root.TryGetProperty("historianTagname", out var nEl) && nEl.ValueKind == JsonValueKind.String) + { + var raw = nEl.GetString(); + if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; // not trimmed + } + return (isHistorized, tagname); + } + + private static (bool IsArray, uint? ArrayLength) ParseArray(JsonElement root) + { + var isArray = root.TryGetProperty("isArray", out var aEl) + && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False) + && aEl.GetBoolean(); + uint? arrayLength = null; + if (isArray + && root.TryGetProperty("arrayLength", out var lEl) + && lEl.ValueKind == JsonValueKind.Number + && lEl.TryGetUInt32(out var len)) + { + arrayLength = len; + } + return (isArray, arrayLength); + } +} + +/// The optional native-alarm intent parsed from a tag's TagConfig.alarm object. +/// OPC UA Part 9 subtype string; default "AlarmCondition". +/// 1..1000 severity; default 500. +/// Per-condition durable-write opt-out (bool?; absent ⇒ ⇒ historize). +public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs new file mode 100644 index 00000000..b19f46fb --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs @@ -0,0 +1,128 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; + +/// +/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1). +/// Ports the three retired OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests tables and +/// adds the / +/// distinction plus the never-throws property. +/// +public sealed class TagConfigIntentTests +{ + // ---- FullName / ExplicitFullName ---- + + [Theory] + [InlineData("{\"FullName\":\"X.Y\"}", "X.Y", "X.Y")] + [InlineData("{\"FullName\":\" \"}", " ", " ")] // whitespace string returned verbatim (not trimmed) + [InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", "A.B", "A.B")] + // fallbacks: FullName = raw blob, ExplicitFullName = null + [InlineData("{\"region\":\"HoldingRegisters\"}", "{\"region\":\"HoldingRegisters\"}", null)] + [InlineData("{\"FullName\":123}", "{\"FullName\":123}", null)] + [InlineData("[1,2]", "[1,2]", null)] + [InlineData("not json {", "not json {", null)] + [InlineData(" ", " ", null)] + public void FullName_and_ExplicitFullName(string cfg, string expectedFullName, string? expectedExplicit) + { + var intent = TagConfigIntent.Parse(cfg); + intent.FullName.ShouldBe(expectedFullName); + intent.ExplicitFullName.ShouldBe(expectedExplicit); + } + + [Fact] + public void Parse_null_yields_empty_FullName_and_null_explicit() + { + var intent = TagConfigIntent.Parse(null); + intent.FullName.ShouldBe(string.Empty); + intent.ExplicitFullName.ShouldBeNull(); + intent.Alarm.ShouldBeNull(); + intent.IsHistorized.ShouldBeFalse(); + intent.IsArray.ShouldBeFalse(); + } + + // ---- Alarm (ported from ExtractTagAlarmTests) ---- + + [Theory] + [InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)] + [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)] + [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)] + [InlineData("not json", false, null, 0)] + [InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)] + [InlineData("{\"alarm\":{\"severity\":\"high\"}}", true, "AlarmCondition", 500)] // non-number severity → 500 + [InlineData("{\"alarm\":{\"alarmType\":123}}", true, "AlarmCondition", 500)] // non-string type → default + public void Alarm_parses_or_null(string cfg, bool present, string? type, int sev) + { + var alarm = TagConfigIntent.Parse(cfg).Alarm; + if (!present) { alarm.ShouldBeNull(); return; } + alarm!.AlarmType.ShouldBe(type); + alarm.Severity.ShouldBe(sev); + } + + [Theory] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)] + [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)] + public void Alarm_historizeToAveva(string cfg, bool? expected) + { + TagConfigIntent.Parse(cfg).Alarm!.HistorizeToAveva.ShouldBe(expected); + } + + // ---- Historize ---- + + [Theory] + [InlineData("{\"FullName\":\"A\",\"isHistorized\":true}", true, null)] + [InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")] + [InlineData("{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)] + [InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null + [InlineData("{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false + [InlineData("{\"FullName\":\"A\"}", false, null)] + public void Historize(string cfg, bool expectedHistorized, string? expectedTagname) + { + var intent = TagConfigIntent.Parse(cfg); + intent.IsHistorized.ShouldBe(expectedHistorized); + intent.HistorianTagname.ShouldBe(expectedTagname); + } + + // ---- Array (ported from ExtractTagArrayTests) ---- + + [Theory] + [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)] + [InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)] + [InlineData("{\"FullName\":\"T.A\"}", false, null)] + [InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)] + [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)] + [InlineData(null, false, null)] + [InlineData("", false, null)] + [InlineData("not json {", false, null)] + [InlineData("[1,2]", false, null)] + [InlineData("{\"isArray\":\"yes\"}", false, null)] + [InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)] + [InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)] + [InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)] + [InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)] + public void Array_parses_or_defaults(string? cfg, bool expectedIsArray, uint? expectedLength) + { + var intent = TagConfigIntent.Parse(cfg); + intent.IsArray.ShouldBe(expectedIsArray); + intent.ArrayLength.ShouldBe(expectedLength); + } + + // ---- never-throws property over an adversarial set ---- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json {")] + [InlineData("[1,2,3]")] + [InlineData("42")] + [InlineData("\"just a string\"")] + [InlineData("{\"alarm\":42,\"isArray\":\"x\",\"arrayLength\":true,\"isHistorized\":123}")] + public void Parse_never_throws(string? cfg) + { + Should.NotThrow(() => TagConfigIntent.Parse(cfg)); + } +} From b6633f75625447ce0afde580694e3f140a3d69e2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:39:54 -0400 Subject: [PATCH 04/24] =?UTF-8?q?feat(commons):=20DeviceConfigIntent=20?= =?UTF-8?q?=E2=80=94=20device-host=20extraction=20+=20normalization=20home?= =?UTF-8?q?=20(R2-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../Types/DeviceConfigIntent.cs | 47 +++++++++++++++++++ .../DeviceConfigIntentTests.cs | 46 ++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 6bbe0462..42326c7b 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -5,7 +5,7 @@ { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] }, { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] }, { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] }, - { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] }, + { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] }, { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] }, { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs new file mode 100644 index 00000000..cca42273 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// SINGLE SOURCE OF TRUTH for extracting + normalizing a Device's connection host from its +/// schemaless DeviceConfig JSON. Shared by the live-compose seam (AddressSpaceComposer), +/// the artifact-decode seam (DeploymentArtifact), and the FixedTree-partition path +/// (DriverHostActor) so an EquipmentNode.DeviceHost and a driver-discovered device-host +/// folder segment for the same device compare byte-equal. Ported verbatim from the historical +/// AddressSpaceComposer.TryExtractDeviceHost / NormalizeDeviceHost. +/// +public static class DeviceConfigIntent +{ + /// + /// Extract a Device's connection host from its schemaless DeviceConfig JSON: the + /// top-level "HostAddress" string (e.g. "10.201.31.5:8193"). Returns + /// when the config is blank, not a JSON object, has no string + /// HostAddress, or the value is blank/whitespace. Never throws. The returned host is + /// deterministically normalized via (trim + lower-case). + /// + /// The device's schemaless DeviceConfig JSON blob. + /// The normalized device host, or when absent/blank/unparseable. + public static string? TryExtractHost(string? deviceConfigJson) + { + if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null; + try + { + using var doc = JsonDocument.Parse(deviceConfigJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl) + || hostEl.ValueKind != JsonValueKind.String) return null; + var raw = hostEl.GetString(); + if (string.IsNullOrWhiteSpace(raw)) return null; + return NormalizeHost(raw); + } + catch (JsonException) { return null; } + } + + /// + /// The single normalization authority for a device host: trims surrounding whitespace and + /// lower-cases (invariant). Idempotent (an already-normalized value is unchanged). + /// + /// The raw host string (non-null; a non-empty HostAddress or folder segment). + /// The normalized host (trimmed + lower-cased). + public static string NormalizeHost(string host) => host.Trim().ToLowerInvariant(); +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs new file mode 100644 index 00000000..55e6db11 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs @@ -0,0 +1,46 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; + +/// +/// Pins device-host extraction + normalization semantics (ported +/// from the historical composer statics; R2-11): null on blank/non-object/non-string/whitespace, +/// trim+lower normalization, and idempotence. +/// +public sealed class DeviceConfigIntentTests +{ + [Theory] + [InlineData("{\"HostAddress\":\"10.0.0.5:8193\"}", "10.0.0.5:8193")] + [InlineData("{\"HostAddress\":\" HOST:8193 \"}", "host:8193")] // trim + lower + [InlineData("{\"HostAddress\":\"HOST:8193\",\"other\":1}", "host:8193")] + // null-yielding cases + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("not json {", null)] + [InlineData("[1,2]", null)] // non-object root + [InlineData("{\"HostAddress\":123}", null)] // non-string + [InlineData("{\"HostAddress\":\" \"}", null)] // whitespace value + [InlineData("{\"other\":\"x\"}", null)] // absent + public void TryExtractHost(string? cfg, string? expected) + { + DeviceConfigIntent.TryExtractHost(cfg).ShouldBe(expected); + } + + [Theory] + [InlineData(" HOST:8193 ", "host:8193")] + [InlineData("host:8193", "host:8193")] + public void NormalizeHost_trims_and_lowercases(string raw, string expected) + { + DeviceConfigIntent.NormalizeHost(raw).ShouldBe(expected); + } + + [Fact] + public void NormalizeHost_is_idempotent() + { + var once = DeviceConfigIntent.NormalizeHost(" HOST:8193 "); + DeviceConfigIntent.NormalizeHost(once).ShouldBe(once); + } +} From a452a20947312e933b9a01e7b943f12bf8e5a3df Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:42:08 -0400 Subject: [PATCH 05/24] refactor(opcuaserver): composer parses TagConfig once per tag via TagConfigIntent (R2-11, 01/P-1) Deletes the composer's ExtractTagFullName/Alarm/Historize/Array statics and (co-located to keep the build green, per plan T5) the three OpcUaServer.Tests ExtractTag* suites whose tables now live in Commons.Tests/TagConfigIntentTests. T10 is the grep-sweep verification. --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../AddressSpaceComposer.cs | 137 ++------------- .../ExtractTagAlarmTests.cs | 37 ---- .../ExtractTagArrayTests.cs | 159 ------------------ .../ExtractTagHistorizeTests.cs | 35 ---- 5 files changed, 12 insertions(+), 358 deletions(-) delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 42326c7b..8db81f17 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -6,7 +6,7 @@ { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] }, { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] }, { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] }, - { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] }, + { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] }, { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] }, { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] }, { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 4ef942e9..fe429e78 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -415,8 +415,11 @@ public static class AddressSpaceComposer .ThenBy(t => t.Name, StringComparer.Ordinal) .Select(t => { - var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig); - var (isArray, arrayLength) = ExtractTagArray(t.TagConfig); + // Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity + // authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the + // artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the + // walker all consume — so the live-compose and artifact-decode plans stay byte-equal. + var intent = TagConfigIntent.Parse(t.TagConfig); return new EquipmentTagPlan( TagId: t.TagId, EquipmentId: t.EquipmentId!, @@ -424,13 +427,13 @@ public static class AddressSpaceComposer FolderPath: t.FolderPath ?? string.Empty, Name: t.Name, DataType: t.DataType, - FullName: ExtractTagFullName(t.TagConfig), + FullName: intent.FullName, Writable: t.AccessLevel == TagAccessLevel.ReadWrite, - Alarm: ExtractTagAlarm(t.TagConfig), - IsHistorized: isHistorized, - HistorianTagname: historianTagname, - IsArray: isArray, - ArrayLength: arrayLength); + Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null, + IsHistorized: intent.IsHistorized, + HistorianTagname: intent.HistorianTagname, + IsArray: intent.IsArray, + ArrayLength: intent.ArrayLength); }) .ToList(); @@ -523,33 +526,6 @@ public static class AddressSpaceComposer }; } - /// - /// Extract the driver-side full reference from a JSON blob: the - /// CK_Tag_TagConfig_IsJson constraint guarantees a JSON object, and every shipped - /// driver stores the wire-level address in a top-level FullName field. Replicated from - /// EquipmentNodeWalker.ExtractFullName because OpcUaServer does not reference the Core - /// driver assembly (kept in sync with the artifact-decode copy in DeploymentArtifact). - /// Falls back to the raw blob when it is not a JSON object with a string FullName. - /// - /// The tag's wire-level address JSON. - /// The extracted full reference, or the raw blob when no FullName is present. - private static string ExtractTagFullName(string tagConfig) - { - if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig; - try - { - using var doc = JsonDocument.Parse(tagConfig); - if (doc.RootElement.ValueKind == JsonValueKind.Object - && doc.RootElement.TryGetProperty("FullName", out var fullName) - && fullName.ValueKind == JsonValueKind.String) - { - return fullName.GetString() ?? tagConfig; - } - } - catch (JsonException) { /* fall through to raw blob */ } - return tagConfig; - } - /// /// Extract a 's connection host from its schemaless DeviceConfig JSON: /// the top-level "HostAddress" string (e.g. "10.201.31.5:8193") — the same value a @@ -593,95 +569,4 @@ public static class AddressSpaceComposer /// The normalized host (trimmed + lower-cased). public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant(); - /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null - /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The - /// artifact-decode side (DeploymentArtifact.ExtractTagAlarm) MUST parse identically (byte-parity). - /// The tag's raw TagConfig JSON (nullable/blank tolerated). - /// The parsed native-alarm intent, or when the tag has no alarm object. - internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig) - { - if (string.IsNullOrWhiteSpace(tagConfig)) return null; - try - { - using var doc = JsonDocument.Parse(tagConfig); - if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; - if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null; - var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String - ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition"; - var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number - && sEl.TryGetInt32(out var sv) ? sv : 500; - // historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the - // durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on). - bool? historize = a.TryGetProperty("historizeToAveva", out var hEl) - && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False - ? hEl.GetBoolean() - : null; - return new EquipmentTagAlarmInfo(type, sev, historize); - } - catch (JsonException) { return null; } - } - - /// Parses the optional server-side HistoryRead intent from a tag's TagConfig JSON: - /// 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 — matching ExtractTagFullName / - /// ExtractTagAlarm. Never throws. The artifact-decode side - /// (DeploymentArtifact.ExtractTagHistorize) MUST parse identically (byte-parity). - /// The tag's raw TagConfig JSON (nullable/blank tolerated). - /// The parsed historize flag and optional historian tagname override. - internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig) - { - if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); - try - { - using var doc = JsonDocument.Parse(tagConfig); - if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); - var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl) - && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) - && hEl.GetBoolean(); - string? tagname = null; - if (doc.RootElement.TryGetProperty("historianTagname", out var nEl) - && nEl.ValueKind == JsonValueKind.String) - { - var raw = nEl.GetString(); - if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; - } - return (isHistorized, tagname); - } - catch (JsonException) { return (false, null); } - } - - /// Parses the optional array intent from a tag's TagConfig JSON: the isArray - /// bool (absent / not a bool / non-object root / blank / malformed ⇒ false) and the optional - /// arrayLength uint. The length is honoured ONLY when isArray is true AND the prop is a - /// JSON number that fits uint (else null ⇒ unbounded 1-D array, - /// ArrayDimensions=[0] at materialisation). Mirrors - /// exactly in structure + null/blank/non-object/malformed-JSON - /// tolerance. Never throws. The artifact-decode side - /// (DeploymentArtifact.ExtractTagArray) MUST parse identically (byte-parity). - /// The tag's raw TagConfig JSON (nullable/blank tolerated). - /// The parsed array flag and optional array length. - internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig) - { - if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); - try - { - using var doc = JsonDocument.Parse(tagConfig); - if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); - var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl) - && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False) - && aEl.GetBoolean(); - uint? arrayLength = null; - if (isArray - && doc.RootElement.TryGetProperty("arrayLength", out var lEl) - && lEl.ValueKind == JsonValueKind.Number - && lEl.TryGetUInt32(out var len)) - { - arrayLength = len; - } - return (isArray, arrayLength); - } - catch (JsonException) { return (false, null); } - } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs deleted file mode 100644 index 49dfd56a..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; - -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; - -public class ExtractTagAlarmTests -{ - [Theory] - [InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)] - [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)] - [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)] - [InlineData("not json", false, null, 0)] - [InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)] - public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev) - { - var info = AddressSpaceComposer.ExtractTagAlarm(cfg); - if (!present) { info.ShouldBeNull(); return; } - info!.AlarmType.ShouldBe(type); - info.Severity.ShouldBe(sev); - } - - /// historizeToAveva (bool?, absent ⇒ null ⇒ historize): an explicit true/false parses - /// through; a missing or non-bool node yields null (the HistorianAdapterActor gate then treats it as - /// default-on). Mirrors the scripted-alarm opt-out posture. - [Theory] - [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)] - [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)] - [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)] - [InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)] - public void ExtractTagAlarm_parses_historizeToAveva(string cfg, bool? expected) - { - var info = AddressSpaceComposer.ExtractTagAlarm(cfg); - info.ShouldNotBeNull(); - info!.HistorizeToAveva.ShouldBe(expected); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs deleted file mode 100644 index 2855eb34..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; - -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; - -/// -/// Verifies parses the optional array intent from a -/// tag's TagConfig JSON exactly as parses -/// the historize intent: the isArray bool (absent / not a bool / non-object root / blank / -/// malformed ⇒ false) and the optional arrayLength uint (only honoured when -/// isArray is true AND the prop is a JSON number that fits uint; else null). Never -/// throws. Also pins the end-to-end thread-through onto -/// / . -/// -public class ExtractTagArrayTests -{ - [Theory] - // isArray true with an explicit arrayLength. - [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)] - // isArray true, no arrayLength ⇒ length null. - [InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)] - // Absent isArray ⇒ false (arrayLength ignored even if present). - [InlineData("{\"FullName\":\"T.A\"}", false, null)] - // arrayLength present but isArray false ⇒ length null (only honoured when the flag is true). - [InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)] - // arrayLength absent-with-flag honoured-as-null when isArray true but no length. - [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)] - // null / empty / malformed-JSON / array-root ⇒ (false, null), never throws. - [InlineData(null, false, null)] - [InlineData("", false, null)] - [InlineData("not json {", false, null)] - [InlineData("[1,2]", false, null)] - // Wrong type for isArray (string, not bool) ⇒ false. - [InlineData("{\"isArray\":\"yes\"}", false, null)] - // Wrong type for arrayLength (string, not number) ⇒ length null, flag still honoured. - [InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)] - // Negative arrayLength does not fit uint ⇒ length null, flag still honoured. - [InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)] - // Float arrayLength (16.5) is not an exact uint ⇒ TryGetUInt32 rejects it ⇒ length null. - [InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)] - // Overflow arrayLength (uint.MaxValue + 1 = 4294967296) does not fit uint ⇒ length null. - [InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)] - public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength) - { - var (isArray, arrayLength) = AddressSpaceComposer.ExtractTagArray(cfg); - isArray.ShouldBe(expectedIsArray); - arrayLength.ShouldBe(expectedLength); - } - - /// End-to-end: an equipment tag whose TagConfig carries isArray/arrayLength - /// surfaces those on its through , - /// exactly as the historize keys thread through. - [Fact] - public void Compose_threads_array_keys_onto_equipment_tag_plan() - { - 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 = "Machine_001", - MachineCode = "MACHINE_001", - }; - var arrayTag = new Tag - { - TagId = "tag-arr", - DriverInstanceId = "drv-1", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Buffer", - DataType = "Int16", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}", - }; - - var result = AddressSpaceComposer.Compose( - new[] { area }, new[] { line }, new[] { equip }, - new[] { driver }, Array.Empty(), - new[] { arrayTag }, new[] { ns }); - - var tag = result.EquipmentTags.ShouldHaveSingleItem(); - tag.TagId.ShouldBe("tag-arr"); - tag.IsArray.ShouldBeTrue(); - tag.ArrayLength.ShouldBe((uint)16); - } - - /// End-to-end: a scalar equipment tag (no array keys) yields IsArray=false, ArrayLength=null. - [Fact] - public void Compose_leaves_scalar_equipment_tag_plan_unflagged() - { - 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 = "Machine_001", - MachineCode = "MACHINE_001", - }; - var scalarTag = new Tag - { - TagId = "tag-scalar", - DriverInstanceId = "drv-1", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Speed", - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40005\"}", - }; - - var result = AddressSpaceComposer.Compose( - new[] { area }, new[] { line }, new[] { equip }, - new[] { driver }, Array.Empty(), - new[] { scalarTag }, new[] { ns }); - - var tag = result.EquipmentTags.ShouldHaveSingleItem(); - tag.IsArray.ShouldBeFalse(); - tag.ArrayLength.ShouldBeNull(); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs deleted file mode 100644 index a48ce961..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; - -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; - -public class ExtractTagHistorizeTests -{ - [Theory] - // isHistorized true, no explicit tagname ⇒ tagname null (defaults to FullName later). - [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true}", true, null)] - // isHistorized true with an explicit historian-tagname override. - [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\"WW.Tag\"}", true, "WW.Tag")] - // Absent isHistorized ⇒ false. - [InlineData("{\"FullName\":\"T.A\"}", false, null)] - // historianTagname parses independently of the flag. - [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":false,\"historianTagname\":\"WW.Tag\"}", false, "WW.Tag")] - // Blank/whitespace tagname ⇒ null. - [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] - // null / empty / malformed-JSON / array-root ⇒ (false, null), never throws. - [InlineData(null, false, null)] - [InlineData("", false, null)] - [InlineData("not json {", false, null)] - [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) = AddressSpaceComposer.ExtractTagHistorize(cfg); - isHistorized.ShouldBe(expectedHistorized); - historianTagname.ShouldBe(expectedTagname); - } -} From 7bbec7771679ba6ffcdc42544d6a7ce1c2208fc1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:46:30 -0400 Subject: [PATCH 06/24] refactor(runtime,opcuaserver): device-host normalization re-homed to Commons DeviceConfigIntent (R2-11) --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../AddressSpaceComposer.cs | 58 +++---------------- .../Drivers/DeploymentArtifact.cs | 4 +- .../Drivers/DriverHostActor.cs | 6 +- .../AddressSpaceComposerDeviceHostTests.cs | 35 ++--------- ...DeploymentArtifactDeviceHostParityTests.cs | 2 +- .../Drivers/DriverHostActorDiscoveryTests.cs | 2 +- 7 files changed, 19 insertions(+), 90 deletions(-) diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 8db81f17..b38dcc59 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -7,7 +7,7 @@ { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] }, { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] }, { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] }, - { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] }, + { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] }, { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] }, { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] }, { "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "pending", "blockedBy": ["T2", "T3"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index fe429e78..eff3d232 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -66,11 +65,11 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin /// (both null ⇒ driver-less / no device), copied straight from the Equipment row. /// is the device's connection host (e.g. "10.0.0.5:8193") resolved from the /// bound Device's schemaless DeviceConfig JSON via -/// null when there is no device, no -/// HostAddress in its config, or the host cannot be parsed. These three let a later task graft a -/// driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a +/// null when there +/// is no device, no HostAddress in its config, or the host cannot be parsed. These three let a later +/// task graft a driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a /// multi-device driver by host. The value is normalized identically on both the live-edit composer and -/// the artifact-decode sides (single source of truth: ); +/// the artifact-decode sides (single source of truth: ); /// the later partition task MUST normalize the driver-discovered device-host folder segment the same way /// (trim + lower-case) so the two compare equal. /// Address-space-rebuild interaction (accepted trade-off). These three fields participate in @@ -350,8 +349,8 @@ public static class AddressSpaceComposer var resolvedScripts = scripts ?? Array.Empty