Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s

Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies
delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson
readers, per-driver Inspect() + writable key, FOCAS capability pre-flight,
Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip
deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs
with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396.
Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 11:29:32 -04:00
74 changed files with 2228 additions and 682 deletions
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// and the artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
/// A secondary/follower node decoding a serialized artifact MUST see the same DeviceHost as the
/// primary so it grafts FixedTree / partitions multi-device drivers identically. Both sides resolve
/// the host through the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> (single source
/// the host through the shared <c>DeviceConfigIntent.TryExtractHost</c> (single source
/// of truth + identical trim + lower-case normalization).
/// </summary>
public sealed class DeploymentArtifactDeviceHostParityTests
@@ -197,7 +197,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
/// vs stored "h1" wrinkle proves the SHARED <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> match.</summary>
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
[Fact]
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
{
@@ -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}}",
};
}