From c1cab33e38a5160d9a1f87f1c961f83914cb291d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 11:57:39 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.4=20Stream=20D=20server-side=20?= =?UTF-8?q?=E2=80=94=20IdentificationFolderBuilder=20materializes=20OPC=20?= =?UTF-8?q?40010=20Machinery=20Identification=20sub-folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the server-side / non-UI piece of Phase 6.4 Stream D. The Razor `IdentificationFields.razor` component for Admin-UI editing ships separately when the Admin UI pass lands (still tracked under #157 UI follow-up). Core.OpcUa additions: - IdentificationFolderBuilder — pure-function builder that materializes the OPC 40010 Machinery companion-spec Identification sub-folder per decision #139. Reads the nine nullable columns off an Equipment row: Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction (short → OPC UA Int32), AssetLocation, ManufacturerUri, DeviceManualUri. Emits one AddProperty call per non-null field; skips the sub-folder entirely when all nine are null so browse trees don't carry pointless empty folders. - HasAnyFields(equipment) — cheap short-circuit so callers can decide whether to invoke Folder() at all. - FolderName constant ("Identification") + FieldNames list exposed so downstream tools / tests can cross-reference without duplicating the decision-#139 field set. ACL binding: the sub-folder + variables live under the Equipment node so Phase 6.2's PermissionTrie treats them as part of the Equipment ScopeId — no new scope level. A user with Equipment-level grant reads the Identification fields; a user without gets BadUserAccessDenied on both the Equipment node + its Identification variables. Documented in the class remarks; cross-reference update to acl-design.md is a follow-up. Tests (9 new IdentificationFolderBuilderTests): - HasAnyFields all-null false / any-non-null true. - Build all-null returns null + doesn't emit Folder. - Build fully-populated emits all 9 fields in decision #139 order. - Only non-null fields are emitted (3-of-9 case). - YearOfConstruction short widens to DriverDataType.Int32 with int value. - String values round-trip through AddProperty. - FieldNames constant matches decision #139 exactly. - FolderName is "Identification". Full solution dotnet test: 1202 passing (was 1193, +9). Pre-existing Client.CLI Subscribe flake unchanged. Production integration: the component that consumes this is the address-space-build flow that walks the live Equipment table + calls IdentificationFolderBuilder.Build(equipmentFolder, equipment) under each Equipment node. That integration is the remaining Stream D follow-up alongside the Razor UI component. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OpcUa/IdentificationFolderBuilder.cs | 91 ++++++++++ .../OpcUa/IdentificationFolderBuilderTests.cs | 158 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/IdentificationFolderBuilder.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/IdentificationFolderBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/IdentificationFolderBuilder.cs new file mode 100644 index 0000000..cb28e36 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/IdentificationFolderBuilder.cs @@ -0,0 +1,91 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +/// +/// Phase 6.4 Stream D: materializes the OPC 40010 Machinery companion-spec Identification +/// sub-folder under an Equipment node. Reads the nine decision-#139 columns off the +/// row and emits one property per non-null field. +/// +/// +/// Pure-function shape — testable without a real OPC UA node manager. The caller +/// passes the builder scoped to the Equipment node; this class handles the Identification +/// sub-folder creation + per-field calls. +/// +/// ACL binding: the sub-folder + its properties inherit the Equipment scope's +/// grants (no new scope level). Phase 6.2's trie treats them as part of the Equipment +/// ScopeId — a user with Equipment-level grant reads Identification; a user without the +/// grant gets BadUserAccessDenied on both the Equipment node + its Identification variables. +/// See docs/v2/acl-design.md §Identification cross-reference. +/// +/// The nine fields per decision #139 are exposed exactly when they carry a non-null +/// value. A row with all nine null produces no Identification sub-folder at all — the +/// caller can use to skip the Folder call entirely +/// and avoid a pointless empty folder appearing in browse trees. +/// +public static class IdentificationFolderBuilder +{ + /// Browse + display name of the sub-folder — fixed per OPC 40010 convention. + public const string FolderName = "Identification"; + + /// + /// Canonical decision #139 field set exposed in the Identification sub-folder. Order + /// matches the decision-log entry so any browse-order reader can cross-reference + /// without re-sorting. + /// + public static IReadOnlyList FieldNames { get; } = new[] + { + "Manufacturer", "Model", "SerialNumber", + "HardwareRevision", "SoftwareRevision", + "YearOfConstruction", "AssetLocation", + "ManufacturerUri", "DeviceManualUri", + }; + + /// True when the equipment row has at least one non-null Identification field. + public static bool HasAnyFields(Equipment equipment) + { + ArgumentNullException.ThrowIfNull(equipment); + return equipment.Manufacturer is not null + || equipment.Model is not null + || equipment.SerialNumber is not null + || equipment.HardwareRevision is not null + || equipment.SoftwareRevision is not null + || equipment.YearOfConstruction is not null + || equipment.AssetLocation is not null + || equipment.ManufacturerUri is not null + || equipment.DeviceManualUri is not null; + } + + /// + /// Build the Identification sub-folder under . No-op + /// when every field is null. Returns the sub-folder builder (or null when no-op) so + /// callers can attach additional nodes underneath if needed. + /// + public static IAddressSpaceBuilder? Build(IAddressSpaceBuilder equipmentBuilder, Equipment equipment) + { + ArgumentNullException.ThrowIfNull(equipmentBuilder); + ArgumentNullException.ThrowIfNull(equipment); + + if (!HasAnyFields(equipment)) return null; + + var folder = equipmentBuilder.Folder(FolderName, FolderName); + AddIfPresent(folder, "Manufacturer", DriverDataType.String, equipment.Manufacturer); + AddIfPresent(folder, "Model", DriverDataType.String, equipment.Model); + AddIfPresent(folder, "SerialNumber", DriverDataType.String, equipment.SerialNumber); + AddIfPresent(folder, "HardwareRevision", DriverDataType.String, equipment.HardwareRevision); + AddIfPresent(folder, "SoftwareRevision", DriverDataType.String, equipment.SoftwareRevision); + AddIfPresent(folder, "YearOfConstruction", DriverDataType.Int32, + equipment.YearOfConstruction is null ? null : (object)(int)equipment.YearOfConstruction.Value); + AddIfPresent(folder, "AssetLocation", DriverDataType.String, equipment.AssetLocation); + AddIfPresent(folder, "ManufacturerUri", DriverDataType.String, equipment.ManufacturerUri); + AddIfPresent(folder, "DeviceManualUri", DriverDataType.String, equipment.DeviceManualUri); + return folder; + } + + private static void AddIfPresent(IAddressSpaceBuilder folder, string name, DriverDataType dataType, object? value) + { + if (value is null) return; + folder.AddProperty(name, dataType, value); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs new file mode 100644 index 0000000..d1ba349 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs @@ -0,0 +1,158 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa; + +[Trait("Category", "Unit")] +public sealed class IdentificationFolderBuilderTests +{ + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = []; + public List<(string BrowseName, DriverDataType DataType, object? Value)> Properties { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + Folders.Add((browseName, displayName)); + return this; // flat recording — identification fields land in the same bucket + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + => throw new NotSupportedException("Identification fields use AddProperty, not Variable"); + + public void AddProperty(string browseName, DriverDataType dataType, object? value) + => Properties.Add((browseName, dataType, value)); + } + + private static Equipment EmptyEquipment() => new() + { + EquipmentId = "EQ-000000000001", + DriverInstanceId = "drv-1", + UnsLineId = "line-1", + Name = "eq-1", + MachineCode = "machine_001", + }; + + private static Equipment FullyPopulatedEquipment() => new() + { + EquipmentId = "EQ-000000000001", + DriverInstanceId = "drv-1", + UnsLineId = "line-1", + Name = "eq-1", + MachineCode = "machine_001", + Manufacturer = "Siemens", + Model = "S7-1500", + SerialNumber = "SN-12345", + HardwareRevision = "Rev-A", + SoftwareRevision = "Fw-2.3.1", + YearOfConstruction = 2023, + AssetLocation = "Warsaw-West/Bldg-3", + ManufacturerUri = "https://siemens.example", + DeviceManualUri = "https://siemens.example/manual", + }; + + [Fact] + public void HasAnyFields_AllNull_ReturnsFalse() + { + IdentificationFolderBuilder.HasAnyFields(EmptyEquipment()).ShouldBeFalse(); + } + + [Fact] + public void HasAnyFields_OneNonNull_ReturnsTrue() + { + var eq = EmptyEquipment(); + eq.SerialNumber = "SN-1"; + IdentificationFolderBuilder.HasAnyFields(eq).ShouldBeTrue(); + } + + [Fact] + public void Build_AllNull_ReturnsNull_AndDoesNotEmit_Folder() + { + var builder = new RecordingBuilder(); + + var result = IdentificationFolderBuilder.Build(builder, EmptyEquipment()); + + result.ShouldBeNull(); + builder.Folders.ShouldBeEmpty("no Identification folder when every field is null"); + builder.Properties.ShouldBeEmpty(); + } + + [Fact] + public void Build_FullyPopulated_EmitsAllNineFields() + { + var builder = new RecordingBuilder(); + + var result = IdentificationFolderBuilder.Build(builder, FullyPopulatedEquipment()); + + result.ShouldNotBeNull(); + builder.Folders.ShouldContain(f => f.BrowseName == "Identification"); + builder.Properties.Count.ShouldBe(9); + builder.Properties.Select(p => p.BrowseName).ShouldBe( + ["Manufacturer", "Model", "SerialNumber", + "HardwareRevision", "SoftwareRevision", + "YearOfConstruction", "AssetLocation", + "ManufacturerUri", "DeviceManualUri"], + "property order matches decision #139 exactly"); + } + + [Fact] + public void Build_OnlyNonNull_Are_Emitted() + { + var eq = EmptyEquipment(); + eq.Manufacturer = "Siemens"; + eq.SerialNumber = "SN-1"; + eq.YearOfConstruction = 2024; + var builder = new RecordingBuilder(); + + IdentificationFolderBuilder.Build(builder, eq); + + builder.Properties.Count.ShouldBe(3, "only the 3 non-null fields are exposed"); + builder.Properties.Select(p => p.BrowseName).ShouldBe( + ["Manufacturer", "SerialNumber", "YearOfConstruction"]); + } + + [Fact] + public void YearOfConstruction_Maps_Short_To_Int32_DriverDataType() + { + var eq = EmptyEquipment(); + eq.YearOfConstruction = 2023; + var builder = new RecordingBuilder(); + + IdentificationFolderBuilder.Build(builder, eq); + + var prop = builder.Properties.Single(p => p.BrowseName == "YearOfConstruction"); + prop.DataType.ShouldBe(DriverDataType.Int32); + prop.Value.ShouldBe(2023, "short is widened to int for OPC UA Int32 representation"); + } + + [Fact] + public void Build_StringValues_RoundTrip() + { + var eq = FullyPopulatedEquipment(); + var builder = new RecordingBuilder(); + + IdentificationFolderBuilder.Build(builder, eq); + + builder.Properties.Single(p => p.BrowseName == "Manufacturer").Value.ShouldBe("Siemens"); + builder.Properties.Single(p => p.BrowseName == "DeviceManualUri").Value.ShouldBe("https://siemens.example/manual"); + } + + [Fact] + public void FieldNames_Match_Decision139_Exactly() + { + IdentificationFolderBuilder.FieldNames.ShouldBe( + ["Manufacturer", "Model", "SerialNumber", + "HardwareRevision", "SoftwareRevision", + "YearOfConstruction", "AssetLocation", + "ManufacturerUri", "DeviceManualUri"]); + } + + [Fact] + public void FolderName_Is_Identification() + { + IdentificationFolderBuilder.FolderName.ShouldBe("Identification"); + } +} -- 2.49.1