feat(uns): equipment CSV import folded into the tree toolbar

This commit is contained in:
Joseph Doherty
2026-06-08 13:56:01 -04:00
parent c0346f14ce
commit 7db9a24403
5 changed files with 515 additions and 1 deletions
@@ -21,7 +21,7 @@
@bind="_filter" @bind:event="oninput" /> @bind="_filter" @bind:event="oninput" />
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ExpandAll">Expand all</button> <button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ExpandAll">Expand all</button>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CollapseAll">Collapse all</button> <button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CollapseAll">Collapse all</button>
<button type="button" class="btn btn-outline-primary btn-sm" disabled>Import equipment CSV</button> <button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenImportModal">Import equipment CSV</button>
</div> </div>
</div> </div>
@@ -89,6 +89,10 @@
OnSaved="OnEquipmentChildModalSavedAsync" OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" /> OnCancel="CloseModals" />
<ImportEquipmentModal Visible="_importModalVisible"
OnImported="OnImportedAsync"
OnCancel="CloseModals" />
@if (_confirmNode is not null) @if (_confirmNode is not null)
{ {
<div class="modal-backdrop fade show" style="display:block"></div> <div class="modal-backdrop fade show" style="display:block"></div>
@@ -158,6 +162,9 @@
private VirtualTagEditDto? _vtagModalExisting; private VirtualTagEditDto? _vtagModalExisting;
private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>(); private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>();
// --- Import-equipment-CSV modal state ---
private bool _importModalVisible;
// --- Owning equipment to refresh in place after a tag/virtual-tag mutation --- // --- Owning equipment to refresh in place after a tag/virtual-tag mutation ---
private string? _childRefreshEquipmentId; private string? _childRefreshEquipmentId;
@@ -357,6 +364,24 @@
} }
} }
/// <summary>Opens the bulk equipment-CSV import modal.</summary>
private void OpenImportModal()
{
CloseModals();
_importModalVisible = true;
}
/// <summary>
/// Handles the import modal's close after a run. A bulk import can add equipment across many
/// lines and clusters, so the whole structural tree is reloaded rather than refreshed in place.
/// </summary>
private async Task OnImportedAsync()
{
_roots = await Svc.LoadStructureAsync();
CloseModals();
StateHasChanged();
}
/// <summary>Opens the delete-confirm modal for a node, stashing it as the pending target.</summary> /// <summary>Opens the delete-confirm modal for a node, stashing it as the pending target.</summary>
private void HandleDelete(UnsNode node) private void HandleDelete(UnsNode node)
{ {
@@ -549,6 +574,7 @@
_vtagModalExisting = null; _vtagModalExisting = null;
_vtagModalEquipmentId = null; _vtagModalEquipmentId = null;
_vtagModalScriptOptions = Array.Empty<(string, string)>(); _vtagModalScriptOptions = Array.Empty<(string, string)>();
_importModalVisible = false;
_childRefreshEquipmentId = null; _childRefreshEquipmentId = null;
_confirmNode = null; _confirmNode = null;
_confirmError = null; _confirmError = null;
@@ -0,0 +1,187 @@
@* Bulk equipment import modal wired straight into IUnsTreeService. Paste a CSV (header + rows),
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId.
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import equipment CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
are detected by MachineCode and skipped (additive-only — no updates).
</p>
<textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
@if (!string.IsNullOrWhiteSpace(_parseError))
{
<div class="text-danger small mt-2">@_parseError</div>
}
@if (_result is not null)
{
<div class="alert alert-info mt-3 mb-0" role="alert">
<div>
<strong>Inserted:</strong> @_result.Inserted
&middot; <strong>Skipped (existing MachineCode):</strong> @_result.Skipped
&middot; <strong>Errors:</strong> @_result.Errors.Count
</div>
@if (_result.Errors.Count > 0)
{
<ul class="small mb-0 mt-2">
@foreach (var err in _result.Errors)
{
<li>@err</li>
}
</ul>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">
@(_result is null ? "Cancel" : "Close")
</button>
<button type="button" class="btn btn-primary" @onclick="ImportAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Import
</button>
</div>
</div>
</div>
</div>
}
@code {
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>
/// Raised when the user closes the modal after an import so the host can reload the tree and
/// dismiss the modal. Fired on close regardless of whether anything was inserted — closing always
/// returns control to the host.
/// </summary>
[Parameter] public EventCallback OnImported { get; set; }
/// <summary>Raised when the user cancels before importing so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private string _csv = "";
private bool _busy;
private string? _parseError;
private EquipmentImportResult? _result;
protected override void OnParametersSet()
{
// Reset the working state each time the host (re)opens the modal.
if (!Visible)
{
_csv = "";
_parseError = null;
_result = null;
}
}
private async Task ImportAsync()
{
_busy = true;
_parseError = null;
_result = null;
try
{
var rows = ParseCsv(_csv);
if (rows is null) { return; }
_result = await Svc.ImportEquipmentAsync(rows);
}
finally
{
_busy = false;
}
}
/// <summary>
/// Closes the modal. Once an import has run (the user has seen the summary), closing raises
/// OnImported so the host reloads the tree; before any import it raises OnCancel.
/// </summary>
private Task CloseAsync() =>
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
/// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary>
private List<EquipmentInput>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<EquipmentInput>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
return null;
}
rows.Add(new EquipmentInput(
Name: parts[0],
MachineCode: parts[1],
UnsLineId: parts[2],
DriverInstanceId: NullIfEmpty(parts, 3),
ZTag: NullIfEmpty(parts, 4),
SAPID: NullIfEmpty(parts, 5),
Manufacturer: NullIfEmpty(parts, 6),
Model: NullIfEmpty(parts, 7),
SerialNumber: null,
HardwareRevision: null,
SoftwareRevision: null,
YearOfConstruction: null,
AssetLocation: null,
ManufacturerUri: null,
DeviceManualUri: null,
Enabled: true));
}
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
}
@@ -89,6 +89,17 @@ public sealed record TagEditDto(string TagId, string EquipmentId, string Name, s
public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId, public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId,
bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion); bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion);
/// <summary>
/// The outcome of a bulk equipment CSV import: how many rows were inserted, how many were skipped
/// (existing MachineCode — the importer is additive-only, never an update), and a per-row error list
/// for rows that could not be inserted (unknown line, unknown driver, or a decision-#122 cluster
/// mismatch). Skipped rows never appear in <see cref="Errors"/>.
/// </summary>
/// <param name="Inserted">The count of new Equipment rows added.</param>
/// <param name="Skipped">The count of rows skipped because their MachineCode already exists.</param>
/// <param name="Errors">The human-readable error strings for rows that failed validation.</param>
public sealed record EquipmentImportResult(int Inserted, int Skipped, IReadOnlyList<string> Errors);
/// <summary> /// <summary>
/// Loads the structural portion of the unified-namespace (UNS) browse tree — /// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database. /// Enterprise → Cluster → Area → Line → Equipment — from the config database.
@@ -258,6 +269,21 @@ public interface IUnsTreeService
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns> /// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default); Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the decision-#122
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end.
/// </summary>
/// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The insert/skip counts and the per-row error list.</returns>
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary> /// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external /// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks /// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
@@ -504,6 +504,81 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(true, null); return new UnsMutationResult(true, null);
} }
/// <inheritdoc />
public async Task<EquipmentImportResult> ImportEquipmentAsync(
IReadOnlyList<EquipmentInput> rows,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// Known lines and the MachineCodes already in the fleet, read once up front. The seen-set
// tracks MachineCodes inserted earlier in this same batch so two CSV rows that share a
// MachineCode skip the duplicate the same way the original ImportEquipment.razor did.
var lineSet = (await db.UnsLines.AsNoTracking().Select(l => l.UnsLineId).ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var existing = (await db.Equipment.AsNoTracking().Select(e => e.MachineCode).ToListAsync(ct))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var inserted = 0;
var skipped = 0;
var errors = new List<string>();
foreach (var row in rows)
{
// Existing MachineCode (in DB or earlier in this batch) → skip, never an error.
if (existing.Contains(row.MachineCode))
{
skipped++;
continue;
}
if (!lineSet.Contains(row.UnsLineId))
{
errors.Add($"Row '{row.MachineCode}': UNS line '{row.UnsLineId}' not found.");
continue;
}
// Reuse the #122 guard: it reports an unknown DriverInstanceId and a driver/line cluster
// mismatch, and is a no-op for driver-less rows.
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
if (guard is not null)
{
errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
continue;
}
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(row.DriverInstanceId) ? null : row.DriverInstanceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = string.IsNullOrWhiteSpace(row.ZTag) ? null : row.ZTag,
SAPID = string.IsNullOrWhiteSpace(row.SAPID) ? null : row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = row.YearOfConstruction,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
Enabled = row.Enabled,
});
existing.Add(row.MachineCode);
inserted++;
}
await db.SaveChangesAsync(ct);
return new EquipmentImportResult(inserted, skipped, errors);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<UnsMutationResult> UpdateEquipmentAsync( public async Task<UnsMutationResult> UpdateEquipmentAsync(
string equipmentId, string equipmentId,
@@ -0,0 +1,200 @@
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 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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceImportTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-import-{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);
/// <summary>Valid rows insert with system-generated EQ- ids and are counted in <c>Inserted</c>.</summary>
[Fact]
public async Task Import_inserts_valid_rows()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", null),
Input("machine-2", "mc_2", "LINE-1", null),
]);
result.Inserted.ShouldBe(2);
result.Skipped.ShouldBe(0);
result.Errors.ShouldBeEmpty();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Count().ShouldBe(2);
var eq = db.Equipment.Single(e => e.MachineCode == "mc_1");
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>
/// A row whose MachineCode already exists in the DB is skipped (not an error), and a second row
/// later in the batch that reuses an earlier row's MachineCode is also skipped.
/// </summary>
[Fact]
public async Task Import_skips_duplicate_machinecode()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
// Pre-existing equipment with MachineCode "mc_existing".
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null));
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
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(2);
result.Errors.ShouldBeEmpty();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Count(e => e.MachineCode == "mc_new").ShouldBe(1);
db.Equipment.Count(e => e.MachineCode == "mc_existing").ShouldBe(1);
}
/// <summary>A row whose UnsLineId does not exist is reported as an error and not inserted.</summary>
[Fact]
public async Task Import_reports_unknown_line()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-BOGUS", null),
Input("machine-2", "mc_2", "LINE-1", null),
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("LINE-BOGUS");
using var db = UnsTreeTestDb.CreateNamed(dbName);
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();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "mc_1").DriverInstanceId.ShouldBe("DRV-1");
}
}