merge worktree-agent-a2c703868462add4a into v3/batch3-uns-rework

This commit is contained in:
Joseph Doherty
2026-07-16 06:16:42 -04:00
12 changed files with 1161 additions and 1060 deletions
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -7,10 +6,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>, including the
/// system-generated <c>EQ-</c> id, fleet-wide MachineCode uniqueness, and the decision-#122
/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than
/// the equipment's line.
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>: the system-generated
/// <c>EQ-</c> id and fleet-wide MachineCode uniqueness. (v3: equipment no longer binds a driver — the
/// reference-only Tags tab points at raw tags — so the old decision-#122 driver-cluster guard is gone,
/// and its tests with it.)
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
@@ -25,32 +24,17 @@ public sealed class UnsTreeServiceEquipmentTests
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
/// <summary>Seeds an area → line (id <c>LINE-1</c>) under <paramref name="cluster"/>.</summary>
private static void SeedLine(string dbName, string cluster)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = cluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
if (driverCluster is not null)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = driverCluster,
Name = "drv",
DriverType = "Modbus",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
new(name, machineCode, unsLineId, driverInstanceId,
private static EquipmentInput Input(string name, string machineCode, string unsLineId) =>
new(name, machineCode, unsLineId,
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
@@ -62,9 +46,9 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task CreateEquipment_generates_EQ_id_and_persists()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
@@ -83,10 +67,10 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task CreateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1"));
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null));
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet.");
@@ -98,120 +82,28 @@ public sealed class UnsTreeServiceEquipmentTests
{
var (service, _) = Fresh();
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null));
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", ""));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Pick a UNS line.");
}
/// <summary>The #122 guard blocks binding equipment to a driver in a different cluster than the line.</summary>
/// <summary>CreateEquipmentAsync returns the generated EQ- id in <c>CreatedId</c> so callers can navigate to the new page.</summary>
[Fact]
public async Task CreateEquipment_driver_in_other_cluster_blocked()
public async Task CreateEquipment_returns_generated_id_in_CreatedId()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
var input = new EquipmentInput("machine-2", "machine_002", "LINE-1",
null, null, null, null, null, null, null, null, null, null, null, true);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("DRV-1");
result.Error.ShouldContain("SITE-A");
result.Error.ShouldContain("MAIN");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>
/// Passing a driver in the same cluster as the line passes the #122 guard and the equipment
/// persists. v3: the equipment↔driver binding column was retired, so the driver is validated by
/// the guard but not stored — the assertion drops to equipment existence.
/// </summary>
[Fact]
public async Task CreateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
var result = await service.CreateEquipmentAsync(input);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
}
/// <summary>Driver-less equipment is allowed regardless of cluster (no #122 guard applies).</summary>
[Fact]
public async Task CreateEquipment_driverless_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
}
/// <summary>
/// The #122 guard blocks binding equipment to a driver when the UNS line does not resolve to
/// a cluster (e.g. the line does not exist in the DB).
/// </summary>
[Fact]
public async Task CreateEquipment_driver_bound_unresolvable_line_blocked()
{
var (service, dbName) = Fresh();
// Seed a driver in MAIN cluster, but do NOT create the UnsLine that the input references.
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = "MAIN",
Name = "drv",
DriverType = "Modbus",
DriverConfig = "{}",
});
db.SaveChanges();
}
var result = await service.CreateEquipmentAsync(
Input("machine-1", "machine_001", "LINE-BOGUS", "DRV-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("LINE-BOGUS");
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>Binding equipment to a DriverInstanceId that does not exist is blocked with a "not found" error.</summary>
[Fact]
public async Task CreateEquipment_driver_not_found_blocked()
{
var (service, dbName) = Fresh();
// Seed area + line in MAIN cluster, but NO DriverInstance.
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(
Input("machine-1", "machine_001", "LINE-1", "DRV-GHOST"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("not found");
result.Error.ShouldContain("DRV-GHOST");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
result.CreatedId.ShouldNotBeNull();
result.CreatedId!.ShouldStartWith("EQ-");
}
// ----- UpdateEquipment -----
@@ -221,8 +113,8 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task UpdateEquipment_changes_fields()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1"));
string equipmentId;
byte[] rv;
@@ -233,7 +125,7 @@ public sealed class UnsTreeServiceEquipmentTests
rv = eq.RowVersion;
}
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null,
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1",
ZTag: null, SAPID: null, Manufacturer: "Acme", Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: (short)2021,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: false);
@@ -258,49 +150,20 @@ public sealed class UnsTreeServiceEquipmentTests
{
var (service, _) = Fresh();
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []);
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1"), []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>The #122 guard blocks an update that binds equipment to a driver in another cluster.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
// The equipment row is unchanged (v3 has no persisted driver-binding column to check).
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).MachineCode.ShouldBe("machine_001");
}
/// <summary>Updating equipment with a MachineCode that already belongs to another row is blocked.</summary>
[Fact]
public async Task UpdateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-a", "mc_a", "LINE-1", null));
await service.CreateEquipmentAsync(Input("machine-b", "mc_b", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-a", "mc_a", "LINE-1"));
await service.CreateEquipmentAsync(Input("machine-b", "mc_b", "LINE-1"));
string equipmentId;
byte[] rv;
@@ -313,7 +176,7 @@ public sealed class UnsTreeServiceEquipmentTests
// Try to rename mc_a → mc_b (which already exists).
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-a", "mc_b", "LINE-1", null), rv);
equipmentId, Input("machine-a", "mc_b", "LINE-1"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
@@ -324,52 +187,6 @@ public sealed class UnsTreeServiceEquipmentTests
verify.Equipment.Single(e => e.EquipmentId == equipmentId).MachineCode.ShouldBe("mc_a");
}
/// <summary>Updating equipment to bind a driver that is in the SAME cluster as the line is allowed.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).UnsLineId.ShouldBe("LINE-1");
}
/// <summary>CreateEquipmentAsync returns the generated EQ- id in <c>CreatedId</c> so callers can navigate to the new page.</summary>
[Fact]
public async Task CreateEquipment_returns_generated_id_in_CreatedId()
{
var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var input = new EquipmentInput("machine-2", "machine_002", "LINE-1",
null, null, null, null, null, null, null, null, null, null, null, null, true);
var result = await service.CreateEquipmentAsync(input);
result.Ok.ShouldBeTrue();
result.CreatedId.ShouldNotBeNull();
result.CreatedId!.ShouldStartWith("EQ-");
}
// ----- DeleteEquipment -----
/// <summary>Deleting equipment removes the row.</summary>
@@ -377,8 +194,8 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task DeleteEquipment_removes_row()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1"));
string equipmentId;
byte[] rv;
@@ -6,10 +6,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the bulk <see cref="UnsTreeService.ImportEquipmentAsync"/> path: valid rows insert,
/// rows whose MachineCode already exists (in the DB or earlier in the same batch) are skipped,
/// rows referencing an unknown UNS line or unknown driver are reported as errors, and the
/// decision-#122 driver-cluster guard rejects a driver in a different cluster than the line.
/// Verifies the bulk <see cref="UnsTreeService.ImportEquipmentAsync"/> path: valid rows insert, rows
/// whose MachineCode already exists (in the DB or earlier in the same batch) are skipped, and rows
/// referencing an unknown UNS line are reported as errors. (v3: equipment no longer binds a driver, so
/// the old DriverInstanceId column and its decision-#122 cluster guard — and their tests — are gone.)
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceImportTests
@@ -20,32 +20,17 @@ public sealed class UnsTreeServiceImportTests
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
/// <summary>Seeds an area → line (id <c>LINE-1</c>) under <paramref name="cluster"/>.</summary>
private static void SeedLine(string dbName, string cluster)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = cluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
if (driverCluster is not null)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = driverCluster,
Name = "drv",
DriverType = "Modbus",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
new(name, machineCode, unsLineId, driverInstanceId,
private static EquipmentInput Input(string name, string machineCode, string unsLineId) =>
new(name, machineCode, unsLineId,
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
@@ -55,12 +40,12 @@ public sealed class UnsTreeServiceImportTests
public async Task Import_inserts_valid_rows()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", null),
Input("machine-2", "mc_2", "LINE-1", null),
Input("machine-1", "mc_1", "LINE-1"),
Input("machine-2", "mc_2", "LINE-1"),
]);
result.Inserted.ShouldBe(2);
@@ -85,15 +70,15 @@ public sealed class UnsTreeServiceImportTests
public async Task Import_skips_duplicate_machinecode()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
// Pre-existing equipment with MachineCode "mc_existing".
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null));
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1"));
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_existing", "LINE-1", null), // dup of DB row → skip
Input("machine-2", "mc_new", "LINE-1", null), // inserts
Input("machine-3", "mc_new", "LINE-1", null), // dup of in-batch row → skip
Input("machine-1", "mc_existing", "LINE-1"), // dup of DB row → skip
Input("machine-2", "mc_new", "LINE-1"), // inserts
Input("machine-3", "mc_new", "LINE-1"), // dup of in-batch row → skip
]);
result.Inserted.ShouldBe(1);
@@ -110,12 +95,12 @@ public sealed class UnsTreeServiceImportTests
public async Task Import_reports_unknown_line()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-BOGUS", null),
Input("machine-2", "mc_2", "LINE-1", null),
Input("machine-1", "mc_1", "LINE-BOGUS"),
Input("machine-2", "mc_2", "LINE-1"),
]);
result.Inserted.ShouldBe(1);
@@ -128,73 +113,4 @@ public sealed class UnsTreeServiceImportTests
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
db.Equipment.Any(e => e.MachineCode == "mc_2").ShouldBeTrue();
}
/// <summary>A driver-bound row whose DriverInstanceId does not resolve is reported as an error.</summary>
[Fact]
public async Task Import_reports_unknown_driver()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); // no driver seeded
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-GHOST"),
]);
result.Inserted.ShouldBe(0);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("DRV-GHOST");
result.Errors[0].ShouldContain("not found");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
}
/// <summary>
/// The decision-#122 guard rejects a row that binds a driver living in a different cluster than
/// the row's UNS line.
/// </summary>
[Fact]
public async Task Import_enforces_122_cluster()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
]);
result.Inserted.ShouldBe(0);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("decision #122");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
}
/// <summary>A driver in the same cluster as the line imports cleanly.</summary>
[Fact]
public async Task Import_allows_driver_in_same_cluster()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(0);
result.Errors.ShouldBeEmpty();
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "mc_1").UnsLineId.ShouldBe("LINE-1");
}
}
@@ -0,0 +1,314 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the v3 Batch-3 UNS reference-only equipment mutations on <see cref="UnsTreeService"/>:
/// adding references (with computed RawPath + inherited datatype/access), removing them, setting the
/// display-name override, the cross-cluster rejection, the same-tag / same-name duplicate rejections,
/// and the <see cref="IEffectiveNameGuard"/>-consumed collision rejection (via a configurable fake).
/// </summary>
/// <remarks>
/// The EF InMemory provider enforces neither <c>RowVersion</c> concurrency nor the filtered-unique
/// indexes, so the service's own pre-checks are what protect the data in these tests.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceReferenceTests
{
private const string EquipmentId = "EQ-1";
/// <summary>A configurable <see cref="IEffectiveNameGuard"/>: returns <see cref="Result"/> from every check.</summary>
private sealed class FakeGuard : IEffectiveNameGuard
{
public string? Result { get; set; }
public string? LastProposedName { get; private set; }
public Task<string?> CheckAsync(
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
string? excludeSourceId, CancellationToken ct = default)
{
LastProposedName = proposedEffectiveName;
return Task.FromResult(Result);
}
}
private static OtOpcUaConfigDbContext Db(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => UnsTreeTestDb.Factory(name);
/// <summary>
/// Seeds two clusters (MAIN, SITE-A), each with driver→device→tags, plus an equipment (<c>EQ-1</c>)
/// under MAIN. MAIN tags: <c>MAIN/dev1/speed</c> (Float, Read) and <c>MAIN/dev1/running</c>
/// (Boolean, ReadWrite), with <c>speed</c> also nested under a group <c>grp</c> variant. SITE-A tag:
/// <c>OTHER/dev2/temp</c> (for the cross-cluster rejection).
/// </summary>
private static void Seed(string name)
{
using var db = Db(name);
db.ServerClusters.Add(new ServerCluster { ClusterId = "MAIN", Name = "Main", Enterprise = "zb", Site = "s1", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
db.ServerClusters.Add(new ServerCluster { ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "s2", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "assembly" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "line-a" });
db.Equipment.Add(new Equipment { EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-1", Name = "machine-1", MachineCode = "mc_1" });
// MAIN raw topology.
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-1", ClusterId = "MAIN", Name = "MAIN", DriverType = "Modbus", DriverConfig = "{}" });
db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", Name = "grp" });
db.Tags.Add(new Tag { TagId = "TAG-speed", DeviceId = "DEV-1", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
db.Tags.Add(new Tag { TagId = "TAG-running", DeviceId = "DEV-1", Name = "running", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" });
db.Tags.Add(new Tag { TagId = "TAG-grouped", DeviceId = "DEV-1", TagGroupId = "TG-1", Name = "nested", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
// SITE-A raw topology (for the cross-cluster rejection).
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-2", ClusterId = "SITE-A", Name = "OTHER", DriverType = "Modbus", DriverConfig = "{}" });
db.Devices.Add(new Device { DeviceId = "DEV-2", DriverInstanceId = "DRV-2", Name = "dev2", DeviceConfig = "{}" });
db.Tags.Add(new Tag { TagId = "TAG-other", DeviceId = "DEV-2", Name = "temp", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
db.SaveChanges();
}
// ----- AddReferences -----
/// <summary>Adding two raw tags persists two UnsTagReference rows for the equipment.</summary>
[Fact]
public async Task AddReferences_persists_rows()
{
var name = $"ref-add-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-running"]);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = Db(name);
var refs = db.UnsTagReferences.Where(r => r.EquipmentId == EquipmentId).ToList();
refs.Count.ShouldBe(2);
refs.Select(r => r.TagId).ShouldBe(new[] { "TAG-speed", "TAG-running" }, ignoreOrder: true);
refs.All(r => r.DisplayNameOverride == null).ShouldBeTrue();
}
/// <summary>Loaded reference rows carry the computed RawPath and inherited (raw) datatype/access.</summary>
[Fact]
public async Task LoadReferences_projects_rawpath_and_inherited_type()
{
var name = $"ref-load-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-grouped"]);
var rows = await service.LoadReferencesForEquipmentAsync(EquipmentId);
rows.Count.ShouldBe(2);
var speed = rows.Single(r => r.RawPath == "MAIN/dev1/speed");
speed.EffectiveName.ShouldBe("speed");
speed.DataType.ShouldBe("Float");
speed.AccessLevel.ShouldBe(TagAccessLevel.Read);
var nested = rows.Single(r => r.EffectiveName == "nested");
nested.RawPath.ShouldBe("MAIN/dev1/grp/nested");
nested.DataType.ShouldBe("Int32");
}
/// <summary>A raw tag whose cluster differs from the equipment's is rejected (cross-cluster).</summary>
[Fact]
public async Task AddReferences_cross_cluster_rejected()
{
var name = $"ref-xcluster-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-other"]);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("cross-cluster");
using var db = Db(name);
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
}
/// <summary>Re-referencing an already-referenced raw tag is rejected.</summary>
[Fact]
public async Task AddReferences_duplicate_tag_rejected()
{
var name = $"ref-dup-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("already referenced");
using var db = Db(name);
db.UnsTagReferences.Count(r => r.EquipmentId == EquipmentId).ShouldBe(1);
}
/// <summary>A guard-reported collision (fake) becomes the mutation's readable failure; nothing is added.</summary>
[Fact]
public async Task AddReferences_guard_collision_rejected()
{
var name = $"ref-guard-{Guid.NewGuid():N}";
Seed(name);
var guard = new FakeGuard { Result = "'speed' already exists on equipment 'machine-1'." };
var service = new UnsTreeService(Factory(name), guard);
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("'speed' already exists on equipment 'machine-1'.");
guard.LastProposedName.ShouldBe("speed");
using var db = Db(name);
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
}
// ----- SetReferenceOverride -----
/// <summary>Setting an override changes the effective name; clearing it falls back to the raw name.</summary>
[Fact]
public async Task SetReferenceOverride_sets_and_clears()
{
var name = $"ref-override-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
var set = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "line-speed", row.RowVersion);
set.Ok.ShouldBeTrue();
var afterSet = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
afterSet.EffectiveName.ShouldBe("line-speed");
afterSet.DisplayNameOverride.ShouldBe("line-speed");
afterSet.RawPath.ShouldBe("MAIN/dev1/speed"); // raw path is unchanged by an override
var clear = await service.SetReferenceOverrideAsync(afterSet.UnsTagReferenceId, " ", afterSet.RowVersion);
clear.Ok.ShouldBeTrue();
var afterClear = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
afterClear.EffectiveName.ShouldBe("speed");
afterClear.DisplayNameOverride.ShouldBeNull();
}
/// <summary>Setting an override that the guard reports as colliding is rejected.</summary>
[Fact]
public async Task SetReferenceOverride_guard_collision_rejected()
{
var name = $"ref-override-guard-{Guid.NewGuid():N}";
Seed(name);
var guard = new FakeGuard();
var service = new UnsTreeService(Factory(name), guard);
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
guard.Result = "collides with a virtual tag.";
var result = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "running", row.RowVersion);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("collides with a virtual tag.");
guard.LastProposedName.ShouldBe("running");
}
// ----- RemoveReference -----
/// <summary>Removing a reference deletes the row; a missing row is a no-op success.</summary>
[Fact]
public async Task RemoveReference_removes_row()
{
var name = $"ref-remove-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
var result = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
result.Ok.ShouldBeTrue();
using var db = Db(name);
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
// Already gone → idempotent success.
var again = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
again.Ok.ShouldBeTrue();
}
// ----- Picker support -----
/// <summary>The picker root is the equipment's own cluster node (structurally cluster-scoped).</summary>
[Fact]
public async Task LoadReferencePickerRoot_returns_cluster_node()
{
var name = $"ref-picker-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var root = await service.LoadReferencePickerRootAsync(EquipmentId);
root.ShouldNotBeNull();
root!.Kind.ShouldBe(RawNodeKind.Cluster);
root.EntityId.ShouldBe("MAIN");
root.ClusterId.ShouldBe("MAIN");
root.HasLazyChildren.ShouldBeTrue();
}
/// <summary>Select-all under a device returns every tag id in the device subtree.</summary>
[Fact]
public async Task LoadDescendantTagIds_device_returns_subtree_tags()
{
var name = $"ref-descend-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var deviceNode = new RawNode
{
Kind = RawNodeKind.Device,
Key = "dev:DEV-1",
DisplayName = "dev1",
ClusterId = "MAIN",
EntityId = "DEV-1",
};
var ids = await service.LoadDescendantTagIdsAsync(deviceNode);
ids.ShouldBe(new[] { "TAG-speed", "TAG-running", "TAG-grouped" }, ignoreOrder: true);
}
/// <summary>Select-all under a tag-group returns only the tags in that group's subtree.</summary>
[Fact]
public async Task LoadDescendantTagIds_group_returns_group_tags()
{
var name = $"ref-descend-grp-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var groupNode = new RawNode
{
Kind = RawNodeKind.TagGroup,
Key = "grp:TG-1",
DisplayName = "grp",
ClusterId = "MAIN",
EntityId = "TG-1",
};
var ids = await service.LoadDescendantTagIdsAsync(groupNode);
ids.ShouldBe(new[] { "TAG-grouped" });
}
}