From a29828e41ec5df8c841bed3cad669005e07856fd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 03:19:45 -0400 Subject: [PATCH] =?UTF-8?q?EquipmentNamespaceContentLoader=20=E2=80=94=20C?= =?UTF-8?q?onfig-DB=20loader=20that=20fills=20the=20(driverInstanceId,=20g?= =?UTF-8?q?enerationId)=20shape=20the=20walker=20wire-in=20from=20PR=20#15?= =?UTF-8?q?5=20consumes.=20Narrow=20follow-up=20to=20PR=20#155:=20the=20ct?= =?UTF-8?q?or=20plumbing=20on=20OpcUaApplicationHost=20already=20takes=20a?= =?UTF-8?q?=20Func=3F=20lookup;=20?= =?UTF-8?q?this=20PR=20lands=20the=20loader=20that=20will=20back=20that=20?= =?UTF-8?q?lookup=20against=20the=20central=20Config=20DB=20at=20SealedBoo?= =?UTF-8?q?tstrap=20time.=20DI=20composition=20in=20Program.cs=20is=20a=20?= =?UTF-8?q?separate=20structural=20PR=20because=20it=20needs=20the=20gener?= =?UTF-8?q?ation-resolve=20chain=20restructured=20to=20run=20before=20OpcU?= =?UTF-8?q?aApplicationHost=20construction=20=E2=80=94=20this=20one=20just?= =?UTF-8?q?=20lands=20the=20loader=20+=20unit=20tests=20so=20the=20wiring?= =?UTF-8?q?=20PR=20reduces=20to=20one=20factory=20lambda.=20Loader=20scope?= =?UTF-8?q?=20is=20one=20driver=20instance=20at=20one=20generation:=20join?= =?UTF-8?q?s=20Equipment=20filtered=20by=20(DriverInstanceId=20=3D=3D=20dr?= =?UTF-8?q?iver,=20GenerationId=20=3D=3D=20gen,=20Enabled)=20first,=20then?= =?UTF-8?q?=20UnsLines=20reachable=20from=20those=20Equipment=20rows,=20th?= =?UTF-8?q?en=20UnsAreas=20reachable=20from=20those=20lines,=20then=20Tags?= =?UTF-8?q?=20filtered=20by=20(DriverInstanceId=20=3D=3D=20driver,=20Gener?= =?UTF-8?q?ationId=20=3D=3D=20gen).=20Returns=20null=20when=20the=20driver?= =?UTF-8?q?=20has=20no=20Equipment=20at=20the=20supplied=20generation=20?= =?UTF-8?q?=E2=80=94=20the=20wire-in's=20null-check=20treats=20that=20as?= =?UTF-8?q?=20"skip=20the=20walker;=20let=20DiscoverAsync=20own=20the=20wh?= =?UTF-8?q?ole=20address=20space"=20which=20is=20the=20correct=20backward-?= =?UTF-8?q?compat=20behavior=20for=20non-Equipment-kind=20drivers=20(Modbu?= =?UTF-8?q?s=20/=20AB=20CIP=20/=20TwinCAT=20/=20FOCAS=20whose=20namespace-?= =?UTF-8?q?kind=20is=20native=20per=20decisions=20#116-#121).=20Only=20loa?= =?UTF-8?q?ds=20the=20UNS=20branches=20that=20actually=20host=20this=20dri?= =?UTF-8?q?ver's=20Equipment=20=E2=80=94=20skips=20pulling=20unrelated=20U?= =?UTF-8?q?NS=20folders=20from=20other=20drivers'=20regions=20of=20the=20c?= =?UTF-8?q?luster=20by=20deriving=20lineIds/areaIds=20from=20the=20filtere?= =?UTF-8?q?d=20Equipment=20set=20rather=20than=20reloading=20the=20full=20?= =?UTF-8?q?UNS=20tree.=20Enabled=3Dfalse=20Equipment=20are=20skipped=20at?= =?UTF-8?q?=20the=20query=20level=20so=20a=20decommissioned=20machine=20do?= =?UTF-8?q?esn't=20produce=20a=20phantom=20browse=20folder=20=E2=80=94=20A?= =?UTF-8?q?dmin=20still=20sees=20it=20in=20the=20diff=20view=20via=20the?= =?UTF-8?q?=20regular=20Config-DB=20queries=20but=20the=20walker's=20brows?= =?UTF-8?q?e=20output=20reflects=20the=20operational=20fleet.=20AsNoTracki?= =?UTF-8?q?ng=20on=20every=20query=20because=20the=20bootstrap=20flow=20is?= =?UTF-8?q?=20read-only=20+=20the=20result=20is=20handed=20off=20to=20a=20?= =?UTF-8?q?pure-function=20walker=20immediately;=20change=20tracking=20wou?= =?UTF-8?q?ld=20pin=20rows=20in=20the=20DbContext=20for=20the=20full=20ser?= =?UTF-8?q?ver=20lifetime=20with=20no=20corresponding=20write=20path.=20Fi?= =?UTF-8?q?ve=20new=20EquipmentNamespaceContentLoaderTests=20using=20InMem?= =?UTF-8?q?oryDatabase:=20(a)=20null=20result=20when=20driver=20has=20no?= =?UTF-8?q?=20Equipment;=20(b)=20baseline=20happy-path=20loads=20the=20ful?= =?UTF-8?q?l=20shape=20correctly;=20(c)=20other=20driver's=20rows=20at=20t?= =?UTF-8?q?he=20same=20generation=20don't=20leak=20into=20this=20driver's?= =?UTF-8?q?=20result=20(per-driver=20scope=20contract);=20(d)=20same-drive?= =?UTF-8?q?r=20rows=20at=20a=20different=20generation=20are=20skipped=20(p?= =?UTF-8?q?er-generation=20scope=20contract=20per=20decision=20#148);=20(e?= =?UTF-8?q?)=20Enabled=3Dfalse=20Equipment=20are=20skipped.=20Server=20pro?= =?UTF-8?q?ject=20builds=200=20errors;=20Server.Tests=20186/186=20(was=201?= =?UTF-8?q?81,=20+5=20new=20loader=20tests).=20Once=20the=20wiring=20PR=20?= =?UTF-8?q?lands=20the=20factory=20lambda=20in=20Program.cs=20the=20loader?= =?UTF-8?q?=20closes=20over=20the=20SealedBootstrap-resolved=20generationI?= =?UTF-8?q?d=20+=20the=20lookup=20delegate=20delegates=20to=20LoadAsync=20?= =?UTF-8?q?via=20IServiceScopeFactory=20=E2=80=94=20a=20one-line=20composi?= =?UTF-8?q?tion,=20no=20ctor-signature=20churn=20on=20OpcUaApplicationHost?= =?UTF-8?q?=20because=20PR=20#155=20already=20established=20the=20seam.?= 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/EquipmentNamespaceContentLoader.cs | 86 +++++++++ .../EquipmentNamespaceContentLoaderTests.cs | 172 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs new file mode 100644 index 0000000..634bd1a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +/// +/// Loads the snapshot the +/// consumes, scoped to a single +/// (driverInstanceId, generationId) pair. Joins the four row sets the walker expects: +/// UnsAreas for the driver's cluster, UnsLines under those areas, Equipment bound to +/// this driver + its lines, and Tags bound to this driver + its equipment — all at the +/// supplied generation. +/// +/// +/// The walker is driver-instance-scoped (decisions #116–#121 put the UNS in the +/// Equipment-kind namespace owned by one driver instance at a time), so this loader is +/// too — a single call returns one driver's worth of rows, never the whole fleet. +/// +/// Returns null when the driver instance has no Equipment rows at the +/// supplied generation. The wire-in in treats null as +/// "this driver has no UNS content, skip the walker and let DiscoverAsync own the whole +/// address space" — the backward-compat path for drivers whose namespace kind is not +/// Equipment (Modbus / AB CIP / TwinCAT / FOCAS). +/// +public sealed class EquipmentNamespaceContentLoader +{ + private readonly OtOpcUaConfigDbContext _db; + + public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db) + { + _db = db; + } + + /// + /// Load the walker-shaped snapshot for at + /// . Returns null when the driver has no + /// Equipment rows at that generation. + /// + public async Task LoadAsync( + string driverInstanceId, long generationId, CancellationToken ct) + { + var equipment = await _db.Equipment + .AsNoTracking() + .Where(e => e.DriverInstanceId == driverInstanceId && e.GenerationId == generationId && e.Enabled) + .ToListAsync(ct).ConfigureAwait(false); + + if (equipment.Count == 0) + return null; + + // Filter UNS tree to only the lines + areas that host at least one Equipment bound to + // this driver — skips loading unrelated UNS branches from the cluster. LinesByArea + // grouping is driven off the Equipment rows so an empty line (no equipment) doesn't + // pull a pointless folder into the walker output. + var lineIds = equipment.Select(e => e.UnsLineId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + var lines = await _db.UnsLines + .AsNoTracking() + .Where(l => l.GenerationId == generationId && lineIds.Contains(l.UnsLineId)) + .ToListAsync(ct).ConfigureAwait(false); + + var areaIds = lines.Select(l => l.UnsAreaId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + var areas = await _db.UnsAreas + .AsNoTracking() + .Where(a => a.GenerationId == generationId && areaIds.Contains(a.UnsAreaId)) + .ToListAsync(ct).ConfigureAwait(false); + + // Tags belonging to this driver at this generation. Walker skips Tags with null + // EquipmentId (those are SystemPlatform-kind Galaxy tags per decision #120) but we + // load them anyway so the same rowset can drive future non-Equipment-kind walks + // without re-hitting the DB. Filtering here is a future optimization; today the + // per-tag cost is bounded by driver scope. + var tags = await _db.Tags + .AsNoTracking() + .Where(t => t.DriverInstanceId == driverInstanceId && t.GenerationId == generationId) + .ToListAsync(ct).ConfigureAwait(false); + + return new EquipmentNamespaceContent( + Areas: areas, + Lines: lines, + Equipment: equipment, + Tags: tags); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs new file mode 100644 index 0000000..17e13ec --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentNamespaceContentLoaderTests.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +[Trait("Category", "Unit")] +public sealed class EquipmentNamespaceContentLoaderTests : IDisposable +{ + private const string DriverId = "galaxy-prod"; + private const string OtherDriverId = "galaxy-dev"; + private const long Gen = 5; + + private readonly OtOpcUaConfigDbContext _db; + private readonly EquipmentNamespaceContentLoader _loader; + + public EquipmentNamespaceContentLoaderTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"eq-content-loader-{Guid.NewGuid():N}") + .Options; + _db = new OtOpcUaConfigDbContext(options); + _loader = new EquipmentNamespaceContentLoader(_db); + } + + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task Returns_Null_When_Driver_Has_No_Equipment_At_Generation() + { + var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None); + result.ShouldBeNull(); + } + + [Fact] + public async Task Loads_Areas_Lines_Equipment_Tags_For_Driver_At_Generation() + { + SeedBaseline(); + + var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None); + + result.ShouldNotBeNull(); + result!.Areas.ShouldHaveSingleItem().UnsAreaId.ShouldBe("area-1"); + result.Lines.ShouldHaveSingleItem().UnsLineId.ShouldBe("line-a"); + result.Equipment.Count.ShouldBe(2); + result.Equipment.ShouldContain(e => e.EquipmentId == "eq-oven-3"); + result.Equipment.ShouldContain(e => e.EquipmentId == "eq-press-7"); + result.Tags.Count.ShouldBe(2); + result.Tags.ShouldContain(t => t.TagId == "tag-temp"); + result.Tags.ShouldContain(t => t.TagId == "tag-press"); + } + + [Fact] + public async Task Skips_Other_Drivers_Equipment() + { + SeedBaseline(); + + // Equipment + Tag owned by a different driver at the same generation — must not leak. + _db.Equipment.Add(new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = Gen, + EquipmentId = "eq-other", EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = OtherDriverId, UnsLineId = "line-a", Name = "other-eq", + MachineCode = "MC-other", + }); + _db.Tags.Add(new Tag + { + TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-other", + DriverInstanceId = OtherDriverId, EquipmentId = "eq-other", + Name = "OtherTag", DataType = "Int32", + AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-other", + }); + await _db.SaveChangesAsync(); + + var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None); + + result.ShouldNotBeNull(); + result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-other"); + result.Tags.ShouldNotContain(t => t.TagId == "tag-other"); + } + + [Fact] + public async Task Skips_Other_Generations() + { + SeedBaseline(); + + // Same driver, different generation — must not leak in. Walker consumes one sealed + // generation per bootstrap per decision #148. + _db.Equipment.Add(new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = 99, + EquipmentId = "eq-futuristic", EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "futuristic", + MachineCode = "MC-fut", + }); + await _db.SaveChangesAsync(); + + var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None); + + result.ShouldNotBeNull(); + result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-futuristic"); + } + + [Fact] + public async Task Skips_Disabled_Equipment() + { + SeedBaseline(); + + _db.Equipment.Add(new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = Gen, + EquipmentId = "eq-disabled", EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "disabled-eq", + MachineCode = "MC-dis", Enabled = false, + }); + await _db.SaveChangesAsync(); + + var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None); + + result.ShouldNotBeNull(); + result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-disabled"); + } + + private void SeedBaseline() + { + _db.UnsAreas.Add(new UnsArea + { + UnsAreaRowId = Guid.NewGuid(), UnsAreaId = "area-1", ClusterId = "c-warsaw", + Name = "warsaw", GenerationId = Gen, + }); + _db.UnsLines.Add(new UnsLine + { + UnsLineRowId = Guid.NewGuid(), UnsLineId = "line-a", UnsAreaId = "area-1", + Name = "line-a", GenerationId = Gen, + }); + _db.Equipment.AddRange( + new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = Gen, + EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3", + MachineCode = "MC-oven-3", + }, + new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = Gen, + EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(), + DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "press-7", + MachineCode = "MC-press-7", + }); + _db.Tags.AddRange( + new Tag + { + TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-temp", + DriverInstanceId = DriverId, EquipmentId = "eq-oven-3", + Name = "Temperature", DataType = "Int32", + AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature", + }, + new Tag + { + TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-press", + DriverInstanceId = DriverId, EquipmentId = "eq-press-7", + Name = "PressTemp", DataType = "Int32", + AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-press-temp", + }); + _db.SaveChanges(); + } +}