From 2b2991c593d51350e6c9ab078205716e76ebf644 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 02:39:00 -0400 Subject: [PATCH] =?UTF-8?q?EquipmentNodeWalker=20=E2=80=94=20pure-function?= =?UTF-8?q?=20UNS=20tree=20materialization=20(ADR-001=20Task=20A,=20task?= =?UTF-8?q?=20#210).=20The=20walker=20traverses=20the=20Config-DB=20snapsh?= =?UTF-8?q?ot=20for=20a=20single=20Equipment-kind=20namespace=20(Areas=20/?= =?UTF-8?q?=20Lines=20/=20Equipment=20/=20Tags)=20and=20streams=20IAddress?= =?UTF-8?q?SpaceBuilder.Folder=20+=20Variable=20+=20AddProperty=20calls=20?= =?UTF-8?q?to=20materialize=20the=20canonical=205-level=20Unified=20Namesp?= =?UTF-8?q?ace=20browse=20tree=20that=20decisions=20#116-#121=20promise=20?= =?UTF-8?q?external=20consumers.=20Pure=20function:=20no=20OPC=20UA=20SDK?= =?UTF-8?q?=20dependency,=20no=20DB=20access,=20no=20state=20=E2=80=94=20c?= =?UTF-8?q?onsumes=20pre-loaded=20EF=20Core=20row=20collections=20+=20stre?= =?UTF-8?q?ams=20into=20the=20supplied=20builder.=20Server-side=20wiring?= =?UTF-8?q?=20(load=20snapshot=20=E2=86=92=20call=20walker=20=E2=86=92=20p?= =?UTF-8?q?er-tag=20capability=20probe)=20is=20Task=20B's=20scope,=20along?= =?UTF-8?q?side=20NodeScopeResolver's=20Config-DB=20join=20+=20the=20ACL?= =?UTF-8?q?=20integration=20test=20that=20closes=20task=20#195.=20This=20P?= =?UTF-8?q?R=20is=20the=20Core.OpcUa=20primitive=20the=20server=20will=20c?= =?UTF-8?q?onsume.=20Walk=20algorithm=20=E2=80=94=20content=20is=20grouped?= =?UTF-8?q?=20up-front=20(lines=20by=20area,=20equipment=20by=20line,=20ta?= =?UTF-8?q?gs=20by=20equipment)=20into=20OrdinalIgnoreCase=20dictionaries?= =?UTF-8?q?=20so=20the=20per-level=20nested=20foreach=20stays=20O(N+M)=20r?= =?UTF-8?q?ather=20than=20O(N=C2=B7M)=20at=20each=20UNS=20level;=20orderin?= =?UTF-8?q?gs=20are=20deterministic=20on=20Name=20with=20StringComparer.Or?= =?UTF-8?q?dinal=20so=20diffs=20across=20runs=20(e.g.=20integration-test?= =?UTF-8?q?=20assertions)=20are=20stable.=20Areas=20=E2=86=92=20Lines=20?= =?UTF-8?q?=E2=86=92=20Equipment=20emitted=20as=20Folder=20nodes=20with=20?= =?UTF-8?q?browse-name=20=3D=20Name=20per=20decision=20#120.=20Under=20eac?= =?UTF-8?q?h=20Equipment=20folder:=20five=20identifier=20properties=20per?= =?UTF-8?q?=20decision=20#121=20(EquipmentId=20+=20EquipmentUuid=20always;?= =?UTF-8?q?=20MachineCode=20always=20=E2=80=94=20it's=20a=20required=20col?= =?UTF-8?q?umn=20on=20the=20entity;=20ZTag=20+=20SAPID=20skipped=20when=20?= =?UTF-8?q?null=20to=20avoid=20empty-string=20property=20noise);=20Identif?= =?UTF-8?q?icationFolderBuilder.Build=20materializes=20the=20OPC=2040010?= =?UTF-8?q?=20sub-folder=20when=20HasAnyFields(equipment)=20returns=20true?= =?UTF-8?q?,=20skipped=20otherwise=20to=20avoid=20a=20pointless=20empty=20?= =?UTF-8?q?folder;=20then=20one=20Variable=20node=20per=20Tag=20row=20boun?= =?UTF-8?q?d=20to=20this=20Equipment=20(Tag.EquipmentId=20non-null=20match?= =?UTF-8?q?es=20Equipment.EquipmentId)=20emitted=20in=20Name=20order.=20Ta?= =?UTF-8?q?gs=20with=20null=20EquipmentId=20are=20walker-skipped=20?= =?UTF-8?q?=E2=80=94=20those=20are=20SystemPlatform-kind=20(Galaxy)=20tags?= =?UTF-8?q?=20that=20take=20the=20driver-native=20DiscoverAsync=20path=20p?= =?UTF-8?q?er=20decision=20#120.=20DriverAttributeInfo=20construction:=20F?= =?UTF-8?q?ullName=20=3D=20Tag.TagConfig=20(driver-specific=20wire-level?= =?UTF-8?q?=20address);=20DriverDataType=20parsed=20from=20Tag.DataType=20?= =?UTF-8?q?which=20stores=20the=20enum=20name=20string=20per=20decision=20?= =?UTF-8?q?#138;=20unparseable=20values=20fall=20back=20to=20DriverDataTyp?= =?UTF-8?q?e.String=20so=20a=20one-off=20driver-specific=20type=20doesn't?= =?UTF-8?q?=20abort=20the=20whole=20walk=20(driver=20still=20sees=20the=20?= =?UTF-8?q?original=20address=20at=20runtime=20+=20can=20surface=20its=20o?= =?UTF-8?q?wn=20typed=20value=20via=20the=20variant).=20Address=20validati?= =?UTF-8?q?on=20is=20deliberately=20NOT=20done=20at=20build=20time=20per?= =?UTF-8?q?=20ADR-001=20Option=20A:=20unreachable=20addresses=20surface=20?= =?UTF-8?q?as=20OPC=20UA=20Bad=20status=20via=20the=20natural=20driver-rea?= =?UTF-8?q?d=20failure=20path=20at=20runtime,=20legible=20to=20operators?= =?UTF-8?q?=20through=20their=20Admin=20UI=20+=20OPC=20UA=20client=20inspe?= =?UTF-8?q?ction.=20Eight=20new=20EquipmentNodeWalkerTests:=20empty=20cont?= =?UTF-8?q?ent=20emits=20nothing;=20Area/Line/Equipment=20folder=20emissio?= =?UTF-8?q?n=20order=20matches=20Name-sorted=20deterministic=20traversal;?= =?UTF-8?q?=20five=20identifier=20properties=20appear=20on=20Equipment=20n?= =?UTF-8?q?odes=20with=20correct=20values,=20ZTag=20+=20SAPID=20skipped=20?= =?UTF-8?q?when=20null=20+=20emitted=20when=20non-null;=20Identification?= =?UTF-8?q?=20sub-folder=20materialized=20when=20at=20least=20one=20OPC=20?= =?UTF-8?q?40010=20field=20is=20non-null=20+=20omitted=20when=20all=20are?= =?UTF-8?q?=20null;=20tags=20with=20matching=20EquipmentId=20emit=20as=20V?= =?UTF-8?q?ariable=20nodes=20under=20the=20Equipment=20folder=20in=20Name?= =?UTF-8?q?=20order,=20tags=20with=20null=20EquipmentId=20walker-skipped;?= =?UTF-8?q?=20unparseable=20DataType=20falls=20back=20to=20String.=20Recor?= =?UTF-8?q?dingBuilder=20test=20double=20captures=20Folder/Variable/Proper?= =?UTF-8?q?ty=20calls=20into=20a=20tree=20structure=20tests=20can=20naviga?= =?UTF-8?q?te.=20Core=20project=20builds=200=20errors;=20Core.Tests=20190/?= =?UTF-8?q?190=20(was=20182,=20+8=20new=20walker=20tests).=20No=20Server/A?= =?UTF-8?q?dmin=20changes=20=E2=80=94=20Task=20B=20lands=20the=20server-si?= =?UTF-8?q?de=20wiring=20+=20consumes=20this=20walker=20from=20DriverNodeM?= =?UTF-8?q?anager.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OpcUa/EquipmentNodeWalker.cs | 173 ++++++++++++++ .../OpcUa/EquipmentNodeWalkerTests.cs | 221 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs new file mode 100644 index 0000000..85a79ec --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs @@ -0,0 +1,173 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +/// +/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind +/// from the Config DB's +/// UnsArea / UnsLine / Equipment / Tag rows. Runs during +/// address-space build per whose +/// Namespace.Kind = Equipment; SystemPlatform-kind namespaces (Galaxy) are +/// exempt per decision #120 and reach this walker only indirectly through +/// . +/// +/// +/// +/// Composition strategy. ADR-001 (2026-04-20) accepted Option A — Config +/// primary. The walker treats the supplied +/// snapshot as the authoritative published surface. Every Equipment row becomes a +/// folder node at the UNS level-5 segment; every bound to an +/// Equipment (non-null ) becomes a variable node under +/// it. Driver-discovered tags that have no Config-DB row are not added by this +/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case + +/// for enrichment, but Equipment-kind composition is fully Tag-row-driven. +/// +/// +/// +/// Under each Equipment node. Five identifier properties per decision #121 +/// (EquipmentId, EquipmentUuid, MachineCode, ZTag, +/// SAPID) are added as OPC UA properties — external systems (ERP, SAP PM) +/// resolve equipment by whichever identifier they natively use without a sidecar. +/// materializes the OPC 40010 +/// Identification sub-folder with the nine decision-#139 fields when at least one +/// is non-null; when all nine are null the sub-folder is omitted rather than +/// appearing empty. +/// +/// +/// +/// Address resolution. Variable nodes carry the driver-side full reference +/// in copied from Tag.TagConfig +/// (the wire-level address JSON blob whose interpretation is driver-specific). At +/// runtime the dispatch layer routes Read/Write calls through the configured +/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via +/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls +/// this "BadNotFound placeholder" behavior — legible to operators via their Admin +/// UI + OPC UA client inspection of node status. +/// +/// +/// +/// Pure function. This class has no dependency on the OPC UA SDK, no +/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls +/// into the supplied . The server-side wiring +/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B +/// PR alongside NodeScopeResolver's Config-DB join. +/// +/// +public static class EquipmentNodeWalker +{ + /// + /// Walk into . + /// The builder is scoped to the Equipment-kind namespace root; the walker emits + /// Area → Line → Equipment folders under it, then identifier properties + the + /// Identification sub-folder + variable nodes per bound Tag under each Equipment. + /// + /// + /// The builder scoped to the Equipment-kind namespace root. Caller is responsible for + /// creating this (e.g. rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)). + /// + /// Pre-loaded + pre-filtered rows for a single published generation. + public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content) + { + ArgumentNullException.ThrowIfNull(namespaceBuilder); + ArgumentNullException.ThrowIfNull(content); + + // Group lines by area + equipment by line + tags by equipment up-front. Avoids an + // O(N·M) re-scan at each UNS level on large fleets. + var linesByArea = content.Lines + .GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); + + var equipmentByLine = content.Equipment + .GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); + + var tagsByEquipment = content.Tags + .Where(t => !string.IsNullOrEmpty(t.EquipmentId)) + .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); + + foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal)) + { + var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name); + if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue; + + foreach (var line in areaLines) + { + var lineBuilder = areaBuilder.Folder(line.Name, line.Name); + if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue; + + foreach (var equipment in lineEquipment) + { + var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name); + AddIdentifierProperties(equipmentBuilder, equipment); + IdentificationFolderBuilder.Build(equipmentBuilder, equipment); + + if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue; + foreach (var tag in equipmentTags) + AddTagVariable(equipmentBuilder, tag); + } + } + } + } + + /// + /// Adds the five operator-facing identifiers from decision #121 as OPC UA properties + /// on the Equipment node. EquipmentId + EquipmentUuid are always populated; + /// MachineCode is required per ; ZTag + SAPID are nullable in + /// the data model so they're skipped when null to avoid empty-string noise in the + /// browse tree. + /// + private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment) + { + equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId); + equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString()); + equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode); + if (!string.IsNullOrEmpty(equipment.ZTag)) + equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag); + if (!string.IsNullOrEmpty(equipment.SAPID)) + equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID); + } + + /// + /// Emit a single Tag row as an . The driver + /// full reference lives in Tag.TagConfig (wire-level address, driver-specific + /// JSON blob); the variable node's data type derives from Tag.DataType. + /// Unreachable-address behavior per ADR-001 Option A: the variable is created; the + /// driver's natural Read failure surfaces an OPC UA Bad status at runtime. + /// + private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag) + { + var attr = new DriverAttributeInfo( + FullName: tag.TagConfig, + DriverDataType: ParseDriverDataType(tag.DataType), + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.FreeAccess, + IsHistorized: false); + equipmentBuilder.Variable(tag.Name, tag.Name, attr); + } + + /// + /// Parse (stored as the enum + /// name string, decision #138) into the enum value. Unknown names fall back to + /// so a one-off driver-specific type doesn't + /// abort the whole walk; the underlying driver still sees the original TagConfig + /// address + can surface its own typed value via the OPC UA variant at read time. + /// + private static DriverDataType ParseDriverDataType(string raw) => + Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String; +} + +/// +/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config +/// DB rows. All four collections are scoped to the same +/// + the same +/// row. The walker assumes this filter +/// was applied by the caller + does no cross-generation or cross-namespace validation. +/// +public sealed record EquipmentNamespaceContent( + IReadOnlyList Areas, + IReadOnlyList Lines, + IReadOnlyList Equipment, + IReadOnlyList Tags); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs new file mode 100644 index 0000000..71d0203 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs @@ -0,0 +1,221 @@ +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 EquipmentNodeWalkerTests +{ + [Fact] + public void Walk_EmptyContent_EmitsNothing() + { + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], [])); + + rec.Children.ShouldBeEmpty(); + } + + [Fact] + public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder() + { + var content = new EquipmentNamespaceContent( + Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")], + Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")], + Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")], + Tags: []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name + var warsaw = rec.Children.First(c => c.BrowseName == "warsaw"); + warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]); + warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]); + } + + [Fact] + public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid() + { + var uuid = Guid.NewGuid(); + var eq = Eq("eq-1", "line-1", "oven-3"); + eq.EquipmentUuid = uuid; + eq.MachineCode = "MC-42"; + eq.ZTag = null; + eq.SAPID = null; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList(); + props.ShouldContain("EquipmentId"); + props.ShouldContain("EquipmentUuid"); + props.ShouldContain("MachineCode"); + props.ShouldNotContain("ZTag"); + props.ShouldNotContain("SAPID"); + equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString()); + } + + [Fact] + public void Walk_Adds_ZTag_And_SAPID_When_Present() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + eq.ZTag = "ZT-0042"; + eq.SAPID = "10000042"; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042"); + equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042"); + } + + [Fact] + public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + eq.Manufacturer = "Trumpf"; + eq.Model = "TruLaser-3030"; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification"); + identification.ShouldNotBeNull(); + identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer"); + identification.Properties.Select(p => p.BrowseName).ShouldContain("Model"); + } + + [Fact] + public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull() + { + var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification"); + } + + [Fact] + public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1"); + var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1"); + var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [tag1, tag2, unboundTag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + equipmentNode.Variables.Count.ShouldBe(2); + equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]); + equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01"); + equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32); + } + + [Fact] + public void Walk_FallsBack_To_String_For_Unparseable_DataType() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1"); + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var variable = rec.Children[0].Children[0].Children[0].Variables.Single(); + variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String); + } + + // ----- builders for test seed rows ----- + + private static UnsArea Area(string id, string name) => new() + { + UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1, + }; + + private static UnsLine Line(string id, string areaId, string name) => new() + { + UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1, + }; + + private static Equipment Eq(string equipmentId, string lineId, string name) => new() + { + EquipmentRowId = Guid.NewGuid(), + GenerationId = 1, + EquipmentId = equipmentId, + EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = "drv", + UnsLineId = lineId, + Name = name, + MachineCode = "MC-" + name, + }; + + private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new() + { + TagRowId = Guid.NewGuid(), + GenerationId = 1, + TagId = tagId, + DriverInstanceId = "drv", + EquipmentId = equipmentId, + Name = name, + DataType = dataType, + AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, + TagConfig = address, + }; + + // ----- recording IAddressSpaceBuilder ----- + + private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder + { + public string BrowseName { get; } = browseName; + public List Children { get; } = new(); + public List Variables { get; } = new(); + public List Properties { get; } = new(); + + public IAddressSpaceBuilder Folder(string name, string _) + { + var child = new RecordingBuilder(name); + Children.Add(child); + return child; + } + + public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr) + { + var v = new RecordingVariable(name, attr); + Variables.Add(v); + return v; + } + + public void AddProperty(string name, DriverDataType _, object? value) => + Properties.Add(new RecordingProperty(name, value)); + } + + private sealed record RecordingProperty(string BrowseName, object? Value); + + private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle + { + public string FullReference => AttributeInfo.FullName; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); + } +}