feat(otopcua): EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E projection)

This commit is contained in:
Joseph Doherty
2026-06-26 13:07:31 -04:00
parent e7d5ebe956
commit cb7ce7f171
4 changed files with 359 additions and 8 deletions
@@ -0,0 +1,111 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Covers follow-up E projection: <see cref="EquipmentNode"/> carries the equipment's
/// <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the resolved <c>DeviceHost</c> (parsed from
/// the bound <see cref="Device"/>'s schemaless <c>DeviceConfig</c> JSON via the shared
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>). A later task grafts a driver's discovered
/// FixedTree onto a zero-tag equipment and partitions a multi-device driver by host using these.
/// </summary>
public sealed class AddressSpaceComposerDeviceHostTests
{
/// <summary>An equipment bound to a driver + a device whose config carries a top-level
/// <c>HostAddress</c> resolves all three fields, with the host trimmed + lower-cased.</summary>
[Fact]
public void Compose_resolves_driver_device_and_device_host()
{
var equipment = new[] { NewEquipment("eq-1", driver: "d1", device: "dev1") };
var devices = new[] { NewDevice("dev1", "d1", "{\"HostAddress\":\"10.0.0.5:8193\"}") };
var node = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: devices)
.EquipmentNodes.ShouldHaveSingleItem();
node.EquipmentId.ShouldBe("eq-1");
node.DriverInstanceId.ShouldBe("d1");
node.DeviceId.ShouldBe("dev1");
node.DeviceHost.ShouldBe("10.0.0.5:8193");
}
/// <summary>An equipment with no driver and no device → all three new fields null (driver-less,
/// no device to resolve a host from).</summary>
[Fact]
public void Compose_equipment_without_driver_or_device_yields_null_bindings()
{
var equipment = new[] { NewEquipment("eq-1", driver: null, device: null) };
var node = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: Array.Empty<Device>())
.EquipmentNodes.ShouldHaveSingleItem();
node.DriverInstanceId.ShouldBeNull();
node.DeviceId.ShouldBeNull();
node.DeviceHost.ShouldBeNull();
}
/// <summary>A bound DeviceId with no matching device row, or a device whose config has no
/// <c>HostAddress</c>, resolves DeviceHost to null while DeviceId is still carried.</summary>
[Fact]
public void Compose_device_host_is_null_when_unresolvable()
{
var equipment = new[]
{
NewEquipment("eq-missing", driver: "d1", device: "dev-missing"),
NewEquipment("eq-nohost", driver: "d1", device: "dev-nohost"),
};
var devices = new[] { NewDevice("dev-nohost", "d1", "{\"Port\":502}") };
var nodes = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: devices)
.EquipmentNodes;
var missing = nodes.Single(n => n.EquipmentId == "eq-missing");
missing.DeviceId.ShouldBe("dev-missing");
missing.DeviceHost.ShouldBeNull();
var noHost = nodes.Single(n => n.EquipmentId == "eq-nohost");
noHost.DeviceId.ShouldBe("dev-nohost");
noHost.DeviceHost.ShouldBeNull();
}
/// <summary>The shared host extractor normalizes (trim + lower-case) and tolerates every malformed
/// shape (blank / non-object / no string HostAddress / blank value / non-JSON) by returning null.</summary>
[Theory]
[InlineData("{\"HostAddress\":\"10.201.31.5:8193\"}", "10.201.31.5:8193")]
[InlineData("{\"HostAddress\":\" HOST-A:8193 \"}", "host-a:8193")] // trimmed + lower-cased
[InlineData("{\"HostAddress\":\"\"}", null)] // blank value
[InlineData("{\"HostAddress\":1234}", null)] // non-string
[InlineData("{\"Port\":502}", null)] // absent
[InlineData("[]", null)] // non-object root
[InlineData("not json", null)] // malformed
[InlineData("", null)] // blank
public void TryExtractDeviceHost_normalizes_and_tolerates(string? deviceConfig, string? expected)
{
AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected);
}
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
{
EquipmentId = id,
DriverInstanceId = driver,
DeviceId = device,
UnsLineId = "line-1",
Name = id,
MachineCode = id.ToUpperInvariant(),
};
private static Device NewDevice(string deviceId, string driverInstanceId, string deviceConfig) => new()
{
DeviceId = deviceId,
DriverInstanceId = driverInstanceId,
Name = deviceId,
DeviceConfig = deviceConfig,
};
}
@@ -0,0 +1,130 @@
using System.Linq;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Proves follow-up E: the equipment's <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the
/// resolved <c>DeviceHost</c> (parsed from the bound <see cref="Device"/>'s schemaless
/// <c>DeviceConfig</c> JSON) round-trip with byte-parity through both <see cref="EquipmentNode"/>
/// producers: the live-edit composer (<see cref="AddressSpaceComposer.Compose(System.Collections.Generic.IReadOnlyList{UnsArea},System.Collections.Generic.IReadOnlyList{UnsLine},System.Collections.Generic.IReadOnlyList{Equipment},System.Collections.Generic.IReadOnlyList{DriverInstance},System.Collections.Generic.IReadOnlyList{ScriptedAlarm},System.Collections.Generic.IReadOnlyList{Device})"/>)
/// 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
/// of truth + identical trim + lower-case normalization).
/// </summary>
public sealed class DeploymentArtifactDeviceHostParityTests
{
/// <summary>
/// One draft exercising every branch: driver + device + host (with a mixed-case/whitespace host
/// that must normalize identically on both sides); a driver-bound equipment with NO device
/// (DeviceId null ⇒ DeviceHost null); a driver-less, device-less equipment (all three null); and a
/// device whose config carries no <c>HostAddress</c> (DeviceId carried, DeviceHost null). The decoded
/// <c>EquipmentNodes</c> must equal the composer's element-wise (positional-record value equality)
/// and in the same order.
/// </summary>
[Fact]
public void Composer_and_artifact_agree_on_equipment_node_device_host()
{
// eq-1: driver d1 + device dev1 (host needs trim + lower-case)
var eq1 = NewEquipment("eq-1", driver: "d1", device: "dev1");
// eq-2: driver d1, NO device → DeviceHost null
var eq2 = NewEquipment("eq-2", driver: "d1", device: null);
// eq-3: driver-less + device-less → all three null
var eq3 = NewEquipment("eq-3", driver: null, device: null);
// eq-4: device dev-nohost whose config has no HostAddress → DeviceHost null
var eq4 = NewEquipment("eq-4", driver: "d1", device: "dev-nohost");
var dev1 = NewDevice("dev1", "d1", "{\"HostAddress\":\" HOST-A:8193 \"}"); // → host-a:8193
var devNoHost = NewDevice("dev-nohost", "d1", "{\"Port\":502}"); // → null
var equipment = new[] { eq1, eq2, eq3, eq4 };
var devices = new[] { dev1, devNoHost };
// ---- Side 1: the live-edit composer ----
var composed = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: devices);
// ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Equipment = equipment.Select(ToEquipmentSnapshot).ToArray(),
Devices = devices.Select(ToDeviceSnapshot).ToArray(),
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// ---- Full byte-parity: every field, same order (positional-record value equality) ----
decoded.EquipmentNodes.Count.ShouldBe(4);
decoded.EquipmentNodes.SequenceEqual(composed.EquipmentNodes).ShouldBeTrue();
// Spell out per-equipment so a divergence names the offending node.
var d1Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-1");
d1Node.DriverInstanceId.ShouldBe("d1");
d1Node.DeviceId.ShouldBe("dev1");
d1Node.DeviceHost.ShouldBe("host-a:8193"); // trimmed + lower-cased on both sides
var d2Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-2");
d2Node.DriverInstanceId.ShouldBe("d1");
d2Node.DeviceId.ShouldBeNull();
d2Node.DeviceHost.ShouldBeNull();
var d3Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-3");
d3Node.DriverInstanceId.ShouldBeNull();
d3Node.DeviceId.ShouldBeNull();
d3Node.DeviceHost.ShouldBeNull();
var d4Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-4");
d4Node.DriverInstanceId.ShouldBe("d1");
d4Node.DeviceId.ShouldBe("dev-nohost");
d4Node.DeviceHost.ShouldBeNull();
}
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
{
EquipmentId = id,
DriverInstanceId = driver,
DeviceId = device,
UnsLineId = "line-1",
Name = id,
MachineCode = id.ToUpperInvariant(),
};
private static Device NewDevice(string deviceId, string driverInstanceId, string deviceConfig) => new()
{
DeviceId = deviceId,
DriverInstanceId = driverInstanceId,
Name = deviceId,
DeviceConfig = deviceConfig,
};
/// <summary>The Pascal-case snapshot an <see cref="Equipment"/> EF entity serialises to in the
/// artifact (matches ConfigComposer) — including the nullable <c>DriverInstanceId</c> / <c>DeviceId</c>
/// the equipment-node decoder re-reads.</summary>
private static object ToEquipmentSnapshot(Equipment e) => new
{
e.EquipmentId,
e.Name,
e.MachineCode,
e.UnsLineId,
e.DriverInstanceId,
e.DeviceId,
};
/// <summary>The Pascal-case snapshot a <see cref="Device"/> EF entity serialises to in the artifact —
/// the decoder re-reads <c>DeviceId</c> + the raw <c>DeviceConfig</c> blob the host rides inside.</summary>
private static object ToDeviceSnapshot(Device d) => new
{
d.DeviceId,
d.DriverInstanceId,
d.Name,
d.DeviceConfig,
};
}