feat(uns): equipment modal wired into the tree

This commit is contained in:
Joseph Doherty
2026-06-08 13:31:14 -04:00
parent 0abd1d8fc2
commit 2beaa43d60
5 changed files with 490 additions and 5 deletions
@@ -63,6 +63,15 @@
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<EquipmentModal Visible="_equipmentModalVisible"
IsNew="_equipmentModalIsNew"
UnsLineId="_equipmentModalLineId"
Existing="_equipmentModalExisting"
Lines="_equipmentModalLineOptions"
Drivers="_equipmentModalDriverOptions"
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
@if (_confirmNode is not null)
{
<div class="modal-backdrop fade show" style="display:block"></div>
@@ -110,6 +119,14 @@
private LineEditDto? _lineModalExisting;
private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>();
// --- Equipment modal state ---
private bool _equipmentModalVisible;
private bool _equipmentModalIsNew;
private string? _equipmentModalLineId;
private EquipmentEditDto? _equipmentModalExisting;
private IReadOnlyList<(string Id, string Display)> _equipmentModalLineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = Array.Empty<(string, string)>();
// --- Delete-confirm state ---
private UnsNode? _confirmNode;
private bool _confirmBusy;
@@ -139,6 +156,18 @@
.Select(a => (a.EntityId!, a.DisplayName))
.ToList();
/// <summary>Returns the <c>(Id, Display)</c> line options inside a single cluster, for the equipment picker.</summary>
private IReadOnlyList<(string Id, string Display)> LinesForCluster(string? clusterId) =>
_roots
.SelectMany(ent => ent.Children)
.Where(c => c.Kind == UnsNodeKind.Cluster && c.ClusterId == clusterId)
.SelectMany(c => c.Children)
.Where(a => a.Kind == UnsNodeKind.Area)
.SelectMany(a => a.Children)
.Where(l => l.Kind == UnsNodeKind.Line && l.EntityId is not null)
.Select(l => (l.EntityId!, l.DisplayName))
.ToList();
/// <summary>
/// Toggles a node's expansion. For equipment nodes whose children have not yet
/// been loaded, lazily fetches the tag/virtual-tag leaves on first expand.
@@ -175,9 +204,10 @@
/// <summary>
/// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a
/// new line scoped to its cluster. Equipment "+ Tag" is handled in a later task.
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster. Equipment
/// "+ Tag" is handled in a later task.
/// </summary>
private void HandleAddChild(UnsNode node)
private async Task HandleAddChild(UnsNode node)
{
CloseModals();
switch (node.Kind)
@@ -196,6 +226,15 @@
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
_lineModalVisible = true;
break;
case UnsNodeKind.Line:
_equipmentModalIsNew = true;
_equipmentModalExisting = null;
_equipmentModalLineId = node.EntityId;
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
}
}
@@ -226,6 +265,17 @@
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
_lineModalVisible = true;
break;
case UnsNodeKind.Equipment:
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
if (equipment is null) { return; }
_equipmentModalIsNew = false;
_equipmentModalExisting = equipment;
_equipmentModalLineId = equipment.UnsLineId;
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
}
}
@@ -238,7 +288,7 @@
/// <summary>
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
/// Area/Line are handled here; other kinds are wired in later tasks.
/// Area/Line/Equipment are handled here; Tag/VirtualTag are wired in later tasks.
/// </summary>
private async Task ConfirmDeleteAsync()
{
@@ -264,8 +314,14 @@
result = await Svc.DeleteLineAsync(node.EntityId!, line.RowVersion);
break;
case UnsNodeKind.Equipment:
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
if (equipment is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteEquipmentAsync(node.EntityId!, equipment.RowVersion);
break;
default:
// Equipment/Tag/VirtualTag deletes are wired in later tasks.
// Tag/VirtualTag deletes are wired in later tasks.
result = new UnsMutationResult(false, "Delete for this node kind is not yet available.");
break;
}
@@ -309,6 +365,10 @@
_lineModalVisible = false;
_lineModalExisting = null;
_lineModalAreaOptions = Array.Empty<(string, string)>();
_equipmentModalVisible = false;
_equipmentModalExisting = null;
_equipmentModalLineOptions = Array.Empty<(string, string)>();
_equipmentModalDriverOptions = Array.Empty<(string, string)>();
_confirmNode = null;
_confirmError = null;
}
@@ -0,0 +1,252 @@
@* Create/edit modal for an equipment, wired straight into IUnsTreeService. The host page owns
visibility and supplies the parent line id (create) or the loaded EquipmentEditDto (edit), plus
the cluster-scoped UNS-line and driver lists. The EquipmentId is system-generated (decision #125)
so it is never an editable field — only shown read-only on edit. On a successful save it raises
OnSaved so the host can reload the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@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">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New equipment" : "Edit equipment")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<h6 class="text-muted">Identity</h6>
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">EquipmentId</label>
<input class="form-control form-control-sm mono" value="@Existing?.EquipmentId" disabled />
<div class="form-text">System-generated; never operator-edited.</div>
</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-name">Name</label>
<InputText id="eq-name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
placeholder="machine-01" />
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
<ValidationMessage For="@(() => _form.Name)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-machinecode">MachineCode</label>
<InputText id="eq-machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
placeholder="machine_001" />
<ValidationMessage For="@(() => _form.MachineCode)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-line">UNS line</label>
<InputSelect id="eq-line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
<option value="">— pick a line —</option>
@foreach (var (id, display) in Lines)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.UnsLineId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-driver">Driver instance</label>
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var (id, display) in Drivers)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
<InputText id="eq-ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-sap">SAPID</label>
<InputText id="eq-sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Surface in deployments</label>
</div>
</div>
<hr />
<h6 class="text-muted">OPC 40010 identification (optional)</h6>
<div class="row">
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new equipment; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The parent line id used to default the UNS-line select on create.</summary>
[Parameter] public string? UnsLineId { get; set; }
/// <summary>The equipment being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public EquipmentEditDto? Existing { get; set; }
/// <summary>The selectable UNS lines — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Lines { get; set; } = Array.Empty<(string, string)>();
/// <summary>The selectable drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel { UnsLineId = UnsLineId ?? "" };
}
else if (Existing is not null)
{
_form = new FormModel
{
Name = Existing.Name,
MachineCode = Existing.MachineCode,
UnsLineId = Existing.UnsLineId,
DriverInstanceId = Existing.DriverInstanceId,
ZTag = Existing.ZTag,
SAPID = Existing.SAPID,
Manufacturer = Existing.Manufacturer,
Model = Existing.Model,
SerialNumber = Existing.SerialNumber,
HardwareRevision = Existing.HardwareRevision,
SoftwareRevision = Existing.SoftwareRevision,
YearOfConstruction = Existing.YearOfConstruction,
AssetLocation = Existing.AssetLocation,
ManufacturerUri = Existing.ManufacturerUri,
DeviceManualUri = Existing.DeviceManualUri,
Enabled = Existing.Enabled,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new EquipmentInput(
_form.Name,
_form.MachineCode,
_form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag,
_form.SAPID,
_form.Manufacturer,
_form.Model,
_form.SerialNumber,
_form.HardwareRevision,
_form.SoftwareRevision,
_form.YearOfConstruction,
_form.AssetLocation,
_form.ManufacturerUri,
_form.DeviceManualUri,
_form.Enabled);
var result = IsNew
? await Svc.CreateEquipmentAsync(input)
: await Svc.UpdateEquipmentAsync(Existing!.EquipmentId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public bool Enabled { get; set; } = true;
}
}
@@ -22,6 +22,33 @@ public sealed record AreaEditDto(string UnsAreaId, string Name, string? Notes, s
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name, string? Notes, byte[] RowVersion);
/// <summary>
/// An equipment projected for editing: its system-generated id, the operator-editable identity and
/// OPC 40010 identification fields, plus the concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="EquipmentId">The system-generated stable id (read-only — never operator-edited, decision #125).</param>
/// <param name="Name">UNS level-5 segment name.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">The owning line id (the UNS-line selection).</param>
/// <param name="DriverInstanceId">Optional driver binding; <c>null</c> when driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
/// <param name="Model">Optional OPC 40010 model designation.</param>
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record EquipmentEditDto(string EquipmentId, string Name, string MachineCode, string UnsLineId,
string? DriverInstanceId, string? ZTag, string? SAPID, string? Manufacturer, string? Model, string? SerialNumber,
string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation,
string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion);
/// <summary>
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
@@ -67,6 +94,26 @@ public interface IUnsTreeService
/// <returns>The line's edit projection, or <c>null</c> when missing.</returns>
Task<LineEditDto?> LoadLineAsync(string unsLineId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="equipmentId">The equipment to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's edit projection, or <c>null</c> when missing.</returns>
Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads every driver instance in a cluster (regardless of namespace kind) so the equipment modal
/// can offer the full cluster driver list for binding. Ordered by <c>DriverInstanceId</c>. Each is
/// projected to a <c>(DriverInstanceId, Display)</c> pair where <c>Display</c> is
/// <c>"{DriverInstanceId} — {Name} ({DriverType})"</c>.
/// </summary>
/// <param name="clusterId">The cluster whose drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster's drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
@@ -148,6 +148,55 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Equipment
.AsNoTracking()
.Where(e => e.EquipmentId == equipmentId)
.Select(e => new EquipmentEditDto(
e.EquipmentId,
e.Name,
e.MachineCode,
e.UnsLineId,
e.DriverInstanceId,
e.ZTag,
e.SAPID,
e.Manufacturer,
e.Model,
e.SerialNumber,
e.HardwareRevision,
e.SoftwareRevision,
e.YearOfConstruction,
e.AssetLocation,
e.ManufacturerUri,
e.DeviceManualUri,
e.Enabled,
e.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(
string clusterId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var drivers = await db.DriverInstances
.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name} ({d.DriverType})"))
.ToList();
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateAreaAsync(
string clusterId,
@@ -1,12 +1,13 @@
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 load-for-edit projections on <see cref="UnsTreeService"/> that prefill the
/// Area/Line edit modals and carry the concurrency token back for last-write-wins saves.
/// Area/Line/Equipment edit modals and carry the concurrency token back for last-write-wins saves.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceLoadEditTests
@@ -65,4 +66,80 @@ public sealed class UnsTreeServiceLoadEditTests
(await service.LoadLineAsync("NOPE")).ShouldBeNull();
}
/// <summary>Loading the seeded equipment maps its identity fields, line, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadEquipment_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
dto.ShouldNotBeNull();
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.Name.ShouldBe("machine-1");
dto.MachineCode.ShouldBe("machine_001");
dto.UnsLineId.ShouldBe("LINE-1");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing equipment returns null.</summary>
[Fact]
public async Task LoadEquipment_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadEquipmentAsync("NOPE")).ShouldBeNull();
}
/// <summary>
/// LoadDriversForCluster returns every driver in the cluster (any namespace kind), ordered by id,
/// with the <c>"{Id} — {Name} ({DriverType})"</c> display; drivers in other clusters are excluded.
/// </summary>
[Fact]
public async Task LoadDriversForCluster_returns_cluster_drivers()
{
var (service, dbName) = Seeded();
await using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-B",
ClusterId = UnsTreeTestDb.PopulatedClusterId,
NamespaceId = "NS-1",
Name = "modbus-b",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-A",
ClusterId = UnsTreeTestDb.PopulatedClusterId,
NamespaceId = "NS-1",
Name = "galaxy-a",
DriverType = "Galaxy",
DriverConfig = "{}",
});
// A driver in a different cluster must be excluded.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-OTHER",
ClusterId = UnsTreeTestDb.EmptyClusterId,
NamespaceId = "NS-2",
Name = "other",
DriverType = "S7",
DriverConfig = "{}",
});
await db.SaveChangesAsync();
}
var drivers = await service.LoadDriversForClusterAsync(UnsTreeTestDb.PopulatedClusterId);
drivers.Count.ShouldBe(2);
drivers[0].DriverInstanceId.ShouldBe("DRV-A");
drivers[0].Display.ShouldBe("DRV-A — galaxy-a (Galaxy)");
drivers[1].DriverInstanceId.ShouldBe("DRV-B");
drivers[1].Display.ShouldBe("DRV-B — modbus-b (ModbusTcp)");
}
}