refactor(runtime,opcuaserver): device-host normalization re-homed to Commons DeviceConfigIntent (R2-11)
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
|
||||
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
|
||||
{ "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] },
|
||||
{ "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "pending", "blockedBy": ["T2", "T3"] },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -66,11 +65,11 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin
|
||||
/// (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
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent.TryExtractHost"/> — <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 artifact-decode sides (single source of truth: <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent"/>);
|
||||
/// 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>
|
||||
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
|
||||
@@ -350,8 +349,8 @@ public static class AddressSpaceComposer
|
||||
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
|
||||
// via the shared DeviceConfigIntent.TryExtractHost (single source of truth + normalization for both
|
||||
// this composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
|
||||
// Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
|
||||
// foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
|
||||
@@ -360,7 +359,7 @@ public static class AddressSpaceComposer
|
||||
foreach (var d in devices ?? Array.Empty<Device>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
|
||||
deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig);
|
||||
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
|
||||
}
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
@@ -526,47 +525,4 @@ public static class AddressSpaceComposer
|
||||
};
|
||||
}
|
||||
|
||||
/// <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 NormalizeDeviceHost(raw);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and
|
||||
/// lower-cases (invariant). <see cref="TryExtractDeviceHost"/> applies this to a <c>Device</c>'s
|
||||
/// parsed <c>HostAddress</c>, and the FixedTree-partition path (<c>DriverHostActor</c>) applies the
|
||||
/// SAME function to a driver-discovered device-host folder segment before comparing the two — so an
|
||||
/// <see cref="EquipmentNode.DeviceHost"/> and a captured folder segment for the same device compare
|
||||
/// equal regardless of case/whitespace. Idempotent (a value already normalized is unchanged).
|
||||
/// </summary>
|
||||
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
|
||||
/// <returns>The normalized host (trimmed + lower-cased).</returns>
|
||||
public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
|
||||
|
||||
}
|
||||
|
||||
@@ -819,7 +819,7 @@ public static class DeploymentArtifact
|
||||
|
||||
/// <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
|
||||
/// the shared <see cref="DeviceConfigIntent.TryExtractHost"/> 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>
|
||||
@@ -834,7 +834,7 @@ public static class DeploymentArtifact
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) continue;
|
||||
map[deviceId!] = AddressSpaceComposer.TryExtractDeviceHost(ReadString(el, "DeviceConfig"));
|
||||
map[deviceId!] = DeviceConfigIntent.TryExtractHost(ReadString(el, "DeviceConfig"));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -712,7 +712,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
|
||||
/// </list>
|
||||
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
|
||||
/// <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> (single source of truth), so they compare equal
|
||||
/// <see cref="DeviceConfigIntent.NormalizeHost"/> (single source of truth), so they compare equal
|
||||
/// regardless of case/whitespace.
|
||||
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
|
||||
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
|
||||
@@ -739,7 +739,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
|
||||
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
|
||||
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
|
||||
var host = AddressSpaceComposer.NormalizeDeviceHost(node.DeviceHost);
|
||||
var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost);
|
||||
if (ambiguousHosts.Contains(host)) continue;
|
||||
if (hostToEquipment.TryGetValue(host, out var existing))
|
||||
{
|
||||
@@ -773,7 +773,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
foreach (var n in msg.Nodes)
|
||||
{
|
||||
var key = n.FolderPathSegments.Count >= 2
|
||||
? AddressSpaceComposer.NormalizeDeviceHost(n.FolderPathSegments[1])
|
||||
? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1])
|
||||
: null;
|
||||
if (key is not null && hostToEquipment.ContainsKey(key))
|
||||
{
|
||||
|
||||
+4
-31
@@ -8,8 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
/// 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
|
||||
/// <c>DeviceConfigIntent.TryExtractHost</c>). A later task grafts a driver's discovered
|
||||
/// FixedTree onto a zero-tag equipment and partitions a multi-device driver by host using these.
|
||||
/// <para>The direct host-extraction + normalization unit tests moved to
|
||||
/// <c>Commons.Tests/DeviceConfigIntentTests</c> (R2-11) — this suite keeps the Compose-level projection
|
||||
/// coverage.</para>
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceComposerDeviceHostTests
|
||||
{
|
||||
@@ -75,36 +78,6 @@ public sealed class AddressSpaceComposerDeviceHostTests
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The extracted-out shared normalizer (the single source of truth the FixedTree-partition path
|
||||
/// reuses on a driver-discovered device-host folder segment) trims + lower-cases, and is idempotent on an
|
||||
/// already-normalized value — so a segment like <c>" HOST-A:8193 "</c> matches a stored
|
||||
/// <c>"host-a:8193"</c> DeviceHost.</summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193", "10.201.31.5:8193")]
|
||||
[InlineData(" HOST-A:8193 ", "host-a:8193")]
|
||||
[InlineData("host-a:8193", "host-a:8193")] // idempotent
|
||||
[InlineData("H1", "h1")]
|
||||
public void NormalizeDeviceHost_trims_and_lowercases(string raw, string expected)
|
||||
{
|
||||
AddressSpaceComposer.NormalizeDeviceHost(raw).ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
|
||||
{
|
||||
EquipmentId = id,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user