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; /// /// Verifies the Equipment CRUD mutations on , including the /// system-generated EQ- 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. /// /// /// The EF InMemory provider does not enforce RowVersion concurrency, so the /// DbUpdateConcurrencyException branches are not exercised here by design. /// [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); } /// /// Seeds a line under an area in , plus an optional driver in /// . The line id is always LINE-1; the driver (when /// requested) is always DRV-1. /// 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 ----- /// A new equipment gets a system-generated EQ- id (EQ- + 12 hex chars) and persists. [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(); } /// Creating equipment with a MachineCode already in the fleet is blocked. [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."); } /// Creating equipment with no UNS line returns the pick-a-line error. [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."); } /// The #122 guard blocks binding equipment to a driver in a different cluster than the line. [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(); } /// Binding equipment to a driver in the same cluster as the line is allowed. [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"); } /// Driver-less equipment is allowed regardless of cluster (no #122 guard applies). [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(); } /// /// 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). /// [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(); } /// Binding equipment to a DriverInstanceId that does not exist is blocked with a "not found" error. [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 ----- /// Updating equipment changes its mutable fields (name, MachineCode, a 40010 field). [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(); } /// Updating equipment that no longer exists returns the row-gone error. [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."); } /// The #122 guard blocks an update that binds equipment to a driver in another cluster. [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(); } /// Updating equipment with a MachineCode that already belongs to another row is blocked. [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"); } /// Updating equipment to bind a driver that is in the SAME cluster as the line is allowed. [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 ----- /// Deleting equipment removes the row. [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(); } /// Deleting equipment that is already gone is a no-op success. [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(); } }