From 1f434499429ffde7e25ede92bbdfc0d1ed6ffdee Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 04:14:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20B2-WP6=20browse=20re-target=20?= =?UTF-8?q?=E2=80=94=20/raw=20device=20browse=20commits=20raw=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Browsing/BrowseLeafSelection.cs | 13 + .../Shared/Drivers/DriverBrowseTree.razor | 53 ++- .../Shared/Raw/RawBrowseModal.razor | 339 ++++++++++++++++++ .../Components/Shared/Raw/RawTree.razor | 26 +- .../Uns/RawBrowseCommitMapper.cs | 150 ++++++++ .../Uns/RawBrowseCommitMapperTests.cs | 201 +++++++++++ 6 files changed, 774 insertions(+), 8 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseLeafSelection.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseLeafSelection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseLeafSelection.cs new file mode 100644 index 00000000..66e22441 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseLeafSelection.cs @@ -0,0 +1,13 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Payload for a leaf toggle in DriverBrowseTree's multi-select mode (WP6 "Browse device…" +/// re-target). Carries the toggled plus the captured browse-folder nesting above it +/// (, root→parent display names) so the browse modal can optionally mirror the +/// nesting onto TagGroups on commit. Fired only for nodes. +/// +/// The toggled leaf browse node (its NodeId is the driver full reference). +/// The ancestor folder display names, root→parent (empty for a top-level leaf). +public sealed record BrowseLeafSelection(BrowseNode Leaf, IReadOnlyList FolderPath); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor index 9011facb..5437eff1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor @@ -20,7 +20,7 @@ } else { - @foreach (var n in _roots) { @RenderNode(n, 0) } + @foreach (var n in _roots) { @RenderNode(n, 0, System.Array.Empty()) } } @@ -34,6 +34,19 @@ /// Fired when the user clicks a leaf (or any node — caller decides what to do with it). [Parameter] public EventCallback OnNodeSelected { get; set; } + /// 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. + [Parameter] public bool MultiSelect { get; set; } + + /// 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. + [Parameter] public IReadOnlyCollection? SelectedLeafIds { get; set; } + + /// 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. + [Parameter] public EventCallback OnLeafToggled { get; set; } + private bool _loading = true; private string? _error; private List? _roots; @@ -86,10 +99,28 @@ await OnNodeSelected.InvokeAsync(item.Node); } - private RenderFragment RenderNode(TreeItem item, int depth) => __builder => + private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList 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 Append(IReadOnlyList path, string segment) + { + var list = new List(path.Count + 1); + list.AddRange(path); + list.Add(segment); + return list; + } + + private RenderFragment RenderNode(TreeItem item, int depth, IReadOnlyList ancestorPath) => __builder => { 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" : "";
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint) { @@ -102,8 +133,18 @@ { } - @item.Node.DisplayName + @if (isMultiLeaf) + { + + @item.Node.DisplayName + } + else + { + @item.Node.DisplayName + } @if (item.Node.Kind == BrowseNodeKind.Leaf) { leaf @@ -129,7 +170,7 @@ @bind="item.Filter" @bind:event="oninput" /> @foreach (var c in FilterChildren(item)) { - @RenderNode(c, depth + 1) + @RenderNode(c, depth + 1, Append(ancestorPath, item.Node.DisplayName)) } } }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor new file mode 100644 index 00000000..a3ba3e70 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor @@ -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) +{ + + +} + +@code { + /// 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. + private const string DefaultDataType = "Double"; + + /// Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close. + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (self-close). Completes the @bind-Visible contract. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The device the browsed tags are committed under. + [Parameter] public string DeviceId { get; set; } = ""; + + /// The target tag group (its device-relative path is prepended to every committed tag), or null + /// to commit directly under the device. + [Parameter] public string? TagGroupId { get; set; } + + /// The owning device's driver type (informational header; the live gate + config come from the + /// merged probe config). + [Parameter] public string DriverType { get; set; } = ""; + + /// Raised after a successful commit so the host can refresh the affected subtree. + [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 _commitErrors = new(); + + private readonly Dictionary _selected = new(StringComparer.Ordinal); + private readonly HashSet _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 FolderPath); +} 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 8508711b..6aa815f3 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 @@ -44,6 +44,9 @@ + + +/// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a +/// ready for . 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 TagConfig as an ordinary address field +/// (nodeId/tagPath/symbolPath/address/attributeRef per driver) — never an +/// identity key. The address-field write reuses the same <Driver>TagConfigModel the typed tag +/// editors use, so a browse-committed tag round-trips through that editor unchanged. +/// +public static class RawBrowseCommitMapper +{ + /// + /// Maps one selected browse leaf into an import row. + /// + /// The owning device's driver type (a value). + /// The leaf's driver-side full reference (the BrowseNode.NodeId = the + /// captured DriverAttributeInfo.FullName) — becomes the driver-typed address field. + /// The leaf's browse name — becomes the raw tag Name (a RawPath segment). + /// The leaf's name (from the browse + /// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree). + /// The OPC-UA built-in type name to fall back to when + /// is null/unmappable. + /// 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. + /// The captured browse-folder nesting above the leaf (root→parent display + /// names); mirrored onto nested TagGroups only when is true. + /// When true, mirror as nested TagGroups. + /// The assembled import row. + public static RawTagImportRow MapLeaf( + string driverType, + string fullName, + string browseName, + string? driverDataType, + string defaultDataType, + string? groupPrefix, + IReadOnlyList? 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)); + } + + /// + /// Maps a enum NAME (as reported by the browse attribute side-channel) to the + /// OPC-UA built-in type name a carries. Mirrors + /// DiscoveredNodeMapper.ToBuiltinTypeString: most names pass through, Float32/Float64 + /// become Float/Double, and Reference (a Galaxy attribute ref) carries as String. + /// Returns null when the input is null/blank or not a known driver data type (caller supplies a default). + /// + /// The name, or null/blank. + /// The OPC-UA built-in type name, or null when unmappable. + public static string? MapDataType(string? driverDataType) + { + if (string.IsNullOrWhiteSpace(driverDataType) + || !Enum.TryParse(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, + }; + } + + /// + /// Builds the driver-typed TagConfig JSON for a browse-committed tag, setting ONLY the driver's + /// address field to and leaving every other field at its model default. Reuses + /// the <Driver>TagConfigModel for driver types that have a typed editor; the model-less Galaxy + /// driver gets the canonical camelCase attributeRef key directly. + /// + /// The owning device's driver type. + /// The leaf's driver-side full reference to write into the address field. + /// The serialised driver-typed TagConfig JSON. + 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); + } + + /// + /// 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). + /// + /// The target TagGroup's device-relative path, or null/blank for the device root. + /// The mirrored folder sub-path, or null/blank. + /// The combined device-relative group path, or null for the device root. + 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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs new file mode 100644 index 00000000..f920cb57 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs @@ -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; + +/// +/// Unit tests for — the WP6 browse-leaf → +/// mapper. Covers the driver data-type mapping, the per-driver address-field placement in the driver-typed +/// TagConfig, and the target-group + folder-mirror path combine. +/// +[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()) + 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().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().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().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().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().ShouldBe("ns=2;s=Tag1"); + } +}