feat(adminui): B2-WP6 browse re-target — /raw device browse commits raw tags
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal against the merged Driver+Device config and commits selected browse leaves as raw Tag rows under the target, via IRawTreeService.ImportTagsAsync. - RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser → universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled), merged config from LoadMergedProbeConfigAsync, multi-select over DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror, commit via ImportTagsAsync. - RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→ OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/ attributeRef), target-group + mirror path combine. Unit-tested (39 tests). - DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path callback), default off preserves single-select pickers. - RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for a leaf toggle in <c>DriverBrowseTree</c>'s multi-select mode (WP6 "Browse device…"
|
||||||
|
/// re-target). Carries the toggled <see cref="Leaf"/> plus the captured browse-folder nesting above it
|
||||||
|
/// (<see cref="FolderPath"/>, root→parent display names) so the browse modal can optionally mirror the
|
||||||
|
/// nesting onto TagGroups on commit. Fired only for <see cref="BrowseNodeKind.Leaf"/> nodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Leaf">The toggled leaf browse node (its <c>NodeId</c> is the driver full reference).</param>
|
||||||
|
/// <param name="FolderPath">The ancestor folder display names, root→parent (empty for a top-level leaf).</param>
|
||||||
|
public sealed record BrowseLeafSelection(BrowseNode Leaf, IReadOnlyList<string> FolderPath);
|
||||||
+47
-6
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@foreach (var n in _roots) { @RenderNode(n, 0) }
|
@foreach (var n in _roots) { @RenderNode(n, 0, System.Array.Empty<string>()) }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,6 +34,19 @@
|
|||||||
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
|
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
|
||||||
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
|
||||||
|
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
|
||||||
|
/// false preserves the single-select address-picker behavior.</summary>
|
||||||
|
[Parameter] public bool MultiSelect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In multi-select mode, the set of currently-selected leaf NodeIds (drives checkbox state +
|
||||||
|
/// row highlight). Owned by the parent; the tree only reads it.</summary>
|
||||||
|
[Parameter] public IReadOnlyCollection<string>? SelectedLeafIds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In multi-select mode, fired when a leaf's checkbox is toggled — carries the leaf plus its
|
||||||
|
/// captured ancestor-folder display-name path (root→parent) for optional TagGroup mirroring.</summary>
|
||||||
|
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnLeafToggled { get; set; }
|
||||||
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
private List<TreeItem>? _roots;
|
private List<TreeItem>? _roots;
|
||||||
@@ -86,10 +99,28 @@
|
|||||||
await OnNodeSelected.InvokeAsync(item.Node);
|
await OnNodeSelected.InvokeAsync(item.Node);
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderFragment RenderNode(TreeItem item, int depth) => __builder =>
|
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
|
||||||
|
{
|
||||||
|
await OnLeafToggled.InvokeAsync(
|
||||||
|
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLeafSelected(string nodeId) => SelectedLeafIds?.Contains(nodeId) == true;
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
|
||||||
|
{
|
||||||
|
var list = new List<string>(path.Count + 1);
|
||||||
|
list.AddRange(path);
|
||||||
|
list.Add(segment);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderFragment RenderNode(TreeItem item, int depth, IReadOnlyList<string> ancestorPath) => __builder =>
|
||||||
{
|
{
|
||||||
var indent = $"padding-left:{depth * 18}px";
|
var indent = $"padding-left:{depth * 18}px";
|
||||||
var selectedCls = _selectedNodeIdLocal == item.Node.NodeId ? "bg-primary-subtle" : "";
|
var isMultiLeaf = MultiSelect && item.Node.Kind == BrowseNodeKind.Leaf;
|
||||||
|
var selectedCls = (isMultiLeaf ? IsLeafSelected(item.Node.NodeId) : _selectedNodeIdLocal == item.Node.NodeId)
|
||||||
|
? "bg-primary-subtle" : "";
|
||||||
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
|
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
|
||||||
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
|
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
|
||||||
{
|
{
|
||||||
@@ -102,8 +133,18 @@
|
|||||||
{
|
{
|
||||||
<span style="width:18px"></span>
|
<span style="width:18px"></span>
|
||||||
}
|
}
|
||||||
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
@if (isMultiLeaf)
|
||||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
{
|
||||||
|
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
|
||||||
|
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
|
||||||
|
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
|
||||||
|
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
||||||
|
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||||
|
}
|
||||||
@if (item.Node.Kind == BrowseNodeKind.Leaf)
|
@if (item.Node.Kind == BrowseNodeKind.Leaf)
|
||||||
{
|
{
|
||||||
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
|
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
|
||||||
@@ -129,7 +170,7 @@
|
|||||||
@bind="item.Filter" @bind:event="oninput" />
|
@bind="item.Filter" @bind:event="oninput" />
|
||||||
@foreach (var c in FilterChildren(item))
|
@foreach (var c in FilterChildren(item))
|
||||||
{
|
{
|
||||||
@RenderNode(c, depth + 1)
|
@RenderNode(c, depth + 1, Append(ancestorPath, item.Node.DisplayName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
@* WP6 "Browse device…" re-target for the v3 /raw tree. Opens a live driver-browse session against the
|
||||||
|
merged Driver+Device config (endpoint lives in DeviceConfig in v3), renders the shared DriverBrowseTree in
|
||||||
|
multi-select mode, and commits the selected leaves as raw Tag rows under the target Device/TagGroup via
|
||||||
|
IRawTreeService.ImportTagsAsync. Each selected leaf's driver reference is written into the driver-typed
|
||||||
|
TagConfig as an address field (NOT identity — identity is the RawPath). An opt-in toggle mirrors the
|
||||||
|
captured browse-folder nesting onto nested TagGroups. Visibility is @bind-Visible; a successful commit
|
||||||
|
raises OnSaved so the host refreshes the tree.
|
||||||
|
|
||||||
|
Two-tier browse gate (reused from BrowserSessionService): a bespoke IDriverBrowser (OpcUaClient, Galaxy)
|
||||||
|
wins; otherwise the universal DiscoveryDriverBrowser serves any driver whose ITagDiscovery reports
|
||||||
|
SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
|
||||||
|
with a tooltip. *@
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
||||||
|
@inject IBrowserSessionService BrowserService
|
||||||
|
@inject IRawTreeService Svc
|
||||||
|
@inject RawTagCsvExportReader ExportReader
|
||||||
|
|
||||||
|
@if (Visible)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||||
|
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
Browse device <span class="text-muted small">(@_driverType)</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_resolving)
|
||||||
|
{
|
||||||
|
<div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading device config…</div>
|
||||||
|
}
|
||||||
|
else if (!_canBrowse)
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary mb-0" title="@_disabledReason">
|
||||||
|
<strong>Browsing unavailable.</strong>
|
||||||
|
<div class="small">@_disabledReason</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-2 small text-muted">
|
||||||
|
Committing under: <code class="mono">@TargetLabel()</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_token == Guid.Empty)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
disabled="@_opening" @onclick="OpenBrowseAsync">
|
||||||
|
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Connect & browse
|
||||||
|
</button>
|
||||||
|
@if (_openError is not null)
|
||||||
|
{
|
||||||
|
<span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="chip chip-ok">Browser open</span>
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
|
||||||
|
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
|
||||||
|
<label class="form-check-label small" for="raw-browse-mirror">
|
||||||
|
Create matching tag-groups (mirror browse folders)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
|
||||||
|
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
|
||||||
|
|
||||||
|
<div class="mt-2 small">
|
||||||
|
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_commitErrors.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-2 mt-2 mb-0">
|
||||||
|
<div class="small mb-1">Nothing was committed — fix these and retry:</div>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
@foreach (var err in _commitErrors)
|
||||||
|
{
|
||||||
|
<li>@err</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="CommitAsync"
|
||||||
|
disabled="@(_busy || _selected.Count == 0)">
|
||||||
|
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Add @_selected.Count tag@(_selected.Count == 1 ? "" : "s")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Fallback OPC-UA built-in type for browse-committed leaves whose browser cannot report a
|
||||||
|
/// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
|
||||||
|
private const string DefaultDataType = "Double";
|
||||||
|
|
||||||
|
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
|
||||||
|
[Parameter] public bool Visible { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
|
||||||
|
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The device the browsed tags are committed under.</summary>
|
||||||
|
[Parameter] public string DeviceId { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>The target tag group (its device-relative path is prepended to every committed tag), or null
|
||||||
|
/// to commit directly under the device.</summary>
|
||||||
|
[Parameter] public string? TagGroupId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The owning device's driver type (informational header; the live gate + config come from the
|
||||||
|
/// merged probe config).</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Raised after a successful commit so the host can refresh the affected subtree.</summary>
|
||||||
|
[Parameter] public EventCallback OnSaved { get; set; }
|
||||||
|
|
||||||
|
private bool _open; // guards re-seeding on unrelated re-renders
|
||||||
|
private bool _resolving;
|
||||||
|
private bool _canBrowse;
|
||||||
|
private string? _disabledReason;
|
||||||
|
|
||||||
|
private string _driverType = "";
|
||||||
|
private string _mergedConfig = "{}";
|
||||||
|
private string _effectiveDeviceId = "";
|
||||||
|
private string? _groupPrefix;
|
||||||
|
|
||||||
|
private Guid _token = Guid.Empty;
|
||||||
|
private bool _opening;
|
||||||
|
private string? _openError;
|
||||||
|
|
||||||
|
private bool _createGroups;
|
||||||
|
private bool _busy;
|
||||||
|
private List<string> _commitErrors = new();
|
||||||
|
|
||||||
|
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
|
||||||
|
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (!Visible)
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_open) { return; }
|
||||||
|
_open = true;
|
||||||
|
await ResetAndResolveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetAndResolveAsync()
|
||||||
|
{
|
||||||
|
_token = Guid.Empty;
|
||||||
|
_opening = false;
|
||||||
|
_openError = null;
|
||||||
|
_createGroups = false;
|
||||||
|
_busy = false;
|
||||||
|
_commitErrors = new();
|
||||||
|
_selected.Clear();
|
||||||
|
_selectedIds.Clear();
|
||||||
|
_driverType = DriverType;
|
||||||
|
_mergedConfig = "{}";
|
||||||
|
_effectiveDeviceId = DeviceId;
|
||||||
|
_groupPrefix = null;
|
||||||
|
_canBrowse = false;
|
||||||
|
_disabledReason = null;
|
||||||
|
|
||||||
|
_resolving = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
|
||||||
|
// the merged blob (same merge the driver-probe/test-connect path uses).
|
||||||
|
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
|
||||||
|
if (merged is { } m)
|
||||||
|
{
|
||||||
|
_driverType = m.DriverType;
|
||||||
|
_mergedConfig = m.MergedConfigJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the target group's device-relative path prefix (prepended to every committed tag).
|
||||||
|
if (!string.IsNullOrEmpty(TagGroupId))
|
||||||
|
{
|
||||||
|
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||||
|
if (ctx is { } c)
|
||||||
|
{
|
||||||
|
_groupPrefix = c.GroupPath;
|
||||||
|
if (string.IsNullOrEmpty(_effectiveDeviceId)) { _effectiveDeviceId = c.DeviceId; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-tier gate: bespoke browser OR universal discovery browser can serve this driver+config.
|
||||||
|
_canBrowse = BrowserService.CanBrowse(_driverType, _mergedConfig);
|
||||||
|
if (!_canBrowse)
|
||||||
|
{
|
||||||
|
_disabledReason =
|
||||||
|
$"The {_driverType} driver has no online discovery — author tags manually (Add tags ▸ Manual entry).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_canBrowse = false;
|
||||||
|
_disabledReason = $"Could not load device config: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_resolving = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenBrowseAsync()
|
||||||
|
{
|
||||||
|
_opening = true;
|
||||||
|
_openError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
|
||||||
|
if (result.Ok) { _token = result.Token; }
|
||||||
|
else { _openError = result.Message; }
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_opening = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnLeafToggledAsync(BrowseLeafSelection sel)
|
||||||
|
{
|
||||||
|
var nodeId = sel.Leaf.NodeId;
|
||||||
|
if (_selectedIds.Contains(nodeId))
|
||||||
|
{
|
||||||
|
_selectedIds.Remove(nodeId);
|
||||||
|
_selected.Remove(nodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the leaf's browse name + driver data type from the attribute side-channel. Universal
|
||||||
|
// (AbCip/TwinCAT/FOCAS) reports both; the OPC UA Client tree reports nothing (empty) — fall back to
|
||||||
|
// the display name + the default data type.
|
||||||
|
string name = sel.Leaf.DisplayName;
|
||||||
|
string? driverDataType = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
|
||||||
|
if (attrs.Count > 0)
|
||||||
|
{
|
||||||
|
name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
|
||||||
|
driverDataType = attrs[0].DriverDataType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedIds.Add(nodeId);
|
||||||
|
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CommitAsync()
|
||||||
|
{
|
||||||
|
_busy = true;
|
||||||
|
_commitErrors = new();
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rows = _selected.Values
|
||||||
|
.Select(s => RawBrowseCommitMapper.MapLeaf(
|
||||||
|
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
|
||||||
|
_groupPrefix, s.FolderPath, _createGroups))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
||||||
|
if (outcome.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
_commitErrors = outcome.Errors.ToList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnSaved.InvokeAsync();
|
||||||
|
await CloseAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseAsync()
|
||||||
|
{
|
||||||
|
var t = _token;
|
||||||
|
_token = Guid.Empty;
|
||||||
|
Visible = false;
|
||||||
|
_open = false;
|
||||||
|
await VisibleChanged.InvokeAsync(false);
|
||||||
|
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string TargetLabel()
|
||||||
|
=> string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;
|
||||||
|
|
||||||
|
private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_token != Guid.Empty)
|
||||||
|
{
|
||||||
|
// Fire-and-forget — don't block circuit teardown on a slow remote.
|
||||||
|
_ = BrowserService.CloseAsync(_token);
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@
|
|||||||
<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
|
<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
|
||||||
TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />
|
TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />
|
||||||
|
|
||||||
|
<RawBrowseModal @bind-Visible="_browseVisible" DeviceId="@_browseDeviceId"
|
||||||
|
TagGroupId="@_browseTagGroupId" DriverType="@_browseDriverType" OnSaved="BrowseSaved" />
|
||||||
|
|
||||||
<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />
|
<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />
|
||||||
|
|
||||||
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
|
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
|
||||||
@@ -105,6 +108,13 @@
|
|||||||
private string? _csvExportTagGroupId;
|
private string? _csvExportTagGroupId;
|
||||||
private string _csvExportDriverType = "";
|
private string _csvExportDriverType = "";
|
||||||
|
|
||||||
|
// --- Browse-device modal (WP6 discovery-browser re-target) ---
|
||||||
|
private bool _browseVisible;
|
||||||
|
private string _browseDeviceId = "";
|
||||||
|
private string? _browseTagGroupId;
|
||||||
|
private string _browseDriverType = "";
|
||||||
|
private RawNode? _browseNode;
|
||||||
|
|
||||||
// --- Name dialog (New folder/group, Rename) ---
|
// --- Name dialog (New folder/group, Rename) ---
|
||||||
private bool _nameVisible;
|
private bool _nameVisible;
|
||||||
private string _nameTitle = "";
|
private string _nameTitle = "";
|
||||||
@@ -511,10 +521,16 @@
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browse device stays a placeholder — Wave C (WP6) wires the discovery-browser modal.
|
// Opens the discovery-browser modal against the target device/group. On a Device node the browse commits
|
||||||
|
// at the device root; on a TagGroup node the group's device-relative path is prepended to every tag.
|
||||||
private Task OnBrowseDevice(RawNode node)
|
private Task OnBrowseDevice(RawNode node)
|
||||||
{
|
{
|
||||||
ShowMessage("Browse device", "Device browse arrives in the browse wave (Wave C).", node.DisplayName);
|
_browseDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
|
||||||
|
_browseTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
|
||||||
|
_browseDriverType = node.DriverType ?? "";
|
||||||
|
_browseNode = node;
|
||||||
|
_browseVisible = true;
|
||||||
|
StateHasChanged();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +614,12 @@
|
|||||||
if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
|
if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task BrowseSaved()
|
||||||
|
{
|
||||||
|
// New tags (and any mirrored groups) land under the browsed node — reload its children.
|
||||||
|
if (_browseNode is not null) { await ReloadNodeAsync(_browseNode); }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Rename / delete outcome surfacing.
|
// Rename / delete outcome surfacing.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a
|
||||||
|
/// <see cref="RawTagImportRow"/> ready for <see cref="IRawTreeService.ImportTagsAsync"/>. Under the v3
|
||||||
|
/// identity contract the tag's identity is its RawPath (device + optional group + name), so the leaf's
|
||||||
|
/// driver reference is written into the driver-typed <c>TagConfig</c> as an ordinary <b>address field</b>
|
||||||
|
/// (<c>nodeId</c>/<c>tagPath</c>/<c>symbolPath</c>/<c>address</c>/<c>attributeRef</c> per driver) — never an
|
||||||
|
/// identity key. The address-field write reuses the same <c><Driver>TagConfigModel</c> the typed tag
|
||||||
|
/// editors use, so a browse-committed tag round-trips through that editor unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static class RawBrowseCommitMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps one selected browse leaf into an import row.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="driverType">The owning device's driver type (a <see cref="DriverTypeNames"/> value).</param>
|
||||||
|
/// <param name="fullName">The leaf's driver-side full reference (the <c>BrowseNode.NodeId</c> = the
|
||||||
|
/// captured <c>DriverAttributeInfo.FullName</c>) — becomes the driver-typed address field.</param>
|
||||||
|
/// <param name="browseName">The leaf's browse name — becomes the raw tag <c>Name</c> (a RawPath segment).</param>
|
||||||
|
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> name (from the browse
|
||||||
|
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).</param>
|
||||||
|
/// <param name="defaultDataType">The OPC-UA built-in type name to fall back to when
|
||||||
|
/// <paramref name="driverDataType"/> is null/unmappable.</param>
|
||||||
|
/// <param name="groupPrefix">The target TagGroup's device-relative path when committing under a group,
|
||||||
|
/// or null when committing at the device root. Prepended to any mirrored folder path.</param>
|
||||||
|
/// <param name="folderPath">The captured browse-folder nesting above the leaf (root→parent display
|
||||||
|
/// names); mirrored onto nested TagGroups only when <paramref name="createGroups"/> is true.</param>
|
||||||
|
/// <param name="createGroups">When true, mirror <paramref name="folderPath"/> as nested TagGroups.</param>
|
||||||
|
/// <returns>The assembled import row.</returns>
|
||||||
|
public static RawTagImportRow MapLeaf(
|
||||||
|
string driverType,
|
||||||
|
string fullName,
|
||||||
|
string browseName,
|
||||||
|
string? driverDataType,
|
||||||
|
string defaultDataType,
|
||||||
|
string? groupPrefix,
|
||||||
|
IReadOnlyList<string>? folderPath,
|
||||||
|
bool createGroups)
|
||||||
|
{
|
||||||
|
var dataType = MapDataType(driverDataType) ?? defaultDataType;
|
||||||
|
var tagConfig = BuildTagConfig(driverType, fullName);
|
||||||
|
var mirrored = createGroups && folderPath is { Count: > 0 }
|
||||||
|
? string.Join(RawPaths.Separator, folderPath)
|
||||||
|
: null;
|
||||||
|
var groupPath = CombineGroupPath(groupPrefix, mirrored);
|
||||||
|
|
||||||
|
// Access baseline mirrors manual entry (Read); write-idempotent stays false (unknown at browse time);
|
||||||
|
// no poll-group (batching is authored later).
|
||||||
|
return new RawTagImportRow(
|
||||||
|
groupPath,
|
||||||
|
new RawTagInput(browseName, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a <see cref="DriverDataType"/> enum NAME (as reported by the browse attribute side-channel) to the
|
||||||
|
/// OPC-UA built-in type name a <see cref="RawTagInput.DataType"/> carries. Mirrors
|
||||||
|
/// <c>DiscoveredNodeMapper.ToBuiltinTypeString</c>: most names pass through, <c>Float32</c>/<c>Float64</c>
|
||||||
|
/// become <c>Float</c>/<c>Double</c>, and <c>Reference</c> (a Galaxy attribute ref) carries as <c>String</c>.
|
||||||
|
/// Returns null when the input is null/blank or not a known driver data type (caller supplies a default).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="driverDataType">The <see cref="DriverDataType"/> name, or null/blank.</param>
|
||||||
|
/// <returns>The OPC-UA built-in type name, or null when unmappable.</returns>
|
||||||
|
public static string? MapDataType(string? driverDataType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(driverDataType)
|
||||||
|
|| !Enum.TryParse<DriverDataType>(driverDataType, ignoreCase: true, out var dt))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return dt switch
|
||||||
|
{
|
||||||
|
DriverDataType.Boolean => "Boolean",
|
||||||
|
DriverDataType.Int16 => "Int16",
|
||||||
|
DriverDataType.Int32 => "Int32",
|
||||||
|
DriverDataType.Int64 => "Int64",
|
||||||
|
DriverDataType.UInt16 => "UInt16",
|
||||||
|
DriverDataType.UInt32 => "UInt32",
|
||||||
|
DriverDataType.UInt64 => "UInt64",
|
||||||
|
DriverDataType.Float32 => "Float",
|
||||||
|
DriverDataType.Float64 => "Double",
|
||||||
|
DriverDataType.String => "String",
|
||||||
|
DriverDataType.DateTime => "DateTime",
|
||||||
|
DriverDataType.Reference => "String",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the driver-typed <c>TagConfig</c> JSON for a browse-committed tag, setting ONLY the driver's
|
||||||
|
/// address field to <paramref name="fullName"/> and leaving every other field at its model default. Reuses
|
||||||
|
/// the <c><Driver>TagConfigModel</c> for driver types that have a typed editor; the model-less Galaxy
|
||||||
|
/// driver gets the canonical camelCase <c>attributeRef</c> key directly.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="driverType">The owning device's driver type.</param>
|
||||||
|
/// <param name="fullName">The leaf's driver-side full reference to write into the address field.</param>
|
||||||
|
/// <returns>The serialised driver-typed <c>TagConfig</c> JSON.</returns>
|
||||||
|
public static string BuildTagConfig(string driverType, string fullName)
|
||||||
|
{
|
||||||
|
var address = fullName ?? "";
|
||||||
|
if (Is(driverType, DriverTypeNames.OpcUaClient))
|
||||||
|
return new OpcUaClientTagConfigModel { NodeId = address }.ToJson();
|
||||||
|
if (Is(driverType, DriverTypeNames.AbCip))
|
||||||
|
return new AbCipTagConfigModel { TagPath = address }.ToJson();
|
||||||
|
if (Is(driverType, DriverTypeNames.AbLegacy))
|
||||||
|
return new AbLegacyTagConfigModel { Address = address }.ToJson();
|
||||||
|
if (Is(driverType, DriverTypeNames.TwinCAT))
|
||||||
|
return new TwinCATTagConfigModel { SymbolPath = address }.ToJson();
|
||||||
|
if (Is(driverType, DriverTypeNames.FOCAS))
|
||||||
|
return new FocasTagConfigModel { Address = address }.ToJson();
|
||||||
|
if (Is(driverType, DriverTypeNames.S7))
|
||||||
|
return new S7TagConfigModel { Address = address }.ToJson();
|
||||||
|
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||||
|
return WriteSingleKey("attributeRef", address);
|
||||||
|
|
||||||
|
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
|
||||||
|
// "address" key so nothing is lost. Browsable drivers are all handled above.
|
||||||
|
return WriteSingleKey("address", address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import
|
||||||
|
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
|
||||||
|
/// separator. Returns null when both are absent (⇒ commit at the device root).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="prefix">The target TagGroup's device-relative path, or null/blank for the device root.</param>
|
||||||
|
/// <param name="suffix">The mirrored folder sub-path, or null/blank.</param>
|
||||||
|
/// <returns>The combined device-relative group path, or null for the device root.</returns>
|
||||||
|
public static string? CombineGroupPath(string? prefix, string? suffix)
|
||||||
|
{
|
||||||
|
prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
|
||||||
|
suffix = string.IsNullOrWhiteSpace(suffix) ? null : suffix.Trim();
|
||||||
|
if (prefix is null) return suffix;
|
||||||
|
return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Is(string driverType, string name)
|
||||||
|
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string WriteSingleKey(string key, string value)
|
||||||
|
{
|
||||||
|
var o = new JsonObject { [key] = value };
|
||||||
|
return o.ToJsonString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="RawBrowseCommitMapper"/> — the WP6 browse-leaf → <see cref="RawTagImportRow"/>
|
||||||
|
/// mapper. Covers the driver data-type mapping, the per-driver address-field placement in the driver-typed
|
||||||
|
/// <c>TagConfig</c>, and the target-group + folder-mirror path combine.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class RawBrowseCommitMapperTests
|
||||||
|
{
|
||||||
|
// ---- MapDataType ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Boolean", "Boolean")]
|
||||||
|
[InlineData("Int16", "Int16")]
|
||||||
|
[InlineData("Int32", "Int32")]
|
||||||
|
[InlineData("Int64", "Int64")]
|
||||||
|
[InlineData("UInt16", "UInt16")]
|
||||||
|
[InlineData("UInt32", "UInt32")]
|
||||||
|
[InlineData("UInt64", "UInt64")]
|
||||||
|
[InlineData("Float32", "Float")]
|
||||||
|
[InlineData("Float64", "Double")]
|
||||||
|
[InlineData("String", "String")]
|
||||||
|
[InlineData("DateTime", "DateTime")]
|
||||||
|
[InlineData("Reference", "String")]
|
||||||
|
public void MapDataType_maps_every_driver_data_type_to_its_opcua_builtin_name(string driverType, string expected)
|
||||||
|
=> RawBrowseCommitMapper.MapDataType(driverType).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapDataType_is_case_insensitive()
|
||||||
|
=> RawBrowseCommitMapper.MapDataType("float32").ShouldBe("Float");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("NotAType")]
|
||||||
|
public void MapDataType_returns_null_for_blank_or_unknown(string? input)
|
||||||
|
=> RawBrowseCommitMapper.MapDataType(input).ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapDataType_covers_the_whole_DriverDataType_enum()
|
||||||
|
{
|
||||||
|
// Guard: if a new DriverDataType is added, this fails until MapDataType handles it.
|
||||||
|
foreach (var dt in Enum.GetValues<DriverDataType>())
|
||||||
|
RawBrowseCommitMapper.MapDataType(dt.ToString()).ShouldNotBeNull(
|
||||||
|
$"DriverDataType.{dt} has no OPC-UA type mapping.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BuildTagConfig (address field per driver) ----------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("OpcUaClient", "nodeId", "ns=2;s=Channel1.Device1.Tag1")]
|
||||||
|
[InlineData("AbCip", "tagPath", "Program:Main.Motor.Speed")]
|
||||||
|
[InlineData("AbLegacy", "address", "N7:0")]
|
||||||
|
[InlineData("TwinCAT", "symbolPath", "MAIN.bStart")]
|
||||||
|
[InlineData("FOCAS", "address", "R100")]
|
||||||
|
[InlineData("S7", "address", "DB1.DBW0")]
|
||||||
|
[InlineData("GalaxyMxGateway", "attributeRef", "Tank_001.Level")]
|
||||||
|
public void BuildTagConfig_writes_the_reference_into_the_drivers_address_field(
|
||||||
|
string driverType, string expectedKey, string reference)
|
||||||
|
{
|
||||||
|
var json = RawBrowseCommitMapper.BuildTagConfig(driverType, reference);
|
||||||
|
|
||||||
|
var o = JsonNode.Parse(json)!.AsObject();
|
||||||
|
o[expectedKey]!.GetValue<string>().ShouldBe(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagConfig_is_case_insensitive_on_driver_type()
|
||||||
|
{
|
||||||
|
var json = RawBrowseCommitMapper.BuildTagConfig("opcuaclient", "ns=2;s=X");
|
||||||
|
JsonNode.Parse(json)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=X");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagConfig_does_not_write_any_identity_key()
|
||||||
|
{
|
||||||
|
// v3: identity is the RawPath — TagConfig must NOT carry a FullName identity key.
|
||||||
|
var json = RawBrowseCommitMapper.BuildTagConfig("OpcUaClient", "ns=2;s=X");
|
||||||
|
JsonNode.Parse(json)!.AsObject().ContainsKey("FullName").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagConfig_unknown_driver_falls_back_to_a_generic_address_key()
|
||||||
|
{
|
||||||
|
var json = RawBrowseCommitMapper.BuildTagConfig("Modbus", "40001");
|
||||||
|
JsonNode.Parse(json)!.AsObject()["address"]!.GetValue<string>().ShouldBe("40001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CombineGroupPath -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null, null)]
|
||||||
|
[InlineData("Grp", null, "Grp")]
|
||||||
|
[InlineData(null, "Sub", "Sub")]
|
||||||
|
[InlineData("Grp", "Sub", "Grp/Sub")]
|
||||||
|
[InlineData("Grp/Deep", "Sub/Leaf", "Grp/Deep/Sub/Leaf")]
|
||||||
|
[InlineData(" ", "Sub", "Sub")]
|
||||||
|
public void CombineGroupPath_joins_prefix_and_suffix(string? prefix, string? suffix, string? expected)
|
||||||
|
=> RawBrowseCommitMapper.CombineGroupPath(prefix, suffix).ShouldBe(expected);
|
||||||
|
|
||||||
|
// ---- MapLeaf (end to end) -------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLeaf_builds_a_read_row_with_typed_config_and_no_group_at_device_root()
|
||||||
|
{
|
||||||
|
var row = RawBrowseCommitMapper.MapLeaf(
|
||||||
|
driverType: "TwinCAT",
|
||||||
|
fullName: "MAIN.rSpeed",
|
||||||
|
browseName: "rSpeed",
|
||||||
|
driverDataType: "Float32",
|
||||||
|
defaultDataType: "Double",
|
||||||
|
groupPrefix: null,
|
||||||
|
folderPath: new[] { "MAIN" },
|
||||||
|
createGroups: false);
|
||||||
|
|
||||||
|
row.TagGroupPath.ShouldBeNull(); // mirror OFF, no target group
|
||||||
|
row.Tag.Name.ShouldBe("rSpeed");
|
||||||
|
row.Tag.DataType.ShouldBe("Float"); // Float32 -> Float
|
||||||
|
row.Tag.AccessLevel.ShouldBe(TagAccessLevel.Read);
|
||||||
|
row.Tag.WriteIdempotent.ShouldBeFalse();
|
||||||
|
row.Tag.PollGroupId.ShouldBeNull();
|
||||||
|
JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["symbolPath"]!.GetValue<string>().ShouldBe("MAIN.rSpeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLeaf_mirrors_folder_path_onto_group_when_create_groups_on()
|
||||||
|
{
|
||||||
|
var row = RawBrowseCommitMapper.MapLeaf(
|
||||||
|
driverType: "AbCip",
|
||||||
|
fullName: "Program:Main.Motor.Speed",
|
||||||
|
browseName: "Speed",
|
||||||
|
driverDataType: "Int32",
|
||||||
|
defaultDataType: "Double",
|
||||||
|
groupPrefix: null,
|
||||||
|
folderPath: new[] { "Program:Main", "Motor" },
|
||||||
|
createGroups: true);
|
||||||
|
|
||||||
|
row.TagGroupPath.ShouldBe("Program:Main/Motor");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLeaf_prepends_target_group_prefix_and_mirror()
|
||||||
|
{
|
||||||
|
var row = RawBrowseCommitMapper.MapLeaf(
|
||||||
|
driverType: "AbCip",
|
||||||
|
fullName: "Program:Main.Motor.Speed",
|
||||||
|
browseName: "Speed",
|
||||||
|
driverDataType: "Int32",
|
||||||
|
defaultDataType: "Double",
|
||||||
|
groupPrefix: "Imported",
|
||||||
|
folderPath: new[] { "Motor" },
|
||||||
|
createGroups: true);
|
||||||
|
|
||||||
|
row.TagGroupPath.ShouldBe("Imported/Motor");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLeaf_uses_target_group_prefix_only_when_mirror_off()
|
||||||
|
{
|
||||||
|
var row = RawBrowseCommitMapper.MapLeaf(
|
||||||
|
driverType: "AbCip",
|
||||||
|
fullName: "Program:Main.Motor.Speed",
|
||||||
|
browseName: "Speed",
|
||||||
|
driverDataType: "Int32",
|
||||||
|
defaultDataType: "Double",
|
||||||
|
groupPrefix: "Imported",
|
||||||
|
folderPath: new[] { "Motor" },
|
||||||
|
createGroups: false);
|
||||||
|
|
||||||
|
row.TagGroupPath.ShouldBe("Imported");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLeaf_falls_back_to_default_data_type_when_browser_reports_none()
|
||||||
|
{
|
||||||
|
// OPC UA Client browse reports no attribute type — the default fills in.
|
||||||
|
var row = RawBrowseCommitMapper.MapLeaf(
|
||||||
|
driverType: "OpcUaClient",
|
||||||
|
fullName: "ns=2;s=Tag1",
|
||||||
|
browseName: "Tag1",
|
||||||
|
driverDataType: null,
|
||||||
|
defaultDataType: "Double",
|
||||||
|
groupPrefix: null,
|
||||||
|
folderPath: null,
|
||||||
|
createGroups: true); // mirror ON but null folderPath -> still device root
|
||||||
|
|
||||||
|
row.TagGroupPath.ShouldBeNull();
|
||||||
|
row.Tag.DataType.ShouldBe("Double");
|
||||||
|
JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=Tag1");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user