From 2e14fe1f24df09e0ffff966fb337a835124df0b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:35:47 -0400 Subject: [PATCH] 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}}", + }; +}