From b6633f75625447ce0afde580694e3f140a3d69e2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:39:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(commons):=20DeviceConfigIntent=20=E2=80=94?= =?UTF-8?q?=20device-host=20extraction=20+=20normalization=20home=20(R2-11?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../Types/DeviceConfigIntent.cs | 47 +++++++++++++++++++ .../DeviceConfigIntentTests.cs | 46 ++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 6bbe0462..42326c7b 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -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"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs new file mode 100644 index 00000000..cca42273 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// SINGLE SOURCE OF TRUTH for extracting + normalizing a Device's connection host from its +/// schemaless DeviceConfig JSON. Shared by the live-compose seam (AddressSpaceComposer), +/// the artifact-decode seam (DeploymentArtifact), and the FixedTree-partition path +/// (DriverHostActor) so an EquipmentNode.DeviceHost and a driver-discovered device-host +/// folder segment for the same device compare byte-equal. Ported verbatim from the historical +/// AddressSpaceComposer.TryExtractDeviceHost / NormalizeDeviceHost. +/// +public static class DeviceConfigIntent +{ + /// + /// Extract a Device's connection host from its schemaless DeviceConfig JSON: the + /// top-level "HostAddress" string (e.g. "10.201.31.5:8193"). Returns + /// when the config is blank, not a JSON object, has no string + /// HostAddress, or the value is blank/whitespace. Never throws. The returned host is + /// deterministically normalized via (trim + lower-case). + /// + /// The device's schemaless DeviceConfig JSON blob. + /// The normalized device host, or when absent/blank/unparseable. + 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; } + } + + /// + /// The single normalization authority for a device host: trims surrounding whitespace and + /// lower-cases (invariant). Idempotent (an already-normalized value is unchanged). + /// + /// The raw host string (non-null; a non-empty HostAddress or folder segment). + /// The normalized host (trimmed + lower-cased). + public static string NormalizeHost(string host) => host.Trim().ToLowerInvariant(); +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs new file mode 100644 index 00000000..55e6db11 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs @@ -0,0 +1,46 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; + +/// +/// Pins 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. +/// +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); + } +}