feat(commons): DeviceConfigIntent — device-host extraction + normalization home (R2-11)

This commit is contained in:
Joseph Doherty
2026-07-13 10:39:54 -04:00
parent cd3f1270ee
commit b6633f7562
3 changed files with 94 additions and 1 deletions
@@ -5,7 +5,7 @@
{ "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": "completed", "blockedBy": [] },
{ "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": "pending", "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": "pending", "blockedBy": ["T1", "T3"] },
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// SINGLE SOURCE OF TRUTH for extracting + normalizing a <c>Device</c>'s connection host from its
/// schemaless <c>DeviceConfig</c> JSON. Shared by the live-compose seam (<c>AddressSpaceComposer</c>),
/// the artifact-decode seam (<c>DeploymentArtifact</c>), and the FixedTree-partition path
/// (<c>DriverHostActor</c>) so an <c>EquipmentNode.DeviceHost</c> and a driver-discovered device-host
/// folder segment for the same device compare byte-equal. Ported verbatim from the historical
/// <c>AddressSpaceComposer.TryExtractDeviceHost</c> / <c>NormalizeDeviceHost</c>.
/// </summary>
public static class DeviceConfigIntent
{
/// <summary>
/// Extract a <c>Device</c>'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>). Returns
/// <see langword="null"/> when the config is blank, not a JSON object, has no string
/// <c>HostAddress</c>, or the value is blank/whitespace. Never throws. The returned host is
/// deterministically normalized via <see cref="NormalizeHost"/> (trim + lower-case).
/// </summary>
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
/// <returns>The normalized device host, or <see langword="null"/> when absent/blank/unparseable.</returns>
public static string? TryExtractHost(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;
return NormalizeHost(raw);
}
catch (JsonException) { return null; }
}
/// <summary>
/// The single normalization authority for a device host: trims surrounding whitespace and
/// lower-cases (invariant). Idempotent (an already-normalized value 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 NormalizeHost(string host) => host.Trim().ToLowerInvariant();
}
@@ -0,0 +1,46 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// Pins <see cref="DeviceConfigIntent"/> device-host extraction + normalization semantics (ported
/// from the historical composer statics; R2-11): null on blank/non-object/non-string/whitespace,
/// trim+lower normalization, and idempotence.
/// </summary>
public sealed class DeviceConfigIntentTests
{
[Theory]
[InlineData("{\"HostAddress\":\"10.0.0.5:8193\"}", "10.0.0.5:8193")]
[InlineData("{\"HostAddress\":\" HOST:8193 \"}", "host:8193")] // trim + lower
[InlineData("{\"HostAddress\":\"HOST:8193\",\"other\":1}", "host:8193")]
// null-yielding cases
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("not json {", null)]
[InlineData("[1,2]", null)] // non-object root
[InlineData("{\"HostAddress\":123}", null)] // non-string
[InlineData("{\"HostAddress\":\" \"}", null)] // whitespace value
[InlineData("{\"other\":\"x\"}", null)] // absent
public void TryExtractHost(string? cfg, string? expected)
{
DeviceConfigIntent.TryExtractHost(cfg).ShouldBe(expected);
}
[Theory]
[InlineData(" HOST:8193 ", "host:8193")]
[InlineData("host:8193", "host:8193")]
public void NormalizeHost_trims_and_lowercases(string raw, string expected)
{
DeviceConfigIntent.NormalizeHost(raw).ShouldBe(expected);
}
[Fact]
public void NormalizeHost_is_idempotent()
{
var once = DeviceConfigIntent.NormalizeHost(" HOST:8193 ");
DeviceConfigIntent.NormalizeHost(once).ShouldBe(once);
}
}