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"); + } +}