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",
|
||||
"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}}",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user