Merge pull request 'EquipmentNamespaceContentLoader � Config-DB loader for walker wire-in' (#156) from equipment-content-loader into v2
This commit was merged in pull request #156.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="EquipmentNamespaceContent"/> snapshot the
|
||||
/// <see cref="EquipmentNodeWalker"/> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para>Returns <c>null</c> when the driver instance has no Equipment rows at the
|
||||
/// supplied generation. The wire-in in <see cref="OpcUaApplicationHost"/> 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).</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentNamespaceContentLoader
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the walker-shaped snapshot for <paramref name="driverInstanceId"/> at
|
||||
/// <paramref name="generationId"/>. Returns <c>null</c> when the driver has no
|
||||
/// Equipment rows at that generation.
|
||||
/// </summary>
|
||||
public async Task<EquipmentNamespaceContent?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<OtOpcUaConfigDbContext>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user