test(runtime): golden TagConfig corpus + compose/decode parity characterization (R2-11)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
|
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
|
||||||
"lastUpdated": "2026-07-12",
|
"lastUpdated": "2026-07-12",
|
||||||
"tasks": [
|
"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": "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": "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": "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}}",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user