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:
@@ -78,17 +78,9 @@ else
|
||||
</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 _driverOptions)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
@* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced
|
||||
here by reference on the Tags tab. The old "Driver instance" select was removed. *@
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
||||
@@ -138,38 +130,48 @@ else
|
||||
}
|
||||
else if (_activeTab == "tags")
|
||||
{
|
||||
@* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at
|
||||
raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name
|
||||
override is the only per-reference editable field. *@
|
||||
<div class="d-flex justify-content-end align-items-center gap-2 mb-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddReference">+ Add reference</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_tagError))
|
||||
@if (!string.IsNullOrWhiteSpace(_refError))
|
||||
{
|
||||
<div class="text-danger small mb-2">@_tagError</div>
|
||||
<div class="text-danger small mb-2">@_refError</div>
|
||||
}
|
||||
@if (_tags is null)
|
||||
@if (_refs is null)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else if (_tags.Count == 0)
|
||||
else if (_refs.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No tags yet.</p>
|
||||
<p class="text-muted">No tag references yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
|
||||
<tr>
|
||||
<th>Effective name</th><th>Raw path</th><th>Data type</th><th>Access</th>
|
||||
<th>Display-name override</th><th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in _tags)
|
||||
@foreach (var r in _refs)
|
||||
{
|
||||
<tr @key="t.TagId">
|
||||
<td>@t.Name</td>
|
||||
<td class="mono">@t.DriverInstanceId</td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@t.AccessLevel</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
|
||||
<tr @key="r.UnsTagReferenceId">
|
||||
<td>@r.EffectiveName</td>
|
||||
<td class="mono small">@r.RawPath</td>
|
||||
<td>@r.DataType</td>
|
||||
<td>@r.AccessLevel</td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
|
||||
placeholder="(uses raw name)" />
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => SaveOverride(r)">Save name</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -177,9 +179,8 @@ else
|
||||
</table>
|
||||
}
|
||||
|
||||
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
||||
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
|
||||
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
|
||||
<AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
|
||||
OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
|
||||
}
|
||||
else if (_activeTab == "vtags")
|
||||
{
|
||||
@@ -290,15 +291,13 @@ else
|
||||
private EquipmentEditDto? _equipment;
|
||||
private FormModel _form = new();
|
||||
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
|
||||
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
|
||||
|
||||
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). ---
|
||||
private IReadOnlyList<EquipmentTagRow>? _tags;
|
||||
private string? _tagError;
|
||||
private bool _tagModalVisible;
|
||||
private bool _tagModalIsNew;
|
||||
private TagEditDto? _tagModalExisting;
|
||||
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
|
||||
// --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
|
||||
// spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
|
||||
private IReadOnlyList<EquipmentReferenceRow>? _refs;
|
||||
private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
|
||||
private string? _refError;
|
||||
private bool _refModalVisible;
|
||||
|
||||
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
|
||||
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
||||
@@ -329,54 +328,48 @@ else
|
||||
{
|
||||
_activeTab = tab;
|
||||
if (IsNew) { return; }
|
||||
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); }
|
||||
if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); }
|
||||
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
|
||||
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
|
||||
}
|
||||
|
||||
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) ---
|
||||
// --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) ---
|
||||
|
||||
private async Task ReloadTagsAsync()
|
||||
private async Task ReloadReferencesAsync()
|
||||
{
|
||||
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
|
||||
_refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!);
|
||||
// Seed the per-row override edit buffer so each row's <input> binds to a live value.
|
||||
_overrideEdits.Clear();
|
||||
foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; }
|
||||
}
|
||||
|
||||
private async Task OpenAddTag()
|
||||
private void OpenAddReference()
|
||||
{
|
||||
_tagError = null;
|
||||
_tagModalIsNew = true;
|
||||
_tagModalExisting = null;
|
||||
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
||||
_tagModalVisible = true;
|
||||
_refError = null;
|
||||
_refModalVisible = true;
|
||||
}
|
||||
|
||||
private async Task OpenEditTag(string tagId)
|
||||
private async Task OnReferencesCommittedAsync()
|
||||
{
|
||||
_tagError = null;
|
||||
var dto = await Svc.LoadTagAsync(tagId);
|
||||
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
|
||||
_tagModalIsNew = false;
|
||||
_tagModalExisting = dto;
|
||||
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
||||
_tagModalVisible = true;
|
||||
_refModalVisible = false;
|
||||
await ReloadReferencesAsync();
|
||||
}
|
||||
|
||||
private async Task OnTagSavedAsync()
|
||||
private async Task SaveOverride(EquipmentReferenceRow r)
|
||||
{
|
||||
_tagModalVisible = false;
|
||||
await ReloadTagsAsync();
|
||||
_refError = null;
|
||||
var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId);
|
||||
var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion);
|
||||
if (res.Ok) { await ReloadReferencesAsync(); }
|
||||
else { _refError = res.Error; }
|
||||
}
|
||||
|
||||
private async Task DeleteTag(string tagId)
|
||||
private async Task RemoveReference(EquipmentReferenceRow r)
|
||||
{
|
||||
_tagModalVisible = false;
|
||||
_tagError = null;
|
||||
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete.
|
||||
var dto = await Svc.LoadTagAsync(tagId);
|
||||
if (dto is null) { await ReloadTagsAsync(); return; }
|
||||
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
|
||||
if (r.Ok) { await ReloadTagsAsync(); }
|
||||
else { _tagError = r.Error; }
|
||||
_refError = null;
|
||||
var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
|
||||
if (res.Ok) { await ReloadReferencesAsync(); }
|
||||
else { _refError = res.Error; }
|
||||
}
|
||||
|
||||
// --- Virtual Tags tab handlers ---
|
||||
@@ -478,7 +471,7 @@ else
|
||||
// path lands on Details because the field initializes to "details" and a fresh page instance
|
||||
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
|
||||
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
|
||||
_tags = null;
|
||||
_refs = null;
|
||||
_vtags = null;
|
||||
_alarms = null;
|
||||
if (!IsNew)
|
||||
@@ -489,7 +482,6 @@ else
|
||||
LoadFormFrom(_equipment);
|
||||
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
|
||||
_lineOptions = ctx.Lines;
|
||||
_driverOptions = ctx.Drivers;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -497,7 +489,6 @@ else
|
||||
_form = new FormModel { UnsLineId = LineId ?? "" };
|
||||
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
|
||||
_lineOptions = ctx.Lines;
|
||||
_driverOptions = ctx.Drivers;
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
@@ -510,7 +501,6 @@ else
|
||||
Name = e.Name,
|
||||
MachineCode = e.MachineCode,
|
||||
UnsLineId = e.UnsLineId,
|
||||
DriverInstanceId = e.DriverInstanceId,
|
||||
ZTag = e.ZTag,
|
||||
SAPID = e.SAPID,
|
||||
Manufacturer = e.Manufacturer,
|
||||
@@ -536,7 +526,6 @@ else
|
||||
_form.Name,
|
||||
_form.MachineCode,
|
||||
_form.UnsLineId,
|
||||
_form.DriverInstanceId,
|
||||
_form.ZTag,
|
||||
_form.SAPID,
|
||||
_form.Manufacturer,
|
||||
@@ -582,7 +571,6 @@ else
|
||||
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; }
|
||||
|
||||
@@ -69,6 +69,28 @@
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnRootsChanged { get; set; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
|
||||
// In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
|
||||
// checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
|
||||
// page's "+ Add reference" modal drives this against a single cluster-scoped root.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
|
||||
/// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
|
||||
[Parameter] public bool PickerMode { get; set; }
|
||||
|
||||
/// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
|
||||
/// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
|
||||
[Parameter] public HashSet<string>? SelectedTagIds { get; set; }
|
||||
|
||||
/// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
|
||||
[Parameter] public EventCallback OnSelectionChanged { get; set; }
|
||||
|
||||
/// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
|
||||
/// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
|
||||
[Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }
|
||||
|
||||
// --- Configure-driver modal (edit) ---
|
||||
private bool _driverCfgVisible;
|
||||
private string? _driverCfgId;
|
||||
@@ -198,6 +220,12 @@
|
||||
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
|
||||
{
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
@if (PickerMode && node.Kind == RawNodeKind.Tag)
|
||||
{
|
||||
<input type="checkbox" class="form-check-input me-1"
|
||||
checked="@IsTagSelected(node)"
|
||||
@onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
|
||||
}
|
||||
<span aria-hidden="true">@KindIcon(node.Kind)</span>
|
||||
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
|
||||
@if (muted)
|
||||
@@ -270,17 +298,67 @@
|
||||
// named handlers below are the seam Wave B/C replaces with the real modals.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
|
||||
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
|
||||
{
|
||||
RawNodeKind.Cluster => ClusterMenu(node),
|
||||
RawNodeKind.Folder => FolderMenu(node),
|
||||
RawNodeKind.Driver => DriverMenu(node),
|
||||
RawNodeKind.Device => DeviceMenu(node),
|
||||
RawNodeKind.TagGroup => TagGroupMenu(node),
|
||||
RawNodeKind.Tag => TagMenu(node),
|
||||
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
|
||||
// Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
|
||||
// affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
|
||||
if (PickerMode)
|
||||
{
|
||||
return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
|
||||
? PickerContainerMenu(node)
|
||||
: Array.Empty<ContextMenuItem>();
|
||||
}
|
||||
|
||||
return node.Kind switch
|
||||
{
|
||||
RawNodeKind.Cluster => ClusterMenu(node),
|
||||
RawNodeKind.Folder => FolderMenu(node),
|
||||
RawNodeKind.Driver => DriverMenu(node),
|
||||
RawNodeKind.Device => DeviceMenu(node),
|
||||
RawNodeKind.TagGroup => TagGroupMenu(node),
|
||||
RawNodeKind.Tag => TagMenu(node),
|
||||
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
|
||||
};
|
||||
}
|
||||
|
||||
// --- Picker-mode menu + selection helpers ---
|
||||
|
||||
private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
|
||||
new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
|
||||
};
|
||||
|
||||
private bool IsTagSelected(RawNode node) =>
|
||||
node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);
|
||||
|
||||
private async Task ToggleTagSelection(RawNode node, bool selected)
|
||||
{
|
||||
if (SelectedTagIds is null || node.EntityId is null) { return; }
|
||||
if (selected) { SelectedTagIds.Add(node.EntityId); }
|
||||
else { SelectedTagIds.Remove(node.EntityId); }
|
||||
await OnSelectionChanged.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SelectAllUnderAsync(RawNode node)
|
||||
{
|
||||
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
|
||||
var ids = await ResolveDescendantTagIds(node);
|
||||
foreach (var id in ids) { SelectedTagIds.Add(id); }
|
||||
await OnSelectionChanged.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ClearAllUnderAsync(RawNode node)
|
||||
{
|
||||
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
|
||||
var ids = await ResolveDescendantTagIds(node);
|
||||
foreach (var id in ids) { SelectedTagIds.Remove(id); }
|
||||
await OnSelectionChanged.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to
|
||||
multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural —
|
||||
IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any
|
||||
other cluster are simply unreachable through the tree, not merely hidden. On commit the selected
|
||||
tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@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">Add tag references</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Pick raw tags to reference under this equipment. The tree is scoped to the
|
||||
equipment's cluster. Tick individual tags, or use a device / tag-group's
|
||||
<em>“Select all tags below”</em> menu to pull many at once.
|
||||
</p>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else if (_roots.Count == 0)
|
||||
{
|
||||
<p class="text-muted">This equipment does not resolve to a cluster, so there are no raw tags to reference.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
|
||||
<RawTree Roots="_roots" PickerMode="true" SelectedTagIds="_selected"
|
||||
OnSelectionChanged="OnSelectionChanged"
|
||||
ResolveDescendantTagIds="ResolveDescendantsAsync" />
|
||||
</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="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@(_busy || _selected.Count == 0)">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Add @_selected.Count reference@(_selected.Count == 1 ? "" : "s")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>The equipment the selected raw tags are referenced under.</summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Raised after references are committed so the host can reload its Tags list and close.</summary>
|
||||
[Parameter] public EventCallback OnCommitted { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
|
||||
private readonly HashSet<string> _selected = new(StringComparer.Ordinal);
|
||||
private bool _busy;
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
private bool _wasVisible;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
|
||||
if (Visible && !_wasVisible)
|
||||
{
|
||||
_selected.Clear();
|
||||
_error = null;
|
||||
_roots = Array.Empty<RawNode>();
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var root = string.IsNullOrEmpty(EquipmentId)
|
||||
? null
|
||||
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
|
||||
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
|
||||
_loading = false;
|
||||
}
|
||||
_wasVisible = Visible;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<string>> ResolveDescendantsAsync(RawNode node) =>
|
||||
Svc.LoadDescendantTagIdsAsync(node);
|
||||
|
||||
// RawTree mutates _selected in place; this just re-renders the footer count.
|
||||
private void OnSelectionChanged() => StateHasChanged();
|
||||
|
||||
private async Task CommitAsync()
|
||||
{
|
||||
if (_selected.Count == 0) { return; }
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList());
|
||||
if (res.Ok) { await OnCommitted.InvokeAsync(); }
|
||||
else { _error = res.Error; }
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task CancelAsync() => OnCancel.InvokeAsync();
|
||||
}
|
||||
+12
-12
@@ -2,9 +2,10 @@
|
||||
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.
|
||||
Required header columns (in order): Name, MachineCode, UnsLineId.
|
||||
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. *@
|
||||
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page.
|
||||
v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
<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>.
|
||||
<span class="mono">Name, MachineCode, UnsLineId</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).
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
<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 mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_parseError))
|
||||
@@ -72,8 +73,8 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
|
||||
// Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
@@ -133,7 +134,7 @@
|
||||
|
||||
/// <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
|
||||
/// three columns are Name, MachineCode, UnsLineId (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>
|
||||
@@ -169,11 +170,10 @@
|
||||
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),
|
||||
ZTag: NullIfEmpty(parts, 3),
|
||||
SAPID: NullIfEmpty(parts, 4),
|
||||
Manufacturer: NullIfEmpty(parts, 5),
|
||||
Model: NullIfEmpty(parts, 6),
|
||||
SerialNumber: null,
|
||||
HardwareRevision: null,
|
||||
SoftwareRevision: null,
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
|
||||
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
|
||||
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
|
||||
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
|
||||
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
|
||||
refresh the equipment's children in place. *@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@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="tagModal">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-id">TagId</label>
|
||||
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="tag-line3-temp-01" />
|
||||
<ValidationMessage For="@(() => _form.TagId)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-name">Name</label>
|
||||
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
|
||||
placeholder="Temperature setpoint" />
|
||||
<ValidationMessage For="@(() => _form.Name)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-driver">Driver instance</label>
|
||||
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
|
||||
<option value="">— pick a driver —</option>
|
||||
@foreach (var d in Drivers)
|
||||
{
|
||||
<option value="@d.Id">@d.Display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-dtype">Data type</label>
|
||||
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-access">Access level</label>
|
||||
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">WriteIdempotent</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
|
||||
<label class="form-check-label">Safe to retry writes (decision #44–45)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
|
||||
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tag config</label>
|
||||
@{
|
||||
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
|
||||
}
|
||||
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="form-text">Pick a driver above to configure this tag.</div>
|
||||
}
|
||||
else if (IsGalaxyDriver)
|
||||
{
|
||||
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
|
||||
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
|
||||
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@onclick="@(() => _showGalaxyPicker = true)">
|
||||
Browse Galaxy
|
||||
</button>
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
|
||||
<div class="form-text">
|
||||
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
|
||||
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
|
||||
</div>
|
||||
|
||||
@if (_showGalaxyPicker)
|
||||
{
|
||||
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
|
||||
Title="Galaxy address"
|
||||
CurrentAddress="@_galaxyAddress"
|
||||
OnPickAddress="@OnGalaxyAddressPicked">
|
||||
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
|
||||
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
|
||||
@bind-SelectedIsAlarm="_galaxyPickedIsAlarm"
|
||||
GetConfigJson="@(() => SelectedDriverConfig)" />
|
||||
</DriverTagPicker>
|
||||
}
|
||||
}
|
||||
else if (editorType is not null)
|
||||
{
|
||||
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "register": 40001, "scale": 0.1 }' />
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||
</div>
|
||||
|
||||
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
|
||||
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
|
||||
`isHistorized` / `historianTagname`, read by AddressSpaceComposer.ExtractTagHistorize),
|
||||
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
|
||||
composes with the typed editor's driver-specific fields (both preserve unknown keys).
|
||||
Shown for EVERY driver once one is picked. *@
|
||||
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">History</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tag-historize"
|
||||
checked="@_historizeState.IsHistorized"
|
||||
@onchange="OnHistorizeChanged" />
|
||||
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When checked, the server serves OPC UA HistoryRead over this tag's value
|
||||
from the configured historian.
|
||||
</div>
|
||||
@if (_historizeState.IsHistorized)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
|
||||
value="@_historizeState.HistorianTagname"
|
||||
@onchange="OnHistorianTagnameChanged" />
|
||||
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
|
||||
keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
|
||||
with the typed editor's driver-specific fields (both preserve unknown keys). When
|
||||
checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
|
||||
driver once one is picked — same place/pattern as the historize control above. *@
|
||||
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Array</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tag-is-array"
|
||||
checked="@_arrayState.IsArray"
|
||||
@onchange="OnIsArrayChanged" />
|
||||
<label class="form-check-label" for="tag-is-array">This tag is an array (1-D)</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When checked, the server materialises this tag as a fixed-length 1-D array node.
|
||||
</div>
|
||||
@if (_arrayState.IsArray)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<label class="form-label" for="tag-array-length">Array length</label>
|
||||
<input type="number" min="1" step="1" class="form-control form-control-sm mono" id="tag-array-length"
|
||||
value="@_arrayState.ArrayLength"
|
||||
@onchange="OnArrayLengthChanged" />
|
||||
<div class="form-text">Number of elements (must be a positive whole number).</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
|
||||
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
|
||||
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
|
||||
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
|
||||
@if (HasNativeAlarm)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Native alarm</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
|
||||
checked="@AlarmHistorizeToAveva"
|
||||
@onchange="OnAlarmHistorizeChanged" />
|
||||
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
|
||||
(the live alerts feed is unaffected). Checked is the default.
|
||||
</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 {
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
|
||||
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
|
||||
|
||||
/// <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 tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
|
||||
[Parameter] public bool IsNew { get; set; }
|
||||
|
||||
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
|
||||
[Parameter] public TagEditDto? Existing { get; set; }
|
||||
|
||||
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
|
||||
/// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
|
||||
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
|
||||
|
||||
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children 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;
|
||||
|
||||
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
|
||||
private bool _showGalaxyPicker;
|
||||
private string _galaxyAddress = "";
|
||||
|
||||
// True when the attribute most-recently selected in the Galaxy picker is itself an alarm
|
||||
// (DriverAttributeInfo.IsAlarm). On commit, this pre-fills a default native-alarm object into the
|
||||
// TagConfig (when none is present) so the operator can author the alarm in one pass.
|
||||
private bool _galaxyPickedIsAlarm;
|
||||
|
||||
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
|
||||
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
|
||||
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
|
||||
private TagHistorizeConfig.HistorizeState _historizeState;
|
||||
|
||||
// Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
|
||||
// Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
|
||||
// merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
|
||||
private TagArrayConfig.ArrayState _arrayState;
|
||||
|
||||
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
|
||||
private string? SelectedDriverType =>
|
||||
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
|
||||
|
||||
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
|
||||
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
|
||||
private string SelectedDriverConfig
|
||||
{
|
||||
get
|
||||
{
|
||||
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
|
||||
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
|
||||
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
|
||||
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
|
||||
|
||||
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
|
||||
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
|
||||
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
|
||||
private void OnDriverChanged()
|
||||
{
|
||||
_form.TagConfig = "{}";
|
||||
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
|
||||
_galaxyAddress = "";
|
||||
_galaxyPickedIsAlarm = false;
|
||||
// The reset TagConfig carries no history intent — reflect that in the historize controls.
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
// Likewise the reset TagConfig carries no array intent.
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
|
||||
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
|
||||
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
|
||||
//
|
||||
// When the picked attribute is itself an alarm (DriverAttributeInfo.IsAlarm), pre-seed a default
|
||||
// native-alarm `alarm` object so the tag materialises as a Part 9 condition and Task 3's
|
||||
// "Historize to AVEVA" toggle auto-appears — sparing the operator hand-written JSON. NativeAlarmModel
|
||||
// .SeedDefaultAlarm only seeds when absent (never overwrites an authored alarm) and preserves FullName.
|
||||
private void OnGalaxyAddressPicked(string address)
|
||||
{
|
||||
_galaxyAddress = address;
|
||||
// Re-picking a Galaxy address owns ONLY the address-derived FullName key — apply it over the EXISTING
|
||||
// TagConfig so every other user-edited field survives verbatim. This preserves a hand-authored `alarm`
|
||||
// object (FB-4: a re-pick must never clobber edited alarm fields) as well as the root history/array
|
||||
// intent and any driver/unknown keys. TagConfigJson.SetFullName uses the same preserve-unknown idiom
|
||||
// as the historize/array merge seams.
|
||||
var config = TagConfigJson.SetFullName(_form.TagConfig, address);
|
||||
_form.TagConfig = _galaxyPickedIsAlarm
|
||||
? NativeAlarmModel.SeedDefaultAlarm(config)
|
||||
: config;
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
["DriverType"] = SelectedDriverType ?? "",
|
||||
["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
|
||||
};
|
||||
|
||||
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
|
||||
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
|
||||
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
|
||||
private string? _nativeAlarmSource;
|
||||
|
||||
private NativeAlarmModel NativeAlarm
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_nativeAlarmSource != _form.TagConfig)
|
||||
{
|
||||
_nativeAlarmSource = _form.TagConfig;
|
||||
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||
}
|
||||
return _nativeAlarm;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
|
||||
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
|
||||
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
|
||||
|
||||
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
|
||||
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
|
||||
// explicit false leaves it unchecked.
|
||||
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
|
||||
|
||||
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
|
||||
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
|
||||
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
|
||||
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var model = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||
if (!model.IsAlarm) { return; }
|
||||
model.HistorizeToAveva = e.Value is true ? null : false;
|
||||
_form.TagConfig = model.ToJson();
|
||||
}
|
||||
|
||||
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
|
||||
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
|
||||
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
|
||||
private void OnHistorizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var isHistorized = e.Value is true;
|
||||
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
|
||||
private void OnHistorianTagnameChanged(ChangeEventArgs e)
|
||||
{
|
||||
var tagname = e.Value?.ToString() ?? string.Empty;
|
||||
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
|
||||
// seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
|
||||
// (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
|
||||
private void OnIsArrayChanged(ChangeEventArgs e)
|
||||
{
|
||||
var isArray = e.Value is true;
|
||||
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
|
||||
// entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
|
||||
// an array with no positive length).
|
||||
private void OnArrayLengthChanged(ChangeEventArgs e)
|
||||
{
|
||||
var length = ParsePositiveLength(e.Value?.ToString());
|
||||
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
|
||||
private static uint? ParsePositiveLength(string? raw)
|
||||
=> uint.TryParse(raw, out var u) && u > 0 ? u : null;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||
if (IsNew)
|
||||
{
|
||||
_form = new FormModel();
|
||||
}
|
||||
else if (Existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
TagId = Existing.TagId,
|
||||
Name = Existing.Name,
|
||||
DriverInstanceId = Existing.DriverInstanceId,
|
||||
DataType = Existing.DataType,
|
||||
AccessLevel = Existing.AccessLevel,
|
||||
WriteIdempotent = Existing.WriteIdempotent,
|
||||
PollGroupId = Existing.PollGroupId,
|
||||
TagConfig = Existing.TagConfig,
|
||||
};
|
||||
}
|
||||
_error = null;
|
||||
_showGalaxyPicker = false;
|
||||
_galaxyPickedIsAlarm = false;
|
||||
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
|
||||
_galaxyAddress = ReadFullName(_form.TagConfig);
|
||||
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
// Seed the array controls from any existing root isArray/arrayLength keys.
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
|
||||
private static string ReadFullName(string? configJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson)) return "";
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fn)
|
||||
&& fn.ValueKind == JsonValueKind.String
|
||||
? fn.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
// Client-side per-driver config validation (the typed editor's Validate()), so a blank
|
||||
// required field is caught here rather than silently saving and failing at deploy/connect.
|
||||
var configError = TagConfigValidator.Validate(SelectedDriverType, _form.TagConfig);
|
||||
if (configError is not null)
|
||||
{
|
||||
_error = configError;
|
||||
return;
|
||||
}
|
||||
|
||||
// Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
|
||||
// per-driver config validation above so a missing length is caught here rather than at deploy.
|
||||
var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
|
||||
if (arrayError is not null)
|
||||
{
|
||||
_error = arrayError;
|
||||
return;
|
||||
}
|
||||
|
||||
var input = new TagInput(
|
||||
_form.TagId,
|
||||
_form.Name,
|
||||
_form.DriverInstanceId,
|
||||
_form.DataType,
|
||||
_form.AccessLevel,
|
||||
_form.WriteIdempotent,
|
||||
_form.PollGroupId,
|
||||
_form.TagConfig);
|
||||
|
||||
var result = IsNew
|
||||
? await Svc.CreateTagAsync(EquipmentId!, input)
|
||||
: await Svc.UpdateTagAsync(Existing!.TagId, 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-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string DriverInstanceId { get; set; } = "";
|
||||
public string DataType { get; set; } = "Float";
|
||||
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
|
||||
public bool WriteIdempotent { get; set; }
|
||||
public string? PollGroupId { get; set; }
|
||||
[Required] public string TagConfig { get; set; } = "{}";
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,28 @@ public sealed record EquipmentTagRow(
|
||||
|
||||
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
|
||||
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each
|
||||
/// row projects a <see cref="Configuration.Entities.UnsTagReference"/> together with the backing raw
|
||||
/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the
|
||||
/// <c>DisplayNameOverride</c> when set, else the backing raw tag's <c>Name</c>. <c>RowVersion</c> is the
|
||||
/// reference row's concurrency token, echoed on the override-set / remove mutations.
|
||||
/// </summary>
|
||||
/// <param name="UnsTagReferenceId">The reference's stable logical id (the mutation key).</param>
|
||||
/// <param name="EffectiveName">Override else the raw tag's Name — the UNS leaf name.</param>
|
||||
/// <param name="RawPath">The backing raw tag's computed RawPath (folder/driver/device/group/tag).</param>
|
||||
/// <param name="DataType">Inherited OPC UA built-in type name from the raw tag (read-only).</param>
|
||||
/// <param name="AccessLevel">Inherited access level from the raw tag (read-only).</param>
|
||||
/// <param name="DisplayNameOverride">The optional display-name override; <c>null</c> = use the raw name.</param>
|
||||
/// <param name="SortOrder">Sibling display ordering within the equipment's Tags list.</param>
|
||||
/// <param name="RowVersion">The reference row's optimistic-concurrency token.</param>
|
||||
public sealed record EquipmentReferenceRow(
|
||||
string UnsTagReferenceId,
|
||||
string EffectiveName,
|
||||
string RawPath,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
string? DisplayNameOverride,
|
||||
int SortOrder,
|
||||
byte[] RowVersion);
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
|
||||
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
|
||||
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
|
||||
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means 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>
|
||||
@@ -28,7 +27,6 @@ public sealed record EquipmentInput(
|
||||
string Name,
|
||||
string MachineCode,
|
||||
string UnsLineId,
|
||||
string? DriverInstanceId,
|
||||
string? ZTag,
|
||||
string? SAPID,
|
||||
string? Manufacturer,
|
||||
|
||||
@@ -139,6 +139,82 @@ public interface IUnsTreeService
|
||||
/// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns>
|
||||
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="Configuration.Entities.UnsTagReference"/> rows for a single equipment as flat
|
||||
/// projections for the equipment page's Tags tab, ordered by <c>SortOrder</c> then effective name.
|
||||
/// Each row carries the effective name (override else the backing raw tag's <c>Name</c>), the backing
|
||||
/// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name
|
||||
/// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the
|
||||
/// equipment has no references.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment whose references to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The equipment's reference rows; empty if it has none.</returns>
|
||||
Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds one <see cref="Configuration.Entities.UnsTagReference"/> per supplied raw <c>TagId</c> to an
|
||||
/// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster
|
||||
/// references are rejected). Each new reference's effective name (the raw tag's <c>Name</c> — no
|
||||
/// override on add) must be unique within the equipment across references, VirtualTags, and
|
||||
/// ScriptedAlarms (enforced via <see cref="IEffectiveNameGuard"/>), and a tag already referenced by
|
||||
/// the equipment is rejected. The batch is all-or-nothing.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The referencing equipment.</param>
|
||||
/// <param name="tagIds">The raw tag ids to reference.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or the first guard/cluster/duplicate failure (nothing is added).</returns>
|
||||
Task<UnsMutationResult> AddReferencesAsync(string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.UnsTagReference.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="unsTagReferenceId">The reference to remove.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets (or clears) a reference's <c>DisplayNameOverride</c>. A whitespace-only override collapses to
|
||||
/// <c>null</c> (use the raw name). The resulting effective name (override else the backing raw tag's
|
||||
/// <c>Name</c>) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
|
||||
/// (enforced via <see cref="IEffectiveNameGuard"/>, excluding this reference). Uses last-write-wins
|
||||
/// optimistic concurrency.
|
||||
/// </summary>
|
||||
/// <param name="unsTagReferenceId">The reference to update.</param>
|
||||
/// <param name="displayNameOverride">The new override, or <c>null</c>/blank to clear it.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a name-collision failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the single cluster-rooted <see cref="RawNode"/> that seeds the "+ Add reference" raw-tag
|
||||
/// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other
|
||||
/// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden).
|
||||
/// The picker's <c>RawTree</c> lazily expands it via <see cref="IRawTreeService.LoadChildrenAsync"/>.
|
||||
/// Returns <c>null</c> when the equipment cannot be resolved to a cluster.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment whose cluster scopes the picker.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The cluster root node, or <c>null</c> when the equipment/cluster can't be resolved.</returns>
|
||||
Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every raw <c>TagId</c> beneath a Device or TagGroup picker node (the whole subtree), for
|
||||
/// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds.
|
||||
/// </summary>
|
||||
/// <param name="node">The picker node (Device or TagGroup) to enumerate tags under.</param>
|
||||
/// <param name="ct">A token to cancel the query.</param>
|
||||
/// <returns>The tag ids in the node's subtree; empty for unsupported kinds.</returns>
|
||||
Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single UNS area 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.
|
||||
@@ -284,24 +360,22 @@ public interface IUnsTreeService
|
||||
/// <summary>
|
||||
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
|
||||
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
|
||||
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
|
||||
/// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
|
||||
/// collapse to <c>null</c>.
|
||||
/// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
|
||||
/// ZTag/SAPID collapse to <c>null</c>. (v3: equipment no longer binds a driver — the reference-only
|
||||
/// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
|
||||
/// </summary>
|
||||
/// <param name="input">The operator-editable equipment fields.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.</returns>
|
||||
/// <returns>Success, a missing-line failure, or a duplicate-MachineCode failure.</returns>
|
||||
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 cluster
|
||||
/// 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
|
||||
/// exist is an error; 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.
|
||||
/// the end. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.)
|
||||
/// </summary>
|
||||
/// <param name="rows">The parsed equipment rows to import.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
@@ -309,16 +383,16 @@ public interface IUnsTreeService
|
||||
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
|
||||
/// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks
|
||||
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
|
||||
/// Updates an equipment's mutable fields (line, name, MachineCode, external ids, and the OPC 40010
|
||||
/// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. (v3: equipment
|
||||
/// no longer binds a driver, so there is no driver-cluster guard.)
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment to update.</param>
|
||||
/// <param name="input">The new operator-editable equipment fields.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns>
|
||||
/// <returns>Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -18,8 +18,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// every AdminUI page uses — so the service is safe to register as Scoped and used per
|
||||
/// Blazor circuit.
|
||||
/// </remarks>
|
||||
public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IUnsTreeService
|
||||
public sealed class UnsTreeService(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
IEffectiveNameGuard? guard = null) : IUnsTreeService
|
||||
{
|
||||
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
|
||||
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
|
||||
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
|
||||
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
|
||||
// db factory. Tests that DO exercise the guard pass a fake.
|
||||
private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
|
||||
{
|
||||
@@ -97,6 +106,369 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(
|
||||
string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var refs = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.UnsTagReferenceId,
|
||||
r.TagId,
|
||||
r.DisplayNameOverride,
|
||||
r.SortOrder,
|
||||
r.RowVersion,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (refs.Count == 0) return Array.Empty<EquipmentReferenceRow>();
|
||||
|
||||
// Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
|
||||
// equipment cluster's raw topology through the shared RawPathResolver (identity authority).
|
||||
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
|
||||
var tags = (await db.Tags.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(t => t.TagId, StringComparer.Ordinal);
|
||||
|
||||
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);
|
||||
|
||||
var rows = new List<EquipmentReferenceRow>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (!tags.TryGetValue(r.TagId, out var tag))
|
||||
{
|
||||
// Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
|
||||
// clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
|
||||
rows.Add(new EquipmentReferenceRow(
|
||||
r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
|
||||
"", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
|
||||
continue;
|
||||
}
|
||||
|
||||
var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
|
||||
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
|
||||
rows.Add(new EquipmentReferenceRow(
|
||||
r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
|
||||
r.DisplayNameOverride, r.SortOrder, r.RowVersion));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> AddReferencesAsync(
|
||||
string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagIds);
|
||||
var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
|
||||
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (equipmentCluster is null)
|
||||
return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");
|
||||
|
||||
// Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
|
||||
var tags = await db.Tags.AsNoTracking()
|
||||
.Where(t => distinctTagIds.Contains(t.TagId))
|
||||
.Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
|
||||
.Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
|
||||
(x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);
|
||||
|
||||
// Existing references on the equipment (to reject re-referencing the same tag).
|
||||
var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => r.TagId)
|
||||
.ToListAsync(ct))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var maxSort = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => (int?)r.SortOrder)
|
||||
.MaxAsync(ct) ?? -1;
|
||||
|
||||
// Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
|
||||
// guard sees only persisted rows; a same-batch collision would otherwise slip through).
|
||||
var batchNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var pending = new List<UnsTagReference>();
|
||||
|
||||
foreach (var tagId in distinctTagIds)
|
||||
{
|
||||
if (!tagById.TryGetValue(tagId, out var tag))
|
||||
return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");
|
||||
|
||||
if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
|
||||
return new UnsMutationResult(false,
|
||||
$"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");
|
||||
|
||||
if (alreadyReferenced.Contains(tagId))
|
||||
return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");
|
||||
|
||||
// Effective name at add = the raw tag's Name (no override yet).
|
||||
if (!batchNames.Add(tag.Name))
|
||||
return new UnsMutationResult(false,
|
||||
$"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");
|
||||
|
||||
var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
|
||||
if (collision is not null) return new UnsMutationResult(false, collision);
|
||||
|
||||
pending.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
|
||||
EquipmentId = equipmentId,
|
||||
TagId = tagId,
|
||||
DisplayNameOverride = null,
|
||||
SortOrder = ++maxSort,
|
||||
});
|
||||
}
|
||||
|
||||
db.UnsTagReferences.AddRange(pending);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent add slipped a same-(equipment,tag) or same-name row past the pre-check.
|
||||
return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> RemoveReferenceAsync(
|
||||
string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(true, null);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.UnsTagReferences.Remove(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> SetReferenceOverrideAsync(
|
||||
string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
// Effective name = the override when set, else the backing raw tag's Name.
|
||||
var rawName = await db.Tags.AsNoTracking()
|
||||
.Where(t => t.TagId == entity.TagId)
|
||||
.Select(t => t.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var effectiveName = normalized ?? rawName ?? string.Empty;
|
||||
|
||||
var collision = await _guard.CheckAsync(
|
||||
entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
|
||||
if (collision is not null) return new UnsMutationResult(false, collision);
|
||||
|
||||
entity.DisplayNameOverride = normalized;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (clusterId is null) return null;
|
||||
|
||||
var cluster = await db.ServerClusters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||
if (cluster is null) return null;
|
||||
|
||||
// Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
|
||||
// via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
|
||||
var rootFolders = await db.RawFolders.AsNoTracking()
|
||||
.CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
|
||||
var rootDrivers = await db.DriverInstances.AsNoTracking()
|
||||
.CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);
|
||||
|
||||
return new RawNode
|
||||
{
|
||||
Kind = RawNodeKind.Cluster,
|
||||
Key = $"clu:{clusterId}",
|
||||
DisplayName = cluster.Name,
|
||||
ClusterId = clusterId,
|
||||
EntityId = clusterId,
|
||||
HasLazyChildren = true,
|
||||
ChildCount = rootFolders + rootDrivers,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
if (node.EntityId is null) return Array.Empty<string>();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
switch (node.Kind)
|
||||
{
|
||||
case RawNodeKind.Device:
|
||||
// Every tag under the device (across all its tag groups).
|
||||
return await db.Tags.AsNoTracking()
|
||||
.Where(t => t.DeviceId == node.EntityId)
|
||||
.Select(t => t.TagId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
case RawNodeKind.TagGroup:
|
||||
{
|
||||
// The group + all descendant groups, then every tag in that group set.
|
||||
var group = await db.TagGroups.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
|
||||
if (group is null) return Array.Empty<string>();
|
||||
|
||||
var deviceGroups = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == group.DeviceId)
|
||||
.Select(g => new { g.TagGroupId, g.ParentTagGroupId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var childrenByParent = deviceGroups
|
||||
.Where(g => g.ParentTagGroupId is not null)
|
||||
.GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);
|
||||
|
||||
var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
|
||||
return await db.Tags.AsNoTracking()
|
||||
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
|
||||
.Select(t => t.TagId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
default:
|
||||
// The picker only offers "select all" on Device / TagGroup containers.
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="rootId"/> plus all transitive descendant group ids (iterative walk).</summary>
|
||||
private static HashSet<string> DescendantGroupsInclusive(
|
||||
string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(rootId);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var id = stack.Pop();
|
||||
if (!result.Add(id)) continue;
|
||||
if (childrenByParent.TryGetValue(id, out var kids))
|
||||
{
|
||||
foreach (var kid in kids) stack.Push(kid);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="RawPathResolver"/> over a cluster's raw topology (folders / drivers / devices /
|
||||
/// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
|
||||
/// Returns <see langword="null"/> when the cluster is unknown/null (callers then fall back to bare name).
|
||||
/// </summary>
|
||||
private static async Task<RawPathResolver?> BuildClusterRawPathResolverAsync(
|
||||
OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clusterId)) return null;
|
||||
|
||||
var folders = (await db.RawFolders.AsNoTracking()
|
||||
.Where(f => f.ClusterId == clusterId)
|
||||
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
|
||||
|
||||
var drivers = (await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == clusterId)
|
||||
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
|
||||
|
||||
var driverIds = drivers.Keys.ToList();
|
||||
|
||||
var devices = (await db.Devices.AsNoTracking()
|
||||
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
|
||||
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
|
||||
|
||||
var deviceIds = devices.Keys.ToList();
|
||||
|
||||
var groups = (await db.TagGroups.AsNoTracking()
|
||||
.Where(g => deviceIds.Contains(g.DeviceId))
|
||||
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
|
||||
|
||||
return new RawPathResolver(folders, drivers, devices, groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
|
||||
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
|
||||
/// backstop. Production always injects the real WP2 guard.
|
||||
/// </summary>
|
||||
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
|
||||
{
|
||||
public static readonly NoOpEffectiveNameGuard Instance = new();
|
||||
|
||||
public Task<string?> CheckAsync(
|
||||
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -446,11 +818,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
// 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.
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
@@ -514,14 +883,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse the driver/line cluster 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;
|
||||
}
|
||||
// v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
@@ -578,11 +940,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
// v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.
|
||||
|
||||
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
|
||||
entity.UnsLineId = input.UnsLineId;
|
||||
@@ -920,6 +1278,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// v3 Batch 3: the effective name must also be unique against this equipment's references + scripted
|
||||
// alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate.
|
||||
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
@@ -969,6 +1332,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT).
|
||||
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
@@ -1193,58 +1560,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Policy:
|
||||
/// <list type="bullet">
|
||||
/// <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
|
||||
/// <item>Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
|
||||
/// line cannot guarantee cluster alignment, so the bind is unsafe).</item>
|
||||
/// <item>Driver bound, line resolves, clusters differ → rejected.</item>
|
||||
/// <item>Driver bound, line resolves, clusters match → allowed (returns <c>null</c>).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
EquipmentInput input,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
|
||||
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
|
||||
var lineCluster = area?.ClusterId;
|
||||
|
||||
if (lineCluster is null)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122).");
|
||||
}
|
||||
|
||||
var driverCluster = await db.DriverInstances
|
||||
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
|
||||
.Select(d => d.ClusterId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (driverCluster is null)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found.");
|
||||
}
|
||||
|
||||
if (driverCluster != lineCluster)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -1277,6 +1592,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct))
|
||||
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
|
||||
|
||||
// v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment.
|
||||
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
db.ScriptedAlarms.Add(new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = input.ScriptedAlarmId,
|
||||
@@ -1307,6 +1626,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct))
|
||||
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
|
||||
|
||||
// v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm).
|
||||
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
entity.Name = input.Name;
|
||||
entity.AlarmType = input.AlarmType;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,10 +6,10 @@ 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.
|
||||
/// 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, and rows
|
||||
/// referencing an unknown UNS line are reported as errors. (v3: equipment no longer binds a driver, so
|
||||
/// the old DriverInstanceId column and its decision-#122 cluster guard — and their tests — are gone.)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceImportTests
|
||||
@@ -20,32 +20,17 @@ public sealed class UnsTreeServiceImportTests
|
||||
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);
|
||||
@@ -55,12 +40,12 @@ public sealed class UnsTreeServiceImportTests
|
||||
public async Task Import_inserts_valid_rows()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
SeedLine(dbName, "MAIN");
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-1", null),
|
||||
Input("machine-2", "mc_2", "LINE-1", null),
|
||||
Input("machine-1", "mc_1", "LINE-1"),
|
||||
Input("machine-2", "mc_2", "LINE-1"),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(2);
|
||||
@@ -85,15 +70,15 @@ public sealed class UnsTreeServiceImportTests
|
||||
public async Task Import_skips_duplicate_machinecode()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
SeedLine(dbName, "MAIN");
|
||||
// Pre-existing equipment with MachineCode "mc_existing".
|
||||
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null));
|
||||
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1"));
|
||||
|
||||
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
|
||||
Input("machine-1", "mc_existing", "LINE-1"), // dup of DB row → skip
|
||||
Input("machine-2", "mc_new", "LINE-1"), // inserts
|
||||
Input("machine-3", "mc_new", "LINE-1"), // dup of in-batch row → skip
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(1);
|
||||
@@ -110,12 +95,12 @@ public sealed class UnsTreeServiceImportTests
|
||||
public async Task Import_reports_unknown_line()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
SeedLine(dbName, "MAIN");
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-BOGUS", null),
|
||||
Input("machine-2", "mc_2", "LINE-1", null),
|
||||
Input("machine-1", "mc_1", "LINE-BOGUS"),
|
||||
Input("machine-2", "mc_2", "LINE-1"),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(1);
|
||||
@@ -128,73 +113,4 @@ public sealed class UnsTreeServiceImportTests
|
||||
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();
|
||||
|
||||
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "mc_1").UnsLineId.ShouldBe("LINE-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the v3 Batch-3 UNS reference-only equipment mutations on <see cref="UnsTreeService"/>:
|
||||
/// adding references (with computed RawPath + inherited datatype/access), removing them, setting the
|
||||
/// display-name override, the cross-cluster rejection, the same-tag / same-name duplicate rejections,
|
||||
/// and the <see cref="IEffectiveNameGuard"/>-consumed collision rejection (via a configurable fake).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The EF InMemory provider enforces neither <c>RowVersion</c> concurrency nor the filtered-unique
|
||||
/// indexes, so the service's own pre-checks are what protect the data in these tests.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceReferenceTests
|
||||
{
|
||||
private const string EquipmentId = "EQ-1";
|
||||
|
||||
/// <summary>A configurable <see cref="IEffectiveNameGuard"/>: returns <see cref="Result"/> from every check.</summary>
|
||||
private sealed class FakeGuard : IEffectiveNameGuard
|
||||
{
|
||||
public string? Result { get; set; }
|
||||
|
||||
public string? LastProposedName { get; private set; }
|
||||
|
||||
public Task<string?> CheckAsync(
|
||||
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId, CancellationToken ct = default)
|
||||
{
|
||||
LastProposedName = proposedEffectiveName;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext Db(string name) =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
||||
|
||||
private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => UnsTreeTestDb.Factory(name);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds two clusters (MAIN, SITE-A), each with driver→device→tags, plus an equipment (<c>EQ-1</c>)
|
||||
/// under MAIN. MAIN tags: <c>MAIN/dev1/speed</c> (Float, Read) and <c>MAIN/dev1/running</c>
|
||||
/// (Boolean, ReadWrite), with <c>speed</c> also nested under a group <c>grp</c> variant. SITE-A tag:
|
||||
/// <c>OTHER/dev2/temp</c> (for the cross-cluster rejection).
|
||||
/// </summary>
|
||||
private static void Seed(string name)
|
||||
{
|
||||
using var db = Db(name);
|
||||
|
||||
db.ServerClusters.Add(new ServerCluster { ClusterId = "MAIN", Name = "Main", Enterprise = "zb", Site = "s1", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
|
||||
db.ServerClusters.Add(new ServerCluster { ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "s2", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
|
||||
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "assembly" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "line-a" });
|
||||
db.Equipment.Add(new Equipment { EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-1", Name = "machine-1", MachineCode = "mc_1" });
|
||||
|
||||
// MAIN raw topology.
|
||||
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-1", ClusterId = "MAIN", Name = "MAIN", DriverType = "Modbus", DriverConfig = "{}" });
|
||||
db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
|
||||
db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", Name = "grp" });
|
||||
db.Tags.Add(new Tag { TagId = "TAG-speed", DeviceId = "DEV-1", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
|
||||
db.Tags.Add(new Tag { TagId = "TAG-running", DeviceId = "DEV-1", Name = "running", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" });
|
||||
db.Tags.Add(new Tag { TagId = "TAG-grouped", DeviceId = "DEV-1", TagGroupId = "TG-1", Name = "nested", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
|
||||
|
||||
// SITE-A raw topology (for the cross-cluster rejection).
|
||||
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-2", ClusterId = "SITE-A", Name = "OTHER", DriverType = "Modbus", DriverConfig = "{}" });
|
||||
db.Devices.Add(new Device { DeviceId = "DEV-2", DriverInstanceId = "DRV-2", Name = "dev2", DeviceConfig = "{}" });
|
||||
db.Tags.Add(new Tag { TagId = "TAG-other", DeviceId = "DEV-2", Name = "temp", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
// ----- AddReferences -----
|
||||
|
||||
/// <summary>Adding two raw tags persists two UnsTagReference rows for the equipment.</summary>
|
||||
[Fact]
|
||||
public async Task AddReferences_persists_rows()
|
||||
{
|
||||
var name = $"ref-add-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
|
||||
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-running"]);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = Db(name);
|
||||
var refs = db.UnsTagReferences.Where(r => r.EquipmentId == EquipmentId).ToList();
|
||||
refs.Count.ShouldBe(2);
|
||||
refs.Select(r => r.TagId).ShouldBe(new[] { "TAG-speed", "TAG-running" }, ignoreOrder: true);
|
||||
refs.All(r => r.DisplayNameOverride == null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Loaded reference rows carry the computed RawPath and inherited (raw) datatype/access.</summary>
|
||||
[Fact]
|
||||
public async Task LoadReferences_projects_rawpath_and_inherited_type()
|
||||
{
|
||||
var name = $"ref-load-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-grouped"]);
|
||||
|
||||
var rows = await service.LoadReferencesForEquipmentAsync(EquipmentId);
|
||||
|
||||
rows.Count.ShouldBe(2);
|
||||
|
||||
var speed = rows.Single(r => r.RawPath == "MAIN/dev1/speed");
|
||||
speed.EffectiveName.ShouldBe("speed");
|
||||
speed.DataType.ShouldBe("Float");
|
||||
speed.AccessLevel.ShouldBe(TagAccessLevel.Read);
|
||||
|
||||
var nested = rows.Single(r => r.EffectiveName == "nested");
|
||||
nested.RawPath.ShouldBe("MAIN/dev1/grp/nested");
|
||||
nested.DataType.ShouldBe("Int32");
|
||||
}
|
||||
|
||||
/// <summary>A raw tag whose cluster differs from the equipment's is rejected (cross-cluster).</summary>
|
||||
[Fact]
|
||||
public async Task AddReferences_cross_cluster_rejected()
|
||||
{
|
||||
var name = $"ref-xcluster-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
|
||||
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-other"]);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("cross-cluster");
|
||||
|
||||
using var db = Db(name);
|
||||
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Re-referencing an already-referenced raw tag is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task AddReferences_duplicate_tag_rejected()
|
||||
{
|
||||
var name = $"ref-dup-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
||||
|
||||
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("already referenced");
|
||||
|
||||
using var db = Db(name);
|
||||
db.UnsTagReferences.Count(r => r.EquipmentId == EquipmentId).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A guard-reported collision (fake) becomes the mutation's readable failure; nothing is added.</summary>
|
||||
[Fact]
|
||||
public async Task AddReferences_guard_collision_rejected()
|
||||
{
|
||||
var name = $"ref-guard-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var guard = new FakeGuard { Result = "'speed' already exists on equipment 'machine-1'." };
|
||||
var service = new UnsTreeService(Factory(name), guard);
|
||||
|
||||
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("'speed' already exists on equipment 'machine-1'.");
|
||||
guard.LastProposedName.ShouldBe("speed");
|
||||
|
||||
using var db = Db(name);
|
||||
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- SetReferenceOverride -----
|
||||
|
||||
/// <summary>Setting an override changes the effective name; clearing it falls back to the raw name.</summary>
|
||||
[Fact]
|
||||
public async Task SetReferenceOverride_sets_and_clears()
|
||||
{
|
||||
var name = $"ref-override-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
||||
|
||||
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
||||
|
||||
var set = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "line-speed", row.RowVersion);
|
||||
set.Ok.ShouldBeTrue();
|
||||
|
||||
var afterSet = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
||||
afterSet.EffectiveName.ShouldBe("line-speed");
|
||||
afterSet.DisplayNameOverride.ShouldBe("line-speed");
|
||||
afterSet.RawPath.ShouldBe("MAIN/dev1/speed"); // raw path is unchanged by an override
|
||||
|
||||
var clear = await service.SetReferenceOverrideAsync(afterSet.UnsTagReferenceId, " ", afterSet.RowVersion);
|
||||
clear.Ok.ShouldBeTrue();
|
||||
|
||||
var afterClear = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
||||
afterClear.EffectiveName.ShouldBe("speed");
|
||||
afterClear.DisplayNameOverride.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Setting an override that the guard reports as colliding is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task SetReferenceOverride_guard_collision_rejected()
|
||||
{
|
||||
var name = $"ref-override-guard-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var guard = new FakeGuard();
|
||||
var service = new UnsTreeService(Factory(name), guard);
|
||||
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
||||
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
||||
|
||||
guard.Result = "collides with a virtual tag.";
|
||||
var result = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "running", row.RowVersion);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("collides with a virtual tag.");
|
||||
guard.LastProposedName.ShouldBe("running");
|
||||
}
|
||||
|
||||
// ----- RemoveReference -----
|
||||
|
||||
/// <summary>Removing a reference deletes the row; a missing row is a no-op success.</summary>
|
||||
[Fact]
|
||||
public async Task RemoveReference_removes_row()
|
||||
{
|
||||
var name = $"ref-remove-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
||||
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
||||
|
||||
var result = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
|
||||
result.Ok.ShouldBeTrue();
|
||||
|
||||
using var db = Db(name);
|
||||
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
||||
|
||||
// Already gone → idempotent success.
|
||||
var again = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
|
||||
again.Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ----- Picker support -----
|
||||
|
||||
/// <summary>The picker root is the equipment's own cluster node (structurally cluster-scoped).</summary>
|
||||
[Fact]
|
||||
public async Task LoadReferencePickerRoot_returns_cluster_node()
|
||||
{
|
||||
var name = $"ref-picker-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
|
||||
var root = await service.LoadReferencePickerRootAsync(EquipmentId);
|
||||
|
||||
root.ShouldNotBeNull();
|
||||
root!.Kind.ShouldBe(RawNodeKind.Cluster);
|
||||
root.EntityId.ShouldBe("MAIN");
|
||||
root.ClusterId.ShouldBe("MAIN");
|
||||
root.HasLazyChildren.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Select-all under a device returns every tag id in the device subtree.</summary>
|
||||
[Fact]
|
||||
public async Task LoadDescendantTagIds_device_returns_subtree_tags()
|
||||
{
|
||||
var name = $"ref-descend-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
|
||||
var deviceNode = new RawNode
|
||||
{
|
||||
Kind = RawNodeKind.Device,
|
||||
Key = "dev:DEV-1",
|
||||
DisplayName = "dev1",
|
||||
ClusterId = "MAIN",
|
||||
EntityId = "DEV-1",
|
||||
};
|
||||
|
||||
var ids = await service.LoadDescendantTagIdsAsync(deviceNode);
|
||||
|
||||
ids.ShouldBe(new[] { "TAG-speed", "TAG-running", "TAG-grouped" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>Select-all under a tag-group returns only the tags in that group's subtree.</summary>
|
||||
[Fact]
|
||||
public async Task LoadDescendantTagIds_group_returns_group_tags()
|
||||
{
|
||||
var name = $"ref-descend-grp-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var service = new UnsTreeService(Factory(name));
|
||||
|
||||
var groupNode = new RawNode
|
||||
{
|
||||
Kind = RawNodeKind.TagGroup,
|
||||
Key = "grp:TG-1",
|
||||
DisplayName = "grp",
|
||||
ClusterId = "MAIN",
|
||||
EntityId = "TG-1",
|
||||
};
|
||||
|
||||
var ids = await service.LoadDescendantTagIdsAsync(groupNode);
|
||||
|
||||
ids.ShouldBe(new[] { "TAG-grouped" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user