391 lines
15 KiB
C#
391 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
|
|
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class UnsTreeServiceEquipmentTests
|
|
{
|
|
private static (UnsTreeService Service, string DbName) Fresh()
|
|
{
|
|
var dbName = $"uns-equip-{Guid.NewGuid():N}";
|
|
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)
|
|
{
|
|
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
|
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, 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,
|
|
NamespaceId = "NS-1",
|
|
Name = "drv",
|
|
DriverType = "ModbusTcp",
|
|
DriverConfig = "{}",
|
|
});
|
|
}
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
|
|
new(name, machineCode, unsLineId, driverInstanceId,
|
|
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
|
|
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
|
|
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
|
|
|
|
// ----- CreateEquipment -----
|
|
|
|
/// <summary>A new equipment gets a system-generated EQ- id (EQ- + 12 hex chars) and persists.</summary>
|
|
[Fact]
|
|
public async Task CreateEquipment_generates_EQ_id_and_persists()
|
|
{
|
|
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);
|
|
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
|
|
eq.EquipmentId.ShouldStartWith("EQ-");
|
|
eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars
|
|
eq.Name.ShouldBe("machine-1");
|
|
eq.UnsLineId.ShouldBe("LINE-1");
|
|
eq.Enabled.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Creating equipment with a MachineCode already in the fleet is blocked.</summary>
|
|
[Fact]
|
|
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));
|
|
|
|
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null));
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet.");
|
|
}
|
|
|
|
/// <summary>Creating equipment with no UNS line returns the pick-a-line error.</summary>
|
|
[Fact]
|
|
public async Task CreateEquipment_missing_line_blocked()
|
|
{
|
|
var (service, _) = Fresh();
|
|
|
|
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null));
|
|
|
|
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>
|
|
[Fact]
|
|
public async Task CreateEquipment_driver_in_other_cluster_blocked()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
|
|
|
|
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
|
|
|
|
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>Binding equipment to a driver in the same cluster as the line is allowed.</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"));
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Error.ShouldBeNull();
|
|
|
|
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
|
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBe("DRV-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").DriverInstanceId.ShouldBeNull();
|
|
}
|
|
|
|
/// <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",
|
|
NamespaceId = "NS-1",
|
|
Name = "drv",
|
|
DriverType = "ModbusTcp",
|
|
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();
|
|
}
|
|
|
|
// ----- UpdateEquipment -----
|
|
|
|
/// <summary>Updating equipment changes its mutable fields (name, MachineCode, a 40010 field).</summary>
|
|
[Fact]
|
|
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));
|
|
|
|
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 updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null,
|
|
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);
|
|
|
|
var result = await service.UpdateEquipmentAsync(equipmentId, updated, rv);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Error.ShouldBeNull();
|
|
|
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
|
var after = verify.Equipment.Single(e => e.EquipmentId == equipmentId);
|
|
after.Name.ShouldBe("machine-renamed");
|
|
after.MachineCode.ShouldBe("machine_renamed");
|
|
after.Manufacturer.ShouldBe("Acme");
|
|
after.YearOfConstruction.ShouldBe((short)2021);
|
|
after.Enabled.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Updating equipment that no longer exists returns the row-gone error.</summary>
|
|
[Fact]
|
|
public async Task UpdateEquipment_missing_row_returns_error()
|
|
{
|
|
var (service, _) = Fresh();
|
|
|
|
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []);
|
|
|
|
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");
|
|
|
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
|
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBeNull();
|
|
}
|
|
|
|
/// <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));
|
|
|
|
string equipmentId;
|
|
byte[] rv;
|
|
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
|
{
|
|
var eq = db.Equipment.Single(e => e.MachineCode == "mc_a");
|
|
equipmentId = eq.EquipmentId;
|
|
rv = eq.RowVersion;
|
|
}
|
|
|
|
// 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);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
result.Error.ShouldBe("MachineCode 'mc_b' already exists in this fleet.");
|
|
|
|
// The original row must be unchanged.
|
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
|
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();
|
|
|
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
|
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBe("DRV-1");
|
|
}
|
|
|
|
// ----- DeleteEquipment -----
|
|
|
|
/// <summary>Deleting equipment removes the row.</summary>
|
|
[Fact]
|
|
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));
|
|
|
|
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.DeleteEquipmentAsync(equipmentId, rv);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Error.ShouldBeNull();
|
|
|
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
|
verify.Equipment.Any(e => e.EquipmentId == equipmentId).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Deleting equipment that is already gone is a no-op success.</summary>
|
|
[Fact]
|
|
public async Task DeleteEquipment_already_gone_returns_ok()
|
|
{
|
|
var (service, _) = Fresh();
|
|
|
|
var result = await service.DeleteEquipmentAsync("EQ-ghost", []);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Error.ShouldBeNull();
|
|
}
|
|
}
|