Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs
Joseph Doherty c1cab33e38 Phase 6.4 Stream D server-side — IdentificationFolderBuilder materializes OPC 40010 Machinery Identification sub-folder
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) <noreply@anthropic.com>
2026-04-19 11:57:39 -04:00

159 lines
5.4 KiB
C#

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