+ @* 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. *@
@@ -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. *@
}
- else if (_tags.Count == 0)
+ else if (_refs.Count == 0)
{
-
No tags yet.
+
No tag references yet.
}
else
{
-
+
-
Name
Driver
Data type
Access
Actions
+
+
Effective name
Raw path
Data type
Access
+
Display-name override
Actions
+
- @foreach (var t in _tags)
+ @foreach (var r in _refs)
{
-
-
@t.Name
-
@t.DriverInstanceId
-
@t.DataType
-
@t.AccessLevel
-
-
-
+
+
@r.EffectiveName
+
@r.RawPath
+
@r.DataType
+
@r.AccessLevel
+
+
+
+
+
+
}
@@ -177,9 +179,8 @@ else
}
-
+
}
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? _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? _refs;
+ private readonly Dictionary _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? _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 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; }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor
index 6aa815f3..fef10dcd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor
@@ -69,6 +69,28 @@
///
[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.
+ // ---------------------------------------------------------------------------
+
+ /// When true, render the tree as a raw-tag picker (checkboxes + select-all) instead of
+ /// the editing tree. Default false — the /raw editing usage is unaffected.
+ [Parameter] public bool PickerMode { get; set; }
+
+ /// The shared, caller-owned set of selected raw TagIds (picker mode only). The component
+ /// mutates it in place and raises after every change.
+ [Parameter] public HashSet? SelectedTagIds { get; set; }
+
+ /// Raised after the selection set changes (picker mode) so the host can update its count / footer.
+ [Parameter] public EventCallback OnSelectionChanged { get; set; }
+
+ /// Supplies the raw TagIds beneath a Device/TagGroup node for the "select all tags below"
+ /// affordance (picker mode). The host wires this to IUnsTreeService.LoadDescendantTagIdsAsync.
+ [Parameter] public Func>>? 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 =>
{
+ @if (PickerMode && node.Kind == RawNodeKind.Tag)
+ {
+ ToggleTagSelection(node, e.Value is true))" />
+ }
@KindIcon(node.Kind)@node.DisplayName
@if (muted)
@@ -270,17 +298,67 @@
// named handlers below are the seam Wave B/C replaces with the real modals.
// ---------------------------------------------------------------------------
- private IReadOnlyList MenuFor(RawNode node) => node.Kind switch
+ private IReadOnlyList 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(), // 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();
+ }
+
+ 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(), // Enterprise: label only, no menu.
+ };
+ }
+
+ // --- Picker-mode menu + selection helpers ---
+
+ private List 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 ClusterMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor
new file mode 100644
index 00000000..3d05abd8
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor
@@ -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)
+{
+
+
+
+
+
+
Add tag references
+
+
+
+
+ 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
+ “Select all tags below” menu to pull many at once.
+
+
+ @if (_loading)
+ {
+
Loading…
+ }
+ else if (_roots.Count == 0)
+ {
+
This equipment does not resolve to a cluster, so there are no raw tags to reference.
- }
- 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. *@
-
-
-
- The Galaxy reference, stored as {"FullName":"tag_name.AttributeName"}.
- Pick one via Browse Galaxy or edit the JSON directly.
-
Schemaless per driver type. Validated server-side at deploy.
- }
-
-
-
- @* 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))
- {
-
-
-
-
-
-
-
- When checked, the server serves OPC UA HistoryRead over this tag's value
- from the configured historian.
-
- @if (_historizeState.IsHistorized)
- {
-
-
-
-
Blank defaults the historian tagname to this tag's driver FullName.
-
- }
-
- }
-
- @* 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))
- {
-
-
-
-
-
-
-
- When checked, the server materialises this tag as a fixed-length 1-D array node.
-
- @if (_arrayState.IsArray)
- {
-
-
-
-
Number of elements (must be a positive whole number).
-
- }
-
- }
-
- @* 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)
- {
-
-
-
-
-
-
-
- When unchecked, this alarm's transitions are NOT written to the AVEVA historian
- (the live alerts feed is unaffected). Checked is the default.
-
-}
-
-@code {
- private static readonly string[] DataTypes =
- ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
- "Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
-
- /// Whether the modal is shown. The host owns this flag.
- [Parameter] public bool Visible { get; set; }
-
- /// true to create a new tag; false to edit .
- [Parameter] public bool IsNew { get; set; }
-
- /// The owning equipment id the created tag binds to (used only on create).
- [Parameter] public string? EquipmentId { get; set; }
-
- /// The tag being edited, when is false.
- [Parameter] public TagEditDto? Existing { get; set; }
-
- /// The candidate drivers — scoped to the equipment's cluster by the host — as
- /// (Id, Display, DriverType, DriverConfig) tuples. DriverType drives typed-editor dispatch;
- /// DriverConfig feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.
- [Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
-
- /// Raised after a successful create/save so the host can refresh the equipment's children and close.
- [Parameter] public EventCallback OnSaved { get; set; }
-
- /// Raised when the user cancels so the host can close.
- [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 BuildEditorParameters() => new Dictionary
- {
- ["ConfigJson"] = _form.TagConfig,
- ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v),
- ["DriverType"] = SelectedDriverType ?? "",
- ["GetDriverConfigJson"] = (Func)(() => 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; } = "{}";
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs
index b1cee5e6..ec313790 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs
@@ -9,3 +9,28 @@ public sealed record EquipmentTagRow(
/// A virtual-tag row for the equipment page's Virtual Tags tab table.
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
+
+///
+/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each
+/// row projects a together with the backing raw
+/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the
+/// DisplayNameOverride when set, else the backing raw tag's Name. RowVersion is the
+/// reference row's concurrency token, echoed on the override-set / remove mutations.
+///
+/// The reference's stable logical id (the mutation key).
+/// Override else the raw tag's Name — the UNS leaf name.
+/// The backing raw tag's computed RawPath (folder/driver/device/group/tag).
+/// Inherited OPC UA built-in type name from the raw tag (read-only).
+/// Inherited access level from the raw tag (read-only).
+/// The optional display-name override; null = use the raw name.
+/// Sibling display ordering within the equipment's Tags list.
+/// The reference row's optimistic-concurrency token.
+public sealed record EquipmentReferenceRow(
+ string UnsTagReferenceId,
+ string EffectiveName,
+ string RawPath,
+ string DataType,
+ TagAccessLevel AccessLevel,
+ string? DisplayNameOverride,
+ int SortOrder,
+ byte[] RowVersion);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs
index 4a39f767..dba8d117 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// UNS level-5 segment; matches ^[a-z0-9-]{1,32}$.
/// Operator colloquial id; unique fleet-wide.
/// Logical FK to the owning .
-/// Optional driver binding; whitespace/empty means driver-less.
/// Optional ERP equipment id.
/// Optional SAP PM equipment id.
/// Optional OPC 40010 manufacturer name.
@@ -28,7 +27,6 @@ public sealed record EquipmentInput(
string Name,
string MachineCode,
string UnsLineId,
- string? DriverInstanceId,
string? ZTag,
string? SAPID,
string? Manufacturer,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
index 1faecc18..b2dd3f01 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
@@ -139,6 +139,82 @@ public interface IUnsTreeService
/// The equipment's virtual-tag rows ordered by Name; empty if it has none.
Task> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
+ // ---------------------------------------------------------------------------------------------
+ // v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
+ // ---------------------------------------------------------------------------------------------
+
+ ///
+ /// Loads the rows for a single equipment as flat
+ /// projections for the equipment page's Tags tab, ordered by SortOrder then effective name.
+ /// Each row carries the effective name (override else the backing raw tag's Name), 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.
+ ///
+ /// The equipment whose references to load.
+ /// A token to cancel the load.
+ /// The equipment's reference rows; empty if it has none.
+ Task> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
+
+ ///
+ /// Adds one per supplied raw TagId 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 Name — no
+ /// override on add) must be unique within the equipment across references, VirtualTags, and
+ /// ScriptedAlarms (enforced via ), and a tag already referenced by
+ /// the equipment is rejected. The batch is all-or-nothing.
+ ///
+ /// The referencing equipment.
+ /// The raw tag ids to reference.
+ /// A token to cancel the operation.
+ /// Success, or the first guard/cluster/duplicate failure (nothing is added).
+ Task AddReferencesAsync(string equipmentId, IReadOnlyList tagIds, CancellationToken ct = default);
+
+ ///
+ /// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
+ /// optimistic concurrency on .
+ ///
+ /// The reference to remove.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a concurrency failure, or a delete-failed failure.
+ Task RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Sets (or clears) a reference's DisplayNameOverride. A whitespace-only override collapses to
+ /// null (use the raw name). The resulting effective name (override else the backing raw tag's
+ /// Name) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
+ /// (enforced via , excluding this reference). Uses last-write-wins
+ /// optimistic concurrency.
+ ///
+ /// The reference to update.
+ /// The new override, or null/blank to clear it.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a missing-row failure, a name-collision failure, or a concurrency failure.
+ Task SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Builds the single cluster-rooted 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 RawTree lazily expands it via .
+ /// Returns null when the equipment cannot be resolved to a cluster.
+ ///
+ /// The equipment whose cluster scopes the picker.
+ /// A token to cancel the load.
+ /// The cluster root node, or null when the equipment/cluster can't be resolved.
+ Task LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
+
+ ///
+ /// Enumerates every raw TagId 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.
+ ///
+ /// The picker node (Device or TagGroup) to enumerate tags under.
+ /// A token to cancel the query.
+ /// The tag ids in the node's subtree; empty for unsupported kinds.
+ Task> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
+
///
/// Loads a single UNS area projected for editing, or null 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
///
/// Creates a new equipment under a UNS line. The EquipmentId is system-generated
/// (EQ- + the first 12 hex chars of a fresh EquipmentUuid).
- /// 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 null.
+ /// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
+ /// ZTag/SAPID collapse to null. (v3: equipment no longer binds a driver — the reference-only
+ /// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
///
/// The operator-editable equipment fields.
/// A token to cancel the operation.
- /// Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.
+ /// Success, a missing-line failure, or a duplicate-MachineCode failure.
Task CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
///
/// Bulk-imports equipment from a parsed set of rows in a single
/// context, applying the same rules as the single-add path: a row whose UnsLineId does not
- /// exist is an error; a row whose DriverInstanceId 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 MachineCode already exists in the DB or 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 MachineCode already exists in the DB or earlier
+ /// in the same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated EQ- id and a fresh EquipmentUuid. 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.)
///
/// The parsed equipment rows to import.
/// A token to cancel the operation.
@@ -309,16 +383,16 @@ public interface IUnsTreeService
Task ImportEquipmentAsync(IReadOnlyList rows, CancellationToken ct = default);
///
- /// 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 .
+ /// 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 . (v3: equipment
+ /// no longer binds a driver, so there is no driver-cluster guard.)
///
/// The equipment to update.
/// The new operator-editable equipment fields.
/// The concurrency token the caller last read.
/// A token to cancel the operation.
- /// Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.
+ /// Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.
Task UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
index 0a12e7ce..a5837358 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
@@ -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.
///
-public sealed class UnsTreeService(IDbContextFactory dbFactory) : IUnsTreeService
+public sealed class UnsTreeService(
+ IDbContextFactory 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;
+
///
public async Task> LoadStructureAsync(CancellationToken ct = default)
{
@@ -97,6 +106,369 @@ public sealed class UnsTreeService(IDbContextFactory dbF
.ToListAsync(ct);
}
+ // ---------------------------------------------------------------------------------------------
+ // v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
+ // ---------------------------------------------------------------------------------------------
+
+ ///
+ public async Task> 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();
+
+ // 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(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;
+ }
+
+ ///
+ public async Task AddReferencesAsync(
+ string equipmentId, IReadOnlyList 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(StringComparer.Ordinal);
+ var pending = new List();
+
+ 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.");
+ }
+ }
+
+ ///
+ public async Task 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}.");
+ }
+ }
+
+ ///
+ public async Task 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.");
+ }
+ }
+
+ ///
+ public async Task 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,
+ };
+ }
+
+ ///
+ public async Task> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(node);
+ if (node.EntityId is null) return Array.Empty();
+
+ 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();
+
+ 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();
+ }
+ }
+
+ /// Returns plus all transitive descendant group ids (iterative walk).
+ private static HashSet DescendantGroupsInclusive(
+ string rootId, IReadOnlyDictionary> childrenByParent)
+ {
+ var result = new HashSet(StringComparer.Ordinal);
+ var stack = new Stack();
+ 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;
+ }
+
+ ///
+ /// Builds a 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 when the cluster is unknown/null (callers then fall back to bare name).
+ ///
+ private static async Task 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);
+ }
+
+ ///
+ /// A no-op 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.
+ ///
+ private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
+ {
+ public static readonly NoOpEffectiveNameGuard Instance = new();
+
+ public Task CheckAsync(
+ string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
+ string? excludeSourceId, CancellationToken ct = default) => Task.FromResult(null);
+ }
+
///
public async Task LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
{
@@ -446,11 +818,8 @@ public sealed class UnsTreeService(IDbContextFactory 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 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 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 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 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 dbF
return null;
}
- ///
- /// An equipment may only bind to a driver in the same cluster as its line.
- /// Policy:
- ///
- /// Driver-less (DriverInstanceId empty) → always allowed (returns null).
- /// Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
- /// line cannot guarantee cluster alignment, so the bind is unsafe).
- /// Driver bound, line resolves, clusters differ → rejected.
- /// Driver bound, line resolves, clusters match → allowed (returns null).
- ///
- ///
- private static async Task 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;
- }
-
///
public async Task> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
@@ -1277,6 +1592,10 @@ public sealed class UnsTreeService(IDbContextFactory 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 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;
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs
index 35059ec0..f15d6bf5 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs
@@ -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;
///
-/// Verifies the Equipment CRUD mutations on , including the
-/// system-generated EQ- id, fleet-wide MachineCode uniqueness, and the decision-#122
-/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than
-/// the equipment's line.
+/// Verifies the Equipment CRUD mutations on : the system-generated
+/// EQ- 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.)
///
///
/// The EF InMemory provider does not enforce RowVersion concurrency, so the
@@ -25,32 +24,17 @@ public sealed class UnsTreeServiceEquipmentTests
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
- ///
- /// Seeds a line under an area in , plus an optional driver in
- /// . The line id is always LINE-1; the driver (when
- /// requested) is always DRV-1.
- ///
- private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
+ /// Seeds an area → line (id LINE-1) under .
+ 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.");
}
- /// The #122 guard blocks binding equipment to a driver in a different cluster than the line.
+ /// CreateEquipmentAsync returns the generated EQ- id in CreatedId so callers can navigate to the new page.
[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();
- }
-
- ///
- /// 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.
- ///
- [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");
- }
-
- /// Driver-less equipment is allowed regardless of cluster (no #122 guard applies).
- [Fact]
- public async Task CreateEquipment_driverless_allowed()
- {
- var (service, dbName) = Fresh();
- SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
-
- var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
-
- result.Ok.ShouldBeTrue();
- result.Error.ShouldBeNull();
-
- using var db = UnsTreeTestDb.CreateNamed(dbName);
- db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
- }
-
- ///
- /// The #122 guard blocks binding equipment to a driver when the UNS line does not resolve to
- /// a cluster (e.g. the line does not exist in the DB).
- ///
- [Fact]
- public async Task CreateEquipment_driver_bound_unresolvable_line_blocked()
- {
- var (service, dbName) = Fresh();
- // Seed a driver in MAIN cluster, but do NOT create the UnsLine that the input references.
- using (var db = UnsTreeTestDb.CreateNamed(dbName))
- {
- db.DriverInstances.Add(new DriverInstance
- {
- DriverInstanceId = "DRV-1",
- ClusterId = "MAIN",
- 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();
- }
-
- /// Binding equipment to a DriverInstanceId that does not exist is blocked with a "not found" error.
- [Fact]
- public async Task CreateEquipment_driver_not_found_blocked()
- {
- var (service, dbName) = Fresh();
- // Seed area + line in MAIN cluster, but NO DriverInstance.
- SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
-
- var result = await service.CreateEquipmentAsync(
- Input("machine-1", "machine_001", "LINE-1", "DRV-GHOST"));
-
- result.Ok.ShouldBeFalse();
- result.Error.ShouldNotBeNull();
- result.Error.ShouldContain("not found");
- result.Error.ShouldContain("DRV-GHOST");
-
- using var db = UnsTreeTestDb.CreateNamed(dbName);
- db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
+ 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.");
}
- /// The #122 guard blocks an update that binds equipment to a driver in another cluster.
- [Fact]
- public async Task UpdateEquipment_driver_in_other_cluster_blocked()
- {
- var (service, dbName) = Fresh();
- SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
- await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
-
- string equipmentId;
- byte[] rv;
- using (var db = UnsTreeTestDb.CreateNamed(dbName))
- {
- var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
- equipmentId = eq.EquipmentId;
- rv = eq.RowVersion;
- }
-
- var result = await service.UpdateEquipmentAsync(
- equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
-
- result.Ok.ShouldBeFalse();
- result.Error.ShouldNotBeNull();
- result.Error.ShouldContain("decision #122");
-
- // 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");
- }
-
/// Updating equipment with a MachineCode that already belongs to another row is blocked.
[Fact]
public async Task UpdateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
- SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
- await service.CreateEquipmentAsync(Input("machine-a", "mc_a", "LINE-1", null));
- await service.CreateEquipmentAsync(Input("machine-b", "mc_b", "LINE-1", null));
+ 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");
}
- /// Updating equipment to bind a driver that is in the SAME cluster as the line is allowed.
- [Fact]
- public async Task UpdateEquipment_driver_in_same_cluster_allowed()
- {
- var (service, dbName) = Fresh();
- SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
- await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
-
- string equipmentId;
- byte[] rv;
- using (var db = UnsTreeTestDb.CreateNamed(dbName))
- {
- var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
- equipmentId = eq.EquipmentId;
- rv = eq.RowVersion;
- }
-
- var result = await service.UpdateEquipmentAsync(
- equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
-
- result.Ok.ShouldBeTrue();
- result.Error.ShouldBeNull();
-
- // 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");
- }
-
- /// CreateEquipmentAsync returns the generated EQ- id in CreatedId so callers can navigate to the new page.
- [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 -----
/// Deleting equipment removes the row.
@@ -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;
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs
index 6544cbb6..ba4686fa 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs
@@ -6,10 +6,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
///
-/// Verifies the bulk 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 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.)
///
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceImportTests
@@ -20,32 +20,17 @@ public sealed class UnsTreeServiceImportTests
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
- ///
- /// Seeds a line under an area in , plus an optional driver in
- /// . The line id is always LINE-1; the driver (when
- /// requested) is always DRV-1.
- ///
- private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
+ /// Seeds an area → line (id LINE-1) under .
+ 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();
}
-
- /// A driver-bound row whose DriverInstanceId does not resolve is reported as an error.
- [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();
- }
-
- ///
- /// The decision-#122 guard rejects a row that binds a driver living in a different cluster than
- /// the row's UNS line.
- ///
- [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();
- }
-
- /// A driver in the same cluster as the line imports cleanly.
- [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");
- }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceReferenceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceReferenceTests.cs
new file mode 100644
index 00000000..8eda6188
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceReferenceTests.cs
@@ -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;
+
+///
+/// Verifies the v3 Batch-3 UNS reference-only equipment mutations on :
+/// 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 -consumed collision rejection (via a configurable fake).
+///
+///
+/// The EF InMemory provider enforces neither RowVersion concurrency nor the filtered-unique
+/// indexes, so the service's own pre-checks are what protect the data in these tests.
+///
+[Trait("Category", "Unit")]
+public sealed class UnsTreeServiceReferenceTests
+{
+ private const string EquipmentId = "EQ-1";
+
+ /// A configurable : returns from every check.
+ private sealed class FakeGuard : IEffectiveNameGuard
+ {
+ public string? Result { get; set; }
+
+ public string? LastProposedName { get; private set; }
+
+ public Task 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().UseInMemoryDatabase(name).Options);
+
+ private static IDbContextFactory Factory(string name) => UnsTreeTestDb.Factory(name);
+
+ ///
+ /// Seeds two clusters (MAIN, SITE-A), each with driver→device→tags, plus an equipment (EQ-1)
+ /// under MAIN. MAIN tags: MAIN/dev1/speed (Float, Read) and MAIN/dev1/running
+ /// (Boolean, ReadWrite), with speed also nested under a group grp variant. SITE-A tag:
+ /// OTHER/dev2/temp (for the cross-cluster rejection).
+ ///
+ 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 -----
+
+ /// Adding two raw tags persists two UnsTagReference rows for the equipment.
+ [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();
+ }
+
+ /// Loaded reference rows carry the computed RawPath and inherited (raw) datatype/access.
+ [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");
+ }
+
+ /// A raw tag whose cluster differs from the equipment's is rejected (cross-cluster).
+ [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();
+ }
+
+ /// Re-referencing an already-referenced raw tag is rejected.
+ [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);
+ }
+
+ /// A guard-reported collision (fake) becomes the mutation's readable failure; nothing is added.
+ [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 -----
+
+ /// Setting an override changes the effective name; clearing it falls back to the raw name.
+ [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();
+ }
+
+ /// Setting an override that the guard reports as colliding is rejected.
+ [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 -----
+
+ /// Removing a reference deletes the row; a missing row is a no-op success.
+ [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 -----
+
+ /// The picker root is the equipment's own cluster node (structurally cluster-scoped).
+ [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();
+ }
+
+ /// Select-all under a device returns every tag id in the device subtree.
+ [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);
+ }
+
+ /// Select-all under a tag-group returns only the tags in that group's subtree.
+ [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" });
+ }
+}