v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the equipment page's Tags tab (v3 reference-only equipment): columns are effective name, computed raw path, inherited datatype/access (read-only), display-name override (editable), and remove. The retired equipment-authored-tag editor (TagModal.razor) is deleted; the VirtualTag and ScriptedAlarm flows are untouched. - "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw usage is byte-unchanged (PickerMode defaults false). - UnsTreeService (+ IUnsTreeService) gain the reference mutations: LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver), AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync, SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync / LoadDescendantTagIdsAsync. - Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered): AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update mutations now call CheckAsync before persisting and surface a collision as the failure. WP2 supplies the implementation + DI registration. - Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput, the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the dead decision-#122 driver-cluster guard. Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers); Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI suite green (639 passed, 3 skipped); full solution builds 0 errors. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user