feat(otopcua): EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E projection)
This commit is contained in:
@@ -58,7 +58,28 @@ public sealed record AddressSpaceComposition(
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
|
||||
|
||||
/// <summary>One UNS level-5 equipment folder in the address space. <see cref="EquipmentId"/> is the
|
||||
/// logical NodeId; <see cref="DisplayName"/> is the friendly UNS Name segment; <see cref="UnsLineId"/>
|
||||
/// is the parent line the folder hangs under.
|
||||
/// <para><see cref="DriverInstanceId"/> / <see cref="DeviceId"/> carry the equipment's optional bindings
|
||||
/// (both <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
|
||||
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
|
||||
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
|
||||
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> — <c>null</c> when there is no device, no
|
||||
/// <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later task graft a
|
||||
/// driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
|
||||
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
|
||||
/// the artifact-decode sides (single source of truth: <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>);
|
||||
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
|
||||
/// (trim + lower-case) so the two compare equal.</para></summary>
|
||||
public sealed record EquipmentNode(
|
||||
string EquipmentId,
|
||||
string DisplayName,
|
||||
string UnsLineId,
|
||||
string? DriverInstanceId = null,
|
||||
string? DeviceId = null,
|
||||
string? DeviceHost = null);
|
||||
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
||||
|
||||
@@ -277,15 +298,17 @@ public static class AddressSpaceComposer
|
||||
/// <param name="equipment">The equipment.</param>
|
||||
/// <param name="driverInstances">The driver instances.</param>
|
||||
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
||||
/// <param name="devices">The per-device rows used to resolve each equipment's <c>DeviceHost</c>. <c>null</c> = none.</param>
|
||||
/// <returns>The composition result.</returns>
|
||||
public static AddressSpaceComposition Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
IReadOnlyList<Device>? devices = null) =>
|
||||
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(), devices: devices);
|
||||
|
||||
/// <summary>
|
||||
/// Composes the address space build plan from the configuration entities.
|
||||
@@ -299,6 +322,8 @@ public static class AddressSpaceComposer
|
||||
/// <param name="namespaces">The namespaces.</param>
|
||||
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
|
||||
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
|
||||
/// <param name="devices">The per-device rows (<c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON) used to resolve
|
||||
/// each equipment's <c>DeviceHost</c> from its bound <c>DeviceId</c>. <c>null</c> = none.</param>
|
||||
/// <returns>The composition result.</returns>
|
||||
public static AddressSpaceComposition Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
@@ -309,10 +334,19 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<Tag> tags,
|
||||
IReadOnlyList<Namespace> namespaces,
|
||||
IReadOnlyList<VirtualTag>? virtualTags = null,
|
||||
IReadOnlyList<Script>? scripts = null)
|
||||
IReadOnlyList<Script>? scripts = null,
|
||||
IReadOnlyList<Device>? devices = null)
|
||||
{
|
||||
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
||||
|
||||
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
|
||||
// via the shared TryExtractDeviceHost (single source of truth + normalization for both this
|
||||
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// byte-parity-equal). Ordinal comparer matches the decode-side map exactly.
|
||||
var deviceHostById = (devices ?? Array.Empty<Device>())
|
||||
.Where(d => d.DeviceId != null)
|
||||
.ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig), StringComparer.Ordinal);
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
||||
@@ -328,7 +362,15 @@ public static class AddressSpaceComposer
|
||||
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
|
||||
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
|
||||
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
|
||||
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
|
||||
// DriverInstanceId / DeviceId are copied straight from the row; DeviceHost resolves from the
|
||||
// bound device's config (null when there's no device or no parseable HostAddress).
|
||||
.Select(e => new EquipmentNode(
|
||||
e.EquipmentId,
|
||||
e.Name,
|
||||
e.UnsLineId,
|
||||
DriverInstanceId: e.DriverInstanceId,
|
||||
DeviceId: e.DeviceId,
|
||||
DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)))
|
||||
.ToList();
|
||||
|
||||
var plans = driverInstances
|
||||
@@ -493,6 +535,37 @@ public static class AddressSpaceComposer
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a <see cref="Device"/>'s connection host from its schemaless <c>DeviceConfig</c> JSON:
|
||||
/// the top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>) — the same value a
|
||||
/// FOCAS driver emits as its discovered device-host folder segment. Returns <c>null</c> when the
|
||||
/// config is blank, not a JSON object, has no string <c>HostAddress</c>, or the value is
|
||||
/// blank/whitespace. Never throws.
|
||||
/// <para>The returned host is deterministically normalized — trimmed and lower-cased — so the
|
||||
/// live-edit composer side and the artifact-decode side (<c>DeploymentArtifact</c>) agree
|
||||
/// byte-for-byte. This method is the SINGLE SOURCE OF TRUTH for that normalization: the later
|
||||
/// FixedTree-partition task MUST normalize the driver-discovered device-host folder segment the
|
||||
/// same way (call this, or apply the identical trim + lower-case) before comparing the two.</para>
|
||||
/// </summary>
|
||||
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
|
||||
/// <returns>The normalized device host, or <c>null</c> when absent/blank/unparseable.</returns>
|
||||
public static string? TryExtractDeviceHost(string? deviceConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(deviceConfigJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl)
|
||||
|| hostEl.ValueKind != JsonValueKind.String) return null;
|
||||
var raw = hostEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
|
||||
return raw.Trim().ToLowerInvariant();
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||
|
||||
@@ -201,7 +201,10 @@ public static class DeploymentArtifact
|
||||
|
||||
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
// DeviceId → connection host, resolved from the artifact's Devices array via the SAME shared
|
||||
// helper the composer uses, so each EquipmentNode.DeviceHost is byte-parity-equal across seams.
|
||||
var deviceHostById = BuildDeviceHostMap(root);
|
||||
var equipment = ReadArray(root, "Equipment", el => ReadEquipmentNode(el, deviceHostById));
|
||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||
var equipmentTags = BuildEquipmentTagPlans(root);
|
||||
@@ -807,7 +810,29 @@ public static class DeploymentArtifact
|
||||
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
||||
}
|
||||
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
|
||||
/// (each row carries a <c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON). The host is resolved via
|
||||
/// the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> so the artifact-decode side
|
||||
/// normalizes byte-identically to the live-edit composer. Ordinal comparer + last-wins on a duplicate
|
||||
/// DeviceId. A missing/empty/non-array <c>Devices</c> property yields an empty map (no device hosts).</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>The resolved DeviceId → host map (host may be null when a device has no parseable HostAddress).</returns>
|
||||
private static IReadOnlyDictionary<string, string?> BuildDeviceHostMap(JsonElement root)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
if (!root.TryGetProperty("Devices", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return map;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) continue;
|
||||
map[deviceId!] = AddressSpaceComposer.TryExtractDeviceHost(ReadString(el, "DeviceConfig"));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el, IReadOnlyDictionary<string, string?> deviceHostById)
|
||||
{
|
||||
var id = ReadString(el, "EquipmentId");
|
||||
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching UnsArea/UnsLine
|
||||
@@ -816,7 +841,19 @@ public static class DeploymentArtifact
|
||||
var displayName = ReadString(el, "Name");
|
||||
var lineId = ReadString(el, "UnsLineId");
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
|
||||
// DriverInstanceId / DeviceId copied straight from the row (null when absent / JSON null);
|
||||
// DeviceHost resolved from the device-host map by DeviceId — byte-parity with the composer's
|
||||
// `e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)`.
|
||||
var driverInstanceId = ReadString(el, "DriverInstanceId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var deviceHost = deviceId is null ? null : deviceHostById.GetValueOrDefault(deviceId);
|
||||
return new EquipmentNode(
|
||||
id!,
|
||||
displayName ?? id!,
|
||||
lineId ?? string.Empty,
|
||||
DriverInstanceId: driverInstanceId,
|
||||
DeviceId: deviceId,
|
||||
DeviceHost: deviceHost);
|
||||
}
|
||||
|
||||
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
|
||||
|
||||
+111
@@ -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,
|
||||
};
|
||||
}
|
||||
+130
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user