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