test(runtime): golden TagConfig corpus + compose/decode parity characterization (R2-11)

This commit is contained in:
Joseph Doherty
2026-07-13 10:35:47 -04:00
parent 46fedda3e4
commit 2e14fe1f24
3 changed files with 195 additions and 1 deletions
@@ -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": [] },
@@ -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;
/// <summary>
/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per
/// <see cref="TagConfigGoldenCorpus"/> blob, serialises the SAME draft to the artifact blob shape,
/// decodes it, and asserts the decoded <see cref="EquipmentTagPlan"/> list equals the composer's
/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse
/// the identical raw <c>TagConfig</c> string, this proves the four byte-parity parse sites agree TODAY,
/// and is the permanent regression guard held green through every subsequent seam swap
/// (<c>TagConfigIntent.Parse</c> consolidation).
/// </summary>
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<ScriptedAlarm>(), 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,
};
}
@@ -0,0 +1,83 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The shared golden corpus of <c>TagConfig</c> 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 <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a
/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test
/// (<see cref="TagConfigCorpusParityTests"/>) can round-trip each through both equipment-tag producers
/// and assert byte-parity field-by-field. The <c>null</c>-input divergence (composer returns the raw
/// null, artifact coalesces to "") is a documented seam handled only at the
/// <c>TagConfigIntent.Parse</c> unit level, not through the parity round-trip.
/// <para>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.</para>
/// </summary>
public static class TagConfigGoldenCorpus
{
/// <summary>The corpus blobs, index-ordered. The parity test builds one equipment tag per blob.</summary>
public static readonly IReadOnlyList<string> 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}}",
};
}