From 1d7afbb1eb1fecc0eb18b641a3f3d6beae18b86e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:05:01 -0400 Subject: [PATCH 01/20] feat(adminui): T0-1 reusable ContextMenu component + demo page Adds a reusable right-click / context menu Blazor component for the AdminUI: - ContextMenu.razor: right-click surface (browser menu suppressed) + optional ellipsis fallback trigger, both anchoring the menu at the pointer; keyboard nav (arrows/Enter/Esc), transparent backdrop outside-click close (no JS). - ContextMenu.razor.css: scoped styles on the shared theme tokens. - ContextMenuItem.cs: Label/OnClick/Icon/Disabled/IsSeparator model + Separator(). - Dev demo page at /dev/context-menu-demo. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Pages/Dev/ContextMenuDemo.razor | 35 ++++ .../Components/Shared/ContextMenu.razor | 168 ++++++++++++++++++ .../Components/Shared/ContextMenu.razor.css | 93 ++++++++++ .../Components/Shared/ContextMenuItem.cs | 28 +++ 4 files changed, 324 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor new file mode 100644 index 00000000..db5cd1da --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor @@ -0,0 +1,35 @@ +@page "/dev/context-menu-demo" +@* Dev-only host page to live-verify the reusable ContextMenu component. *@ +@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] +@rendermode InteractiveServer + +ContextMenu demo + +

ContextMenu demo

+

Right-click the card below, or use the β‹― button.

+ +
+ +
+ Right-click me +
+
+ + +
+ +

Last action: @_lastAction

+ +@code { + private string _lastAction = "(none)"; + + private IReadOnlyList _items => new List + { + new() { Label = "Edit", Icon = "✏️", OnClick = () => Set("Edit") }, + new() { Label = "Duplicate", Icon = "πŸ“„", OnClick = () => Set("Duplicate") }, + ContextMenuItem.Separator(), + new() { Label = "Delete", Icon = "πŸ—‘οΈ", Disabled = true, OnClick = () => Set("Delete") }, + }; + + private void Set(string action) => _lastAction = action; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor new file mode 100644 index 00000000..ea8b00fc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor @@ -0,0 +1,168 @@ +@* Reusable right-click / context menu. + + Consumers get two (independently optional) triggers over one item list: + β€’ ChildContent β€” wrapped as a right-clickable surface (browser menu suppressed). + β€’ ShowEllipsis β€” a "β‹―" button, the keyboard/touch fallback for the right-click. + + Both triggers open the same menu anchored at the pointer. Outside-click closes + via a transparent full-viewport backdrop (the standard Blazor dropdown pattern β€” + no JS alert/confirm). Keyboard: Arrow up/down move selection, Enter activates, + Esc closes. *@ + +@if (ChildContent is not null) +{ + + @ChildContent + +} + +@if (ShowEllipsis) +{ + +} + +@if (_open) +{ + @* Transparent catch-all: any click that misses the menu closes it. *@ +
+ + +} + +@code { + private static readonly System.Globalization.CultureInfo Culture = + System.Globalization.CultureInfo.InvariantCulture; + + /// The rows to show. Separators are permitted. + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + + /// Wrapped as the right-clickable surface. Optional. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Render the "β‹―" trigger button (keyboard/touch fallback). + [Parameter] public bool ShowEllipsis { get; set; } = true; + + private bool _open; + private double _x; + private double _y; + private int _activeIndex = -1; + private ElementReference _menuRef; + private bool _focusPending; + + // Both triggers carry a MouseEventArgs, so the menu anchors at the pointer + // (right-click point, or the click point on the "β‹―" button) with no JS. + private void OpenAt(MouseEventArgs e) + { + _x = e.ClientX; + _y = e.ClientY; + _activeIndex = FirstEnabledIndex(); + _open = true; + _focusPending = true; + } + + private void Close() + { + _open = false; + _activeIndex = -1; + } + + private void Activate(ContextMenuItem item) + { + if (item.Disabled || item.IsSeparator) return; + Close(); + item.OnClick?.Invoke(); + } + + private void OnKeyDown(KeyboardEventArgs e) + { + switch (e.Key) + { + case "Escape": + Close(); + break; + case "ArrowDown": + Move(1); + break; + case "ArrowUp": + Move(-1); + break; + case "Enter": + case " ": + if (_activeIndex >= 0 && _activeIndex < Items.Count) + { + Activate(Items[_activeIndex]); + } + break; + } + } + + // Advance the selection in the given direction, skipping separators and + // disabled rows, wrapping around the ends. + private void Move(int dir) + { + if (Items.Count == 0) return; + var i = _activeIndex; + for (var step = 0; step < Items.Count; step++) + { + i = (i + dir + Items.Count) % Items.Count; + var item = Items[i]; + if (!item.IsSeparator && !item.Disabled) + { + _activeIndex = i; + return; + } + } + } + + private int FirstEnabledIndex() + { + for (var i = 0; i < Items.Count; i++) + { + if (!Items[i].IsSeparator && !Items[i].Disabled) return i; + } + return -1; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_focusPending) + { + _focusPending = false; + try + { + await _menuRef.FocusAsync(); + } + catch + { + // The menu may have closed before the focus round-trip completed. + } + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css new file mode 100644 index 00000000..df025623 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css @@ -0,0 +1,93 @@ +/* Scoped styles for ContextMenu. Colours come from the shared ZB.MOM.WW.Theme + tokens (theme.css) so the menu tracks the app palette in light and dark. */ + +.ctx-surface { + display: inline; +} + +/* The "β‹―" fallback trigger β€” a compact, quiet icon button. */ +.ctx-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 1px solid transparent; + border-radius: 0.375rem; + background: transparent; + color: var(--ink-soft); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; +} + + .ctx-ellipsis:hover, + .ctx-ellipsis[aria-expanded="true"] { + background: var(--hover-bg); + border-color: var(--bs-border-color); + color: var(--ink); + } + +/* Transparent full-viewport catch-all; sits just under the menu. */ +.ctx-backdrop { + position: fixed; + inset: 0; + z-index: 1080; + background: transparent; +} + +.ctx-menu { + position: fixed; + z-index: 1081; /* above the backdrop, below toasts (1090) */ + min-width: 11rem; + max-width: 20rem; + padding: 0.25rem; + background: var(--card); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + outline: none; +} + +.ctx-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.35rem 0.6rem; + border: 0; + border-radius: 0.375rem; + background: transparent; + color: var(--ink); + font-size: 0.875rem; + text-align: left; + cursor: pointer; +} + + .ctx-item:hover:not(:disabled), + .ctx-item.active:not(:disabled) { + background: var(--hover-bg); + } + + .ctx-item:disabled { + color: var(--ink-faint); + cursor: not-allowed; + } + +.ctx-icon { + display: inline-flex; + flex: 0 0 auto; + width: 1.1rem; + justify-content: center; +} + +.ctx-label { + flex: 1 1 auto; +} + +.ctx-separator { + height: 1px; + margin: 0.25rem 0.3rem; + background: var(--bs-border-color); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs new file mode 100644 index 00000000..a513f7f0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; + +/// +/// One entry in a . An item is either an actionable +/// row ( + ) or a visual divider +/// ( = true, in which case the other members are +/// ignored). Use the factory for dividers. +/// +public sealed class ContextMenuItem +{ + /// Text shown for the row. + public string Label { get; init; } = ""; + + /// Invoked when the row is activated (click / Enter). + public Action? OnClick { get; init; } + + /// Optional leading icon β€” an emoji or single glyph. + public string? Icon { get; init; } + + /// Renders the row greyed-out and non-interactive. + public bool Disabled { get; init; } + + /// When true this entry is a horizontal divider, not a row. + public bool IsSeparator { get; init; } + + /// A horizontal divider between groups of items. + public static ContextMenuItem Separator() => new() { IsSeparator = true }; +} From 12b99789743cc2045315635967ccca66ab5a5661 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:05:09 -0400 Subject: [PATCH 02/20] feat(core): T0-3 DriverTypeNames constants + reflection guard Add DriverTypeNames as the single source of truth for driver-type dispatch strings, one const per currently-registered driver factory (value = the exact factory DriverTypeName). Fixes the latent drift between hand-authored dispatch-map literals and the real factory names (e.g. TwinCAT/FOCAS/GalaxyMxGateway) at the source. A reflection guard test in Core.Abstractions.Tests builds the real registered-factory set by driving each *DriverFactoryExtensions.Register into a live DriverFactoryRegistry (mirroring the Host's DriverFactoryBootstrap) and asserts bidirectional parity with the constants, so any future rename on either side breaks the test gate. Consumers are rewired in Batch 2 WP8; Calculation's const lands with its factory in WP7. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../DriverTypeNames.cs | 68 +++++++++++++ .../DriverTypeNamesGuardTests.cs | 97 +++++++++++++++++++ ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 12 +++ 3 files changed, 177 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs new file mode 100644 index 00000000..2e166a21 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -0,0 +1,68 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Single source of truth for the driver-type identifier strings the runtime +/// dispatches on. Each constant's value is the exact DriverTypeName +/// that the corresponding driver factory registers under in the process +/// DriverFactoryRegistry β€” the string the deploy pipeline stores in +/// DriverInstance.DriverType and every dispatch map keys by. +/// +/// +/// +/// Historically several dispatch maps hand-authored these strings and drifted from +/// the authoritative factory names (e.g. "TwinCat" vs the real +/// "TwinCAT", "Focas" vs "FOCAS"). Consumers should reference +/// these constants instead of literals so the drift can never recur. +/// +/// +/// The reflection guard test +/// (DriverTypeNamesGuardTests) asserts bidirectional parity between these +/// constants and the set the driver factories actually register, so a rename on +/// either side breaks the build's test gate. Only constants for +/// currently-registered factories belong here β€” a not-yet-registered driver +/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own +/// constant when its factory is wired in. +/// +/// +public static class DriverTypeNames +{ + /// Modbus TCP / RTU-over-TCP driver. + public const string Modbus = "Modbus"; + + /// Siemens S7 driver. + public const string S7 = "S7"; + + /// Allen-Bradley CIP (ControlLogix / CompactLogix) driver. + public const string AbCip = "AbCip"; + + /// Allen-Bradley legacy (PCCC / DF1-over-TCP) driver. + public const string AbLegacy = "AbLegacy"; + + /// Beckhoff TwinCAT ADS driver. + public const string TwinCAT = "TwinCAT"; + + /// FANUC FOCAS CNC driver. + public const string FOCAS = "FOCAS"; + + /// OPC UA client (upstream-server) driver. + public const string OpcUaClient = "OpcUaClient"; + + /// AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway. + public const string Galaxy = "GalaxyMxGateway"; + + /// + /// Every driver-type string declared above, for callers that need to enumerate + /// the full set (e.g. validation of an authored DriverType). + /// + public static IReadOnlyCollection All { get; } = + [ + Modbus, + S7, + AbCip, + AbLegacy, + TwinCAT, + FOCAS, + OpcUaClient, + Galaxy, + ]; +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs new file mode 100644 index 00000000..d5b12aaf --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +/// +/// Guards against drift from the driver factories that are the +/// authority for these strings. The test builds the real registered-factory set the way +/// production does β€” it drives each driver assembly's *DriverFactoryExtensions.Register +/// entry point into a live , exactly as the Host's +/// DriverFactoryBootstrap.Register does β€” then asserts bidirectional parity with the +/// constants. If a factory renames its DriverTypeName (or a constant is added/removed +/// without a matching factory), this fails, forcing the constant and the factory back in sync. +/// +public sealed class DriverTypeNamesGuardTests +{ + /// + /// Build the authoritative registered-factory set by invoking every driver assembly's + /// registration extension against a fresh registry β€” the same set of calls, in the same + /// spirit, as DriverFactoryBootstrap.Register on a driver-role node. + /// + private static IReadOnlyCollection RegisteredDriverTypeNames() + { + var registry = new DriverFactoryRegistry(); + + // loggerFactory is optional on every extension; the factory func is never invoked here, + // so null is fine β€” we only need the type-name key each Register call installs. + Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry); + Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory: null); + Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); + Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory: null); + Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory: null); + Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory: null); + Driver.S7.S7DriverFactoryExtensions.Register(registry); + Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); + + return registry.RegisteredTypes; + } + + /// Reflect every public const string declared on . + private static IReadOnlyCollection DeclaredConstantValues() => + typeof(DriverTypeNames) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToArray(); + + [Fact] + public void Every_registered_factory_has_a_matching_constant() + { + var constants = DeclaredConstantValues(); + + foreach (var registered in RegisteredDriverTypeNames()) + { + constants.ShouldContain(registered, + $"driver factory registers '{registered}' but no DriverTypeNames constant has that value β€” " + + "add/rename the constant so dispatch maps can reference it instead of a drifting literal"); + } + } + + [Fact] + public void Every_constant_matches_a_registered_factory() + { + var registered = RegisteredDriverTypeNames(); + + foreach (var constant in DeclaredConstantValues()) + { + registered.ShouldContain(constant, + $"DriverTypeNames has '{constant}' but no registered driver factory uses it β€” " + + "remove the constant or wire the factory (do not add constants for unregistered drivers)"); + } + } + + [Fact] + public void Constants_and_registered_factories_are_exactly_the_same_set() + { + DeclaredConstantValues() + .OrderBy(s => s, StringComparer.Ordinal) + .ShouldBe( + RegisteredDriverTypeNames().OrderBy(s => s, StringComparer.Ordinal), + "DriverTypeNames must be exactly the set of registered driver-type strings β€” no drift, no extras, no gaps"); + } + + [Fact] + public void All_collection_equals_the_declared_constants() + { + // Keeps the convenience `All` collection honest against the individual consts. + DriverTypeNames.All + .OrderBy(s => s, StringComparer.Ordinal) + .ShouldBe( + DeclaredConstantValues().OrderBy(s => s, StringComparer.Ordinal), + "DriverTypeNames.All must list every declared constant exactly once"); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index daf8a0f2..f11c7282 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -21,6 +21,18 @@ + + + + + + + + + + From 17c7e97efbda07f90cc225beb9eea25bcc897c7f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:06:43 -0400 Subject: [PATCH 03/20] feat(commons): T0-2 RFC-4180 CSV parser + writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project (greenfield β€” no CSV code existed). CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable overload + eager Parse(string) + thin ParseWithHeader. Handles quoted fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict malformed-input policy: FormatException with 1-based line/column on a quote inside an unquoted field, a char after a closing quote, or an unterminated quote. Faithful empty-line policy: a blank line is one empty field (callers filter). CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote, CR, LF only; internal quotes doubled), configurable delimiter/newline (default CRLF), optional quote-all, no trailing terminator. Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the writer quote-on-demand rules, and the required Parse(Write(rows)) == rows round-trip property over a table of every tricky character (x CRLF/LF/ quote-all/semicolon). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Csv/CsvParser.cs | 293 ++++++++++++++++++ .../Csv/CsvWriter.cs | 142 +++++++++ .../Csv/CsvParserTests.cs | 292 +++++++++++++++++ .../Csv/CsvRoundTripTests.cs | 94 ++++++ .../Csv/CsvWriterTests.cs | 126 ++++++++ 5 files changed, 947 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs new file mode 100644 index 00000000..1eefc2f0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs @@ -0,0 +1,293 @@ +using System.Globalization; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Csv; + +/// +/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own β€” it consumes a +/// or a and yields rows of fields. This is the single +/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in +/// ) rather than re-deriving it at each call site. +/// +/// +/// Faithful to RFC 4180. The grammar handled: +/// +/// Fields are separated by the delimiter (default ','); records are separated by +/// newlines. +/// A field may be quoted with double-quotes ("). A quoted field may contain the +/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it +/// ("" β†’ "). +/// An unquoted field runs literally up to the next delimiter or newline; leading and +/// trailing spaces are preserved (RFC 4180 Β§2.4 β€” spaces are part of a field). +/// CRLF, bare LF, and bare CR are all accepted as record terminators. +/// +/// Empty-line policy. Faithful to the RFC: a line with no characters yields a single row +/// containing one empty field ([""]); it is NOT silently dropped. Callers that want blank lines +/// skipped should filter the result (e.g. rows.Where(r => r.Length > 1 || r[0].Length > 0)). +/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its +/// absence does not lose the last row. +/// Malformed-input policy. This parser is strict. It throws +/// (carrying a 1-based line/column position) for the malformed +/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g. +/// ab"c), (2) a stray character after a closing quote other than the delimiter or a newline +/// (e.g. "ab"c), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate +/// β€” silent lenient recovery hides data-shape bugs in imported files. +/// +public static class CsvParser +{ + private const char Quote = '"'; + private const char Cr = '\r'; + private const char Lf = '\n'; + + /// + /// Parses fully into rows of fields. Convenience wrapper over + /// ; materialises the whole document. + /// + /// The CSV document. null is treated as empty. + /// The field separator (default comma). + /// The rows, each an array of field values. Empty input yields zero rows. + /// The input violates the strict RFC 4180 grammar. + public static IReadOnlyList Parse(string? text, char delimiter = ',') + { + using var reader = new StringReader(text ?? string.Empty); + var rows = new List(); + foreach (var row in Parse(reader, delimiter)) + { + rows.Add(row); + } + + return rows; + } + + /// + /// Streams rows from lazily β€” a single forward pass, one row + /// materialised at a time. The reader is not disposed by this method. + /// + /// The source. Read to end-of-input. + /// The field separator (default comma). + /// A lazily-evaluated sequence of rows; each row is a freshly allocated field array. + /// is null. + /// is a quote, CR, or LF. + /// The input violates the strict RFC 4180 grammar. + public static IEnumerable Parse(TextReader reader, char delimiter = ',') + { + ArgumentNullException.ThrowIfNull(reader); + if (delimiter is Quote or Cr or Lf) + { + throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter)); + } + + return Iterate(reader, delimiter); + } + + private static IEnumerable Iterate(TextReader reader, char delimiter) + { + var field = new StringBuilder(); + var row = new List(); + + // True once the current row has produced any field boundary or content β€” i.e. once we've seen a + // char that commits us to emitting at least one field. Reset to false right after a record + // terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row. + var rowOpen = false; + + // 1-based cursor, maintained for FormatException messages. + var line = 1; + var col = 0; + + int read; + while ((read = reader.Read()) != -1) + { + var c = (char)read; + col++; + + if (c == Quote) + { + if (field.Length != 0) + { + throw new FormatException( + $"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " + + "A field is quoted only when the quote is its first character."); + } + + rowOpen = true; + + // Consume the quoted body; the opening quote has been read. + ReadQuotedField(reader, field, ref line, ref col); + + // A closing quote must be followed by the delimiter, a newline, or EOF. + var next = reader.Peek(); + if (next == -1) + { + row.Add(field.ToString()); + field.Clear(); + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + yield break; + } + + var nc = (char)next; + if (nc == delimiter) + { + reader.Read(); + col++; + row.Add(field.ToString()); + field.Clear(); + continue; + } + + if (nc is Cr or Lf) + { + row.Add(field.ToString()); + field.Clear(); + ConsumeNewline(reader, ref line, ref col); + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + continue; + } + + throw new FormatException( + $"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " + + "A quoted field must be followed by a delimiter, a newline, or end-of-input."); + } + + if (c == delimiter) + { + row.Add(field.ToString()); + field.Clear(); + rowOpen = true; + continue; + } + + if (c is Cr or Lf) + { + row.Add(field.ToString()); + field.Clear(); + if (c == Cr && reader.Peek() == Lf) + { + reader.Read(); + } + + line++; + col = 0; + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + continue; + } + + field.Append(c); + rowOpen = true; + } + + // EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen. + if (rowOpen || field.Length != 0 || row.Count != 0) + { + row.Add(field.ToString()); + yield return row.ToArray(); + } + } + + /// + /// Reads the body of a quoted field into . The opening quote has already + /// been consumed. On return the reader sits immediately after the closing quote. + /// + private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col) + { + var openLine = line; + var openCol = col; + + int read; + while ((read = reader.Read()) != -1) + { + var c = (char)read; + col++; + + if (c == Quote) + { + if (reader.Peek() == Quote) + { + reader.Read(); + col++; + field.Append(Quote); + continue; + } + + return; // closing quote + } + + if (c == Lf) + { + line++; + col = 0; + } + + field.Append(c); + } + + throw new FormatException( + $"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " + + "before the closing double-quote."); + } + + /// Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read. + private static void ConsumeNewline(TextReader reader, ref int line, ref int col) + { + var first = reader.Read(); + if (first == Cr && reader.Peek() == Lf) + { + reader.Read(); + } + + line++; + col = 0; + } + + /// + /// Header-aware convenience: parses and maps every subsequent row onto the + /// first (header) row's field names. Thin wrapper over . + /// + /// The CSV document; the first row is treated as the header. + /// The field separator (default comma). + /// + /// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than + /// the header maps only the columns present; a column beyond the header's width is keyed by its + /// 0-based index rendered as a string. An empty document yields zero rows. + /// + /// The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name. + public static IReadOnlyList> ParseWithHeader(string? text, char delimiter = ',') + { + var rows = Parse(text, delimiter); + if (rows.Count == 0) + { + return Array.Empty>(); + } + + var header = rows[0]; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var name in header) + { + if (!seen.Add(name)) + { + throw new FormatException($"Duplicate header column name '{name}'."); + } + } + + var result = new List>(rows.Count - 1); + for (var i = 1; i < rows.Count; i++) + { + var cells = rows[i]; + var map = new Dictionary(cells.Length, StringComparer.Ordinal); + for (var c = 0; c < cells.Length; c++) + { + var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture); + map[key] = cells[c]; + } + + result.Add(map); + } + + return result; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs new file mode 100644 index 00000000..2e654c74 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs @@ -0,0 +1,142 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Csv; + +/// +/// A pure RFC 4180 CSV writer with no file/stream I/O of its own β€” it renders rows of fields to a +/// or a . The inverse of : +/// CsvParser.Parse(CsvWriter.WriteToString(rows)) reproduces rows exactly for any field +/// content. +/// +/// +/// Quote-on-demand. A field is wrapped in double-quotes only when it must be β€” i.e. when +/// it contains the delimiter, a double-quote, CR, or LF β€” and internal double-quotes are doubled +/// (" β†’ ""). Fields that need no quoting are emitted verbatim, so ordinary values stay +/// human-readable. Pass quoteAllFields: true to force every field quoted. +/// Newline. The record terminator defaults to CRLF (\r\n) per RFC 4180 and is +/// configurable. No terminator is written after the final row (matching the parser's +/// no-phantom-trailing-row contract, so a round-trip is exact). +/// +public static class CsvWriter +{ + private const char Quote = '"'; + private const char Cr = '\r'; + private const char Lf = '\n'; + + /// The RFC 4180 record terminator, "\r\n". The default newline for every write. + public const string Crlf = "\r\n"; + + /// + /// Renders to a CSV string. + /// + /// The rows to write; each inner sequence is one record's fields. A null field is written as empty. + /// The field separator (default comma). + /// The record terminator between rows (default CRLF). Not appended after the last row. + /// When true, every field is quoted regardless of content. + /// The CSV text. An empty yields the empty string. + /// is null. + /// is a quote, CR, or LF. + public static string WriteToString( + IEnumerable> rows, + char delimiter = ',', + string newline = Crlf, + bool quoteAllFields = false) + { + var sb = new StringBuilder(); + using var writer = new StringWriter(sb); + Write(writer, rows, delimiter, newline, quoteAllFields); + return sb.ToString(); + } + + /// + /// Writes to . The writer is not disposed or + /// flushed by this method. + /// + /// The destination. + /// The rows to write; each inner sequence is one record's fields. A null field is written as empty. + /// The field separator (default comma). + /// The record terminator between rows (default CRLF). Not appended after the last row. + /// When true, every field is quoted regardless of content. + /// or is null. + /// is a quote, CR, or LF. + public static void Write( + TextWriter writer, + IEnumerable> rows, + char delimiter = ',', + string newline = Crlf, + bool quoteAllFields = false) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(rows); + if (delimiter is Quote or Cr or Lf) + { + throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter)); + } + + var firstRow = true; + foreach (var row in rows) + { + if (!firstRow) + { + writer.Write(newline); + } + + firstRow = false; + + WriteRow(writer, row, delimiter, quoteAllFields); + } + } + + private static void WriteRow(TextWriter writer, IEnumerable row, char delimiter, bool quoteAllFields) + { + ArgumentNullException.ThrowIfNull(row); + + var firstField = true; + foreach (var field in row) + { + if (!firstField) + { + writer.Write(delimiter); + } + + firstField = false; + + WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields); + } + } + + private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields) + { + if (!quoteAllFields && !NeedsQuoting(field, delimiter)) + { + writer.Write(field); + return; + } + + writer.Write(Quote); + foreach (var ch in field) + { + if (ch == Quote) + { + writer.Write(Quote); // double an internal quote + } + + writer.Write(ch); + } + + writer.Write(Quote); + } + + private static bool NeedsQuoting(string field, char delimiter) + { + foreach (var ch in field) + { + if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf) + { + return true; + } + } + + return false; + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs new file mode 100644 index 00000000..a657f536 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvParserTests.cs @@ -0,0 +1,292 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv; + +/// +/// The authoritative RFC 4180 read corpus for . Every edge the RFC pins β€” +/// quoting, embedded delimiters/newlines, escaped quotes, space preservation, empty vs. absent +/// fields, trailing-newline handling, CRLF/LF/CR terminators β€” plus this parser's declared +/// strict malformed-input and faithful empty-line policies, lives here. +/// +public sealed class CsvParserTests +{ + // ---- simple rows ---- + + [Fact] + public void Parses_simple_rows() + { + var rows = CsvParser.Parse("a,b,c\nd,e,f"); + + rows.Count.ShouldBe(2); + rows[0].ShouldBe(new[] { "a", "b", "c" }); + rows[1].ShouldBe(new[] { "d", "e", "f" }); + } + + [Fact] + public void Single_field_single_row() + { + CsvParser.Parse("hello").ShouldBe(new[] { new[] { "hello" } }); + } + + [Fact] + public void Empty_input_yields_no_rows() + { + CsvParser.Parse("").Count.ShouldBe(0); + CsvParser.Parse((string?)null).Count.ShouldBe(0); + } + + // ---- empty vs. absent fields ---- + + [Fact] + public void Middle_empty_field_is_preserved() + { + CsvParser.Parse("a,,c").Single().ShouldBe(new[] { "a", "", "c" }); + } + + [Fact] + public void Trailing_comma_yields_trailing_empty_field() + { + CsvParser.Parse("a,").Single().ShouldBe(new[] { "a", "" }); + } + + [Fact] + public void Leading_comma_yields_leading_empty_field() + { + CsvParser.Parse(",a").Single().ShouldBe(new[] { "", "a" }); + } + + [Fact] + public void All_empty_fields() + { + CsvParser.Parse(",,").Single().ShouldBe(new[] { "", "", "" }); + } + + // ---- quoting ---- + + [Fact] + public void Quoted_field_with_embedded_comma() + { + CsvParser.Parse("\"a,b\",c").Single().ShouldBe(new[] { "a,b", "c" }); + } + + [Fact] + public void Quoted_field_with_embedded_lf() + { + CsvParser.Parse("\"line1\nline2\",b").Single().ShouldBe(new[] { "line1\nline2", "b" }); + } + + [Fact] + public void Quoted_field_with_embedded_crlf() + { + CsvParser.Parse("\"line1\r\nline2\",b").Single().ShouldBe(new[] { "line1\r\nline2", "b" }); + } + + [Fact] + public void Escaped_quote_becomes_single_quote() + { + // "She said ""hi""" -> She said "hi" + CsvParser.Parse("\"She said \"\"hi\"\"\"").Single().ShouldBe(new[] { "She said \"hi\"" }); + } + + [Fact] + public void Empty_quoted_field_is_empty_string() + { + CsvParser.Parse("\"\",\"\"").Single().ShouldBe(new[] { "", "" }); + } + + [Fact] + public void Quoted_field_containing_only_escaped_quotes() + { + // """" -> a single literal double-quote + CsvParser.Parse("\"\"\"\"").Single().ShouldBe(new[] { "\"" }); + } + + [Fact] + public void Quoted_field_at_end_of_row_then_more_rows() + { + var rows = CsvParser.Parse("\"a,b\"\nc,d"); + rows.Count.ShouldBe(2); + rows[0].ShouldBe(new[] { "a,b" }); + rows[1].ShouldBe(new[] { "c", "d" }); + } + + // ---- space preservation (RFC 4180 Β§2.4) ---- + + [Fact] + public void Spaces_outside_quotes_are_preserved() + { + CsvParser.Parse(" a , b ").Single().ShouldBe(new[] { " a ", " b " }); + } + + [Fact] + public void Spaces_inside_quotes_are_preserved() + { + CsvParser.Parse("\" padded \"").Single().ShouldBe(new[] { " padded " }); + } + + // ---- line endings ---- + + [Fact] + public void Lf_line_endings() + { + CsvParser.Parse("a\nb\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void Crlf_line_endings() + { + CsvParser.Parse("a\r\nb\r\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void Bare_cr_line_endings() + { + CsvParser.Parse("a\rb\rc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" }); + } + + // ---- trailing newline: present vs. absent ---- + + [Fact] + public void Trailing_lf_does_not_add_phantom_row() + { + CsvParser.Parse("a,b\n").Count.ShouldBe(1); + CsvParser.Parse("a,b\n").Single().ShouldBe(new[] { "a", "b" }); + } + + [Fact] + public void Trailing_crlf_does_not_add_phantom_row() + { + CsvParser.Parse("a,b\r\n").Count.ShouldBe(1); + } + + [Fact] + public void No_trailing_newline_keeps_last_row() + { + var withNl = CsvParser.Parse("a\nb\n"); + var withoutNl = CsvParser.Parse("a\nb"); + withNl.Count.ShouldBe(2); + withoutNl.Count.ShouldBe(2); + withNl[1].ShouldBe(withoutNl[1]); + } + + [Fact] + public void Quoted_field_with_trailing_newline_no_phantom_row() + { + var rows = CsvParser.Parse("\"a\"\n"); + rows.Count.ShouldBe(1); + rows[0].ShouldBe(new[] { "a" }); + } + + // ---- empty-line policy (faithful RFC: empty line == one empty field) ---- + + [Fact] + public void Empty_line_is_a_single_empty_field() + { + // a\n\nb -> ["a"], [""], ["b"] β€” the blank line is NOT dropped. + var rows = CsvParser.Parse("a\n\nb"); + rows.Count.ShouldBe(3); + rows[0].ShouldBe(new[] { "a" }); + rows[1].ShouldBe(new[] { "" }); + rows[2].ShouldBe(new[] { "b" }); + } + + [Fact] + public void Callers_can_filter_blank_lines() + { + var rows = CsvParser.Parse("a\n\nb") + .Where(r => r.Length > 1 || r[0].Length > 0) + .ToList(); + rows.Count.ShouldBe(2); + } + + // ---- streaming overload ---- + + [Fact] + public void Streams_over_a_textreader() + { + using var reader = new StringReader("x,y\nz,w"); + var rows = CsvParser.Parse(reader).ToList(); + rows.Count.ShouldBe(2); + rows[1].ShouldBe(new[] { "z", "w" }); + } + + [Fact] + public void Custom_delimiter_semicolon() + { + CsvParser.Parse("a;b;c", ';').Single().ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void Custom_delimiter_leaves_comma_literal() + { + CsvParser.Parse("a,b;c", ';').Single().ShouldBe(new[] { "a,b", "c" }); + } + + // ---- strict malformed-input policy ---- + + [Fact] + public void Unterminated_quote_throws() + { + var ex = Should.Throw(() => CsvParser.Parse("\"unclosed")); + ex.Message.ShouldContain("Unterminated"); + } + + [Fact] + public void Quote_inside_unquoted_field_throws() + { + var ex = Should.Throw(() => CsvParser.Parse("ab\"c")); + ex.Message.ShouldContain("unquoted"); + } + + [Fact] + public void Character_after_closing_quote_throws() + { + var ex = Should.Throw(() => CsvParser.Parse("\"ab\"c")); + ex.Message.ShouldContain("after closing quote"); + } + + [Fact] + public void Malformed_message_carries_position() + { + var ex = Should.Throw(() => CsvParser.Parse("a,b\nab\"c")); + ex.Message.ShouldContain("line 2"); + } + + [Fact] + public void Delimiter_may_not_be_a_quote() + { + Should.Throw(() => CsvParser.Parse("a,b", '"').ToString()); + } + + // ---- ParseWithHeader ---- + + [Fact] + public void ParseWithHeader_maps_rows_by_name() + { + var rows = CsvParser.ParseWithHeader("name,age\nalice,30\nbob,40"); + rows.Count.ShouldBe(2); + rows[0]["name"].ShouldBe("alice"); + rows[0]["age"].ShouldBe("30"); + rows[1]["name"].ShouldBe("bob"); + } + + [Fact] + public void ParseWithHeader_empty_document_yields_no_rows() + { + CsvParser.ParseWithHeader("").Count.ShouldBe(0); + } + + [Fact] + public void ParseWithHeader_header_only_yields_no_rows() + { + CsvParser.ParseWithHeader("name,age").Count.ShouldBe(0); + } + + [Fact] + public void ParseWithHeader_duplicate_header_throws() + { + Should.Throw(() => CsvParser.ParseWithHeader("name,name\nx,y")); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs new file mode 100644 index 00000000..f2521798 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs @@ -0,0 +1,94 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv; + +/// +/// The required round-trip property: for arbitrary field content, Parse(Write(rows)) == rows. +/// This is what makes the writer a true inverse of the parser and pins the quote-on-demand rules to +/// the parser's grammar. The corpus deliberately includes every tricky character. +/// +public sealed class CsvRoundTripTests +{ + public static IEnumerable Tables() + { + // Each case is a jagged table of fields. + yield return new object[] { new[] { new[] { "a", "b", "c" } } }; + yield return new object[] { new[] { new[] { "a", "b" }, new[] { "c", "d" } } }; + // NOTE: a single row of a single empty field ([[""]]) is deliberately excluded β€” it serialises to + // the empty string, which is indistinguishable from an empty document and parses back to zero rows. + // That one degenerate case is the sole non-round-trippable shape; every multi-field / multi-row + // empty case below (",," and blank interior lines) does round-trip exactly. + yield return new object[] { new[] { new[] { "", "", "" } } }; + yield return new object[] { new[] { new[] { "a" }, new[] { "" }, new[] { "b" } } }; + yield return new object[] { new[] { new[] { "a,b", "c" } } }; // embedded delimiter + yield return new object[] { new[] { new[] { "he said \"hi\"" } } }; // embedded quote + yield return new object[] { new[] { new[] { "line1\nline2" } } }; // embedded LF + yield return new object[] { new[] { new[] { "line1\r\nline2" } } }; // embedded CRLF + yield return new object[] { new[] { new[] { "line1\rline2" } } }; // embedded CR + yield return new object[] { new[] { new[] { " leading", "trailing " } } }; // spaces + yield return new object[] { new[] { new[] { " ", "\t" } } }; // whitespace-only + yield return new object[] { new[] { new[] { "\"", "\"\"", ",", "\n" } } }; // every special alone + yield return new object[] { new[] { new[] { "mix,\"quote\"\nand\r\nnewlines" } } }; + yield return new object[] + { + new[] + { + new[] { "id", "name", "note" }, + new[] { "1", "alice", "says \"hi, there\"" }, + new[] { "2", "bob", "multi\nline\nnote" }, + new[] { "3", "", "" }, + }, + }; + } + + [Theory] + [MemberData(nameof(Tables))] + public void RoundTrips_with_default_crlf(string[][] rows) + { + var csv = CsvWriter.WriteToString(rows); + var parsed = CsvParser.Parse(csv); + + ShouldMatch(parsed, rows); + } + + [Theory] + [MemberData(nameof(Tables))] + public void RoundTrips_with_lf_newline(string[][] rows) + { + var csv = CsvWriter.WriteToString(rows, newline: "\n"); + var parsed = CsvParser.Parse(csv); + + ShouldMatch(parsed, rows); + } + + [Theory] + [MemberData(nameof(Tables))] + public void RoundTrips_with_quote_all_fields(string[][] rows) + { + var csv = CsvWriter.WriteToString(rows, quoteAllFields: true); + var parsed = CsvParser.Parse(csv); + + ShouldMatch(parsed, rows); + } + + [Theory] + [MemberData(nameof(Tables))] + public void RoundTrips_with_semicolon_delimiter(string[][] rows) + { + var csv = CsvWriter.WriteToString(rows, delimiter: ';'); + var parsed = CsvParser.Parse(csv, ';'); + + ShouldMatch(parsed, rows); + } + + private static void ShouldMatch(IReadOnlyList parsed, string[][] expected) + { + parsed.Count.ShouldBe(expected.Length); + for (var i = 0; i < expected.Length; i++) + { + parsed[i].ShouldBe(expected[i], $"row {i} mismatch"); + } + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs new file mode 100644 index 00000000..0f1ae510 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvWriterTests.cs @@ -0,0 +1,126 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv; + +/// +/// Quote-on-demand and terminator behaviour for : a field is quoted only when +/// it must be (delimiter / quote / CR / LF), internal quotes double, CRLF terminates records by +/// default (configurable), and no terminator trails the final row. +/// +public sealed class CsvWriterTests +{ + [Fact] + public void Writes_simple_rows_with_crlf_default() + { + var csv = CsvWriter.WriteToString(new[] + { + new[] { "a", "b", "c" }, + new[] { "d", "e", "f" }, + }); + + csv.ShouldBe("a,b,c\r\nd,e,f"); + } + + [Fact] + public void No_trailing_newline_after_last_row() + { + CsvWriter.WriteToString(new[] { new[] { "a" } }).ShouldBe("a"); + } + + [Fact] + public void Empty_rows_yield_empty_string() + { + CsvWriter.WriteToString(Array.Empty()).ShouldBe(""); + } + + // ---- quote-on-demand ---- + + [Fact] + public void Plain_field_is_not_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "plain" } }).ShouldBe("plain"); + } + + [Fact] + public void Field_with_comma_is_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a,b" } }).ShouldBe("\"a,b\""); + } + + [Fact] + public void Field_with_quote_is_quoted_and_doubled() + { + CsvWriter.WriteToString(new[] { new[] { "a\"b" } }).ShouldBe("\"a\"\"b\""); + } + + [Fact] + public void Field_with_lf_is_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a\nb" } }).ShouldBe("\"a\nb\""); + } + + [Fact] + public void Field_with_cr_is_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a\rb" } }).ShouldBe("\"a\rb\""); + } + + [Fact] + public void Leading_and_trailing_spaces_are_not_quoted() + { + // RFC does not require quoting for spaces; the parser preserves them either way. + CsvWriter.WriteToString(new[] { new[] { " a " } }).ShouldBe(" a "); + } + + [Fact] + public void Empty_fields_are_written_bare() + { + CsvWriter.WriteToString(new[] { new[] { "a", "", "c" } }).ShouldBe("a,,c"); + } + + [Fact] + public void Null_field_is_written_as_empty() + { + CsvWriter.WriteToString(new[] { new[] { "a", null, "c" } }).ShouldBe("a,,c"); + } + + // ---- options ---- + + [Fact] + public void QuoteAllFields_forces_every_field_quoted() + { + CsvWriter.WriteToString(new[] { new[] { "a", "b" } }, quoteAllFields: true) + .ShouldBe("\"a\",\"b\""); + } + + [Fact] + public void Custom_newline_lf() + { + CsvWriter.WriteToString(new[] { new[] { "a" }, new[] { "b" } }, newline: "\n") + .ShouldBe("a\nb"); + } + + [Fact] + public void Custom_delimiter_semicolon_quotes_on_semicolon_not_comma() + { + CsvWriter.WriteToString(new[] { new[] { "a,b", "c;d" } }, delimiter: ';') + .ShouldBe("a,b;\"c;d\""); + } + + [Fact] + public void Writes_to_a_textwriter() + { + var sw = new StringWriter(); + CsvWriter.Write(sw, new[] { new[] { "x", "y" } }); + sw.ToString().ShouldBe("x,y"); + } + + [Fact] + public void Delimiter_may_not_be_a_quote() + { + Should.Throw(() => + CsvWriter.WriteToString(new[] { new[] { "a" } }, delimiter: '"')); + } +} From 86ca1a76d19763e16bc6efb3216827f452ba935a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:07:38 -0400 Subject: [PATCH 04/20] feat(driver-calc): T0-4 Calculation evaluator core + project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Driver.Calculation project + CalculationEvaluator, mirroring the VirtualTag RoslynVirtualTagEvaluator: source-hash compile cache (CompiledScriptCache), 2 s TimedScriptEvaluator, passthrough fast-path, single-tag mode (ctx.SetVirtualTag dropped + logged), sandbox/compile/ timeout/throw all surfaced as VirtualTagEvalResult.Failure for WP7 to map to Bad quality. IScriptCacheOwner.ClearCompiledScripts for the deploy generation boundary. 11 parity unit tests. Both projects added to the solution. Evaluator + tests only β€” driver shell / registration is WP7. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- ZB.MOM.WW.OtOpcUa.slnx | 2 + .../CalculationEvaluator.cs | 193 ++++++++++++++ ...B.MOM.WW.OtOpcUa.Driver.Calculation.csproj | 32 +++ .../CalculationEvaluatorTests.cs | 247 ++++++++++++++++++ ...WW.OtOpcUa.Driver.Calculation.Tests.csproj | 26 ++ 5 files changed, 500 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 14ad26d1..a1090eab 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -21,6 +21,7 @@ + @@ -81,6 +82,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs new file mode 100644 index 00000000..481ce36e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; +using SerilogLogger = Serilog.ILogger; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; + +/// +/// T0-4 β€” the Calculation driver's script evaluator. A calc tag is a raw tag whose value is +/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of +/// the VirtualTag RoslynVirtualTagEvaluator: it compiles each unique source once via +/// (the Roslyn-backed sandbox), caches the +/// resulting evaluator keyed by source in a , +/// and runs steady-state evaluations as in-process method invocations against the dependency +/// dictionary β€” fast enough to run inline on the driver's dispatch. It reuses +/// verbatim so the Monaco editor, sandbox, and diagnostics pipeline +/// apply to calc scripts with no divergence. +/// +/// +/// Single-tag mode. A calc tag produces exactly one output value β€” its own β€” so cross-tag +/// ctx.SetVirtualTag writes are dropped (and logged at Debug). Fan-out between tags is owned +/// by the host's DependencyMuxActor (calc-of-calc chains re-enter the mux), never by the +/// eval engine, exactly as in the VirtualTag adapter. +/// +/// +/// Error surfacing (for WP7). This evaluator never throws for script faults: compile error, +/// sandbox violation, runtime throw, and timeout all return +/// with a descriptive Reason. A successful run returns +/// carrying the computed value (which may be null). WP7's CalculationDriver maps a +/// Failure to Bad quality + a ScriptLogEntry and an Ok to the published value. +/// +/// +/// Cache management. Implements so WP7 can drop compiled +/// scripts at each deploy generation whose source set changed β€” mirroring the VirtualTag adapter's +/// apply-boundary clear, which is what stops collectible AssemblyLoadContexts accreting across +/// script edits. +/// +/// +public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable +{ + // Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose() + // that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() β€” driven by the + // WP7 driver per deploy generation β€” is what stops ALCs accreting across script edits. + private readonly CompiledScriptCache _cache = new(); + private readonly ILogger _logger; + private readonly SerilogLogger _scriptRoot; + private readonly TimeSpan _runTimeout; + private bool _disposed; + + /// Initializes a new . + /// Logger for host-side compilation/execution diagnostics. + /// Root script logger; user ctx.Logger.* output flows through this to the Script-log page. + /// Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator). + public CalculationEvaluator( + ILogger logger, + ScriptRootLogger scriptRoot, + TimeSpan? runTimeout = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger; + _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2); + } + + /// + /// Evaluate for the calc tag against + /// the snapshot in (RawPath β†’ last value). Never throws β€” script + /// faults are reported via ; a successful run returns + /// carrying the single computed value. + /// + /// Identity of the calc tag (bound to the script log; in the live path equals the script id). + /// The C# script source producing the tag's value. + /// Read-only snapshot of dependency values keyed by RawPath. + public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary dependencies) + { + if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed"); + if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression"); + + // Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn. + // Semantics are byte-identical to the compiled mirror: a present dependency returns its value + // (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script + // returns null β€” Ok(null) here too. Near-misses fall through to the compiled path. + if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef)) + { + return dependencies.TryGetValue(passthroughRef, out var ptValue) + ? VirtualTagEvalResult.Ok(ptValue) + : VirtualTagEvalResult.Ok(null); + } + + var readCache = BuildReadCache(dependencies); + // Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which + // the Script-log page attributes on) from the calc-tag id so each line is attributable. + var scriptLog = _scriptRoot + .ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId) + .ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId); + var context = new VirtualTagContext( + readCache, + // Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped. + setVirtualTag: (path, _) => + _logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)", + calcTagId, path), + logger: scriptLog); + + // Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent + // ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's + // disposed guard) re-fetches and retries exactly once. The loop runs at most twice. + for (var attempt = 0; ; attempt++) + { + ScriptEvaluator evaluator; + try + { + evaluator = _cache.GetOrCompile(scriptSource); + } + catch (CompilationErrorException ex) + { + _logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId); + return VirtualTagEvalResult.Failure($"compile error: {ex.Message}"); + } + catch (ScriptSandboxViolationException ex) + { + _logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId); + return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}"); + } + catch (ObjectDisposedException) + { + // Fetch-time disposal is a shutdown signal (the cache is tearing down), not the + // apply-boundary race β€” never retry. + return VirtualTagEvalResult.Failure("evaluator disposed"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId); + return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}"); + } + + try + { + // Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't + // interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a + // runaway script to the wall-clock budget instead of wedging the driver's dispatch. + var timed = new TimedScriptEvaluator(evaluator, _runTimeout); + var raw = timed.RunAsync(context).GetAwaiter().GetResult(); + return VirtualTagEvalResult.Ok(raw); + } + catch (ScriptTimeoutException) + { + return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s"); + } + catch (ObjectDisposedException) when (!_disposed && attempt == 0) + { + // A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the + // run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation) + // and retry once. A second disposal mid-retry falls through to the failure path below. + continue; + } + catch (ObjectDisposedException) + { + return VirtualTagEvalResult.Failure("evaluator disposed during evaluation"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId); + return VirtualTagEvalResult.Failure($"script threw: {ex.Message}"); + } + } + } + + /// + public void ClearCompiledScripts() => _cache.Clear(); + + private static IReadOnlyDictionary BuildReadCache( + IReadOnlyDictionary deps) + { + // VirtualTagContext.GetTag returns a DataValueSnapshot β€” wrap each raw dep value as Good-quality + // so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null. + var nowUtc = DateTime.UtcNow; + var cache = new Dictionary(StringComparer.Ordinal); + foreach (var kv in deps) + { + cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc); + } + return cache; + } + + /// Disposes the evaluator, draining + disposing every cached compile's collectible ALC. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cache.Dispose(); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj new file mode 100644 index 00000000..c3aa6ec4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Calculation + ZB.MOM.WW.OtOpcUa.Driver.Calculation + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs new file mode 100644 index 00000000..3893e2bc --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs @@ -0,0 +1,247 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Serilog; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests; + +/// +/// T0-4 β€” parity tests for against the VirtualTag +/// RoslynVirtualTagEvaluator it mirrors: the passthrough fast-path, the wall-clock timeout, +/// sandbox-violation rejection, single-tag SetVirtualTag drop, compile-error surfacing, and +/// the compile-cache reuse / clear contract. All script faults surface as +/// VirtualTagEvalResult.Failure (never a thrown exception) so WP7 can map them to Bad quality. +/// +public sealed class CalculationEvaluatorTests +{ + /// Builds a no-op for tests that don't assert on script logging. + private static ScriptRootLogger NoOpScriptRoot() => + new(new LoggerConfiguration().CreateLogger()); + + // ── passthrough fast-path ─────────────────────────────────────────────────────────────── + + /// The mirror shape return ctx.GetTag("X").Value; returns the dependency value + /// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path). + [Fact] + public void Passthrough_returns_dependency_value_without_compiling() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-mirror", + scriptSource: "return ctx.GetTag(\"a\").Value;", + dependencies: new Dictionary { ["a"] = 42 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(42); + } + + /// Decisive proof the fast-path skips Roslyn: the compiled-script cache stays EMPTY after a + /// mirror evaluation, then grows to one entry once a genuine (non-mirror) expression forces compilation. + [Fact] + public void Passthrough_does_not_populate_the_compiled_script_cache() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;", + new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(0); + + sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;", + new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(1); + } + + // ── timeout ───────────────────────────────────────────────────────────────────────────── + + /// An infinite-loop script returns Failure ("timed out") within the wall-clock budget instead + /// of wedging the caller. The whole call is WaitAsync-bounded so a regression FAILS rather than hangs. + [Fact] + public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang() + { + using var sut = new CalculationEvaluator( + NullLogger.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200)); + + var evalTask = Task.Run( + () => sut.Evaluate("calc-loop", "while(true){}return 0;", new Dictionary()), + TestContext.Current.CancellationToken); + + var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("timed out"); + } + + // ── sandbox violation ─────────────────────────────────────────────────────────────────── + + /// A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects + /// it: the ScriptSandboxViolationException from compile is caught and returned as a Failure whose + /// reason names the sandbox violation β€” never propagated as an exception. + [Fact] + public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-fileio", + scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");", + dependencies: new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("sandbox violation"); + } + + // ── single-tag mode: ctx.SetVirtualTag drop ───────────────────────────────────────────── + + /// Single-tag mode: a script calling ctx.SetVirtualTag has that cross-tag write dropped + /// (logged at Debug) while the script's own return value β€” the calc tag's single output β€” is preserved. + [Fact] + public void SetVirtualTag_call_is_dropped_and_logged_single_output_preserved() + { + var logger = new CapturingLogger(); + using var sut = new CalculationEvaluator(logger, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-fanout", + scriptSource: "ctx.SetVirtualTag(\"other\", 99); return (int)ctx.GetTag(\"a\").Value + 1;", + dependencies: new Dictionary { ["a"] = 41 }); + + // The write to "other" is dropped, but the calc tag's own output flows through unaffected. + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(42); + + // The drop was logged (single-tag evaluator), never applied. + logger.Entries.ShouldContain(e => + e.Level == LogLevel.Debug && e.Message.Contains("dropped") && e.Message.Contains("other")); + } + + // ── compile error ─────────────────────────────────────────────────────────────────────── + + /// A compile error returns Failure with a descriptive reason (parity with the VT evaluator). + [Fact] + public void Evaluate_compile_error_returns_Failure_with_reason() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("compile"); + } + + /// A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator). + [Fact] + public void Evaluate_runtime_exception_returns_Failure_with_reason() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-div0", + scriptSource: "int a = 0; return 1 / a;", + dependencies: new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("threw"); + } + + // ── cache reuse + clear ───────────────────────────────────────────────────────────────── + + /// A compiled (non-mirror) expression is cached and re-used across calls: two evaluations of the + /// same source produce fresh values but leave the cache at exactly one entry. + [Fact] + public void Evaluate_caches_compiled_expression_across_calls() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;"; + + var first = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 5 }); + var second = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 7 }); + + first.Success.ShouldBeTrue(first.Reason); + first.Value.ShouldBe(10); + second.Success.ShouldBeTrue(second.Reason); + second.Value.ShouldBe(14); + CompiledCacheCount(sut).ShouldBe(1); + } + + /// drops every cached compile β€” the hook + /// WP7 calls per deploy generation to release stale AssemblyLoadContexts β€” and the same source recompiles + /// cleanly afterward. + [Fact] + public void ClearCompiledScripts_empties_cache_then_recompiles() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;"; + + sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(1); + + sut.ClearCompiledScripts(); + CompiledCacheCount(sut).ShouldBe(0); + + var again = sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 41 }); + again.Success.ShouldBeTrue(again.Reason); + again.Value.ShouldBe(42); + CompiledCacheCount(sut).ShouldBe(1); + } + + // ── guard rails ───────────────────────────────────────────────────────────────────────── + + /// Empty / whitespace scripts return Failure (parity with the VT evaluator). + [Fact] + public void Evaluate_empty_expression_returns_Failure() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + sut.Evaluate("calc-empty", "", new Dictionary()).Success.ShouldBeFalse(); + sut.Evaluate("calc-empty", " ", new Dictionary()).Success.ShouldBeFalse(); + } + + /// Evaluation after disposal returns Failure ("disposed") rather than throwing. + [Fact] + public void Evaluate_after_dispose_returns_Failure() + { + var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + sut.Dispose(); + + var result = sut.Evaluate("calc", "return 1;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason!.ShouldContain("disposed"); + } + + /// Reads the count of the private CompiledScriptCache (which exposes a Count + /// property) via reflection β€” mirrors the VT evaluator test's cache-count probe. + private static int CompiledCacheCount(CalculationEvaluator sut) + { + var field = typeof(CalculationEvaluator) + .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); + field.ShouldNotBeNull(); + var cache = field.GetValue(sut)!; + var countProp = cache.GetType().GetProperty("Count"); + countProp.ShouldNotBeNull(); + return (int)countProp.GetValue(cache)!; + } + + /// Minimal that records emitted entries so the SetVirtualTag-drop + /// test can assert the Debug "dropped" line was logged. + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj new file mode 100644 index 00000000..b3cbfe8b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + From c3277b52c98989ef9c41338540ae5a4d6b0848e0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:17:37 -0400 Subject: [PATCH 05/20] =?UTF-8?q?review(track0):=20async=20ContextMenu=20O?= =?UTF-8?q?nClick=20+=20keyboard-=E2=8B=AF=20anchoring=20+=20focus-return;?= =?UTF-8?q?=20discovery-driven=20DriverTypeNames=20guard;=20CSV=20round-tr?= =?UTF-8?q?ip=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave-0 reviewer findings (no Critical/High): - T0-1 M: ContextMenuItem.OnClick Actionβ†’Func (kills async-void at menu call sites Wave A/B/C need); keyboard-activated β‹― now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too. - T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught. - T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Pages/Dev/ContextMenuDemo.razor | 6 +- .../Components/Shared/ContextMenu.razor | 136 +++++++++++------- .../Components/Shared/ContextMenu.razor.css | 14 ++ .../Components/Shared/ContextMenuItem.cs | 9 +- .../Csv/CsvRoundTripTests.cs | 9 +- .../DriverTypeNamesGuardTests.cs | 76 +++++++--- 6 files changed, 169 insertions(+), 81 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor index db5cd1da..77791f2a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor @@ -31,5 +31,9 @@ new() { Label = "Delete", Icon = "πŸ—‘οΈ", Disabled = true, OnClick = () => Set("Delete") }, }; - private void Set(string action) => _lastAction = action; + private Task Set(string action) + { + _lastAction = action; + return Task.CompletedTask; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor index ea8b00fc..40a613d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor @@ -4,10 +4,12 @@ β€’ ChildContent β€” wrapped as a right-clickable surface (browser menu suppressed). β€’ ShowEllipsis β€” a "β‹―" button, the keyboard/touch fallback for the right-click. - Both triggers open the same menu anchored at the pointer. Outside-click closes - via a transparent full-viewport backdrop (the standard Blazor dropdown pattern β€” - no JS alert/confirm). Keyboard: Arrow up/down move selection, Enter activates, - Esc closes. *@ + Mouse/touch open the menu anchored at the pointer (fixed positioning). Keyboard + activation of the "β‹―" button carries no pointer coordinates, so the menu instead + anchors just below the button (absolute positioning inside the trigger wrap). + Outside-click (or a right-click elsewhere) closes via a transparent full-viewport + backdrop β€” no JS alert/confirm. Keyboard: Arrow up/down move selection, Enter/Space + activate, Esc closes; focus returns to the "β‹―" trigger on close. *@ @if (ChildContent is not null) { @@ -16,44 +18,29 @@ } -@if (ShowEllipsis) -{ - -} - @if (_open) { - @* Transparent catch-all: any click that misses the menu closes it. *@ -
+ @* Transparent catch-all: any click β€” or a right-click β€” that misses the menu closes it. *@ +
+} - + +} + +@if (_open && !_anchorToEllipsis) +{ + @RenderMenu("ctx-menu", $"top:{_y.ToString(Culture)}px; left:{_x.ToString(Culture)}px") } @code { @@ -72,35 +59,44 @@ private bool _open; private double _x; private double _y; + private bool _anchorToEllipsis; private int _activeIndex = -1; private ElementReference _menuRef; - private bool _focusPending; + private ElementReference _ellipsisRef; + private bool _focusMenuPending; + private bool _focusEllipsisPending; - // Both triggers carry a MouseEventArgs, so the menu anchors at the pointer - // (right-click point, or the click point on the "β‹―" button) with no JS. + // Mouse right-click / touch-tap carry real pointer coordinates β†’ anchor the menu there + // (fixed). Keyboard activation of the "β‹―" button dispatches a click with clientX/Y == 0 + // (no pointer) β†’ anchor the menu below the button instead of at viewport origin (0,0). private void OpenAt(MouseEventArgs e) { + _anchorToEllipsis = e.ClientX == 0 && e.ClientY == 0; _x = e.ClientX; _y = e.ClientY; _activeIndex = FirstEnabledIndex(); _open = true; - _focusPending = true; + _focusMenuPending = true; } private void Close() { + if (!_open) return; + var returnFocus = _anchorToEllipsis; // keyboard-opened via the β‹― button β€” restore its focus _open = false; _activeIndex = -1; + _anchorToEllipsis = false; + if (returnFocus && ShowEllipsis) _focusEllipsisPending = true; } - private void Activate(ContextMenuItem item) + private async Task Activate(ContextMenuItem item) { if (item.Disabled || item.IsSeparator) return; Close(); - item.OnClick?.Invoke(); + if (item.OnClick is not null) await item.OnClick.Invoke(); } - private void OnKeyDown(KeyboardEventArgs e) + private async Task OnKeyDown(KeyboardEventArgs e) { switch (e.Key) { @@ -117,7 +113,7 @@ case " ": if (_activeIndex >= 0 && _activeIndex < Items.Count) { - Activate(Items[_activeIndex]); + await Activate(Items[_activeIndex]); } break; } @@ -150,19 +146,49 @@ return -1; } + // The menu body, shared between the pointer-anchored (fixed) and ellipsis-anchored + // (absolute) placements so the item loop lives in exactly one place. + private RenderFragment RenderMenu(string cssClass, string? style) => __builder => + { + + }; + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (_focusPending) + if (_focusMenuPending) { - _focusPending = false; - try - { - await _menuRef.FocusAsync(); - } - catch - { - // The menu may have closed before the focus round-trip completed. - } + _focusMenuPending = false; + try { await _menuRef.FocusAsync(); } + catch { /* the menu may have closed before the focus round-trip completed */ } + } + if (_focusEllipsisPending) + { + _focusEllipsisPending = false; + try { await _ellipsisRef.FocusAsync(); } + catch { /* the trigger may have unmounted */ } } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css index df025623..7c8c9c7d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css @@ -5,6 +5,12 @@ display: inline; } +/* Positioned wrap so a keyboard-opened menu can anchor below the "β‹―" button. */ +.ctx-ellipsis-wrap { + position: relative; + display: inline-block; +} + /* The "β‹―" fallback trigger β€” a compact, quiet icon button. */ .ctx-ellipsis { display: inline-flex; @@ -50,6 +56,14 @@ outline: none; } +/* Keyboard-opened placement: anchored just below the "β‹―" trigger instead of the pointer. */ +.ctx-menu--anchored { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25rem; +} + .ctx-item { display: flex; align-items: center; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs index a513f7f0..d790c2a0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs @@ -11,8 +11,13 @@ public sealed class ContextMenuItem /// Text shown for the row. public string Label { get; init; } = ""; - /// Invoked when the row is activated (click / Enter). - public Action? OnClick { get; init; } + /// + /// Invoked when the row is activated (click / Enter). Async so per-node menu + /// actions (delete, redeploy, rename) can await their service calls without an + /// exception-swallowing async void; the menu awaits this and re-renders. + /// A synchronous handler returns . + /// + public Func? OnClick { get; init; } /// Optional leading icon β€” an emoji or single glyph. public string? Icon { get; init; } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs index f2521798..e6604976 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/CsvRoundTripTests.cs @@ -16,10 +16,11 @@ public sealed class CsvRoundTripTests // Each case is a jagged table of fields. yield return new object[] { new[] { new[] { "a", "b", "c" } } }; yield return new object[] { new[] { new[] { "a", "b" }, new[] { "c", "d" } } }; - // NOTE: a single row of a single empty field ([[""]]) is deliberately excluded β€” it serialises to - // the empty string, which is indistinguishable from an empty document and parses back to zero rows. - // That one degenerate case is the sole non-round-trippable shape; every multi-field / multi-row - // empty case below (",," and blank interior lines) does round-trip exactly. + // NOTE: a table whose FINAL row is a single empty field ([[""]], or e.g. [["a"],[""]]) is deliberately + // excluded β€” that trailing lone-empty-field row serialises to nothing after the preceding row's + // terminator, so the parser's no-phantom-trailing-row rule drops it on the way back. This is the only + // non-round-trippable shape; a single-empty-field row anywhere but last (see the interior [""] case + // below), ",,", and blank interior lines all round-trip exactly. yield return new object[] { new[] { new[] { "", "", "" } } }; yield return new object[] { new[] { new[] { "a" }, new[] { "" }, new[] { "b" } } }; yield return new object[] { new[] { new[] { "a,b", "c" } } }; // embedded delimiter diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs index d5b12aaf..64c86d23 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs @@ -8,34 +8,72 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; /// /// Guards against drift from the driver factories that are the -/// authority for these strings. The test builds the real registered-factory set the way -/// production does β€” it drives each driver assembly's *DriverFactoryExtensions.Register -/// entry point into a live , exactly as the Host's -/// DriverFactoryBootstrap.Register does β€” then asserts bidirectional parity with the -/// constants. If a factory renames its DriverTypeName (or a constant is added/removed -/// without a matching factory), this fails, forcing the constant and the factory back in sync. +/// authority for these strings. Factory discovery is reflection-driven, not a hand-copied list: +/// the test scans the driver assemblies deployed to its output directory for the +/// *DriverFactoryExtensions.Register(DriverFactoryRegistry, …) convention and invokes each into +/// a live β€” the same registration path production takes β€” then +/// asserts bidirectional parity with the constants. Because discovery is by convention, a brand-new +/// driver assembly referenced by this project (and thus copied to bin) is picked up automatically: if +/// it registers a DriverType string with no matching constant (or +/// a constant is added/removed without a matching factory), this fails, forcing the two back in sync. /// public sealed class DriverTypeNamesGuardTests { + // Driver-adjacent assemblies that ship no runtime factory (contracts/addressing/CLI helpers). + private static readonly string[] NonFactoryDriverAssemblySuffixes = + { ".Contracts", ".Addressing", ".Cli" }; + /// - /// Build the authoritative registered-factory set by invoking every driver assembly's - /// registration extension against a fresh registry β€” the same set of calls, in the same - /// spirit, as DriverFactoryBootstrap.Register on a driver-role node. + /// Build the authoritative registered-factory set by discovering every + /// *DriverFactoryExtensions.Register in the deployed driver assemblies (by convention) and + /// invoking it against a fresh registry. The factory func is never invoked β€” we only need the + /// type-name key each Register call installs β€” so is passed for the + /// optional loggerFactory/other parameters. /// private static IReadOnlyCollection RegisteredDriverTypeNames() { var registry = new DriverFactoryRegistry(); + var invoked = 0; - // loggerFactory is optional on every extension; the factory func is never invoked here, - // so null is fine β€” we only need the type-name key each Register call installs. - Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry); - Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory: null); - Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); - Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory: null); - Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory: null); - Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory: null); - Driver.S7.S7DriverFactoryExtensions.Register(registry); - Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); + var binDir = Path.GetDirectoryName(typeof(DriverTypeNamesGuardTests).Assembly.Location)!; + foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll")) + { + var name = Path.GetFileNameWithoutExtension(dll); + if (NonFactoryDriverAssemblySuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal))) + continue; + + Assembly asm; + try { asm = Assembly.LoadFrom(dll); } + catch { continue; } + + Type[] types; + try { types = asm.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; } + + foreach (var type in types) + { + if (type is null || !type.IsClass || type.Name is not { } n + || !n.EndsWith("DriverFactoryExtensions", StringComparison.Ordinal)) + continue; + + var register = type.GetMethod("Register", BindingFlags.Public | BindingFlags.Static); + var ps = register?.GetParameters(); + if (register is null || ps is null || ps.Length == 0 + || ps[0].ParameterType != typeof(DriverFactoryRegistry)) + continue; + + var args = new object?[ps.Length]; + args[0] = registry; // remaining params (optional loggerFactory, etc.) default to null + register.Invoke(null, args); + invoked++; + } + } + + // Discovery must find *something*; a zero here means the bin scan/convention broke, not that the + // fleet has no drivers β€” fail loudly rather than pass a vacuously-empty parity check. + invoked.ShouldBeGreaterThan(0, + "no *DriverFactoryExtensions.Register was discovered in the output directory β€” the driver " + + "assemblies did not deploy to bin, or the naming convention changed"); return registry.RegisteredTypes; } From fbf3c26c2ae15cca70c1765715871ce62f118488 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:20:05 -0400 Subject: [PATCH 06/20] contracts(b2-waveA): RawNode view-model + IRawTreeService read surface + RawRenameResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave A contract-first fan-out types for the /raw project tree: - RawNode/RawNodeKind: shared tree view-model (Enterpriseβ†’Clusterβ†’Folderβ†’Driverβ†’Deviceβ†’TagGroupβ†’Tag), lazy-load metadata, per-kind ids + DriverType propagation for menu gating. - IRawTreeService: committed READ surface (LoadRootsAsync + LoadChildrenAsync); WP1 extends with the mutation surface, WP2 consumes read-only. - RawRenameResult: rename outcome carrying non-blocking warnings (historized/UNS-referenced now; script-scan in Batch 3). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Uns/IRawTreeService.cs | 44 ++++++++ .../ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs | 101 ++++++++++++++++++ .../Uns/RawRenameResult.cs | 24 +++++ 3 files changed, 169 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawRenameResult.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs new file mode 100644 index 00000000..bc0050c8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs @@ -0,0 +1,44 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Loads and mutates the v3 Raw project tree (/raw) β€” the cluster-rooted +/// Enterprise β†’ Cluster β†’ Folder β†’ Driver β†’ Device β†’ TagGroup β†’ Tag hierarchy β€” from the config +/// database. The peer of for the Raw subtree. +/// +/// Contract split (Batch 2 Wave A): this file's committed surface is the read contract +/// the RawTree component consumes β€” (eager Enterpriseβ†’Cluster roots) +/// and (one lazy level per expand). B2-WP1 (RawTreeService) +/// extends this interface with the mutation surface β€” create/rename/delete per node type, +/// move-into-folder, and the per-node edit projections β€” enforcing the integrity rules with +/// user-readable errors (a delete blocked by a UnsTagReference names the referencing equipment). +/// Rename methods return so the UI can show non-blocking warnings. +/// Mutations reuse . WP2 must not add to this interface. +/// +/// +public interface IRawTreeService +{ + /// + /// Loads the eager top of the Raw tree: Enterprise β†’ Cluster roots, each cluster marked + /// so its folders + drivers load on first expand. Empty + /// clusters are retained so they stay visible and authorable. The returned nodes are detached + /// view-models, safe to hold and mutate UI state on after the underlying context is disposed. + /// + /// A token to cancel the load. + /// The enterprise roots, populated down to (but not through) their clusters. + Task> LoadRootsAsync(CancellationToken ct = default); + + /// + /// Loads the direct children of one container node β€” the next level down, keyed off + /// 's and : + /// a Cluster yields its root Folders + Drivers; a Folder yields sub-Folders + Drivers; a Driver + /// yields its Devices; a Device yields its root TagGroups + Tags; a TagGroup yields sub-TagGroups + /// + Tags. Tags are leaves and yield nothing. Ordering is deterministic and ordinal. Reads + /// untracked; returns detached nodes. Per-device tag counts can be large β€” the implementation may + /// page, but the committed shape returns the level's nodes (paging knobs, if any, are WP1's to add + /// on the concrete type without changing this signature's meaning for the tree). + /// + /// The container node whose direct children to load. + /// A token to cancel the load. + /// The parent's direct child nodes; empty for a leaf or an empty container. + Task> LoadChildrenAsync(RawNode parent, CancellationToken ct = default); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs new file mode 100644 index 00000000..973a989c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs @@ -0,0 +1,101 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The kind of node within the v3 Raw project tree (/raw). Determines how the +/// renderer styles the row, which context-menu it shows, and which entity (if any) it +/// links to. The Raw tree is cluster-rooted and mirrors the RawPath hierarchy: +/// Enterprise β†’ Cluster β†’ Folder(s) β†’ Driver β†’ Device β†’ TagGroup(s) β†’ Tag. +/// +public enum RawNodeKind +{ + /// Top-level grouping label (a string on cluster rows, not a DB entity). + Enterprise, + + /// A server cluster β€” the root of a Raw project tree. + Cluster, + + /// A driver-organizing RawFolder (nestable under a cluster or another folder). + Folder, + + /// A DriverInstance. + Driver, + + /// A Device under a driver (universal in v3 β€” every driver has β‰₯1). + Device, + + /// A tag-organizing TagGroup (nestable under a device or another group). + TagGroup, + + /// A raw Tag (signal) β€” a leaf. + Tag, +} + +/// +/// View-model for a single node in the Raw project tree. Carries the stable identity, +/// display text, per-kind ids, and lazy-load metadata the renderer needs, plus transient UI +/// state (expansion/loading) that is never persisted. Detached from EF β€” safe to hold and +/// mutate UI state on after the loading context is disposed. This is the shared contract +/// between (which produces nodes) and the RawTree +/// component (which renders + lazily expands them). +/// +public sealed class RawNode +{ + /// The kind of node β€” drives styling, context-menu, and entity linking. + public required RawNodeKind Kind { get; init; } + + /// Stable per-node key, unique within the tree (e.g. drv:{driverInstanceId}). + public required string Key { get; init; } + + /// Human-readable label shown in the tree (the node's Name, or Enterprise/Cluster label). + public required string DisplayName { get; init; } + + /// Owning cluster id for every node under a cluster (and the cluster itself); null for Enterprise. + public string? ClusterId { get; init; } + + /// + /// This node's own logical entity id (RawFolderId/DriverInstanceId/DeviceId/ + /// TagGroupId/TagId, or the ClusterId for a cluster); null for Enterprise. The + /// child loader keys off + this id to fetch the next level. + /// + public string? EntityId { get; init; } + + /// + /// The bound DriverType for (and propagated to Device/TagGroup/Tag + /// nodes beneath it, so tag editors and the browse-enable gate can dispatch without another query); + /// null for folders/clusters/enterprise. Use the DriverTypeNames constants when matching. + /// + public string? DriverType { get; init; } + + /// Whether a Driver/Device node is enabled (drives the enable/disable menu label + styling); true otherwise. + public bool Enabled { get; init; } = true; + + /// Direct child count (for the badge). For a device this is its tag-group + tag count. + public int ChildCount { get; set; } + + /// + /// Whether this node's children load lazily on expand. True for every container kind in the Raw tree + /// (Cluster/Folder/Driver/Device/TagGroup) β€” unlike the UNS tree, /raw actually exercises the + /// lazy plumbing (per-device tag counts can be large). Tags are leaves (false). + /// + public bool HasLazyChildren { get; init; } + + /// Lazily-materialised children β€” empty until flips on expand. + public List Children { get; } = new(); + + /// Optimistic-concurrency token for this node's backing row (empty for Enterprise); echoed on mutations. + public byte[] RowVersion { get; init; } = Array.Empty(); + + // --- Runtime UI state (not persisted) --- + + /// Whether the node is currently expanded in the UI. + public bool Expanded { get; set; } + + /// Whether the node's lazy children have been loaded. + public bool Loaded { get; set; } + + /// Whether a lazy-load is currently in flight for this node. + public bool Loading { get; set; } + + /// Last load error message, if any. + public string? Error { get; set; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawRenameResult.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawRenameResult.cs new file mode 100644 index 00000000..6db0eea3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawRenameResult.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Outcome of a Raw-tree rename. Like it reports success/failure, +/// but a rename additionally surfaces : non-blocking advisories the UI shows +/// after the rename succeeds (a RawPath segment changed, so downstream references may need attention). +/// In this batch the warnings answerable from the schema are wired β€” a renamed node with historized +/// tags beneath it, or raw tags referenced by an equipment (UnsTagReference). Batch 3 adds the +/// script-substring scan to the same list. On failure is false, +/// carries the operator-facing message, and is empty. +/// +/// Whether the rename was applied. +/// The operator-facing failure message, or null on success. +/// Non-blocking advisories to show after a successful rename; empty when there are none. +public readonly record struct RawRenameResult(bool Ok, string? Error, IReadOnlyList Warnings) +{ + /// A successful rename carrying zero or more advisory warnings. + public static RawRenameResult Success(IReadOnlyList? warnings = null) => + new(true, null, warnings ?? Array.Empty()); + + /// A failed rename carrying the operator-facing message. + public static RawRenameResult Failure(string error) => + new(false, error, Array.Empty()); +} From b80b27f44b8165c2649447c2fb1fbf3371213828 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:27:00 -0400 Subject: [PATCH 07/20] feat(adminui): B2-WP2 /raw page + lazy RawTree + context menus (modals stubbed) Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Raw/GlobalRaw.razor | 68 ++++ .../Components/Shared/Raw/RawStubModal.razor | 49 +++ .../Components/Shared/Raw/RawTree.razor | 301 ++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index c5e29aa0..50b5ecdf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -16,6 +16,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor new file mode 100644 index 00000000..57af836f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -0,0 +1,68 @@ +@page "/raw" +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] +@rendermode RenderMode.InteractiveServer +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@inject IRawTreeService Svc + +@* Peer of GlobalUns (/uns) for the v3 Raw project tree β€” the cluster-rooted + Enterprise β†’ Cluster β†’ Folder β†’ Driver β†’ Device β†’ TagGroup β†’ Tag hierarchy. + Loads the eager roots on init, then hands off to RawTree, which lazily expands + each container via IRawTreeService.LoadChildrenAsync and surfaces per-node + context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@ + +Raw + +
+

Raw

+ Changes apply on the next deployment. +
+ +
+
+ Project tree +
+ + @if (_loading) + { +
+ Loading… +
+ } + else if (_loadError is not null) + { +
@_loadError
+ } + else if (_roots.Count == 0) + { +
No clusters yet.
+ } + else + { +
+ +
+ } +
+ +@code { + private IReadOnlyList _roots = Array.Empty(); + private bool _loading = true; + private string? _loadError; + + protected override async Task OnInitializedAsync() + { + try + { + _roots = await Svc.LoadRootsAsync(); + } + catch (Exception ex) + { + _loadError = $"Failed to load the Raw tree: {ex.Message}"; + } + finally + { + _loading = false; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor new file mode 100644 index 00000000..79aff451 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor @@ -0,0 +1,49 @@ +@* Placeholder modal for the /raw context-menu actions whose real dialogs + (Configure / Device / TagModal / CSV import-export / Browse device / Rename) + land in later Batch-2 waves (B/C). This wave wires every menu action to a + named handler that opens this "coming soon" placeholder, so the tree + lazy + expansion + menus can be live-verified without the real modals existing yet. + Wave B/C replaces each handler body with the real modal invocation and can + then delete this component. Markup mirrors the GlobalUns delete-confirm modal. *@ + +@if (Visible) +{ + + +} + +@code { + /// Whether the placeholder is shown. + [Parameter] public bool Visible { get; set; } + + /// Modal title β€” the action name (e.g. "Configure driver"). + [Parameter] public string Title { get; set; } = ""; + + /// "Coming soon" body message. + [Parameter] public string Message { get; set; } = ""; + + /// The target node's display name, shown as context. + [Parameter] public string? NodeName { get; set; } + + /// Raised when the placeholder is dismissed. + [Parameter] public EventCallback OnClose { 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 new file mode 100644 index 00000000..ed14b6d6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -0,0 +1,301 @@ +@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw). + + Unlike UnsTree (which eager-loads the whole structural tree and never touches + the RawNode lazy fields), THIS component actually exercises the lazy plumbing: + expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends + the returned level into node.Children and flips Loaded/Loading, showing a spinner + in flight and an error row on failure. Per-node context menus (right-click surface + + "β‹―" fallback) are built per RawNodeKind; every action opens a placeholder + (RawStubModal) in this wave β€” Wave B/C replaces each named handler's body with the + real Configure / Device / TagModal / CSV / Browse modal. This component is a single + instance that recurses via the RenderNode RenderFragment, so it owns one stub modal + and one injected service for the whole tree. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@inject IRawTreeService Svc + +@foreach (var root in Roots) +{ + @RenderNode(root, 0) +} + + + +@code { + /// The top-level Raw nodes to render (the enterprise roots from LoadRootsAsync). + [Parameter, EditorRequired] public IReadOnlyList Roots { get; set; } = default!; + + // --- Stub-placeholder state (one instance for the whole recursive tree) --- + private bool _stubVisible; + private string _stubTitle = ""; + private string _stubMessage = ""; + private string? _stubNodeName; + + // --------------------------------------------------------------------------- + // Row rendering + // --------------------------------------------------------------------------- + + private RenderFragment RenderNode(RawNode node, int depth) => __builder => + { + var indent = $"padding-left:{depth * 18}px"; + var hasExpander = node.HasLazyChildren || node.Children.Count > 0; + var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device; + var items = MenuFor(node); + +
+ @if (hasExpander) + { + + } + else + { + + } + + @if (items.Count > 0) + { + + @RenderRowBody(node, muted) + + } + else + { + @RenderRowBody(node, muted) + } +
+ + @if (node.Expanded && node.Loading) + { +
+ Loading… +
+ } + else if (node.Expanded && node.Error is not null) + { +
+ @node.Error +
+ } + else if (node.Expanded) + { + @foreach (var child in node.Children) + { + @RenderNode(child, depth + 1) + } + } + }; + + // The icon + label + badge shown for a node β€” the ContextMenu right-click surface. + private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder => + { + + + @node.DisplayName + @if (muted) + { + disabled + } + @if (node.ChildCount > 0) + { + @node.ChildCount + } + + }; + + private static string KindIcon(RawNodeKind kind) => kind switch + { + RawNodeKind.Enterprise => "🏒", + RawNodeKind.Cluster => "πŸ–₯️", + RawNodeKind.Folder => "πŸ“", + RawNodeKind.Driver => "πŸ”Œ", + RawNodeKind.Device => "πŸ“Ÿ", + RawNodeKind.TagGroup => "πŸ—‚οΈ", + RawNodeKind.Tag => "🏷️", + _ => "β€’", + }; + + // --------------------------------------------------------------------------- + // Lazy expansion β€” the whole point of WP2 (UNS never did this). + // --------------------------------------------------------------------------- + + /// + /// Toggles a node's expansion. On first expand of an unloaded container this sets + /// Loading, awaits , appends the + /// returned level into Children, flips Loaded, and re-renders β€” showing a + /// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never + /// reaches here. + /// + private async Task ToggleAsync(RawNode node) + { + if (node.Loading) { return; } + + node.Expanded = !node.Expanded; + + if (node.Expanded && node.HasLazyChildren && !node.Loaded) + { + node.Loading = true; + node.Error = null; + StateHasChanged(); + try + { + var children = await Svc.LoadChildrenAsync(node); + node.Children.Clear(); + node.Children.AddRange(children); + node.ChildCount = node.Children.Count; + node.Loaded = true; + } + catch (Exception ex) + { + node.Error = ex.Message; + } + finally + { + node.Loading = false; + } + StateHasChanged(); + } + } + + // --------------------------------------------------------------------------- + // Per-kind context menus. Every OnClick opens a placeholder in this wave; the + // named handlers below are the seam Wave B/C replaces with the real modals. + // --------------------------------------------------------------------------- + + private IReadOnlyList MenuFor(RawNode node) => 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. + }; + + private List ClusterMenu(RawNode node) => new() + { + new() { Label = "New folder", Icon = "πŸ“", OnClick = () => OnNewFolder(node) }, + new() { Label = "New driver", Icon = "πŸ”Œ", OnClick = () => OnNewDriver(node) }, + }; + + private List FolderMenu(RawNode node) => new() + { + new() { Label = "New folder", Icon = "πŸ“", OnClick = () => OnNewFolder(node) }, + new() { Label = "New driver", Icon = "πŸ”Œ", OnClick = () => OnNewDriver(node) }, + ContextMenuItem.Separator(), + new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) }, + new() { Label = "Delete", Icon = "πŸ—‘οΈ", OnClick = () => OnDeleteFolder(node) }, + }; + + private List DriverMenu(RawNode node) => new() + { + new() { Label = "Configure", Icon = "βš™", OnClick = () => OnConfigureDriver(node) }, + new() { Label = "Test connect", Icon = "πŸ”Ž", OnClick = () => OnTestConnect(node) }, + new() { Label = "New device", Icon = "πŸ“Ÿ", OnClick = () => OnNewDevice(node) }, + ContextMenuItem.Separator(), + new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "β–Ά", + OnClick = () => OnToggleDriverEnabled(node) }, + new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) }, + new() { Label = "Delete", Icon = "πŸ—‘οΈ", OnClick = () => OnDeleteDriver(node) }, + }; + + private List DeviceMenu(RawNode node) => new() + { + new() { Label = "Configure", Icon = "βš™", OnClick = () => OnConfigureDevice(node) }, + new() { Label = "New tag-group", Icon = "πŸ—‚οΈ", OnClick = () => OnNewTagGroup(node) }, + ContextMenuItem.Separator(), + // "Add tags β–Έ" flattened β€” ContextMenu is single-level, so the submenu is a labelled group. + new() { Label = "Add tags", Disabled = true }, + new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) }, + new() { Label = "Import CSV", Icon = "πŸ“₯", OnClick = () => OnImportCsv(node) }, + new() { Label = "Export CSV", Icon = "πŸ“€", OnClick = () => OnExportCsv(node) }, + new() { Label = "Browse device…", Icon = "πŸ”­", OnClick = () => OnBrowseDevice(node) }, + ContextMenuItem.Separator(), + new() { Label = "Delete", Icon = "πŸ—‘οΈ", OnClick = () => OnDeleteDevice(node) }, + }; + + private List TagGroupMenu(RawNode node) => new() + { + new() { Label = "New group", Icon = "πŸ—‚οΈ", OnClick = () => OnNewGroup(node) }, + ContextMenuItem.Separator(), + new() { Label = "Add tags", Disabled = true }, + new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) }, + new() { Label = "Import CSV", Icon = "πŸ“₯", OnClick = () => OnImportCsv(node) }, + new() { Label = "Export CSV", Icon = "πŸ“€", OnClick = () => OnExportCsv(node) }, + new() { Label = "Browse device…", Icon = "πŸ”­", OnClick = () => OnBrowseDevice(node) }, + ContextMenuItem.Separator(), + new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) }, + new() { Label = "Delete", Icon = "πŸ—‘οΈ", OnClick = () => OnDeleteTagGroup(node) }, + }; + + private List TagMenu(RawNode node) => new() + { + new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) }, + new() { Label = "Delete", Icon = "πŸ—‘οΈ", OnClick = () => OnDeleteTag(node) }, + }; + + // --------------------------------------------------------------------------- + // Named action handlers β€” the Wave B/C replacement seam. Each opens a placeholder + // today; a later wave swaps the body for the real modal invocation. + // --------------------------------------------------------------------------- + + // Folder / Cluster + private Task OnNewFolder(RawNode node) => ShowStub("New folder", node); + private Task OnNewDriver(RawNode node) => ShowStub("New driver", node); + private Task OnRenameFolder(RawNode node) => ShowStub("Rename folder", node); + private Task OnDeleteFolder(RawNode node) => ShowStub("Delete folder", node); + + // Driver + private Task OnConfigureDriver(RawNode node) => ShowStub("Configure driver", node); + private Task OnTestConnect(RawNode node) => ShowStub("Test connect", node); + private Task OnNewDevice(RawNode node) => ShowStub("New device", node); + private Task OnToggleDriverEnabled(RawNode node) => ShowStub(node.Enabled ? "Disable driver" : "Enable driver", node); + private Task OnRenameDriver(RawNode node) => ShowStub("Rename driver", node); + private Task OnDeleteDriver(RawNode node) => ShowStub("Delete driver", node); + + // Device + private Task OnConfigureDevice(RawNode node) => ShowStub("Configure device", node); + private Task OnNewTagGroup(RawNode node) => ShowStub("New tag-group", node); + private Task OnDeleteDevice(RawNode node) => ShowStub("Delete device", node); + + // Add-tags submenu (shared by Device + TagGroup) + private Task OnAddManualTags(RawNode node) => ShowStub("Add tags β€” manual entry", node); + private Task OnImportCsv(RawNode node) => ShowStub("Import tags CSV", node); + private Task OnExportCsv(RawNode node) => ShowStub("Export tags CSV", node); + private Task OnBrowseDevice(RawNode node) => ShowStub("Browse device", node); + + // TagGroup + private Task OnNewGroup(RawNode node) => ShowStub("New group", node); + private Task OnRenameTagGroup(RawNode node) => ShowStub("Rename tag-group", node); + private Task OnDeleteTagGroup(RawNode node) => ShowStub("Delete tag-group", node); + + // Tag + private Task OnEditTag(RawNode node) => ShowStub("Edit tag", node); + private Task OnDeleteTag(RawNode node) => ShowStub("Delete tag", node); + + // --------------------------------------------------------------------------- + // Placeholder plumbing (removed once every handler above has its real modal). + // --------------------------------------------------------------------------- + + private Task ShowStub(string action, RawNode node) + { + _stubTitle = action; + _stubMessage = $"{action} β€” coming in a later wave."; + _stubNodeName = node.DisplayName; + _stubVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private void CloseStub() + { + _stubVisible = false; + _stubTitle = ""; + _stubMessage = ""; + _stubNodeName = null; + } +} From 76b8325b84ae24f8b99489ee43a525c23c88d03b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:34:36 -0400 Subject: [PATCH 08/20] feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../EndpointRouteBuilderExtensions.cs | 1 + .../Uns/IRawTreeService.cs | 210 ++++ .../Uns/RawTreeAssembly.cs | 61 + .../Uns/RawTreeService.cs | 1019 +++++++++++++++++ .../Uns/RawTreeServiceTests.cs | 348 ++++++ .../Uns/RawTreeTestDb.cs | 168 +++ 6 files changed, 1807 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 5f2578c6..72452017 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -48,6 +48,7 @@ public static class EndpointRouteBuilderExtensions services.AddHostedService(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs index bc0050c8..9b2cc646 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs @@ -1,5 +1,34 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +/// +/// Detached, editable projection of a raw Tag β€” the read side of tag editing. WP1 exposes this +/// loader; WP4 owns the create/update authoring surface and should align its input model with these +/// fields (identity is the RawPath, formed from the tag's device + optional group + name). +/// +/// The tag's stable logical id. +/// The owning device's logical id. +/// The containing tag group's logical id, or null when directly under the device. +/// The tag name (a RawPath segment). +/// The OPC UA built-in type name. +/// The tag's access-level baseline. +/// Whether writes to this tag are retry-eligible. +/// The optional poll-group id. +/// The schemaless per-driver TagConfig JSON blob. +/// The optimistic-concurrency token. +public readonly record struct RawTagEditDto( + string TagId, + string DeviceId, + string? TagGroupId, + string Name, + string DataType, + TagAccessLevel AccessLevel, + bool WriteIdempotent, + string? PollGroupId, + string TagConfig, + byte[] RowVersion); + /// /// Loads and mutates the v3 Raw project tree (/raw) β€” the cluster-rooted /// Enterprise β†’ Cluster β†’ Folder β†’ Driver β†’ Device β†’ TagGroup β†’ Tag hierarchy β€” from the config @@ -41,4 +70,185 @@ public interface IRawTreeService /// A token to cancel the load. /// The parent's direct child nodes; empty for a leaf or an empty container. Task> LoadChildrenAsync(RawNode parent, CancellationToken ct = default); + + // --- Folder mutations --- + + /// + /// Creates a new RawFolder under a cluster ( null) or under + /// another folder. The name is validated as a RawPath segment and must be unique among its siblings + /// (case-sensitive ordinal). On success the result's CreatedId carries the new folder's + /// logical id. + /// + /// The owning cluster id. + /// The parent folder's logical id, or null for a cluster-root folder. + /// The folder name (a RawPath segment). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new RawFolderId on success. + Task CreateFolderAsync(string clusterId, string? parentFolderId, string name, CancellationToken ct = default); + + /// + /// Renames a RawFolder. Validates the new name as a RawPath segment and sibling-unique. + /// Because a folder rename changes the RawPath of every tag beneath it, the returned + /// carries advisory warnings for historized and equipment-referenced + /// tags in the folder's subtree. + /// + /// The folder's logical id. + /// The new folder name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameFolderAsync(string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes an empty RawFolder. Blocked (with the blocker named) while it still contains child + /// folders or driver instances. + /// + /// The folder's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteFolderAsync(string rawFolderId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Moves a driver instance into a folder () or to the cluster root + /// (null target). The target folder must be in the driver's cluster and the driver's name must be + /// unique among the target's driver siblings. + /// + /// The driver instance's logical id. + /// The destination folder's logical id, or null for the cluster root. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The move outcome. + Task MoveDriverToFolderAsync(string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default); + + // --- Driver mutations --- + + /// + /// Creates a minimal driver instance and its auto-created default Device so the tree has a + /// child to show. Full typed driver/device config authoring is Wave B (WP3) β€” this create takes the + /// driver config JSON verbatim and seeds an empty ({}) default device. The driver name is a + /// RawPath segment, unique among its cluster/folder siblings. + /// + /// The owning cluster id. + /// The containing folder's logical id, or null for a cluster-root driver. + /// The driver instance name (a RawPath segment). + /// The driver-type string (see DriverTypeNames). + /// The driver config JSON blob (opaque here; validated in Wave B). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new DriverInstanceId on success. + Task CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default); + + /// + /// Renames a driver instance. Validates the new name as a sibling-unique RawPath segment and returns + /// downstream-impact warnings for historized/equipment-referenced tags beneath the driver. + /// + /// The driver instance's logical id. + /// The new driver name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameDriverAsync(string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// Enables or disables a driver instance (last-write-wins on ). + /// The driver instance's logical id. + /// The new enabled state. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The update outcome. + Task SetDriverEnabledAsync(string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes a driver instance, cascading its auto-created empty devices. Blocked (with the blocking + /// device named) when any of its devices still contains tag groups or tags β€” remove those first. + /// + /// The driver instance's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteDriverAsync(string driverInstanceId, byte[] rowVersion, CancellationToken ct = default); + + // --- Device mutations --- + + /// + /// Creates a minimal Device under a driver. Full typed device-config authoring is Wave B + /// (WP3) β€” this create takes the device config JSON verbatim. The device name must be unique among + /// the driver's devices. + /// + /// The owning driver instance's logical id. + /// The device name (a RawPath segment). + /// The device config JSON blob (opaque here; validated in Wave B). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new DeviceId on success. + Task CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default); + + /// + /// Renames a Device. Validates the new name as a driver-unique RawPath segment and returns + /// downstream-impact warnings for historized/equipment-referenced tags beneath the device. + /// + /// The device's logical id. + /// The new device name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameDeviceAsync(string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// Deletes a Device. Blocked (with the blocker named) while it holds tag groups or tags. + /// The device's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteDeviceAsync(string deviceId, byte[] rowVersion, CancellationToken ct = default); + + // --- TagGroup mutations --- + + /// + /// Creates a new TagGroup under a device ( null) or under + /// another group. The name is validated as a RawPath segment, unique among its siblings. + /// + /// The owning device's logical id. + /// The parent group's logical id, or null for a device-root group. + /// The group name (a RawPath segment). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new TagGroupId on success. + Task CreateTagGroupAsync(string deviceId, string? parentGroupId, string name, CancellationToken ct = default); + + /// + /// Renames a TagGroup. Validates the new name as a sibling-unique RawPath segment and returns + /// downstream-impact warnings for historized/equipment-referenced tags beneath the group. + /// + /// The group's logical id. + /// The new group name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameTagGroupAsync(string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// Deletes a TagGroup. Blocked (with the blocker named) while it holds child groups or tags. + /// The group's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default); + + // --- Tag mutations / projections (create + update are WP4) --- + + /// + /// Deletes a raw Tag. Blocked while any UnsTagReference points at it; the failure + /// message names the referencing equipment(s) so the operator can remove the reference(s) first. + /// + /// The tag's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Loads a raw tag's editable fields as a detached projection. WP1 provides the read-only projection; + /// the create/update authoring surface is WP4 (which should align its input shape with + /// ). + /// + /// The tag's logical id. + /// A token to cancel the load. + /// The tag's editable projection, or null when the tag does not exist. + Task LoadTagForEditAsync(string tagId, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs new file mode 100644 index 00000000..53d9c6cc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Pure, EF-free assembly of the eager top of the Raw project tree (/raw) β€” Enterprise β†’ Cluster β€” +/// from flat cluster rows. The peer of for the Raw subtree. Everything below +/// a cluster loads lazily via , so this helper only nests +/// clusters under their enterprise label and stamps each cluster with its root-level child count and the +/// lazy-load flag. Empty clusters are retained so they stay visible and authorable. +/// +public static class RawTreeAssembly +{ + /// + /// Builds the Enterpriseβ†’Cluster roots. Ordering is deterministic and ordinal at both levels. Each + /// cluster is marked and carries the supplied root child count + /// (its root folders + cluster-root drivers). + /// + /// The flat cluster rows to nest under their enterprise label. + /// Per-cluster count of root folders + cluster-root drivers (badge). + /// The assembled enterprise root nodes, populated down to (but not through) their clusters. + public static IReadOnlyList BuildRoots( + IReadOnlyList clusters, + IReadOnlyDictionary rootChildCounts) + { + return clusters + .GroupBy(c => c.Enterprise, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(entGroup => + { + var ent = new RawNode + { + Kind = RawNodeKind.Enterprise, + Key = $"ent:{entGroup.Key}", + DisplayName = entGroup.Key, + ClusterId = null, + EntityId = null, + HasLazyChildren = false, + }; + + var clusterNodes = entGroup + .OrderBy(c => c.Name, StringComparer.Ordinal) + .ThenBy(c => c.ClusterId, StringComparer.Ordinal) + .Select(c => new RawNode + { + Kind = RawNodeKind.Cluster, + Key = $"clu:{c.ClusterId}", + DisplayName = string.IsNullOrEmpty(c.Site) ? c.Name : $"{c.Site} ({c.Name})", + ClusterId = c.ClusterId, + // A cluster's own logical id IS its ClusterId β€” EntityId mirrors it for uniform navigation. + EntityId = c.ClusterId, + HasLazyChildren = true, + ChildCount = rootChildCounts.GetValueOrDefault(c.ClusterId), + }) + .ToList(); + + ent.Children.AddRange(clusterNodes); + ent.ChildCount = ent.Children.Count; + return ent; + }) + .ToList(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs new file mode 100644 index 00000000..d5370f59 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -0,0 +1,1019 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Default . Reads the Raw-tree structural rows with untracked queries, +/// lazily materialising one level per expand, and applies structural mutations (create/rename/delete/ +/// move) with last-write-wins optimistic concurrency on RowVersion β€” the same pattern +/// uses for the UNS subtree. Integrity rules (sibling-name uniqueness, +/// non-empty-container delete blocks, referenced-tag delete blocks) are pre-checked with user-readable +/// errors so the refusal is deterministic even under the EF InMemory provider (which does not enforce +/// the FK Restrict / filtered-unique indexes the real SQL schema carries). +/// +/// +/// Each call creates and disposes its own context via the pooled factory, so the service is safe to +/// register as Scoped and used per Blazor circuit. +/// +public sealed class RawTreeService(IDbContextFactory dbFactory) : IRawTreeService +{ + // --------------------------------------------------------------------------------------------- + // Read surface + // --------------------------------------------------------------------------------------------- + + /// + public async Task> LoadRootsAsync(CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var clusters = await db.ServerClusters + .AsNoTracking() + .Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name)) + .ToListAsync(ct); + + // Per-cluster root child count = root folders (ParentRawFolderId == null) + cluster-root drivers + // (RawFolderId == null), for the cluster badge. + var rootFolderCounts = (await db.RawFolders.AsNoTracking() + .Where(f => f.ParentRawFolderId == null) + .GroupBy(f => f.ClusterId) + .Select(g => new { ClusterId = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal); + + var rootDriverCounts = (await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId == null) + .GroupBy(d => d.ClusterId) + .Select(g => new { ClusterId = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal); + + var rootChildCounts = new Dictionary(StringComparer.Ordinal); + foreach (var c in clusters) + { + rootChildCounts[c.ClusterId] = + rootFolderCounts.GetValueOrDefault(c.ClusterId) + rootDriverCounts.GetValueOrDefault(c.ClusterId); + } + + return RawTreeAssembly.BuildRoots(clusters, rootChildCounts); + } + + /// + public async Task> LoadChildrenAsync(RawNode parent, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(parent); + if (parent.EntityId is null) + { + return Array.Empty(); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return parent.Kind switch + { + RawNodeKind.Cluster => await LoadClusterChildrenAsync(db, parent.EntityId, ct), + RawNodeKind.Folder => await LoadFolderChildrenAsync(db, parent.ClusterId, parent.EntityId, ct), + RawNodeKind.Driver => await LoadDriverChildrenAsync(db, parent, ct), + RawNodeKind.Device => await LoadDeviceChildrenAsync(db, parent, ct), + RawNodeKind.TagGroup => await LoadTagGroupChildrenAsync(db, parent, ct), + _ => Array.Empty(), // Tag (leaf) / Enterprise + }; + } + + private static async Task> LoadClusterChildrenAsync( + OtOpcUaConfigDbContext db, string clusterId, CancellationToken ct) + { + var folders = await db.RawFolders.AsNoTracking() + .Where(f => f.ClusterId == clusterId && f.ParentRawFolderId == null) + .OrderBy(f => f.SortOrder).ThenBy(f => f.Name) + .ToListAsync(ct); + + var drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == clusterId && d.RawFolderId == null) + .OrderBy(d => d.Name) + .ToListAsync(ct); + + var nodes = new List(folders.Count + drivers.Count); + nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, folders, ct)); + nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct)); + return nodes; + } + + private static async Task> LoadFolderChildrenAsync( + OtOpcUaConfigDbContext db, string? clusterId, string folderId, CancellationToken ct) + { + // Resolve the cluster from the folder itself when the parent node did not carry it. + clusterId ??= await db.RawFolders.AsNoTracking() + .Where(f => f.RawFolderId == folderId).Select(f => f.ClusterId).FirstOrDefaultAsync(ct); + + var subFolders = await db.RawFolders.AsNoTracking() + .Where(f => f.ParentRawFolderId == folderId) + .OrderBy(f => f.SortOrder).ThenBy(f => f.Name) + .ToListAsync(ct); + + var drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId == folderId) + .OrderBy(d => d.Name) + .ToListAsync(ct); + + var nodes = new List(subFolders.Count + drivers.Count); + nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, subFolders, ct)); + nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct)); + return nodes; + } + + private static async Task> LoadDriverChildrenAsync( + OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) + { + var driverId = parent.EntityId!; + // The driver node carries its own DriverType; fall back to a lookup if absent. + var driverType = parent.DriverType + ?? await db.DriverInstances.AsNoTracking() + .Where(d => d.DriverInstanceId == driverId).Select(d => d.DriverType).FirstOrDefaultAsync(ct); + + var devices = await db.Devices.AsNoTracking() + .Where(dev => dev.DriverInstanceId == driverId) + .OrderBy(dev => dev.Name) + .ToListAsync(ct); + + var deviceIds = devices.Select(d => d.DeviceId).ToList(); + var groupCounts = await CountRootGroupsByDeviceAsync(db, deviceIds, ct); + var tagCounts = await CountRootTagsByDeviceAsync(db, deviceIds, ct); + + return devices.Select(dev => new RawNode + { + Kind = RawNodeKind.Device, + Key = $"dev:{dev.DeviceId}", + DisplayName = dev.Name, + ClusterId = parent.ClusterId, + EntityId = dev.DeviceId, + DriverType = driverType, + Enabled = dev.Enabled, + HasLazyChildren = true, + ChildCount = groupCounts.GetValueOrDefault(dev.DeviceId) + tagCounts.GetValueOrDefault(dev.DeviceId), + RowVersion = dev.RowVersion, + }).ToList(); + } + + private static async Task> LoadDeviceChildrenAsync( + OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) + { + var deviceId = parent.EntityId!; + + var groups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == deviceId && g.ParentTagGroupId == null) + .OrderBy(g => g.SortOrder).ThenBy(g => g.Name) + .ToListAsync(ct); + + var tags = await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId && t.TagGroupId == null) + .OrderBy(t => t.Name) + .ToListAsync(ct); + + var nodes = new List(groups.Count + tags.Count); + nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, groups, ct)); + nodes.AddRange(tags.Select(t => BuildTagNode(parent, t))); + return nodes; + } + + private static async Task> LoadTagGroupChildrenAsync( + OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) + { + var groupId = parent.EntityId!; + + var subGroups = await db.TagGroups.AsNoTracking() + .Where(g => g.ParentTagGroupId == groupId) + .OrderBy(g => g.SortOrder).ThenBy(g => g.Name) + .ToListAsync(ct); + + var tags = await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId == groupId) + .OrderBy(t => t.Name) + .ToListAsync(ct); + + var nodes = new List(subGroups.Count + tags.Count); + nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, subGroups, ct)); + nodes.AddRange(tags.Select(t => BuildTagNode(parent, t))); + return nodes; + } + + // --- Node builders (compute per-node child counts in bulk) --- + + private static async Task> BuildFolderNodesAsync( + OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList folders, CancellationToken ct) + { + if (folders.Count == 0) return Array.Empty(); + var ids = folders.Select(f => f.RawFolderId).ToList(); + + var subFolderCounts = (await db.RawFolders.AsNoTracking() + .Where(f => f.ParentRawFolderId != null && ids.Contains(f.ParentRawFolderId)) + .GroupBy(f => f.ParentRawFolderId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + var driverCounts = (await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId != null && ids.Contains(d.RawFolderId)) + .GroupBy(d => d.RawFolderId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + return folders.Select(f => new RawNode + { + Kind = RawNodeKind.Folder, + Key = $"fld:{f.RawFolderId}", + DisplayName = f.Name, + ClusterId = clusterId, + EntityId = f.RawFolderId, + HasLazyChildren = true, + ChildCount = subFolderCounts.GetValueOrDefault(f.RawFolderId) + driverCounts.GetValueOrDefault(f.RawFolderId), + RowVersion = f.RowVersion, + }).ToList(); + } + + private static async Task> BuildDriverNodesAsync( + OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList drivers, CancellationToken ct) + { + if (drivers.Count == 0) return Array.Empty(); + var ids = drivers.Select(d => d.DriverInstanceId).ToList(); + + var deviceCounts = (await db.Devices.AsNoTracking() + .Where(dev => ids.Contains(dev.DriverInstanceId)) + .GroupBy(dev => dev.DriverInstanceId) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + return drivers.Select(d => new RawNode + { + Kind = RawNodeKind.Driver, + Key = $"drv:{d.DriverInstanceId}", + DisplayName = d.Name, + ClusterId = clusterId, + EntityId = d.DriverInstanceId, + DriverType = d.DriverType, + Enabled = d.Enabled, + HasLazyChildren = true, + ChildCount = deviceCounts.GetValueOrDefault(d.DriverInstanceId), + RowVersion = d.RowVersion, + }).ToList(); + } + + private static async Task> BuildTagGroupNodesAsync( + OtOpcUaConfigDbContext db, RawNode parent, IReadOnlyList groups, CancellationToken ct) + { + if (groups.Count == 0) return Array.Empty(); + var ids = groups.Select(g => g.TagGroupId).ToList(); + + var subGroupCounts = (await db.TagGroups.AsNoTracking() + .Where(g => g.ParentTagGroupId != null && ids.Contains(g.ParentTagGroupId)) + .GroupBy(g => g.ParentTagGroupId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + var tagCounts = (await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId != null && ids.Contains(t.TagGroupId)) + .GroupBy(t => t.TagGroupId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + return groups.Select(g => new RawNode + { + Kind = RawNodeKind.TagGroup, + Key = $"grp:{g.TagGroupId}", + DisplayName = g.Name, + ClusterId = parent.ClusterId, + EntityId = g.TagGroupId, + DriverType = parent.DriverType, + HasLazyChildren = true, + ChildCount = subGroupCounts.GetValueOrDefault(g.TagGroupId) + tagCounts.GetValueOrDefault(g.TagGroupId), + RowVersion = g.RowVersion, + }).ToList(); + } + + private static RawNode BuildTagNode(RawNode parent, Tag t) => new() + { + Kind = RawNodeKind.Tag, + Key = $"tag:{t.TagId}", + DisplayName = t.Name, + ClusterId = parent.ClusterId, + EntityId = t.TagId, + DriverType = parent.DriverType, + HasLazyChildren = false, + ChildCount = 0, + RowVersion = t.RowVersion, + }; + + private static async Task> CountRootGroupsByDeviceAsync( + OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) + { + if (deviceIds.Count == 0) return new Dictionary(StringComparer.Ordinal); + return (await db.TagGroups.AsNoTracking() + .Where(g => g.ParentTagGroupId == null && deviceIds.Contains(g.DeviceId)) + .GroupBy(g => g.DeviceId) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + } + + private static async Task> CountRootTagsByDeviceAsync( + OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) + { + if (deviceIds.Count == 0) return new Dictionary(StringComparer.Ordinal); + return (await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId == null && deviceIds.Contains(t.DeviceId)) + .GroupBy(t => t.DeviceId) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + } + + // --------------------------------------------------------------------------------------------- + // Folder mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateFolderAsync( + string clusterId, string? parentFolderId, string name, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct)) + return new UnsMutationResult(false, $"Cluster '{clusterId}' not found."); + + if (parentFolderId is not null) + { + var parent = await db.RawFolders.AsNoTracking() + .FirstOrDefaultAsync(f => f.RawFolderId == parentFolderId, ct); + if (parent is null) return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' not found."); + if (parent.ClusterId != clusterId) + return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' is in a different cluster."); + } + + if (await db.RawFolders.AnyAsync( + f => f.ClusterId == clusterId && f.ParentRawFolderId == parentFolderId && f.Name == name, ct)) + return new UnsMutationResult(false, $"A folder named '{name}' already exists here."); + + var id = NewId("RF"); + db.RawFolders.Add(new RawFolder + { + RawFolderId = id, + ClusterId = clusterId, + ParentRawFolderId = parentFolderId, + Name = name, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, id); + } + + /// + public async Task RenameFolderAsync( + string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.RawFolders.AnyAsync( + f => f.ClusterId == entity.ClusterId && f.ParentRawFolderId == entity.ParentRawFolderId + && f.Name == newName && f.RawFolderId != rawFolderId, ct)) + return RawRenameResult.Failure($"A folder named '{newName}' already exists here."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathFolderAsync(db, rawFolderId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "folder", ct); + } + + /// + public async Task DeleteFolderAsync( + string rawFolderId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + if (await db.RawFolders.AnyAsync(f => f.ParentRawFolderId == rawFolderId, ct)) + return new UnsMutationResult(false, + $"Cannot delete folder '{entity.Name}': it still contains sub-folders β€” remove or move them first."); + + if (await db.DriverInstances.AnyAsync(d => d.RawFolderId == rawFolderId, ct)) + return new UnsMutationResult(false, + $"Cannot delete folder '{entity.Name}': it still contains driver instances β€” remove or move them first."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.RawFolders.Remove(entity); + return await SaveDeleteAsync(db, "folder", ct); + } + + /// + public async Task MoveDriverToFolderAsync( + string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (driver is null) return new UnsMutationResult(false, "Row no longer exists."); + + if (targetFolderId is not null) + { + var folder = await db.RawFolders.AsNoTracking() + .FirstOrDefaultAsync(f => f.RawFolderId == targetFolderId, ct); + if (folder is null) return new UnsMutationResult(false, $"Target folder '{targetFolderId}' not found."); + if (folder.ClusterId != driver.ClusterId) + return new UnsMutationResult(false, + $"Target folder '{folder.Name}' is in cluster '{folder.ClusterId}' but the driver is in cluster '{driver.ClusterId}'."); + } + + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == driver.ClusterId && d.RawFolderId == targetFolderId + && d.Name == driver.Name && d.DriverInstanceId != driverInstanceId, ct)) + return new UnsMutationResult(false, + $"A driver named '{driver.Name}' already exists in the target location."); + + db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion; + driver.RawFolderId = targetFolderId; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values."); + } + } + + // --------------------------------------------------------------------------------------------- + // Driver mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateDriverAsync( + string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + if (string.IsNullOrWhiteSpace(driverType)) + return new UnsMutationResult(false, "Pick a driver type."); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct)) + return new UnsMutationResult(false, $"Cluster '{clusterId}' not found."); + + if (folderId is not null) + { + var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == folderId, ct); + if (folder is null) return new UnsMutationResult(false, $"Folder '{folderId}' not found."); + if (folder.ClusterId != clusterId) + return new UnsMutationResult(false, $"Folder '{folderId}' is in a different cluster."); + } + + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == clusterId && d.RawFolderId == folderId && d.Name == name, ct)) + return new UnsMutationResult(false, $"A driver named '{name}' already exists here."); + + var driverId = NewId("DRV"); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = driverId, + ClusterId = clusterId, + RawFolderId = folderId, + Name = name, + DriverType = driverType, + DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson, + }); + + // Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3) + // refines device/driver config authoring; here the default device carries an empty config. + db.Devices.Add(new Device + { + DeviceId = NewId("DEV"), + DriverInstanceId = driverId, + Name = "Device1", + DeviceConfig = "{}", + }); + + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, driverId); + } + + /// + public async Task RenameDriverAsync( + string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId + && d.Name == newName && d.DriverInstanceId != driverInstanceId, ct)) + return RawRenameResult.Failure($"A driver named '{newName}' already exists here."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "driver", ct); + } + + /// + public async Task SetDriverEnabledAsync( + string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Enabled = enabled; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this driver while you were editing."); + } + } + + /// + public async Task DeleteDriverAsync( + string driverInstanceId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (driver is null) return new UnsMutationResult(true, null); + + var devices = await db.Devices.Where(dev => dev.DriverInstanceId == driverInstanceId).ToListAsync(ct); + + // A driver always has β‰₯1 device, so "has devices" cannot be the block. Instead: cascade the + // (empty) auto-devices, but refuse β€” naming the offending device β€” when any device still holds + // tag groups or tags, so tag authoring is never silently discarded. + foreach (var dev in devices) + { + if (await db.TagGroups.AnyAsync(g => g.DeviceId == dev.DeviceId, ct) + || await db.Tags.AnyAsync(t => t.DeviceId == dev.DeviceId, ct)) + { + return new UnsMutationResult(false, + $"Cannot delete driver '{driver.Name}': device '{dev.Name}' still contains tag groups or tags β€” remove them first."); + } + } + + db.Devices.RemoveRange(devices); + db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.DriverInstances.Remove(driver); + return await SaveDeleteAsync(db, "driver", ct); + } + + // --------------------------------------------------------------------------------------------- + // Device mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateDeviceAsync( + string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct)) + return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found."); + + if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct)) + return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver."); + + var id = NewId("DEV"); + db.Devices.Add(new Device + { + DeviceId = id, + DriverInstanceId = driverInstanceId, + Name = name, + DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, id); + } + + /// + public async Task RenameDeviceAsync( + string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.Devices.AnyAsync( + dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == newName && dev.DeviceId != deviceId, ct)) + return RawRenameResult.Failure($"A device named '{newName}' already exists on this driver."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDeviceAsync(db, deviceId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "device", ct); + } + + /// + public async Task DeleteDeviceAsync( + string deviceId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + if (await db.TagGroups.AnyAsync(g => g.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, + $"Cannot delete device '{entity.Name}': it still contains tag groups β€” remove them first."); + + if (await db.Tags.AnyAsync(t => t.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, + $"Cannot delete device '{entity.Name}': it still contains tags β€” remove them first."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.Devices.Remove(entity); + return await SaveDeleteAsync(db, "device", ct); + } + + // --------------------------------------------------------------------------------------------- + // TagGroup mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateTagGroupAsync( + string deviceId, string? parentGroupId, string name, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, $"Device '{deviceId}' not found."); + + if (parentGroupId is not null) + { + var parent = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == parentGroupId, ct); + if (parent is null) return new UnsMutationResult(false, $"Parent group '{parentGroupId}' not found."); + if (parent.DeviceId != deviceId) + return new UnsMutationResult(false, $"Parent group '{parentGroupId}' is on a different device."); + } + + if (await db.TagGroups.AnyAsync( + g => g.DeviceId == deviceId && g.ParentTagGroupId == parentGroupId && g.Name == name, ct)) + return new UnsMutationResult(false, $"A tag group named '{name}' already exists here."); + + var id = NewId("TG"); + db.TagGroups.Add(new TagGroup + { + TagGroupId = id, + DeviceId = deviceId, + ParentTagGroupId = parentGroupId, + Name = name, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, id); + } + + /// + public async Task RenameTagGroupAsync( + string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.TagGroups.AnyAsync( + g => g.DeviceId == entity.DeviceId && g.ParentTagGroupId == entity.ParentTagGroupId + && g.Name == newName && g.TagGroupId != tagGroupId, ct)) + return RawRenameResult.Failure($"A tag group named '{newName}' already exists here."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathGroupAsync(db, tagGroupId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "tag group", ct); + } + + /// + public async Task DeleteTagGroupAsync( + string tagGroupId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + if (await db.TagGroups.AnyAsync(g => g.ParentTagGroupId == tagGroupId, ct)) + return new UnsMutationResult(false, + $"Cannot delete tag group '{entity.Name}': it still contains sub-groups β€” remove them first."); + + if (await db.Tags.AnyAsync(t => t.TagGroupId == tagGroupId, ct)) + return new UnsMutationResult(false, + $"Cannot delete tag group '{entity.Name}': it still contains tags β€” remove them first."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.TagGroups.Remove(entity); + return await SaveDeleteAsync(db, "tag group", ct); + } + + // --------------------------------------------------------------------------------------------- + // Tag delete + edit projection (create/update are WP4) + // --------------------------------------------------------------------------------------------- + + /// + public async Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + // Blocked while any UNS equipment references this raw tag β€” name the referencing equipment(s). + var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.TagId == tagId) + .Select(r => r.EquipmentId) + .Distinct() + .ToListAsync(ct); + + if (referencingEquipmentIds.Count > 0) + { + var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct); + return new UnsMutationResult(false, + $"Cannot delete tag '{entity.Name}': it is referenced by equipment {display} β€” remove the UNS reference(s) first."); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.Tags.Remove(entity); + return await SaveDeleteAsync(db, "tag", ct); + } + + /// + public async Task LoadTagForEditAsync(string tagId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.Tags.AsNoTracking() + .Where(t => t.TagId == tagId) + .Select(t => new RawTagEditDto( + t.TagId, + t.DeviceId, + t.TagGroupId, + t.Name, + t.DataType, + t.AccessLevel, + t.WriteIdempotent, + t.PollGroupId, + t.TagConfig, + t.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + // --------------------------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------------------------- + + /// Generates a system id of the form {prefix}-{12 hex} (≀ the 64-char id limit). + private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}"; + + /// Commits a rename, translating a concurrency clash into a friendly failure. + private static async Task SaveRenameAsync( + OtOpcUaConfigDbContext db, IReadOnlyList warnings, string noun, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + return RawRenameResult.Success(warnings); + } + catch (DbUpdateConcurrencyException) + { + return RawRenameResult.Failure($"Another user changed this {noun} while you were editing. Reload to see the latest values."); + } + } + + /// Commits a delete, translating a concurrency clash / stray FK block into a friendly failure. + private static async Task SaveDeleteAsync( + OtOpcUaConfigDbContext db, string noun, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it."); + } + catch (Exception ex) + { + // Defensive net for a real SQL Server where a child slipped past the pre-check (race). + return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); + } + } + + /// + /// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and + /// equipment-referenced tags beneath the renamed node (whose RawPath β€” and thus historian tagname / + /// UNS projection path β€” changes with the rename). Batch 3 appends the script-substring scan here. + /// + private static async Task> BuildRenameWarningsAsync( + OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct) + { + var warnings = new List(); + if (tags.Count == 0) return warnings; + + var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized); + if (historized > 0) + { + warnings.Add(historized == 1 + ? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path." + : $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths."); + } + + var tagIds = tags.Select(t => t.TagId).ToList(); + var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() + .Where(r => tagIds.Contains(r.TagId)) + .Select(r => r.EquipmentId) + .Distinct() + .ToListAsync(ct); + + if (referencingEquipmentIds.Count > 0) + { + var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct); + warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename."); + } + + // Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append + // a "N script(s) reference tags beneath this node" warning to this same list. + + return warnings; + } + + /// Renders a friendly "Name (Id), …" list for the referencing equipment ids. + private static async Task DescribeEquipmentAsync( + OtOpcUaConfigDbContext db, IReadOnlyList equipmentIds, CancellationToken ct) + { + var names = (await db.Equipment.AsNoTracking() + .Where(e => equipmentIds.Contains(e.EquipmentId)) + .Select(e => new { e.EquipmentId, e.Name }) + .ToListAsync(ct)) + .ToDictionary(x => x.EquipmentId, x => x.Name, StringComparer.Ordinal); + + return string.Join(", ", equipmentIds + .OrderBy(id => id, StringComparer.Ordinal) + .Select(id => names.TryGetValue(id, out var n) ? $"'{n}' ({id})" : $"'{id}'")); + } + + // --- Subtree tag collectors (walk the in-DB subtree for rename impact) --- + + private static async Task> CollectTagsBeneathFolderAsync( + OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct) + { + var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); + if (folder is null) return Array.Empty<(string, string)>(); + + // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId. + var clusterFolders = await db.RawFolders.AsNoTracking() + .Where(f => f.ClusterId == folder.ClusterId) + .Select(f => new { f.RawFolderId, f.ParentRawFolderId }) + .ToListAsync(ct); + + var childrenByParent = clusterFolders + .Where(f => f.ParentRawFolderId is not null) + .GroupBy(f => f.ParentRawFolderId!, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => x.RawFolderId).ToList(), StringComparer.Ordinal); + + var folderIds = DescendantsInclusive(rawFolderId, childrenByParent); + + var driverIds = await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId)) + .Select(d => d.DriverInstanceId) + .ToListAsync(ct); + if (driverIds.Count == 0) return Array.Empty<(string, string)>(); + + var deviceIds = await db.Devices.AsNoTracking() + .Where(dev => driverIds.Contains(dev.DriverInstanceId)) + .Select(dev => dev.DeviceId) + .ToListAsync(ct); + if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + + return await TagsForDevicesAsync(db, deviceIds, ct); + } + + private static async Task> CollectTagsBeneathDriverAsync( + OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct) + { + var deviceIds = await db.Devices.AsNoTracking() + .Where(dev => dev.DriverInstanceId == driverInstanceId) + .Select(dev => dev.DeviceId) + .ToListAsync(ct); + if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + return await TagsForDevicesAsync(db, deviceIds, ct); + } + + private static async Task> CollectTagsBeneathDeviceAsync( + OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) + { + return (await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId) + .Select(t => new { t.TagId, t.TagConfig }) + .ToListAsync(ct)) + .Select(t => (t.TagId, t.TagConfig)) + .ToList(); + } + + private static async Task> CollectTagsBeneathGroupAsync( + OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct) + { + var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (group is null) return Array.Empty<(string, string)>(); + + var deviceGroups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == group.DeviceId) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId }) + .ToListAsync(ct); + + var childrenByParent = deviceGroups + .Where(g => g.ParentTagGroupId is not null) + .GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal); + + var groupIds = DescendantsInclusive(tagGroupId, childrenByParent); + + return (await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) + .Select(t => new { t.TagId, t.TagConfig }) + .ToListAsync(ct)) + .Select(t => (t.TagId, t.TagConfig)) + .ToList(); + } + + private static async Task> TagsForDevicesAsync( + OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) + { + return (await db.Tags.AsNoTracking() + .Where(t => deviceIds.Contains(t.DeviceId)) + .Select(t => new { t.TagId, t.TagConfig }) + .ToListAsync(ct)) + .Select(t => (t.TagId, t.TagConfig)) + .ToList(); + } + + /// + /// Returns plus all its transitive descendants, using a + /// parentβ†’children adjacency map. Iterative (stack-based) so deep trees can't blow the call stack. + /// + private static HashSet DescendantsInclusive( + 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; // guard against cycles / double-visits + if (childrenByParent.TryGetValue(id, out var kids)) + { + foreach (var kid in kids) stack.Push(kid); + } + } + return result; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs new file mode 100644 index 00000000..4657e5a8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs @@ -0,0 +1,348 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Covers β€” the B2-WP1 Raw-tree data layer: lazy children per level, +/// create/rename/delete/move happy paths and guards (sibling-name collision, non-empty-container delete +/// blocks naming the blocker, referenced-tag delete blocks naming the equipment), and rename warnings. +/// +/// As with the UnsTreeService suite, the EF InMemory provider does not enforce RowVersion +/// concurrency tokens, so the DbUpdateConcurrencyException branch is not directly exercised; the +/// concurrent-modification failure is instead covered via the "row vanished under a stale handle" path +/// (another actor deletes the row before the rename commits). +/// +/// +[Trait("Category", "Unit")] +public sealed class RawTreeServiceTests +{ + private static (RawTreeService Service, string DbName) Seeded() + { + var name = RawTreeTestDb.SeedFresh(); + return (new RawTreeService(RawTreeTestDb.Factory(name)), name); + } + + private static byte[] RowVersionOf( + string dbName, Func selector) + { + using var db = RawTreeTestDb.CreateNamed(dbName); + return selector(db); + } + + // ----------------------------------------------------------------------------------- Read layer + + [Fact] + public async Task LoadRoots_yields_enterprise_and_cluster_with_lazy_flag_and_root_count() + { + var (service, _) = Seeded(); + + var roots = await service.LoadRootsAsync(); + + var ent = roots.ShouldHaveSingleItem(); + ent.Kind.ShouldBe(RawNodeKind.Enterprise); + ent.DisplayName.ShouldBe("zb"); + + var cluster = ent.Children.ShouldHaveSingleItem(); + cluster.Kind.ShouldBe(RawNodeKind.Cluster); + cluster.EntityId.ShouldBe(RawTreeTestDb.ClusterId); + cluster.HasLazyChildren.ShouldBeTrue(); + cluster.Children.ShouldBeEmpty(); // clusters load lazily + cluster.ChildCount.ShouldBe(2); // 1 root folder + 1 cluster-root driver + } + + [Fact] + public async Task LoadChildren_of_cluster_yields_root_folders_and_root_drivers() + { + var (service, _) = Seeded(); + var cluster = (await service.LoadRootsAsync()).Single().Children.Single(); + + var children = await service.LoadChildrenAsync(cluster); + + var folder = children.Single(n => n.Kind == RawNodeKind.Folder); + folder.DisplayName.ShouldBe("PLCs"); + folder.EntityId.ShouldBe(RawTreeTestDb.RootFolderId); + folder.HasLazyChildren.ShouldBeTrue(); + folder.ChildCount.ShouldBe(1); // the foldered driver + + var driver = children.Single(n => n.Kind == RawNodeKind.Driver); + driver.DisplayName.ShouldBe("s7-1"); + driver.DriverType.ShouldBe("S7"); + driver.ChildCount.ShouldBe(1); // its default device + } + + [Fact] + public async Task LoadChildren_propagates_driver_type_down_to_device_group_and_tag() + { + var (service, _) = Seeded(); + var cluster = (await service.LoadRootsAsync()).Single().Children.Single(); + var folder = (await service.LoadChildrenAsync(cluster)).Single(n => n.Kind == RawNodeKind.Folder); + + var driver = (await service.LoadChildrenAsync(folder)).ShouldHaveSingleItem(); + driver.Kind.ShouldBe(RawNodeKind.Driver); + driver.DriverType.ShouldBe("Modbus"); + + var device = (await service.LoadChildrenAsync(driver)).ShouldHaveSingleItem(); + device.Kind.ShouldBe(RawNodeKind.Device); + device.DriverType.ShouldBe("Modbus"); // propagated + device.ChildCount.ShouldBe(1); // one root group (tags live under the group) + + var group = (await service.LoadChildrenAsync(device)).ShouldHaveSingleItem(); + group.Kind.ShouldBe(RawNodeKind.TagGroup); + group.DriverType.ShouldBe("Modbus"); // propagated + group.ChildCount.ShouldBe(2); // two tags + + var tags = await service.LoadChildrenAsync(group); + tags.Count.ShouldBe(2); + tags.ShouldAllBe(t => t.Kind == RawNodeKind.Tag); + tags.ShouldAllBe(t => !t.HasLazyChildren); + tags.ShouldAllBe(t => t.DriverType == "Modbus"); // propagated + (await service.LoadChildrenAsync(tags[0])).ShouldBeEmpty(); // leaf + } + + // -------------------------------------------------------------------------------- Create/happy + + [Fact] + public async Task CreateFolder_then_CreateDriver_autocreates_default_device() + { + var (service, dbName) = Seeded(); + + var folder = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "Cell-B"); + folder.Ok.ShouldBeTrue(); + folder.CreatedId.ShouldNotBeNull(); + + var driver = await service.CreateDriverAsync(RawTreeTestDb.ClusterId, folder.CreatedId, "abcip-1", "AbCip", "{}"); + driver.Ok.ShouldBeTrue(); + + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Devices.Count(d => d.DriverInstanceId == driver.CreatedId).ShouldBe(1); // default device auto-created + } + + [Fact] + public async Task CreateFolder_rejects_sibling_name_collision() + { + var (service, _) = Seeded(); + + var dup = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "PLCs"); // already a root folder + dup.Ok.ShouldBeFalse(); + dup.Error!.ShouldContain("already exists"); + } + + [Fact] + public async Task CreateFolder_rejects_invalid_segment() + { + var (service, _) = Seeded(); + + var bad = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "a/b"); + bad.Ok.ShouldBeFalse(); + bad.Error!.ShouldContain("/"); + } + + [Fact] + public async Task CreateTagGroup_rejects_sibling_collision() + { + var (service, _) = Seeded(); + + var dup = await service.CreateTagGroupAsync(RawTreeTestDb.FolderedDeviceId, null, "Motors"); + dup.Ok.ShouldBeFalse(); + dup.Error!.ShouldContain("already exists"); + } + + // ------------------------------------------------------------------------------- Rename + warn + + [Fact] + public async Task RenameFolder_warns_about_historized_tag_beneath() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + var result = await service.RenameFolderAsync(RawTreeTestDb.RootFolderId, "PLC", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldContain(w => w.Contains("historized")); + } + + [Fact] + public async Task RenameDriver_warns_about_equipment_reference_beneath() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId).RowVersion); + + var result = await service.RenameDriverAsync(RawTreeTestDb.RootDriverId, "s7-main", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldContain(w => w.Contains(RawTreeTestDb.EquipmentName)); + } + + [Fact] + public async Task RenameFolder_rejects_sibling_collision() + { + var (service, dbName) = Seeded(); + await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "Spare"); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + var result = await service.RenameFolderAsync(RawTreeTestDb.RootFolderId, "Spare", rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("already exists"); + } + + // -------------------------------------------------------------------------------- Delete blocks + + [Fact] + public async Task DeleteFolder_blocked_by_contained_driver_names_the_blocker() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + var result = await service.DeleteFolderAsync(RawTreeTestDb.RootFolderId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("driver"); + result.Error!.ShouldContain("PLCs"); + } + + [Fact] + public async Task DeleteDevice_blocked_by_tags() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.RootDeviceId).RowVersion); + + var result = await service.DeleteDeviceAsync(RawTreeTestDb.RootDeviceId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("tags"); + } + + [Fact] + public async Task DeleteTagGroup_blocked_by_tags() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.TagGroups.Single(g => g.TagGroupId == RawTreeTestDb.RootGroupId).RowVersion); + + var result = await service.DeleteTagGroupAsync(RawTreeTestDb.RootGroupId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("tags"); + } + + [Fact] + public async Task DeleteDriver_blocked_when_a_device_holds_tags_names_the_device() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId).RowVersion); + + var result = await service.DeleteDriverAsync(RawTreeTestDb.RootDriverId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("Device1"); // the offending device is named + } + + [Fact] + public async Task DeleteTag_blocked_by_reference_names_the_equipment() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.ReferencedTagId).RowVersion); + + var result = await service.DeleteTagAsync(RawTreeTestDb.ReferencedTagId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain(RawTreeTestDb.EquipmentName); + result.Error!.ShouldContain(RawTreeTestDb.EquipmentId); + } + + // -------------------------------------------------------------------------------- Delete happy + + [Fact] + public async Task DeleteTag_succeeds_when_unreferenced() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); + + var result = await service.DeleteTagAsync(RawTreeTestDb.PlainTagId, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.Any(t => t.TagId == RawTreeTestDb.PlainTagId).ShouldBeFalse(); + } + + [Fact] + public async Task DeleteFolder_succeeds_when_empty() + { + var (service, dbName) = Seeded(); + var created = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "Empty"); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == created.CreatedId!).RowVersion); + + var result = await service.DeleteFolderAsync(created.CreatedId!, rv); + + result.Ok.ShouldBeTrue(); + } + + // ------------------------------------------------------------------------------------ Move + + [Fact] + public async Task MoveDriverToFolder_reparents_the_driver() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId).RowVersion); + + var result = await service.MoveDriverToFolderAsync(RawTreeTestDb.RootDriverId, RawTreeTestDb.RootFolderId, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId) + .RawFolderId.ShouldBe(RawTreeTestDb.RootFolderId); + } + + [Fact] + public async Task MoveDriverToFolder_to_cluster_root_clears_the_folder() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); + + var result = await service.MoveDriverToFolderAsync(RawTreeTestDb.FolderedDriverId, null, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId) + .RawFolderId.ShouldBeNull(); + } + + // -------------------------------------------------------------------------- Concurrency / edit + + [Fact] + public async Task RenameFolder_surfaces_failure_when_row_vanished_concurrently() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + // Simulate another actor deleting the folder's contents + the folder itself out from under us. + using (var db = RawTreeTestDb.CreateNamed(dbName)) + { + db.RawFolders.Remove(db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId)); + db.SaveChanges(); + } + + var result = await service.RenameFolderAsync(RawTreeTestDb.RootFolderId, "Whatever", rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task LoadTagForEdit_returns_projection() + { + var (service, _) = Seeded(); + + var dto = await service.LoadTagForEditAsync(RawTreeTestDb.HistorizedTagId); + + dto.ShouldNotBeNull(); + dto.Value.Name.ShouldBe("speed"); + dto.Value.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId); + dto.Value.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId); + dto.Value.DataType.ShouldBe("Float"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs new file mode 100644 index 00000000..bc3c93b3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +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; + +/// +/// Shared in-memory fixture for RawTreeService tests. Seeds one enterprise ("zb") with a single +/// cluster ("MAIN") carrying a small but complete Raw-tree slice: +/// a root folder β†’ driver β†’ default device β†’ a nested tag-group with two tags (one historized), plus a +/// cluster-root driver whose device has a device-root tag referenced by a UNS equipment. +/// +internal static class RawTreeTestDb +{ + public const string ClusterId = "MAIN"; + public const string RootFolderId = "RF-root"; + public const string FolderedDriverId = "DRV-foldered"; + public const string FolderedDeviceId = "DEV-foldered"; + public const string RootGroupId = "TG-root"; + public const string HistorizedTagId = "TAG-hist"; + public const string PlainTagId = "TAG-plain"; + + public const string RootDriverId = "DRV-root"; + public const string RootDeviceId = "DEV-root"; + public const string ReferencedTagId = "TAG-referenced"; + + public const string EquipmentId = "EQ-000000000001"; + public const string EquipmentName = "packer-1"; + + /// Creates a context bound to the supplied InMemory database name. + public static OtOpcUaConfigDbContext CreateNamed(string name) => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(name) + .Options); + + /// An whose contexts share one InMemory database. + public static IDbContextFactory Factory(string name) => new NamedFactory(name); + + /// Seeds the fixture into a fresh, uniquely-named InMemory database and returns its name. + public static string SeedFresh() + { + var name = $"raw-{Guid.NewGuid():N}"; + using var db = CreateNamed(name); + Seed(db); + return name; + } + + /// Seeds the fixture into the supplied (already-bound) context and saves. + public static void Seed(OtOpcUaConfigDbContext db) + { + db.ServerClusters.Add(new ServerCluster + { + ClusterId = ClusterId, + Name = "Main", + Enterprise = "zb", + Site = "warsaw-west", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + + // Foldered branch: folder β†’ driver β†’ device β†’ group β†’ two tags (one historized). + db.RawFolders.Add(new RawFolder + { + RawFolderId = RootFolderId, + ClusterId = ClusterId, + ParentRawFolderId = null, + Name = "PLCs", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = FolderedDriverId, + ClusterId = ClusterId, + RawFolderId = RootFolderId, + Name = "modbus-1", + DriverType = "Modbus", + DriverConfig = "{}", + }); + db.Devices.Add(new Device + { + DeviceId = FolderedDeviceId, + DriverInstanceId = FolderedDriverId, + Name = "Device1", + DeviceConfig = "{}", + }); + db.TagGroups.Add(new TagGroup + { + TagGroupId = RootGroupId, + DeviceId = FolderedDeviceId, + ParentTagGroupId = null, + Name = "Motors", + }); + db.Tags.Add(new Tag + { + TagId = HistorizedTagId, + DeviceId = FolderedDeviceId, + TagGroupId = RootGroupId, + Name = "speed", + DataType = "Float", + AccessLevel = TagAccessLevel.ReadWrite, + TagConfig = "{\"isHistorized\":true}", + }); + db.Tags.Add(new Tag + { + TagId = PlainTagId, + DeviceId = FolderedDeviceId, + TagGroupId = RootGroupId, + Name = "state", + DataType = "Boolean", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + + // Cluster-root branch: driver β†’ device β†’ device-root tag referenced by a UNS equipment. + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = RootDriverId, + ClusterId = ClusterId, + RawFolderId = null, + Name = "s7-1", + DriverType = "S7", + DriverConfig = "{}", + }); + db.Devices.Add(new Device + { + DeviceId = RootDeviceId, + DriverInstanceId = RootDriverId, + Name = "Device1", + DeviceConfig = "{}", + }); + db.Tags.Add(new Tag + { + TagId = ReferencedTagId, + DeviceId = RootDeviceId, + TagGroupId = null, + Name = "temperature", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + + // A UNS equipment referencing the cluster-root tag (delete-block + rename-warning source). + db.Equipment.Add(new Equipment + { + EquipmentId = EquipmentId, + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-1", + Name = EquipmentName, + MachineCode = "packer_001", + }); + db.UnsTagReferences.Add(new UnsTagReference + { + UnsTagReferenceId = "REF-1", + EquipmentId = EquipmentId, + TagId = ReferencedTagId, + }); + + db.SaveChanges(); + } + + private sealed class NamedFactory(string name) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateNamed(name)); + } +} From 928e06dd011f891d77f53a14135d6b774f2434e2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:45:06 -0400 Subject: [PATCH 09/20] review(b2-waveA): /raw auto-expand enterprise roots; friendly create-race + delete failures; auth-guard classifies /raw + dev demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave-A reviewer findings (no Critical/High): - M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed. - L-1: create mutations route SaveChanges through SaveCreateAsync β€” a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException. - L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance. - L-5: RawNode.ChildCount doc clarified (direct children only). - Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw β†’ ConfigEditor, /dev/context-menu-demo β†’ AuthenticatedRead). M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree β€” no case-variant rows can coexist, so runtime ordinal RawPath keying never collides. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Components/Pages/Raw/GlobalRaw.razor | 6 ++++ .../ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs | 2 +- .../Uns/RawTreeService.cs | 36 +++++++++++++------ .../PageAuthorizationGuardTests.cs | 6 ++-- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor index 57af836f..845b9e06 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -55,6 +55,12 @@ try { _roots = await Svc.LoadRootsAsync(); + // Auto-expand the Enterprise roots so the Cluster level (which owns the lazy plumbing) is + // visible on load, mirroring GlobalUns.ExpandStructural. Enterprise children (clusters) are + // eagerly materialised by LoadRootsAsync, so this needs no lazy fetch; clusters stay + // collapsed and load their folders/drivers on first expand. + foreach (var enterprise in _roots) + enterprise.Expanded = true; } catch (Exception ex) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs index 973a989c..ad54d8c2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs @@ -69,7 +69,7 @@ public sealed class RawNode /// Whether a Driver/Device node is enabled (drives the enable/disable menu label + styling); true otherwise. public bool Enabled { get; init; } = true; - /// Direct child count (for the badge). For a device this is its tag-group + tag count. + /// Direct child count (for the badge) β€” for a device, its root tag-groups + root tags (direct children only, not the whole subtree). public int ChildCount { get; set; } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index d5370f59..0965d71c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -370,8 +370,7 @@ public sealed class RawTreeService(IDbContextFactory dbF ParentRawFolderId = parentFolderId, Name = name, }); - await db.SaveChangesAsync(ct); - return new UnsMutationResult(true, null, id); + return await SaveCreateAsync(db, "folder", id, ct); } /// @@ -510,8 +509,7 @@ public sealed class RawTreeService(IDbContextFactory dbF DeviceConfig = "{}", }); - await db.SaveChangesAsync(ct); - return new UnsMutationResult(true, null, driverId); + return await SaveCreateAsync(db, "driver", driverId, ct); } /// @@ -618,8 +616,7 @@ public sealed class RawTreeService(IDbContextFactory dbF Name = name, DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson, }); - await db.SaveChangesAsync(ct); - return new UnsMutationResult(true, null, id); + return await SaveCreateAsync(db, "device", id, ct); } /// @@ -703,8 +700,7 @@ public sealed class RawTreeService(IDbContextFactory dbF ParentTagGroupId = parentGroupId, Name = name, }); - await db.SaveChangesAsync(ct); - return new UnsMutationResult(true, null, id); + return await SaveCreateAsync(db, "tag group", id, ct); } /// @@ -840,10 +836,30 @@ public sealed class RawTreeService(IDbContextFactory dbF { return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it."); } - catch (Exception ex) + catch (Exception) { // Defensive net for a real SQL Server where a child slipped past the pre-check (race). - return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); + // Don't surface the raw EF/SQL text (leaks schema detail); give operator-friendly guidance. + return new UnsMutationResult(false, + $"Delete failed β€” a related record still references this {noun}. Reload and retry."); + } + } + + /// Commits a create, translating a lost sibling-name race (filtered-unique-index clash) into a friendly failure. + private static async Task SaveCreateAsync( + OtOpcUaConfigDbContext db, string noun, string createdId, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, createdId); + } + catch (DbUpdateException) + { + // The name-uniqueness pre-check narrows but doesn't close the race against a concurrent + // create of the same-name sibling; the DB's filtered-unique index is the backstop. + return new UnsMutationResult(false, + $"A {noun} with that name already exists here (created concurrently). Reload and retry."); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs index 4e12e47b..03297bd5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs @@ -29,8 +29,9 @@ public class PageAuthorizationGuardTests /// private static readonly IReadOnlyDictionary ExpectedPolicy = new Dictionary { - // ── ConfigEditor (20): the config-authoring surface (incl. live ResilienceConfig) ── + // ── ConfigEditor (21): the config-authoring surface (incl. live ResilienceConfig) ── [typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor, + [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = AdminUiPolicies.ConfigEditor, [typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor, [typeof(NewCluster)] = AdminUiPolicies.ConfigEditor, [typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor, @@ -54,8 +55,9 @@ public class PageAuthorizationGuardTests // ── FleetAdmin (1) ── [typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin, - // ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ── + // ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ── [typeof(Home)] = AdminUiPolicies.AuthenticatedRead, + [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead, [typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead, [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead, [typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead, From 76cffe1f491269c537c89c1c6492ce450f967355 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:58:13 -0400 Subject: [PATCH 10/20] =?UTF-8?q?feat(adminui):=20B2=20Wave-B=20service=20?= =?UTF-8?q?prelude=20=E2=80=94=20tag=20CRUD/import=20+=20driver/device=20c?= =?UTF-8?q?onfig=20+=20merged=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Uns/IRawTreeService.cs | 143 ++++++ .../Uns/RawTagInput.cs | 44 ++ .../Uns/RawTreeService.cs | 418 +++++++++++++++++ .../Uns/RawTreeServiceWaveBTests.cs | 428 ++++++++++++++++++ 4 files changed, 1033 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagInput.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs index 9b2cc646..63131c3c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs @@ -29,6 +29,48 @@ public readonly record struct RawTagEditDto( string TagConfig, byte[] RowVersion); +/// +/// Detached, editable projection of a DriverInstance β€” the read side of the Wave-B (WP3) +/// "Configure driver" modal. DriverType is included so the modal can dispatch to the correct +/// typed driver-config editor; it is immutable, so does +/// not change it. +/// +/// The driver instance's stable logical id. +/// The driver instance name (a RawPath segment). +/// The immutable driver-type string (see DriverTypeNames). +/// The schemaless per-driver-type DriverConfig JSON blob. +/// Optional per-instance resilience-pipeline overrides JSON, or null. +/// The optimistic-concurrency token. +public sealed record RawDriverEditDto( + string DriverInstanceId, + string Name, + string DriverType, + string DriverConfig, + string? ResilienceConfig, + byte[] RowVersion); + +/// +/// Detached, editable projection of a Device β€” the read side of the Wave-B (WP3) "Configure +/// device" modal. DriverType is joined in from the parent DriverInstance so the modal can +/// dispatch to the correct typed device-config editor (endpoint/connection settings now live in +/// DeviceConfig). +/// +/// The device's stable logical id. +/// The owning driver instance's logical id. +/// The device name (a RawPath segment). +/// The schemaless per-driver-type DeviceConfig JSON blob (host/endpoint + per-device settings). +/// Whether the device is enabled. +/// The parent driver's type string, joined in for editor dispatch. +/// The optimistic-concurrency token. +public sealed record RawDeviceEditDto( + string DeviceId, + string DriverInstanceId, + string Name, + string DeviceConfig, + bool Enabled, + string DriverType, + byte[] RowVersion); + /// /// Loads and mutates the v3 Raw project tree (/raw) β€” the cluster-rooted /// Enterprise β†’ Cluster β†’ Folder β†’ Driver β†’ Device β†’ TagGroup β†’ Tag hierarchy β€” from the config @@ -251,4 +293,105 @@ public interface IRawTreeService /// A token to cancel the load. /// The tag's editable projection, or null when the tag does not exist. Task LoadTagForEditAsync(string tagId, CancellationToken ct = default); + + /// + /// Creates a raw Tag under a device ( null) or a tag group. The + /// name is validated as a RawPath segment and must be unique among its siblings on the same + /// (DeviceId, TagGroupId); the is validated for JSON + /// well-formedness. On success the result's CreatedId carries the new tag's logical id. + /// + /// The owning device's logical id. + /// The containing tag group's logical id, or null for a device-root tag. + /// The tag's editable fields (identity is the RawPath β€” no TagId here). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new TagId on success. + Task CreateTagAsync(string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default); + + /// + /// Updates a raw Tag's editable fields. The new name is re-validated as a RawPath segment and + /// sibling-unique on the tag's existing (DeviceId, TagGroupId) excluding itself; the + /// is validated for JSON well-formedness. Last-write-wins on + /// . The tag's DeviceId/TagGroupId (its identity location) + /// are preserved β€” moving a tag is not this method's job. + /// + /// The tag's logical id. + /// The tag's edited fields. + /// The optimistic-concurrency token echoed from the loaded tag. + /// A token to cancel the operation. + /// The update outcome. + Task UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Commits a batch of raw tags under a device β€” the WP5 CSV-import path. Each row's + /// TagGroupPath is a /-separated tag-group path under the device whose segments are + /// auto-created as nested TagGroups (reusing existing ones). Per-row validation errors (bad + /// name segment, malformed TagConfig JSON, a duplicate name within the device+group) are + /// collected, not thrown. The import is all-or-nothing: if any row errors, nothing is inserted + /// and the errors are returned; otherwise all tags (and any newly-needed groups) are committed in one + /// SaveChanges. + /// + /// The target device's logical id. + /// The rows to import. + /// A token to cancel the operation. + /// The import outcome: the inserted count on success, or the per-row errors with a 0 count. + Task ImportTagsAsync(string deviceId, IReadOnlyList rows, CancellationToken ct = default); + + /// + /// Loads a driver instance's editable fields as a detached projection for the WP3 "Configure driver" + /// modal. DriverType is included for typed-editor dispatch (it is immutable). + /// + /// The driver instance's logical id. + /// A token to cancel the load. + /// The driver's editable projection, or null when it does not exist. + Task LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default); + + /// + /// Updates a driver instance's name + config from the WP3 "Configure driver" modal. The name is + /// validated as a sibling-unique RawPath segment (same scope as ); + /// last-write-wins on . DriverType is immutable and is not changed. + /// + /// The driver instance's logical id. + /// The (possibly changed) driver name. + /// The new DriverConfig JSON blob. + /// The new resilience-config JSON, or null/blank to clear. + /// The optimistic-concurrency token echoed from the loaded projection. + /// A token to cancel the operation. + /// The update outcome. + Task UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Loads a device's editable fields as a detached projection for the WP3 "Configure device" modal. + /// DriverType is joined in from the parent driver so the modal can dispatch to the correct typed + /// device-config editor. + /// + /// The device's logical id. + /// A token to cancel the load. + /// The device's editable projection, or null when it does not exist. + Task LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default); + + /// + /// Updates a device's name + config + enabled state from the WP3 "Configure device" modal. The name + /// is validated as a RawPath segment unique among the driver's devices; last-write-wins on + /// . + /// + /// The device's logical id. + /// The (possibly changed) device name. + /// The new DeviceConfig JSON blob (endpoint + per-device settings). + /// The new enabled state. + /// The optimistic-concurrency token echoed from the loaded projection. + /// A token to cancel the operation. + /// The update outcome. + Task UpdateDeviceAsync(string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Builds the single merged config blob the driver-probe (test-connect) path binds from for one device: + /// the parent driver's DriverConfig folded together with this device's DeviceConfig (via + /// DriverDeviceConfigMerger, endpoint keys merging up from DeviceConfig). Used by the + /// "Test connect" button inside the WP3 driver/device modals now that the endpoint lives in + /// DeviceConfig. + /// + /// The device to probe. + /// A token to cancel the load. + /// The parent driver's type + the merged config JSON, or null when the device/driver cannot be resolved. + Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagInput.cs new file mode 100644 index 00000000..64f50f78 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagInput.cs @@ -0,0 +1,44 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Parameter object carrying the operator-editable fields for a raw Tag create or update in the +/// v3 Raw tree (WP4 manual entry). Identity is the RawPath β€” the tag's device + optional tag-group + +/// name β€” so there is no TagId here: it is generated on create and never carried in the input. +/// Whitespace-only collapses to null; is +/// validated for JSON well-formedness by the service (blank collapses to {}). +/// +/// The tag name (a RawPath segment). +/// OPC UA built-in type name (Boolean / Int32 / Float / etc.). +/// Tag-level OPC UA access baseline. +/// Whether writes to this tag are safe to retry. +/// Optional poll-group batching key; whitespace/empty collapses to null. +/// Schemaless per-driver-type JSON config; validated for JSON well-formedness. +public sealed record RawTagInput( + string Name, + string DataType, + TagAccessLevel AccessLevel, + bool WriteIdempotent, + string? PollGroupId, + string TagConfig); + +/// +/// One row of the WP5 CSV import commit. is a /-separated tag-group +/// path under the target device (its segments are auto-created as nested TagGroups, reusing any +/// that already exist); null/blank places the tag directly under the device. +/// carries the tag's editable fields. +/// +/// The /-separated group path under the device, or null for the device root. +/// The tag's editable fields. +public sealed record RawTagImportRow(string? TagGroupPath, RawTagInput Tag); + +/// +/// Outcome of . The import is all-or-nothing: on any +/// per-row validation error nothing is inserted, is 0, and +/// carries the operator-facing per-row messages. On success +/// is empty and is the number of tags committed. +/// +/// The number of tags inserted (0 when any row errored). +/// The per-row validation errors; empty on success. +public sealed record RawTagImportResult(int Inserted, IReadOnlyList Errors); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 0965d71c..f619c59e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1,7 +1,9 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; @@ -801,6 +803,371 @@ public sealed class RawTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); } + // --------------------------------------------------------------------------------------------- + // Tag create / update (WP4) + CSV import (WP5) + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateTagAsync( + string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(input.Name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig); + if (jsonError is not null) return new UnsMutationResult(false, jsonError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, $"Device '{deviceId}' not found."); + + if (tagGroupId is not null) + { + var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (group is null) return new UnsMutationResult(false, $"Tag group '{tagGroupId}' not found."); + if (group.DeviceId != deviceId) + return new UnsMutationResult(false, $"Tag group '{tagGroupId}' is on a different device."); + } + + if (await db.Tags.AnyAsync( + t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct)) + return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here."); + + var id = NewId("TAG"); + db.Tags.Add(new Tag + { + TagId = id, + DeviceId = deviceId, + TagGroupId = tagGroupId, + Name = input.Name, + DataType = input.DataType, + AccessLevel = input.AccessLevel, + WriteIdempotent = input.WriteIdempotent, + PollGroupId = NullIfBlank(input.PollGroupId), + TagConfig = tagConfig, + }); + return await SaveCreateAsync(db, "tag", id, ct); + } + + /// + public async Task UpdateTagAsync( + string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(input.Name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig); + if (jsonError is not null) return new UnsMutationResult(false, jsonError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + + // Re-validate name uniqueness on the tag's EXISTING (DeviceId, TagGroupId), excluding self. An + // identity move (changing device/group) is not this method's job β€” those fields are preserved. + if (await db.Tags.AnyAsync( + t => t.DeviceId == entity.DeviceId && t.TagGroupId == entity.TagGroupId + && t.Name == input.Name && t.TagId != tagId, ct)) + return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = input.Name; + entity.DataType = input.DataType; + entity.AccessLevel = input.AccessLevel; + entity.WriteIdempotent = input.WriteIdempotent; + entity.PollGroupId = NullIfBlank(input.PollGroupId); + entity.TagConfig = tagConfig; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this tag while you were editing. Reload to see the latest values."); + } + } + + /// + public async Task ImportTagsAsync( + string deviceId, IReadOnlyList rows, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(rows); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) + return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." }); + + // Existing groups on the device: a (parent-group-id | null, name) β†’ group-id lookup we extend with + // pending (auto-created) groups so a repeated path within the batch resolves to one group. + var groupLookup = (await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == deviceId) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name }) + .ToListAsync(ct)) + .ToDictionary(g => (g.ParentTagGroupId, g.Name), g => g.TagGroupId, GroupKeyComparer.Instance); + + // Existing tag identities on the device: (group-id | null, name). Extended with pending inserts so + // an intra-batch duplicate is caught too. + var takenNames = (await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId) + .Select(t => new { t.TagGroupId, t.Name }) + .ToListAsync(ct)) + .Select(t => (t.TagGroupId, t.Name)) + .ToHashSet(TagKeyComparer.Instance); + + var pendingGroups = new List(); + var pendingTags = new List(); + var errors = new List(); + + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var rowLabel = $"Row {i + 1}"; + + // Resolve (auto-creating) the tag-group path under the device. + string? groupId; + var groupError = ResolvePendingGroupPath(deviceId, row.TagGroupPath, groupLookup, pendingGroups, out groupId); + if (groupError is not null) + { + errors.Add($"{rowLabel}: {groupError}"); + continue; + } + + var nameError = RawPaths.ValidateSegment(row.Tag.Name); + if (nameError is not null) + { + errors.Add($"{rowLabel}: {nameError}"); + continue; + } + + var jsonError = TryNormalizeTagConfig(row.Tag.TagConfig, out var tagConfig); + if (jsonError is not null) + { + errors.Add($"{rowLabel}: {jsonError}"); + continue; + } + + if (!takenNames.Add((groupId, row.Tag.Name))) + { + errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path."); + continue; + } + + pendingTags.Add(new Tag + { + TagId = NewId("TAG"), + DeviceId = deviceId, + TagGroupId = groupId, + Name = row.Tag.Name, + DataType = row.Tag.DataType, + AccessLevel = row.Tag.AccessLevel, + WriteIdempotent = row.Tag.WriteIdempotent, + PollGroupId = NullIfBlank(row.Tag.PollGroupId), + TagConfig = tagConfig, + }); + } + + // All-or-nothing: any row error inserts nothing (the review-grid pre-validates; this is the backstop). + if (errors.Count > 0) return new RawTagImportResult(0, errors); + + db.TagGroups.AddRange(pendingGroups); + db.Tags.AddRange(pendingTags); + + try + { + await db.SaveChangesAsync(ct); + return new RawTagImportResult(pendingTags.Count, Array.Empty()); + } + catch (DbUpdateException) + { + // A concurrent create slipped a same-path sibling past the pre-check (the DB filtered-unique + // index is the backstop). Surface an operator-friendly all-or-nothing failure. + return new RawTagImportResult(0, new[] { "Import failed β€” a tag or group at one of these paths was created concurrently. Reload and retry." }); + } + } + + /// + /// Resolves a /-separated tag-group path under a device to a group id, auto-creating any + /// missing nested (added to and + /// so a repeated path reuses the same group). Returns null on success + /// with set (null = the device root for a blank path), or a friendly error. + /// + private static string? ResolvePendingGroupPath( + string deviceId, string? groupPath, + Dictionary<(string? Parent, string Name), string> groupLookup, + List pendingGroups, + out string? groupId) + { + groupId = null; + if (string.IsNullOrWhiteSpace(groupPath)) return null; + + string? parent = null; + foreach (var segment in groupPath.Split(RawPaths.Separator)) + { + var segError = RawPaths.ValidateSegment(segment); + if (segError is not null) return $"tag-group path segment '{segment}': {segError}"; + + var key = (parent, segment); + if (!groupLookup.TryGetValue(key, out var id)) + { + id = NewId("TG"); + pendingGroups.Add(new TagGroup + { + TagGroupId = id, + DeviceId = deviceId, + ParentTagGroupId = parent, + Name = segment, + }); + groupLookup[key] = id; + } + + parent = id; + } + + groupId = parent; + return null; + } + + // --------------------------------------------------------------------------------------------- + // Driver config (WP3 "Configure driver") + // --------------------------------------------------------------------------------------------- + + /// + public async Task LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.DriverInstances.AsNoTracking() + .Where(d => d.DriverInstanceId == driverInstanceId) + .Select(d => new RawDriverEditDto( + d.DriverInstanceId, + d.Name, + d.DriverType, + d.DriverConfig, + d.ResilienceConfig, + d.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task UpdateDriverAsync( + string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + + // Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder). + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId + && d.Name == name && d.DriverInstanceId != driverInstanceId, ct)) + return new UnsMutationResult(false, $"A driver named '{name}' already exists here."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = name; + entity.DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson; + entity.ResilienceConfig = NullIfBlank(resilienceConfig); // DriverType is immutable β€” not touched. + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values."); + } + } + + // --------------------------------------------------------------------------------------------- + // Device config (WP3 "Configure device") + merged probe config (Test connect) + // --------------------------------------------------------------------------------------------- + + /// + public async Task LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // DriverType is joined in from the parent driver β€” the device forms dispatch the typed editor on it. + return await db.Devices.AsNoTracking() + .Where(dev => dev.DeviceId == deviceId) + .Join(db.DriverInstances.AsNoTracking(), + dev => dev.DriverInstanceId, + drv => drv.DriverInstanceId, + (dev, drv) => new RawDeviceEditDto( + dev.DeviceId, + dev.DriverInstanceId, + dev.Name, + dev.DeviceConfig, + dev.Enabled, + drv.DriverType, + dev.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task UpdateDeviceAsync( + string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + + if (await db.Devices.AnyAsync( + dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct)) + return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = name; + entity.DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson; + entity.Enabled = enabled; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this device while you were editing. Reload to see the latest values."); + } + } + + /// + public async Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync( + string deviceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var device = await db.Devices.AsNoTracking().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); + if (device is null) return null; + + var driver = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.DriverInstanceId == device.DriverInstanceId, ct); + if (driver is null) return null; + + // Endpoint now lives in DeviceConfig β€” merge it up (single device β‡’ DeviceConfig keys win) into the + // one config blob the IDriverProbe path binds from. No tags feed a connect probe. + var merged = DriverDeviceConfigMerger.Merge( + driver.DriverConfig, + new[] { new DriverDeviceConfigMerger.DeviceRow(device.Name, device.DeviceConfig) }, + Array.Empty()); + + return (driver.DriverType, merged); + } + // --------------------------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------------------------- @@ -808,6 +1175,57 @@ public sealed class RawTreeService(IDbContextFactory dbF /// Generates a system id of the form {prefix}-{12 hex} (≀ the 64-char id limit). private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}"; + /// Collapses a whitespace-only optional string to null. + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + /// + /// Validates a TagConfig blob for JSON well-formedness (blank β‡’ {}). Returns a friendly + /// error when the blob is not parseable JSON; otherwise null with set. + /// + private static string? TryNormalizeTagConfig(string? tagConfig, out string normalized) + { + normalized = string.IsNullOrWhiteSpace(tagConfig) ? "{}" : tagConfig; + try + { + using var _ = JsonDocument.Parse(normalized); + return null; + } + catch (JsonException) + { + return "TagConfig is not well-formed JSON."; + } + } + + /// Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key. + private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)> + { + public static readonly GroupKeyComparer Instance = new(); + + public bool Equals((string? Parent, string Name) x, (string? Parent, string Name) y) => + string.Equals(x.Parent, y.Parent, StringComparison.Ordinal) + && string.Equals(x.Name, y.Name, StringComparison.Ordinal); + + public int GetHashCode((string? Parent, string Name) obj) => + HashCode.Combine( + obj.Parent is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Parent), + StringComparer.Ordinal.GetHashCode(obj.Name)); + } + + /// Ordinal (case-sensitive) equality for a nullable-group + name tag identity key. + private sealed class TagKeyComparer : IEqualityComparer<(string? GroupId, string Name)> + { + public static readonly TagKeyComparer Instance = new(); + + public bool Equals((string? GroupId, string Name) x, (string? GroupId, string Name) y) => + string.Equals(x.GroupId, y.GroupId, StringComparison.Ordinal) + && string.Equals(x.Name, y.Name, StringComparison.Ordinal); + + public int GetHashCode((string? GroupId, string Name) obj) => + HashCode.Combine( + obj.GroupId is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.GroupId), + StringComparer.Ordinal.GetHashCode(obj.Name)); + } + /// Commits a rename, translating a concurrency clash into a friendly failure. private static async Task SaveRenameAsync( OtOpcUaConfigDbContext db, IReadOnlyList warnings, string noun, CancellationToken ct) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs new file mode 100644 index 00000000..2b7d8ad5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs @@ -0,0 +1,428 @@ +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.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Covers the B2 Wave-B service prelude added to β€” the tag CRUD/import path +/// (WP4 manual entry + WP5 CSV import) and the driver/device config + merged-probe projections (WP3 +/// modals). Reuses the shared InMemory fixture. +/// +/// As with the WP1 suite, the EF InMemory provider does not enforce RowVersion tokens, so the +/// optimistic-concurrency failure is exercised via the "row vanished under a stale handle" path. +/// +/// +[Trait("Category", "Unit")] +public sealed class RawTreeServiceWaveBTests +{ + private static (RawTreeService Service, string DbName) Seeded() + { + var name = RawTreeTestDb.SeedFresh(); + return (new RawTreeService(RawTreeTestDb.Factory(name)), name); + } + + private static byte[] RowVersionOf(string dbName, Func selector) + { + using var db = RawTreeTestDb.CreateNamed(dbName); + return selector(db); + } + + private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") => + new(name, dataType, TagAccessLevel.ReadWrite, WriteIdempotent: false, PollGroupId: null, tagConfig); + + // -------------------------------------------------------------------------------- CreateTag (WP4) + + [Fact] + public async Task CreateTag_under_group_succeeds_and_returns_new_id() + { + var (service, dbName) = Seeded(); + + var result = await service.CreateTagAsync( + RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("torque")); + + result.Ok.ShouldBeTrue(); + result.CreatedId.ShouldNotBeNull(); + + using var db = RawTreeTestDb.CreateNamed(dbName); + var tag = db.Tags.Single(t => t.TagId == result.CreatedId); + tag.Name.ShouldBe("torque"); + tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId); + tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId); + } + + [Fact] + public async Task CreateTag_collapses_whitespace_pollgroup_to_null() + { + var (service, dbName) = Seeded(); + + var result = await service.CreateTagAsync( + RawTreeTestDb.FolderedDeviceId, null, + new RawTagInput("flow", "Float", TagAccessLevel.Read, false, " ", "{}")); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.Single(t => t.TagId == result.CreatedId).PollGroupId.ShouldBeNull(); + } + + [Fact] + public async Task CreateTag_rejects_sibling_name_collision() + { + var (service, _) = Seeded(); + + // "speed" already exists under RootGroup. + var dup = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("speed")); + + dup.Ok.ShouldBeFalse(); + dup.Error!.ShouldContain("already exists"); + } + + [Fact] + public async Task CreateTag_rejects_malformed_tagconfig_json() + { + var (service, _) = Seeded(); + + var bad = await service.CreateTagAsync( + RawTreeTestDb.FolderedDeviceId, null, Tag("pressure", tagConfig: "{not json")); + + bad.Ok.ShouldBeFalse(); + bad.Error!.ShouldContain("JSON"); + } + + [Fact] + public async Task CreateTag_rejects_invalid_name_segment() + { + var (service, _) = Seeded(); + + var bad = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, null, Tag("a/b")); + + bad.Ok.ShouldBeFalse(); + bad.Error!.ShouldContain("/"); + } + + // -------------------------------------------------------------------------------- UpdateTag (WP4) + + [Fact] + public async Task UpdateTag_edits_fields_last_write_wins() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); + + var result = await service.UpdateTagAsync( + RawTreeTestDb.PlainTagId, + new RawTagInput("running", "Boolean", TagAccessLevel.ReadWrite, true, "pg1", "{\"scale\":2}"), + rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + var tag = db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId); + tag.Name.ShouldBe("running"); + tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite); + tag.WriteIdempotent.ShouldBeTrue(); + tag.PollGroupId.ShouldBe("pg1"); + tag.TagConfig.ShouldContain("scale"); + // Identity location preserved. + tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId); + tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId); + } + + [Fact] + public async Task UpdateTag_rejects_rename_collision_excluding_self() + { + var (service, dbName) = Seeded(); + // Rename "state" (PlainTag) to "speed" β€” a sibling under the same group β‡’ collision. + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); + + var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("speed"), rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("already exists"); + } + + [Fact] + public async Task UpdateTag_allows_renaming_to_same_name_excluding_self() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); + + // Keeping its own name "state" must not trip the excl-self uniqueness check. + var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("state", "Boolean"), rv); + + result.Ok.ShouldBeTrue(); + } + + [Fact] + public async Task UpdateTag_fails_when_row_vanished_concurrently() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); + + using (var db = RawTreeTestDb.CreateNamed(dbName)) + { + db.Tags.Remove(db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId)); + db.SaveChanges(); + } + + var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("whatever", "Boolean"), rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + } + + // ------------------------------------------------------------------------------ ImportTags (WP5) + + [Fact] + public async Task ImportTags_autocreates_nested_groups_and_reuses_them() + { + var (service, dbName) = Seeded(); + + var rows = new[] + { + new RawTagImportRow("Line/A", Tag("t1")), + new RawTagImportRow("Line/A", Tag("t2")), // reuses Line + Line/A + new RawTagImportRow("Line/B", Tag("t3")), // reuses Line, new B + }; + + var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); + + result.Errors.ShouldBeEmpty(); + result.Inserted.ShouldBe(3); + + using var db = RawTreeTestDb.CreateNamed(dbName); + var line = db.TagGroups.Single(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.ParentTagGroupId == null && g.Name == "Line"); + var groupA = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "A"); + var groupB = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "B"); + db.Tags.Count(t => t.TagGroupId == groupA.TagGroupId).ShouldBe(2); // Line/A reused, not duplicated + db.Tags.Count(t => t.TagGroupId == groupB.TagGroupId).ShouldBe(1); + // Exactly one "Line" group created despite three references. + db.TagGroups.Count(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Line").ShouldBe(1); + } + + [Fact] + public async Task ImportTags_places_tag_at_device_root_when_path_blank() + { + var (service, dbName) = Seeded(); + + var result = await service.ImportTagsAsync( + RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("rootTag")) }); + + result.Inserted.ShouldBe(1); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.Single(t => t.Name == "rootTag").TagGroupId.ShouldBeNull(); + } + + [Fact] + public async Task ImportTags_all_or_nothing_on_a_bad_row_inserts_nothing() + { + var (service, dbName) = Seeded(); + + var rows = new[] + { + new RawTagImportRow("Grp", Tag("good1")), + new RawTagImportRow("Grp", Tag("bad", tagConfig: "{oops")), // malformed JSON β‡’ error + new RawTagImportRow("Grp", Tag("good2")), + }; + + var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); + + result.Inserted.ShouldBe(0); + result.Errors.ShouldNotBeEmpty(); + result.Errors[0].ShouldContain("Row 2"); + + // Nothing committed β€” not the two good tags, not the auto-created group. + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.Any(t => t.Name == "good1" || t.Name == "good2").ShouldBeFalse(); + db.TagGroups.Any(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Grp").ShouldBeFalse(); + } + + [Fact] + public async Task ImportTags_detects_intra_batch_duplicate() + { + var (service, _) = Seeded(); + + var rows = new[] + { + new RawTagImportRow("Grp", Tag("dup")), + new RawTagImportRow("Grp", Tag("dup")), // same path + name within the batch + }; + + var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); + + result.Inserted.ShouldBe(0); + result.Errors.ShouldContain(e => e.Contains("Row 2") && e.Contains("already exists")); + } + + [Fact] + public async Task ImportTags_detects_collision_with_existing_tag() + { + var (service, _) = Seeded(); + + // "temperature" already sits at the device root of RootDevice. + var result = await service.ImportTagsAsync( + RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("temperature")) }); + + result.Inserted.ShouldBe(0); + result.Errors.ShouldContain(e => e.Contains("already exists")); + } + + // ----------------------------------------------------------------------- Driver config (WP3) + + [Fact] + public async Task LoadDriverForEdit_projects_fields() + { + var (service, _) = Seeded(); + + var dto = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId); + + dto.ShouldNotBeNull(); + dto!.Name.ShouldBe("modbus-1"); + dto.DriverType.ShouldBe("Modbus"); + dto.DriverConfig.ShouldBe("{}"); + } + + [Fact] + public async Task LoadDriverForEdit_returns_null_when_missing() + { + var (service, _) = Seeded(); + (await service.LoadDriverForEditAsync("DRV-nope")).ShouldBeNull(); + } + + [Fact] + public async Task UpdateDriver_edits_name_and_config_preserving_type() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); + + var result = await service.UpdateDriverAsync( + RawTreeTestDb.FolderedDriverId, "modbus-main", "{\"Host\":\"10.0.0.1\"}", "{\"x\":1}", rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + var drv = db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId); + drv.Name.ShouldBe("modbus-main"); + drv.DriverConfig.ShouldContain("Host"); + drv.ResilienceConfig.ShouldBe("{\"x\":1}"); + drv.DriverType.ShouldBe("Modbus"); // immutable + } + + [Fact] + public async Task UpdateDriver_rejects_sibling_name_collision() + { + var (service, dbName) = Seeded(); + // Two cluster-root vs foldered drivers differ in scope; create a sibling in the same folder to collide. + await service.CreateDriverAsync(RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "modbus-2", "Modbus", "{}"); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); + + var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "modbus-2", "{}", null, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("already exists"); + } + + [Fact] + public async Task UpdateDriver_fails_when_row_vanished_concurrently() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); + + using (var db = RawTreeTestDb.CreateNamed(dbName)) + { + db.Devices.RemoveRange(db.Devices.Where(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)); + db.Tags.RemoveRange(db.Tags.Where(t => t.DeviceId == RawTreeTestDb.FolderedDeviceId)); + db.TagGroups.RemoveRange(db.TagGroups.Where(g => g.DeviceId == RawTreeTestDb.FolderedDeviceId)); + db.DriverInstances.Remove(db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)); + db.SaveChanges(); + } + + var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "x", "{}", null, rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + } + + // ----------------------------------------------------------------------- Device config (WP3) + + [Fact] + public async Task LoadDeviceForEdit_projects_fields_with_driver_type_join() + { + var (service, _) = Seeded(); + + var dto = await service.LoadDeviceForEditAsync(RawTreeTestDb.FolderedDeviceId); + + dto.ShouldNotBeNull(); + dto!.Name.ShouldBe("Device1"); + dto.DriverInstanceId.ShouldBe(RawTreeTestDb.FolderedDriverId); + dto.Enabled.ShouldBeTrue(); + dto.DriverType.ShouldBe("Modbus"); // joined from the parent driver + } + + [Fact] + public async Task LoadDeviceForEdit_returns_null_when_missing() + { + var (service, _) = Seeded(); + (await service.LoadDeviceForEditAsync("DEV-nope")).ShouldBeNull(); + } + + [Fact] + public async Task UpdateDevice_edits_name_config_and_enabled() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion); + + var result = await service.UpdateDeviceAsync( + RawTreeTestDb.FolderedDeviceId, "PLC-A", "{\"Host\":\"10.0.0.9\",\"Port\":502}", enabled: false, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId); + dev.Name.ShouldBe("PLC-A"); + dev.DeviceConfig.ShouldContain("Host"); + dev.Enabled.ShouldBeFalse(); + } + + [Fact] + public async Task UpdateDevice_rejects_sibling_name_collision() + { + var (service, dbName) = Seeded(); + await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device2", "{}"); + var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion); + + var result = await service.UpdateDeviceAsync(RawTreeTestDb.FolderedDeviceId, "Device2", "{}", true, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("already exists"); + } + + // ------------------------------------------------------------- Merged probe config (Test connect) + + [Fact] + public async Task LoadMergedProbeConfig_merges_deviceconfig_endpoint_up() + { + var (service, dbName) = Seeded(); + // Put an endpoint on the device config (endpoint lives in DeviceConfig in v3). + using (var db = RawTreeTestDb.CreateNamed(dbName)) + { + var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId); + dev.DeviceConfig = "{\"Host\":\"10.1.2.3\",\"Port\":5020}"; + db.SaveChanges(); + } + + var probe = await service.LoadMergedProbeConfigAsync(RawTreeTestDb.FolderedDeviceId); + + probe.ShouldNotBeNull(); + probe!.Value.DriverType.ShouldBe("Modbus"); + // Endpoint key from DeviceConfig appears merged up at the top level of the probe config. + probe.Value.MergedConfigJson.ShouldContain("Host"); + probe.Value.MergedConfigJson.ShouldContain("10.1.2.3"); + } + + [Fact] + public async Task LoadMergedProbeConfig_returns_null_for_unknown_device() + { + var (service, _) = Seeded(); + (await service.LoadMergedProbeConfigAsync("DEV-nope")).ShouldBeNull(); + } +} From ada552fbec67e8c27c6b44046fa9a36cee4cc724 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 03:08:05 -0400 Subject: [PATCH 11/20] feat(adminui): B2-WP4 manual tag entry + raw tag modal + Calculation editor Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Shared/Raw/RawManualTagEntryModal.razor | 289 ++++++++++++++++++ .../Components/Shared/Raw/RawTagModal.razor | 257 ++++++++++++++++ .../CalculationTagConfigEditor.razor | 279 +++++++++++++++++ .../TagEditors/CalculationTagConfigModel.cs | 76 +++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 1 + .../Uns/TagEditors/TagConfigValidator.cs | 1 + .../Uns/CalculationTagConfigModelTests.cs | 115 +++++++ 7 files changed, 1018 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CalculationTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CalculationTagConfigModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor new file mode 100644 index 00000000..3f4b5c9d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -0,0 +1,289 @@ +@* Bulk manual tag-entry grid for the v3 /raw tree: add MANY new tags at once under a Device or a + TagGroup. Each row carries Name / DataType / AccessLevel / WriteIdempotent / PollGroup plus a + per-row driver-typed TagConfig editor (revealed in an expandable full-width sub-row β€” the grid stays + compact). The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each + row is created via CreateTagAsync; committed rows drop off, failed rows stay with their inline error so + nothing is lost silently. Visibility is @bind-Visible; a successful commit raises OnSaved. *@ +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IRawTreeService Svc + +@if (Visible) +{ + + +} + +@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. 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 new tags are created under. + [Parameter] public string DeviceId { get; set; } = ""; + + /// The containing tag group, or null to create the tags directly under the device. + [Parameter] public string? TagGroupId { get; set; } + + /// The owning device's driver type, used for typed TagConfig-editor dispatch. + [Parameter] public string DriverType { get; set; } = ""; + + /// Raised after at least one tag is committed so the host can refresh the tree. + [Parameter] public EventCallback OnSaved { get; set; } + + private readonly List _rows = new(); + private bool _busy; + private string? _summary; + private bool _summaryOk; + private bool _open; // guards re-seeding rows on unrelated re-renders + + protected override void OnParametersSet() + { + if (!Visible) + { + _open = false; + return; + } + if (_open) { return; } // same open, re-render β†’ preserve in-progress rows + _open = true; + _rows.Clear(); + _rows.Add(new RowModel()); + _summary = null; + _summaryOk = false; + } + + private IDictionary BuildEditorParameters(RowModel row) => new Dictionary + { + ["ConfigJson"] = row.TagConfig, + ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => row.TagConfig = v), + ["DriverType"] = DriverType, + ["GetDriverConfigJson"] = (Func)(() => "{}"), + }; + + private void AddRow() => _rows.Add(new RowModel()); + + private void RemoveRow(RowModel row) + { + if (_rows.Count > 1) { _rows.Remove(row); } + } + + private static TagAccessLevel ParseAccess(object? v) + => Enum.TryParse(v?.ToString(), out var a) ? a : TagAccessLevel.Read; + + private async Task CommitAsync() + { + _busy = true; + _summary = null; + try + { + // --- Client-side validation first: name segment + per-driver config + intra-grid dup names. --- + foreach (var row in _rows) { row.Error = null; } + + var seen = new HashSet(RawPaths.Comparer); + var anyClientError = false; + foreach (var row in _rows) + { + var nameError = RawPaths.ValidateSegment(row.Name); + if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; } + if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; } + var configError = TagConfigValidator.Validate(DriverType, row.TagConfig); + if (configError is not null) { row.Error = configError; anyClientError = true; } + } + if (anyClientError) + { + _summaryOk = false; + _summary = "Fix the highlighted rows and try again."; + return; + } + + // --- Commit each row; keep failures (with their error), drop committed rows. --- + var created = 0; + var failed = 0; + foreach (var row in _rows) + { + var input = new RawTagInput( + row.Name, + row.DataType, + row.AccessLevel, + row.WriteIdempotent, + string.IsNullOrWhiteSpace(row.PollGroupId) ? null : row.PollGroupId, + string.IsNullOrWhiteSpace(row.TagConfig) ? "{}" : row.TagConfig); + + var result = await Svc.CreateTagAsync(DeviceId, TagGroupId, input); + if (result.Ok) + { + row.Committed = true; + created++; + } + else + { + row.Error = result.Error; + failed++; + } + } + + _rows.RemoveAll(r => r.Committed); + + if (failed == 0) + { + if (created > 0) { await OnSaved.InvokeAsync(); } + await CloseAsync(); + } + else + { + // Some succeeded, some failed: refresh the tree for the good ones, keep the modal open on + // the failed rows so the operator can correct them without re-entering the whole batch. + if (created > 0) { await OnSaved.InvokeAsync(); } + _summaryOk = false; + _summary = $"{created} tag(s) created; {failed} failed. Fix the highlighted rows and retry."; + } + } + finally + { + _busy = false; + } + } + + private async Task CloseAsync() + { + Visible = false; + _open = false; + await VisibleChanged.InvokeAsync(false); + } + + private sealed class RowModel + { + public string Key { get; } = Guid.NewGuid().ToString("N"); + public string Name { 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; } + public string TagConfig { get; set; } = "{}"; + public bool Expanded { get; set; } + public bool Committed { get; set; } + public string? Error { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor new file mode 100644 index 00000000..4f6ed3f2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor @@ -0,0 +1,257 @@ +@* Edit modal for a single raw Tag in the v3 /raw tree. Unlike the equipment TagModal there is NO + driver selector β€” the driver is fixed by the device the tag lives under, so the owning DriverType is + passed in and drives the typed TagConfig editor dispatch (TagConfigEditorMap; raw-JSON fallback for + unmapped drivers, e.g. Galaxy). Edit-only: the host opens it for an existing tree node (TagId), it + loads via LoadTagForEditAsync, and saves via UpdateTagAsync. Visibility is @bind-Visible; a successful + save raises OnSaved so the coordinator can refresh the tree. *@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IRawTreeService Svc + +@if (Visible) +{ + + +} + +@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. 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 raw tag to edit. The modal loads it via LoadTagForEditAsync on open. + [Parameter] public string? TagId { get; set; } + + /// The owning device's driver type, used for typed TagConfig-editor dispatch. + [Parameter] public string? DriverType { get; set; } + + /// Raised after a successful save so the host can refresh the tree. + [Parameter] public EventCallback OnSaved { get; set; } + + private FormModel _form = new(); + private byte[] _rowVersion = Array.Empty(); + private bool _busy; + private string? _error; + private string? _nameError; + private string? _loadError; + + // Tracks which (open, tag) this modal last loaded for, so unrelated Blazor Server re-renders don't + // reload + clobber in-progress edits. Null while closed. + private string? _loadedKey; + + protected override async Task OnParametersSetAsync() + { + if (!Visible) + { + _loadedKey = null; // closed β†’ next open reloads fresh + return; + } + + var key = TagId; + if (key == _loadedKey) { return; } // same open, re-render β†’ preserve in-progress edits + _loadedKey = key; + + _error = null; + _nameError = null; + _loadError = null; + + if (string.IsNullOrEmpty(TagId)) + { + _loadError = "No tag selected."; + return; + } + + var dto = await Svc.LoadTagForEditAsync(TagId); + // RawTagEditDto is a record struct β†’ a missing tag comes back as default (all-zero), not null. + if (dto is not { } d || string.IsNullOrEmpty(d.TagId)) + { + _loadError = "This tag no longer exists."; + return; + } + + _form = new FormModel + { + Name = d.Name, + DataType = string.IsNullOrEmpty(d.DataType) ? "Float" : d.DataType, + AccessLevel = d.AccessLevel, + WriteIdempotent = d.WriteIdempotent, + PollGroupId = d.PollGroupId, + TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig, + }; + _rowVersion = d.RowVersion; + } + + private IDictionary BuildEditorParameters() => new Dictionary + { + ["ConfigJson"] = _form.TagConfig, + ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), + ["DriverType"] = DriverType ?? "", + ["GetDriverConfigJson"] = (Func)(() => "{}"), + }; + + private async Task SaveAsync() + { + _busy = true; + _error = null; + _nameError = null; + try + { + // Client-side name validation (RawPath segment) so a bad name is caught before the round-trip. + var nameError = RawPaths.ValidateSegment(_form.Name); + if (nameError is not null) + { + _nameError = nameError; + return; + } + + // Client-side per-driver config validation (the typed editor's Validate()). + var configError = TagConfigValidator.Validate(DriverType, _form.TagConfig); + if (configError is not null) + { + _error = configError; + return; + } + + var input = new RawTagInput( + _form.Name, + _form.DataType, + _form.AccessLevel, + _form.WriteIdempotent, + string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId, + string.IsNullOrWhiteSpace(_form.TagConfig) ? "{}" : _form.TagConfig); + + var result = await Svc.UpdateTagAsync(TagId!, input, _rowVersion); + if (result.Ok) + { + await OnSaved.InvokeAsync(); + await CloseAsync(); + } + else + { + _error = result.Error; + } + } + finally + { + _busy = false; + } + } + + private async Task CloseAsync() + { + Visible = false; + _loadedKey = null; + await VisibleChanged.InvokeAsync(false); + } + + private sealed class FormModel + { + [Required] public string Name { 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/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor new file mode 100644 index 00000000..9a49751a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor @@ -0,0 +1,279 @@ +@* Typed TagConfig editor for the Calculation pseudo-driver. Authors the calc binding β€” + scriptId + change/timer triggers β€” reusing the VirtualTagModal's script dropdown, "New + script" action, and inline Monaco source panel (all backed by the same IUnsTreeService + script seams). Dispatched from RawTagModal / RawManualTagEntryModal via TagConfigEditorMap, + so it takes the same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter + shape every typed editor takes; DriverType/GetDriverConfigJson are accepted for dispatch + uniformity but unused (a calc tag has no address to browse). *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@inject IUnsTreeService Svc + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
Blank = change-trigger only. Min 50 ms.
+
+
+ +@* No script bound yet β†’ offer a one-click "New script" that creates a blank Script row, binds + it, and expands the inline editor so the operator can author the body without leaving. *@ +@if (string.IsNullOrEmpty(_m.ScriptId)) +{ +
+ + Creates a blank C# script bound to this calc tag. + @if (!string.IsNullOrWhiteSpace(_scriptCreateError)) + { +
@_scriptCreateError
+ } +
+} + +@* Inline script-source editor β€” shown only when a script is bound. Saves the SHARED Script row on + its own concurrency-guarded button; independent of the enclosing tag save. *@ +@if (!string.IsNullOrEmpty(_m.ScriptId) && _scriptLoaded) +{ +
+
+ Script source + +
+ @if (_scriptExpanded) + { +
+
+ Editing shared script "@_scriptName" β€” used by + @_scriptUsageCount virtual tag(s). Changes affect every tag using it. +
+ + @if (!string.IsNullOrWhiteSpace(_scriptError)) + { +
@_scriptError
+ } + else if (_scriptSaved) + { +
Script saved.
+ } +
+ +
+
+ } +
+} + +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver β€” accepted for dispatch uniformity; unused here. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig β€” accepted for dispatch uniformity; + /// unused (a calc tag has no address to browse). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private CalculationTagConfigModel _m = new(); + private string? _lastConfigJson; + // Unique suffix so multiple editor instances (e.g. manual-entry grid rows) get distinct control ids. + private readonly string _uid = Guid.NewGuid().ToString("N")[..8]; + + // Script dropdown options β€” loaded once, plus any inline-created script appended. + private List<(string Id, string Display)> _scripts = new(); + + // Inline script-source editor state (independent of the tag save). + private string _scriptSource = ""; + private bool _scriptLoaded; + private byte[] _scriptRowVersion = Array.Empty(); + private string? _scriptName; + private int _scriptUsageCount; + private bool _scriptExpanded; + private bool _scriptSaving; + private bool _scriptSaved; + private string? _scriptError; + + // "New script" action state. + private bool _scriptCreating; + private string? _scriptCreateError; + + protected override async Task OnInitializedAsync() + { + var scripts = await Svc.LoadScriptsAsync(); + _scripts = scripts.Select(s => (s.ScriptId, s.Display)).ToList(); + } + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes) can't reset the operator's in-progress edits. + protected override async Task OnParametersSetAsync() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = CalculationTagConfigModel.FromJson(ConfigJson); + await LoadScriptSourceAsync(); + } + + private async Task Update(Action apply) + { + apply(); + await WriteBackAsync(); + } + + // Push the model back to the host as JSON and pin _lastConfigJson so the echo doesn't re-parse. + private async Task WriteBackAsync() + { + var json = _m.ToJson(); + _lastConfigJson = json; + await ConfigJsonChanged.InvokeAsync(json); + } + + private static int? ParseTimer(object? v) + => int.TryParse(v?.ToString(), out var i) && i > 0 ? i : null; + + /// Refreshes the inline script panel + writes the new binding back when the operator picks + /// a different script. + private async Task OnScriptChangedAsync(ChangeEventArgs e) + { + _m.ScriptId = e.Value?.ToString() ?? ""; + await WriteBackAsync(); + await LoadScriptSourceAsync(); + } + + /// Loads the bound script's source for the inline panel; clears it when no script is bound. + private async Task LoadScriptSourceAsync() + { + _scriptSaved = false; + _scriptError = null; + + if (string.IsNullOrEmpty(_m.ScriptId)) + { + _scriptSource = ""; + _scriptLoaded = false; + _scriptRowVersion = Array.Empty(); + _scriptName = null; + _scriptUsageCount = 0; + return; + } + + var loaded = await Svc.GetScriptSourceAsync(_m.ScriptId); + if (loaded is null) + { + _scriptSource = ""; + _scriptLoaded = false; + _scriptRowVersion = Array.Empty(); + _scriptName = null; + _scriptUsageCount = 0; + return; + } + + _scriptSource = loaded.Value.SourceCode; + _scriptLoaded = true; + _scriptRowVersion = loaded.Value.RowVersion; + _scriptName = loaded.Value.Name; + _scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_m.ScriptId); + } + + /// Creates a blank script, binds this calc tag to it, and expands the inline editor. Persists + /// only the new Script row β€” the tag binding lives in the model until the host's save. + private async Task CreateNewScriptAsync() + { + _scriptCreating = true; + _scriptCreateError = null; + try + { + const string seedName = "Calc script"; + var result = await Svc.CreateScriptAsync(seedName); + if (!result.Ok || string.IsNullOrEmpty(result.CreatedId)) + { + _scriptCreateError = result.Error ?? "Could not create the script."; + return; + } + + // Add the option BEFORE binding so the + } + + + + + +} + +@code { + /// Whether the modal is shown (two-way bound). + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (supports @bind-Visible). + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The device to export. May be blank when is supplied. + [Parameter] public string DeviceId { get; set; } = ""; + + /// When set, only tags under this group's subtree are exported; null exports the whole device. + [Parameter] public string? TagGroupId { get; set; } + + /// The device's driver-type string β€” selects the per-driver typed column set. + [Parameter] public string DriverType { get; set; } = ""; + + private bool _loading; + private bool _built; + private string? _error; + private string _csv = ""; + private string _dataUri = ""; + private string _fileName = "tags.csv"; + private int _rowCount; + private string? _groupFilter; + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_built) + { + _built = true; + await BuildAsync(); + } + else if (!Visible) + { + _built = false; + } + } + + private async Task BuildAsync() + { + _loading = true; + _error = null; + try + { + var deviceId = DeviceId; + _groupFilter = null; + + if (!string.IsNullOrEmpty(TagGroupId)) + { + var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); + if (ctx is { } c) + { + _groupFilter = c.GroupPath; + if (string.IsNullOrEmpty(deviceId)) + { + deviceId = c.DeviceId; + } + } + } + + if (string.IsNullOrEmpty(deviceId)) + { + _error = "Could not resolve the target device."; + return; + } + + var tags = await ExportReader.ReadDeviceTagsAsync(deviceId); + + // When exporting a single group, keep only that group's subtree (its path + descendants). + if (!string.IsNullOrEmpty(_groupFilter)) + { + var prefix = _groupFilter; + tags = tags + .Where(t => t.TagGroupPath is not null && + (t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal))) + .ToList(); + } + + _rowCount = tags.Count; + _csv = RawTagCsvMapper.Export(DriverType, tags); + _fileName = BuildFileName(deviceId); + + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv)); + _dataUri = $"data:text/csv;charset=utf-8;base64,{base64}"; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loading = false; + } + } + + private static string BuildFileName(string deviceId) + { + var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()); + return $"tags-{safe}.csv"; + } + + private async Task Close() + { + _built = false; + _csv = ""; + _dataUri = ""; + Visible = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor new file mode 100644 index 00000000..60c5f650 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor @@ -0,0 +1,318 @@ +@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5). + + Flow: upload a file (InputFile) β†’ parse via the T0-2 CsvParser + the per-driver CsvColumnMap β†’ + a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were + typed vs. taken from the TagConfigJson fallback β†’ Commit, which calls IRawTreeService.ImportTagsAsync. + ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid β€” no partial commit. + + Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub): + + For a Device node: DeviceId = node.EntityId, TagGroupId = null. + For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@ +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@inject IRawTreeService Svc +@inject RawTagCsvExportReader ExportReader + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown (two-way bound). + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (supports @bind-Visible). + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The target device's logical id. May be blank when is supplied + /// (the device is then resolved from the group). + [Parameter] public string DeviceId { get; set; } = ""; + + /// The target tag-group's logical id, or null to import at the device root. Its device-relative + /// path is prepended to every imported row's TagGroupPath. + [Parameter] public string? TagGroupId { get; set; } + + /// The device's driver-type string β€” selects the per-driver typed column set. + [Parameter] public string DriverType { get; set; } = ""; + + /// Raised after a successful commit so the coordinator can reload the node's children. + [Parameter] public EventCallback OnSaved { get; set; } + + private RawCsvParseResult? _result; + private readonly HashSet _removed = new(); + private List _commitErrors = new(); + private string? _fatal; + private bool _parsing; + private bool _committing; + private bool _resolved; + + private string _effectiveDeviceId = ""; + private string? _groupPrefix; + + private string TargetLabel => + string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}"; + + private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0; + private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0; + private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0; + + // Commit is unblocked only when every remaining row is valid AND at least one row remains. + private bool CanCommit => + _result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0; + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_resolved) + { + await ResolveTargetAsync(); + _resolved = true; + } + else if (!Visible) + { + _resolved = false; + } + } + + // Resolve the effective device id + the group-path prefix once per open. + private async Task ResolveTargetAsync() + { + _effectiveDeviceId = DeviceId; + _groupPrefix = null; + + if (!string.IsNullOrEmpty(TagGroupId)) + { + var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); + if (ctx is { } c) + { + _groupPrefix = c.GroupPath; + if (string.IsNullOrEmpty(_effectiveDeviceId)) + { + _effectiveDeviceId = c.DeviceId; + } + } + } + } + + private async Task OnFileSelected(InputFileChangeEventArgs e) + { + _fatal = null; + _commitErrors = new(); + _removed.Clear(); + _parsing = true; + + try + { + using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024); + using var reader = new StreamReader(stream); + var text = await reader.ReadToEndAsync(); + _result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix); + _fatal = _result.FatalError; + } + catch (Exception ex) + { + _fatal = $"Could not read the file: {ex.Message}"; + _result = null; + } + finally + { + _parsing = false; + } + } + + private void RemoveRow(int rowNumber) => _removed.Add(rowNumber); + + private void Reset() + { + _result = null; + _fatal = null; + _commitErrors = new(); + _removed.Clear(); + } + + private async Task Commit() + { + if (_result is null || !CanCommit) + { + return; + } + + _committing = true; + _commitErrors = new(); + try + { + var rows = _result.Rows + .Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber)) + .Select(r => r.ImportRow!) + .ToList(); + + var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows); + if (outcome.Errors.Count > 0) + { + _commitErrors = outcome.Errors.ToList(); + return; + } + + await OnSaved.InvokeAsync(); + await Close(); + } + catch (Exception ex) + { + _commitErrors = new() { ex.Message }; + } + finally + { + _committing = false; + } + } + + private async Task Close() + { + Reset(); + Visible = false; + _resolved = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 72452017..7239ee9e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -49,6 +49,8 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude). + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExportReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExportReader.cs new file mode 100644 index 00000000..d55c334f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExportReader.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Read-only enumeration helper for the WP5 CSV export path. Reads a device's tag-groups + tags directly +/// from the config DB and flattens them (with each tag's reconstructed /-separated +/// ) into the export shape. Owned by WP5 β€” it does NOT extend +/// (that prelude is closed); it takes its own +/// so export never mutates. Also resolves a tag-group's path + +/// owning device (the import modal's target-group prefix seam). +/// +/// The config-DB context factory. +public sealed class RawTagCsvExportReader(IDbContextFactory dbFactory) +{ + /// + /// The owning device + reconstructed path for a tag-group (used by the import modal to prepend a + /// target-group prefix to every imported row). + /// + /// The group's owning device id. + /// The group's /-separated path under the device (device-relative, no device segment). + public readonly record struct TagGroupContext(string DeviceId, string GroupPath); + + /// Resolves the driver-type string of the device's parent driver, or null when unresolved. + /// The device's logical id. + /// A cancellation token. + /// The driver-type string, or null. + public async Task ResolveDriverTypeAsync(string deviceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + return await (from dev in db.Devices.AsNoTracking() + join drv in db.DriverInstances.AsNoTracking() on dev.DriverInstanceId equals drv.DriverInstanceId + where dev.DeviceId == deviceId + select drv.DriverType).FirstOrDefaultAsync(ct); + } + + /// + /// Resolves a tag-group's owning device + device-relative path. Walks the ParentTagGroupId chain + /// to the device root and joins the segment names with /. Returns null when the group is unknown. + /// + /// The tag-group's logical id. + /// A cancellation token. + /// The owning device + reconstructed path, or null when the group does not exist. + public async Task ResolveGroupContextAsync(string tagGroupId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (group is null) + { + return null; + } + + var byId = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == group.DeviceId) + .ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct); + + var path = BuildGroupPath(tagGroupId, byId); + return new TagGroupContext(group.DeviceId, path); + } + + /// + /// Enumerates every tag under a device (across all nested tag-groups), each carrying its + /// device-relative group path, in a stable order (group path, then name). + /// + /// The device's logical id. + /// A cancellation token. + /// The device's tags flattened for export. + public async Task> ReadDeviceTagsAsync(string deviceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var groups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == deviceId) + .ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct); + + var tags = await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId) + .ToListAsync(ct); + + return tags + .Select(t => new RawCsvExportTag( + TagGroupPath: t.TagGroupId is null ? null : NullIfEmpty(BuildGroupPath(t.TagGroupId, groups)), + Name: t.Name, + DataType: t.DataType, + AccessLevel: t.AccessLevel, + WriteIdempotent: t.WriteIdempotent, + PollGroupId: t.PollGroupId, + TagConfig: t.TagConfig)) + .OrderBy(x => x.TagGroupPath ?? "", RawPaths.Comparer) + .ThenBy(x => x.Name, RawPaths.Comparer) + .ToList(); + } + + private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s; + + // Joins a group's segment names rootβ†’leaf into a device-relative path, following ParentTagGroupId. + private static string BuildGroupPath( + string? groupId, + IReadOnlyDictionary byId) + { + var segments = new Stack(); + var guard = 0; + while (groupId is not null && byId.TryGetValue(groupId, out var g)) + { + segments.Push(g.Name); + groupId = g.ParentTagGroupId; + if (++guard > 1024) + { + break; // defensive: never loop forever on a malformed parent cycle + } + } + + return string.Join(RawPaths.SeparatorString, segments); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs new file mode 100644 index 00000000..c6390206 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -0,0 +1,457 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 / +/// and the per-driver to the service's +/// commit shape: turns an uploaded CSV into a +/// per-row review model with validation verdicts; renders a device's tags back to +/// the same column shape. No I/O, no DB, no Blazor β€” unit-testable in isolation. +/// +/// A per-tag driver TagConfig JSON is assembled in a fixed precedence: +/// TagConfigJson (base) ← typed driver columns (override) ← the alarm / historize / array flag +/// columns. A typed column therefore wins over a conflicting key carried in the TagConfigJson +/// fallback; the review model flags when that override happened so the operator sees it. +/// +/// +public static class RawTagCsvMapper +{ + // Root TagConfig keys owned by the flag columns β€” stripped from the export residual so the + // TagConfigJson fallback never duplicates a value already carried by a dedicated column. + private static readonly string[] FlagKeys = + ["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"]; + + // ---------------------------------------------------------------------------------------- Import + + /// + /// Parses an uploaded CSV into a review model: one per data row with a + /// validation verdict, the assembled (when valid), and the typed vs. + /// TagConfigJson-fallback provenance. Intra-batch duplicate names (same effective group + name) + /// are flagged on the later row. This is a dry parse β€” nothing is committed. + /// + /// The target device's driver-type string (selects the typed column map). + /// The uploaded CSV document. + /// The target tag-group's path prefix (from the modal's TagGroupId), prepended + /// to every row's TagGroupPath; null/blank when importing at the device root. + /// The parse result: header list, per-row review rows, and any fatal (whole-file) error. + public static RawCsvParseResult Parse(string driverType, string? csvText, string? groupPathPrefix = null) + { + IReadOnlyList raw; + try + { + raw = CsvParser.Parse(csvText); + } + catch (FormatException ex) + { + return new RawCsvParseResult(Array.Empty(), Array.Empty(), $"Malformed CSV: {ex.Message}"); + } + + if (raw.Count == 0) + { + return new RawCsvParseResult(Array.Empty(), Array.Empty(), "The file is empty."); + } + + var headers = raw[0]; + var headerIndex = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < headers.Length; i++) + { + if (!headerIndex.TryAdd(headers[i], i)) + { + return new RawCsvParseResult(headers, Array.Empty(), $"Duplicate header column '{headers[i]}'."); + } + } + + if (!headerIndex.ContainsKey("Name")) + { + return new RawCsvParseResult(headers, Array.Empty(), "Required column 'Name' is missing from the header row."); + } + + var map = CsvColumnMap.Resolve(driverType); + var reviewed = new List(raw.Count - 1); + + // Skip a wholly-blank trailing/interstitial line (the RFC parser yields [""] for an empty line). + for (var r = 1; r < raw.Count; r++) + { + var cells = raw[r]; + if (cells.Length == 1 && cells[0].Length == 0) + { + continue; + } + + reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix)); + } + + FlagDuplicates(reviewed); + return new RawCsvParseResult(headers, reviewed, null); + } + + private static RawCsvReviewRow BuildRow( + int rowNumber, + string[] headers, + IReadOnlyDictionary headerIndex, + string[] cells, + CsvDriverTagMap? map, + string? groupPathPrefix) + { + var row = new Dictionary(StringComparer.Ordinal); + foreach (var (name, idx) in headerIndex) + { + row[name] = idx < cells.Length ? cells[idx] : ""; + } + + string Cell(string name) => row.TryGetValue(name, out var v) ? v.Trim() : ""; + + var typedUsed = new List(); + var overrodeFallback = new List(); + + try + { + // --- Common identity/access fields --- + var name = Cell("Name"); + if (RawPaths.ValidateSegment(name) is { } nameErr) + { + return Invalid(rowNumber, cells, $"Name: {nameErr}"); + } + + var dataType = Cell("DataType"); + if (dataType.Length == 0) + { + return Invalid(rowNumber, cells, "DataType is required."); + } + + var accessRaw = Cell("AccessLevel"); + var access = accessRaw.Length == 0 + ? TagAccessLevel.Read + : CsvValues.ParseEnum("AccessLevel", accessRaw); + + var writeIdemRaw = Cell("WriteIdempotent"); + var writeIdempotent = writeIdemRaw.Length != 0 && CsvValues.ParseBool("WriteIdempotent", writeIdemRaw); + + var pollGroup = Cell("PollGroup"); + var pollGroupId = pollGroup.Length == 0 ? null : pollGroup; + + var groupPath = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath")); + + // --- Assemble the TagConfig JSON: base ← typed ← flags --- + var baseJson = Cell(CsvColumnMap.TagConfigJsonColumn); + var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid); + if (!baseValid) + { + return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON."); + } + + if (map is not null) + { + foreach (var col in map.Columns) + { + if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv)) + { + typedUsed.Add(col.Header); + if (baseKeys.Contains(col.JsonKey)) + { + overrodeFallback.Add(col.Header); + } + } + } + } + + var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row) + ?? (baseJson.Length == 0 ? "{}" : baseJson); + + // Historize flags. + var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized")); + json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname")); + + // Array flags. + var isArray = ParseFlag("IsArray", Cell("IsArray")); + uint? arrayLength = null; + var arrayLenRaw = Cell("ArrayLength"); + if (arrayLenRaw.Length != 0) + { + var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw); + if (n < 0) + { + return Invalid(rowNumber, cells, "ArrayLength must not be negative."); + } + + arrayLength = (uint)n; + } + + if (TagArrayConfig.Validate(isArray, arrayLength) is { } arrErr) + { + return Invalid(rowNumber, cells, arrErr); + } + + json = TagArrayConfig.Set(json, isArray, arrayLength); + + // Alarm flags β€” an alarm is authored iff Alarm.AlarmType is present. + json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva")); + + var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); + + return new RawCsvReviewRow + { + RowNumber = rowNumber, + Cells = cells, + Headers = headers, + Error = null, + ImportRow = importRow, + TypedColumnsUsed = typedUsed, + FallbackKeys = baseKeys, + TypedOverrodeFallback = overrodeFallback, + EffectiveGroupPath = groupPath ?? "", + Name = name, + }; + } + catch (FormatException ex) + { + return Invalid(rowNumber, cells, ex.Message); + } + } + + private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new() + { + RowNumber = rowNumber, + Cells = cells, + Headers = Array.Empty(), + Error = error, + TypedColumnsUsed = Array.Empty(), + FallbackKeys = Array.Empty(), + TypedOverrodeFallback = Array.Empty(), + }; + + // Flags a same-(group+name) collision within the parsed batch on the SECOND-and-later row. + private static void FlagDuplicates(List rows) + { + var seen = new HashSet<(string, string)>(); + foreach (var row in rows.Where(r => r.Ok)) + { + if (!seen.Add((row.EffectiveGroupPath, row.Name))) + { + var where = row.EffectiveGroupPath.Length == 0 ? "the device root" : $"group '{row.EffectiveGroupPath}'"; + row.Error = $"Duplicate tag name '{row.Name}' under {where} (already appears earlier in this file)."; + row.ImportRow = null; + } + } + } + + private static string? CombineGroupPath(string? prefix, string cell) + { + var suffix = string.IsNullOrWhiteSpace(cell) ? null : cell.Trim(); + prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim(); + if (prefix is null) + { + return suffix; + } + + return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}"; + } + + private static bool ParseFlag(string header, string cell) + => cell.Length != 0 && CsvValues.ParseBool(header, cell); + + private static string ApplyAlarm(string json, string alarmType, string severityRaw, string histRaw) + { + if (alarmType.Length == 0) + { + return json; + } + + var model = NativeAlarmModel.FromJson(json); + model.IsAlarm = true; + model.AlarmType = alarmType; + model.Severity = severityRaw.Length == 0 ? model.Severity : CsvValues.ParseInt("Alarm.Severity", severityRaw); + model.HistorizeToAveva = histRaw.Length == 0 ? null : CsvValues.ParseBool("Alarm.HistorizeToAveva", histRaw); + return model.ToJson(); + } + + private static IReadOnlyCollection ReadRootKeys(string json, out bool valid) + { + JsonNode? node; + try + { + node = JsonNode.Parse(json); + } + catch (System.Text.Json.JsonException) + { + valid = false; + return Array.Empty(); + } + + if (node is not JsonObject obj) + { + valid = false; + return Array.Empty(); + } + + valid = true; + return obj.Select(kvp => kvp.Key).ToArray(); + } + + // ---------------------------------------------------------------------------------------- Export + + /// + /// Renders a device's tags to a CSV string in the same column shape consumes: + /// the common columns, the driver's typed columns, then the trailing TagConfigJson fallback + /// carrying only the residual keys (those not surfaced by a typed or flag column) so a round-trip is + /// exact and non-redundant. + /// + /// The device's driver-type string (selects the typed column set). + /// The tags to export. + /// The CSV document (CRLF, quote-on-demand). + public static string Export(string driverType, IEnumerable tags) + { + var map = CsvColumnMap.Resolve(driverType); + var typedHeaders = map?.Headers ?? Array.Empty(); + var header = CsvColumnMap.HeadersFor(driverType); + + var rows = new List { header.ToArray() }; + foreach (var tag in tags) + { + rows.Add(BuildExportRow(tag, map, typedHeaders)); + } + + return CsvWriter.WriteToString(rows); + } + + private static string[] BuildExportRow(RawCsvExportTag tag, CsvDriverTagMap? map, IReadOnlyList typedHeaders) + { + var json = string.IsNullOrWhiteSpace(tag.TagConfig) ? "{}" : tag.TagConfig; + + var hist = TagHistorizeConfig.Read(json); + var array = TagArrayConfig.Read(json); + var alarm = NativeAlarmModel.FromJson(json); + var typed = map?.ReadTypedColumns(json) ?? new Dictionary(); + + var cells = new List(CsvColumnMap.CommonLeadingColumns.Count + typedHeaders.Count + 1) + { + tag.Name, + tag.TagGroupPath ?? "", + tag.DataType, + tag.AccessLevel.ToString(), + tag.WriteIdempotent ? "true" : "false", + tag.PollGroupId ?? "", + hist.IsHistorized ? "true" : "", + hist.HistorianTagname, + array.IsArray ? "true" : "", + array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "", + alarm.IsAlarm ? alarm.AlarmType : "", + alarm.IsAlarm ? alarm.Severity.ToString(CultureInfo.InvariantCulture) : "", + alarm.IsAlarm ? (CsvValues.FormatNullableBool(alarm.HistorizeToAveva) ?? "") : "", + }; + + foreach (var h in typedHeaders) + { + cells.Add(typed.TryGetValue(h, out var v) ? v : ""); + } + + cells.Add(ComputeResidual(json, map)); + return cells.ToArray(); + } + + // The TagConfigJson fallback cell: the tag's root keys minus every key a typed or flag column already + // surfaces. Empty object β‡’ blank cell. + private static string ComputeResidual(string json, CsvDriverTagMap? map) + { + var o = TagConfigJson.ParseOrNew(json); + foreach (var key in map?.JsonKeys ?? Array.Empty()) + { + o.Remove(key); + } + + foreach (var key in FlagKeys) + { + o.Remove(key); + } + + return o.Count == 0 ? "" : TagConfigJson.Serialize(o); + } +} + +/// +/// One tag flattened for CSV export: its effective tag-group path under the device plus the operator-facing +/// fields. Produced by and consumed by . +/// +/// The /-separated group path under the device, or null for a device-root tag. +/// The tag name. +/// The OPC UA built-in type name. +/// The tag's access-level baseline. +/// Whether writes are retry-eligible. +/// The optional poll-group id. +/// The raw per-driver TagConfig JSON blob. +public sealed record RawCsvExportTag( + string? TagGroupPath, + string Name, + string DataType, + TagAccessLevel AccessLevel, + bool WriteIdempotent, + string? PollGroupId, + string TagConfig); + +/// +/// One reviewed CSV data row: its 1-based row number, raw cells, validation verdict, and β€” when valid β€” +/// the assembled plus the typed vs. TagConfigJson-fallback provenance +/// the review grid renders. +/// +public sealed class RawCsvReviewRow +{ + /// The 1-based data-row number in the source file (header is row 0). + public int RowNumber { get; init; } + + /// The raw cell values as parsed. + public IReadOnlyList Cells { get; init; } = Array.Empty(); + + /// The header row (for aligning in the grid). + public IReadOnlyList Headers { get; init; } = Array.Empty(); + + /// The validation error, or null when the row is valid. + public string? Error { get; set; } + + /// Whether the row is valid (commit-eligible). + public bool Ok => Error is null; + + /// The assembled import row when valid; null otherwise. + public RawTagImportRow? ImportRow { get; set; } + + /// The typed-column headers that supplied a value on this row. + public IReadOnlyList TypedColumnsUsed { get; init; } = Array.Empty(); + + /// The root keys present in the row's TagConfigJson fallback. + public IReadOnlyCollection FallbackKeys { get; init; } = Array.Empty(); + + /// The typed columns that overrode a conflicting key carried in TagConfigJson. + public IReadOnlyList TypedOverrodeFallback { get; init; } = Array.Empty(); + + /// The effective tag-group path (prefix + row's TagGroupPath) used for duplicate detection. + public string EffectiveGroupPath { get; init; } = ""; + + /// The tag name (echoed for duplicate detection + grid display). + public string Name { get; init; } = ""; +} + +/// +/// The outcome of : the parsed header row, the per-row review model, and +/// a fatal (whole-file) error when the document could not be parsed at all (malformed CSV, duplicate/absent +/// required header). When is set, is empty. +/// +/// The parsed header row. +/// The per-row review model. +/// A whole-file error, or null when the file parsed. +public sealed record RawCsvParseResult( + IReadOnlyList Headers, + IReadOnlyList Rows, + string? FatalError) +{ + /// Whether the file parsed and every row is valid (commit is unblocked). + public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok); + + /// The valid rows' assembled import rows, ready for ImportTagsAsync. + public IReadOnlyList ToImportRows() + => Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs new file mode 100644 index 00000000..aa5e71af --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -0,0 +1,485 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; +using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Metadata for one per-driver typed CSV column: its CSV header, the root TagConfig JSON key it +/// maps to, and β€” for the model-backed maps β€” the name of the property on the driver's +/// TagConfigModel]]> it corresponds to. is the anchor +/// the CSV column-map reflection guard test asserts against so a driver-model rename can never silently +/// drift the CSV surface. It is null for the model-less maps (Galaxy / Calculation), which map +/// a header directly onto a JSON key. +/// +/// The CSV column header (PascalCase, e.g. ModbusDataType). +/// The root TagConfig JSON key this column reads/writes (camelCase, e.g. dataType). +/// The backing model property name, or null for a model-less map. +public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty); + +/// +/// Per-driver mapping between a CSV row's typed columns and a tag's driver-specific +/// TagConfig JSON. The map is the inverse pair the WP5 CSV import/export path pivots on: +/// folds a row's typed columns onto a base TagConfig (typed column wins +/// on conflict); projects a stored TagConfig back to its typed cells for +/// export. Lives next to the TagConfigModel]]> types so model + map evolve +/// together. +/// +public abstract class CsvDriverTagMap +{ + /// The driver-type string this map serves (a value). + public abstract string DriverType { get; } + + /// The backing model type whose scalar surface this map mirrors, or null for a + /// model-less (raw-JSON-key) map (Galaxy / Calculation). + public abstract Type? ModelType { get; } + + /// The ordered typed columns this driver adds to the common CSV column set. + public abstract IReadOnlyList Columns { get; } + + /// The typed column headers, in order. + public IReadOnlyList Headers => Columns.Select(c => c.Header).ToArray(); + + /// The root TagConfig JSON keys this map owns (used to compute the export residual). + public IReadOnlyList JsonKeys => Columns.Select(c => c.JsonKey).ToArray(); + + /// + /// Folds the typed columns present (non-blank) in onto + /// and returns the merged driver TagConfig JSON. The base is loaded first + /// (so its unrecognised keys survive) and each present typed column then overrides its key β€” so a + /// typed column wins over a conflicting key carried in the TagConfigJson base. + /// + /// The base TagConfig JSON (from the row's TagConfigJson column), or null/blank. + /// The CSV row as a headerβ†’value map. + /// The merged TagConfig JSON string. + /// A typed cell is not a valid value for its column (bad enum / number / bool). + public abstract string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row); + + /// + /// Projects the typed columns out of a stored TagConfig JSON for export β€” the inverse of + /// . A column absent from the JSON is omitted from the result. + /// + /// The tag's stored TagConfig JSON, or null/blank. + /// The typed columns present, keyed by CSV header. + public abstract IReadOnlyDictionary ReadTypedColumns(string? tagConfigJson); +} + +/// One typed column of a model-backed map: its metadata plus the read/write pair over the +/// concrete working model. +/// The driver's TagConfig working-model type. +/// The column metadata (header / JSON key / model property). +/// Projects the model's value to a cell string, or null to omit the column on export. +/// Parses a non-blank cell onto the model (throws on a bad value). +public sealed record CsvModelColumn( + CsvTypedColumn Meta, + Func Get, + Action Set); + +/// +/// A driven by a driver's typed working model. Import loads the base JSON +/// through the model's FromJson (preserving unknown keys), applies each present typed cell via its +/// , and serialises back through the model's ToJson β€” so +/// the CSV path reuses the exact same preserve-unknown / typed-wins merge the TagModal editor uses. +/// +/// The driver's TagConfig working-model type. +public sealed class ModelBackedCsvDriverTagMap : CsvDriverTagMap +{ + private readonly Func _fromJson; + private readonly Func _toJson; + private readonly IReadOnlyList> _columns; + + /// Builds a model-backed map. + /// The driver-type string served. + /// The model's FromJson loader (preserves unknown keys). + /// The model's ToJson serialiser. + /// The typed columns (metadata + read/write pairs). + public ModelBackedCsvDriverTagMap( + string driverType, + Func fromJson, + Func toJson, + IReadOnlyList> columns) + { + DriverType = driverType; + _fromJson = fromJson; + _toJson = toJson; + _columns = columns; + Columns = columns.Select(c => c.Meta).ToArray(); + } + + /// + public override string DriverType { get; } + + /// + public override Type? ModelType => typeof(TModel); + + /// + public override IReadOnlyList Columns { get; } + + /// + public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row) + { + var model = _fromJson(baseJson); + foreach (var col in _columns) + { + if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + col.Set(model, raw.Trim()); + } + } + + return _toJson(model); + } + + /// + public override IReadOnlyDictionary ReadTypedColumns(string? tagConfigJson) + { + var model = _fromJson(tagConfigJson); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var col in _columns) + { + if (col.Get(model) is { } v) + { + result[col.Meta.Header] = v; + } + } + + return result; + } +} + +/// The value shape of a raw-key (model-less) CSV column. +public enum CsvRawKeyKind +{ + /// A JSON string value. + String, + + /// A JSON boolean value (parsed case-insensitively from true/false). + Bool, + + /// A JSON integer value. + Int, +} + +/// Metadata + value-kind for one column of a raw-key map. +/// The column metadata (header / JSON key; model property is null). +/// The JSON value kind the header maps onto. +public sealed record CsvRawKeyColumn(CsvTypedColumn Meta, CsvRawKeyKind Kind); + +/// +/// A for drivers with no typed working model (Galaxy, Calculation): each +/// column maps a CSV header directly onto a root TagConfig JSON key, preserving every other key across +/// the round-trip. +/// +public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap +{ + private readonly IReadOnlyList _columns; + + /// Builds a raw-key map. + /// The driver-type string served. + /// The headerβ†’JSON-key columns with their value kinds. + public RawKeyCsvDriverTagMap(string driverType, IReadOnlyList columns) + { + DriverType = driverType; + _columns = columns; + Columns = columns.Select(c => c.Meta).ToArray(); + } + + /// + public override string DriverType { get; } + + /// + public override Type? ModelType => null; + + /// + public override IReadOnlyList Columns { get; } + + /// + public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row) + { + var o = TagConfigJson.ParseOrNew(baseJson); + foreach (var col in _columns) + { + if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + var trimmed = raw.Trim(); + o[col.Meta.JsonKey] = col.Kind switch + { + CsvRawKeyKind.Bool => JsonValue.Create(CsvValues.ParseBool(col.Meta.Header, trimmed)), + CsvRawKeyKind.Int => JsonValue.Create(CsvValues.ParseInt(col.Meta.Header, trimmed)), + _ => JsonValue.Create(trimmed), + }; + } + } + + return TagConfigJson.Serialize(o); + } + + /// + public override IReadOnlyDictionary ReadTypedColumns(string? tagConfigJson) + { + var o = TagConfigJson.ParseOrNew(tagConfigJson); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var col in _columns) + { + if (o.TryGetPropertyValue(col.Meta.JsonKey, out var node) && node is JsonValue v) + { + result[col.Meta.Header] = col.Kind switch + { + CsvRawKeyKind.Bool => v.TryGetValue(out var b) ? (b ? "true" : "false") : v.ToString(), + CsvRawKeyKind.Int => v.TryGetValue(out var l) + ? l.ToString(CultureInfo.InvariantCulture) + : v.ToString(), + _ => v.TryGetValue(out var s) ? s : v.ToString(), + }; + } + } + + return result; + } +} + +/// Case-insensitive value parsers/formatters for typed CSV cells, throwing a uniform +/// (naming the column + the offending value) the review grid surfaces. +public static class CsvValues +{ + /// Parses an enum member name case-insensitively; throws with the legal members listed. + /// The enum type. + /// The CSV column header (for the error message). + /// The cell value. + /// The parsed enum value. + /// The value is not a member of . + public static TEnum ParseEnum(string header, string value) where TEnum : struct, Enum + => Enum.TryParse(value, ignoreCase: true, out var v) && Enum.IsDefined(v) + ? v + : throw new FormatException( + $"Column '{header}': '{value}' is not a valid {typeof(TEnum).Name} " + + $"(expected one of: {string.Join(", ", Enum.GetNames())})."); + + /// Parses an integer (invariant); throws a column-named . + /// The CSV column header (for the error message). + /// The cell value. + /// The parsed integer. + /// The value is not an integer. + public static int ParseInt(string header, string value) + => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) + ? i + : throw new FormatException($"Column '{header}': '{value}' is not a whole number."); + + /// Parses a boolean case-insensitively (true/false); throws a column-named error. + /// The CSV column header (for the error message). + /// The cell value. + /// The parsed boolean. + /// The value is not a boolean. + public static bool ParseBool(string header, string value) + => bool.TryParse(value, out var b) + ? b + : throw new FormatException($"Column '{header}': '{value}' is not a boolean (expected true or false)."); + + /// Formats a nullable boolean for export: null β‡’ omitted (null cell), else lowercased. + /// The nullable boolean. + /// The cell string, or null to omit the column. + public static string? FormatNullableBool(bool? value) => value is { } b ? (b ? "true" : "false") : null; +} + +/// +/// Registry of the WP5 CSV column dictionary: the common columns every device's export/import carries, +/// plus the per-driver typed resolved by driver type. The single authority +/// for "which columns does a device of driver X have". +/// +public static class CsvColumnMap +{ + /// The TagConfigJson fallback column header β€” the base under which typed columns merge. + public const string TagConfigJsonColumn = "TagConfigJson"; + + /// The common column headers that precede any typed driver columns (in order). + public static IReadOnlyList CommonLeadingColumns { get; } = new[] + { + "Name", + "TagGroupPath", + "DataType", + "AccessLevel", + "WriteIdempotent", + "PollGroup", + "IsHistorized", + "HistorianTagname", + "IsArray", + "ArrayLength", + "Alarm.AlarmType", + "Alarm.Severity", + "Alarm.HistorizeToAveva", + }; + + /// All common columns (leading columns + the trailing TagConfigJson fallback). + public static IReadOnlyList CommonColumns { get; } = + [.. CommonLeadingColumns, TagConfigJsonColumn]; + + /// The Calculation (virtual-tag) driver-type string. Not yet a + /// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface. + public const string CalculationDriverType = "Calculation"; + + private static readonly IReadOnlyDictionary Maps = BuildMaps(); + + /// All registered driver maps. + public static IReadOnlyCollection All => (IReadOnlyCollection)Maps.Values; + + /// Resolves the typed map for a driver type, or null when the driver has no typed + /// columns (only the common set applies). + /// The driver-type string. + /// The map, or null. + public static CsvDriverTagMap? Resolve(string? driverType) + => driverType is not null && Maps.TryGetValue(driverType, out var m) ? m : null; + + /// The full ordered header row for a device of the given driver type: the leading common + /// columns, then the driver's typed columns, then the trailing TagConfigJson fallback. + /// The device's driver-type string. + /// The ordered CSV header names. + public static IReadOnlyList HeadersFor(string? driverType) + { + var typed = Resolve(driverType)?.Headers ?? Array.Empty(); + return [.. CommonLeadingColumns, .. typed, TagConfigJsonColumn]; + } + + private static Dictionary BuildMaps() + { + var maps = new CsvDriverTagMap[] + { + new ModelBackedCsvDriverTagMap( + DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region), + m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum("Region", s)), + ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address), + m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)), + ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("ModbusDataType", s)), + ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder), + m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum("ByteOrder", s)), + ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex), + m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)), + ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength), + m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)), + ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.S7, S7TagConfigModel.FromJson, m => m.ToJson(), + new[] + { + S7Col("Address", "address", nameof(S7TagConfigModel.Address), + m => m.Address, (m, s) => m.Address = s), + S7Col("S7DataType", "dataType", nameof(S7TagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("S7DataType", s)), + S7Col("StringLength", "stringLength", nameof(S7TagConfigModel.StringLength), + m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)), + S7Col("Writable", "writable", nameof(S7TagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.AbCip, AbCipTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + AbCipCol("TagPath", "tagPath", nameof(AbCipTagConfigModel.TagPath), + m => m.TagPath, (m, s) => m.TagPath = s), + AbCipCol("AbCipDataType", "dataType", nameof(AbCipTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("AbCipDataType", s)), + AbCipCol("Writable", "writable", nameof(AbCipTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.AbLegacy, AbLegacyTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + AbLegacyCol("Address", "address", nameof(AbLegacyTagConfigModel.Address), + m => m.Address, (m, s) => m.Address = s), + AbLegacyCol("AbLegacyDataType", "dataType", nameof(AbLegacyTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("AbLegacyDataType", s)), + AbLegacyCol("Writable", "writable", nameof(AbLegacyTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.TwinCAT, TwinCATTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + TwinCatCol("SymbolPath", "symbolPath", nameof(TwinCATTagConfigModel.SymbolPath), + m => m.SymbolPath, (m, s) => m.SymbolPath = s), + TwinCatCol("TwinCATDataType", "dataType", nameof(TwinCATTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("TwinCATDataType", s)), + TwinCatCol("Writable", "writable", nameof(TwinCATTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.FOCAS, FocasTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + FocasCol("Address", "address", nameof(FocasTagConfigModel.Address), + m => m.Address, (m, s) => m.Address = s), + FocasCol("FocasDataType", "dataType", nameof(FocasTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("FocasDataType", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.OpcUaClient, OpcUaClientTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + OpcUaCol("NodeId", "nodeId", nameof(OpcUaClientTagConfigModel.NodeId), + m => m.NodeId, (m, s) => m.NodeId = s), + }), + + new RawKeyCsvDriverTagMap(DriverTypeNames.Galaxy, new[] + { + // Galaxy's driver reference is the PascalCase FullName key (tag_name.AttributeName). + new CsvRawKeyColumn(new CsvTypedColumn("AttributeRef", "FullName", null), CsvRawKeyKind.String), + }), + + new RawKeyCsvDriverTagMap(CalculationDriverType, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("ScriptId", "scriptId", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool), + new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int), + }), + }; + + return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase); + } + + // Per-driver column factories β€” one per model so the Get/Set lambdas stay strongly typed. + private static CsvModelColumn ModbusCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn S7Col( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn AbCipCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn AbLegacyCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn TwinCatCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn FocasCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn OpcUaCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs new file mode 100644 index 00000000..e45703e9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs @@ -0,0 +1,85 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Drift guard for the WP5 CSV column dictionary (): every typed column of a +/// model-backed driver map must name a real property on that driver's TagConfigModel]]>, +/// and the registered typed-column set per driver must match the batch-plan column dictionary exactly. A +/// model rename or a column-dictionary change breaks this test rather than silently corrupting import/export. +/// +[Trait("Category", "Unit")] +public sealed class CsvColumnMapReflectionTests +{ + [Fact] + public void Every_model_backed_typed_column_maps_to_a_real_model_property() + { + foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is not null)) + { + foreach (var col in map.Columns) + { + col.ModelProperty.ShouldNotBeNull( + $"{map.DriverType} column '{col.Header}' is model-backed but declares no ModelProperty."); + + map.ModelType!.GetProperty(col.ModelProperty!) + .ShouldNotBeNull( + $"{map.DriverType} column '{col.Header}' maps to '{col.ModelProperty}', " + + $"which is not a property on {map.ModelType!.Name}."); + } + } + } + + [Fact] + public void Model_less_maps_declare_no_model_property() + { + foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is null)) + { + foreach (var col in map.Columns) + { + col.ModelProperty.ShouldBeNull( + $"{map.DriverType} column '{col.Header}' is model-less but declares ModelProperty '{col.ModelProperty}'."); + } + } + } + + [Theory] + [InlineData(DriverTypeNames.Modbus, "Region", "Address", "ModbusDataType", "ByteOrder", "BitIndex", "StringLength", "Writable")] + [InlineData(DriverTypeNames.S7, "Address", "S7DataType", "StringLength", "Writable")] + [InlineData(DriverTypeNames.AbCip, "TagPath", "AbCipDataType", "Writable")] + [InlineData(DriverTypeNames.AbLegacy, "Address", "AbLegacyDataType", "Writable")] + [InlineData(DriverTypeNames.TwinCAT, "SymbolPath", "TwinCATDataType", "Writable")] + [InlineData(DriverTypeNames.FOCAS, "Address", "FocasDataType")] + [InlineData(DriverTypeNames.OpcUaClient, "NodeId")] + [InlineData(DriverTypeNames.Galaxy, "AttributeRef")] + [InlineData(CsvColumnMap.CalculationDriverType, "ScriptId", "ChangeTriggered", "TimerIntervalMs")] + public void Registered_typed_columns_match_the_batch_plan_dictionary(string driverType, params string[] expected) + { + var map = CsvColumnMap.Resolve(driverType); + map.ShouldNotBeNull($"No CSV map registered for driver '{driverType}'."); + map!.Headers.ShouldBe(expected); + } + + [Fact] + public void HeadersFor_puts_typed_columns_between_common_leading_and_the_TagConfigJson_fallback() + { + var headers = CsvColumnMap.HeadersFor(DriverTypeNames.Modbus); + + // Leading common columns come first, in order. + headers.Take(CsvColumnMap.CommonLeadingColumns.Count).ShouldBe(CsvColumnMap.CommonLeadingColumns); + // The typed Modbus columns come next. + headers.Skip(CsvColumnMap.CommonLeadingColumns.Count).Take(7) + .ShouldBe(CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.Headers); + // TagConfigJson is always the trailing fallback. + headers[^1].ShouldBe(CsvColumnMap.TagConfigJsonColumn); + } + + [Fact] + public void Unknown_driver_headers_are_the_common_set_only() + { + CsvColumnMap.Resolve("NoSuchDriver").ShouldBeNull(); + CsvColumnMap.HeadersFor("NoSuchDriver").ShouldBe(CsvColumnMap.CommonColumns); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs new file mode 100644 index 00000000..31dc679f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs @@ -0,0 +1,233 @@ +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.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Property test for the WP5 CSV exportβ†’import round-trip: a device's +/// tags then re-parse the produced CSV with and assert the reconstructed +/// tag set is identical (name / group path / data type / access / write flag / poll group and a semantically +/// equal TagConfig). Exercises typed driver columns, the historize/array/alarm flag columns, the +/// residual TagConfigJson fallback (typed-wins), and the model-less (Galaxy) raw-key map. +/// +[Trait("Category", "Unit")] +public sealed class RawTagCsvRoundTripTests +{ + [Fact] + public void Modbus_device_round_trips_typed_columns_flags_and_residual() + { + // A rich Modbus config: every typed column + historize + array + alarm + a residual ("scale") + // key the TagConfigJson fallback must carry. + var richTag = new RawCsvExportTag( + TagGroupPath: "Bank1/Analog", + Name: "flow-01", + DataType: "Float", + AccessLevel: TagAccessLevel.ReadWrite, + WriteIdempotent: true, + PollGroupId: "fast", + TagConfig: """ + {"region":"InputRegisters","address":10,"dataType":"Float32","byteOrder":"WordSwap", + "bitIndex":0,"stringLength":0,"writable":false,"scale":3.5, + "isHistorized":true,"historianTagname":"MyHist","isArray":true,"arrayLength":4, + "alarm":{"alarmType":"LimitAlarm","severity":800,"historizeToAveva":false}} + """); + + // A device-root tag (null group path) with only typed columns + no flags. + var plainTag = new RawCsvExportTag( + TagGroupPath: null, + Name: "coil-3", + DataType: "Boolean", + AccessLevel: TagAccessLevel.Read, + WriteIdempotent: false, + PollGroupId: null, + TagConfig: ModbusModel("Coils", 3, "Bool", "BigEndian", writable: null)); + + AssertRoundTrip(DriverTypeNames.Modbus, new[] { richTag, plainTag }); + } + + [Fact] + public void OpcUaClient_and_Galaxy_maps_round_trip() + { + var opc = new RawCsvExportTag( + "Line/Motors", "m1", "Double", TagAccessLevel.ReadWrite, false, null, + """{"nodeId":"nsu=urn:plc;s=Motor1.Speed","isHistorized":true}"""); + + AssertRoundTrip(DriverTypeNames.OpcUaClient, new[] { opc }); + + var galaxy = new RawCsvExportTag( + null, "Reactor_Temp", "Float", TagAccessLevel.Read, false, null, + """{"FullName":"Reactor_001.Temp","historianTagname":"Reactor.Temp"}"""); + + AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy }); + } + + [Fact] + public void Export_header_matches_the_driver_column_dictionary() + { + var csv = RawTagCsvMapper.Export(DriverTypeNames.S7, Array.Empty()); + var rows = CsvParser.Parse(csv); + rows.ShouldHaveSingleItem(); // header only + rows[0].ShouldBe(CsvColumnMap.HeadersFor(DriverTypeNames.S7).ToArray()); + } + + [Fact] + public void A_typed_column_wins_over_a_conflicting_TagConfigJson_key() + { + // Base carries address="DB1.DBW99"; the typed Address column carries "DB1.DBW5" β€” the typed value + // must win, and the review row must flag the override. (S7 Address is a string field.) + var csv = string.Join("\r\n", + "Name,DataType,Address,S7DataType,TagConfigJson", + """flow,Int16,DB1.DBW5,Int16,"{""address"":""DB1.DBW99""}" """.Trim()); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.S7, csv); + result.FatalError.ShouldBeNull(); + var row = result.Rows.ShouldHaveSingleItem(); + row.Ok.ShouldBeTrue(); + row.TypedOverrodeFallback.ShouldContain("Address"); + + var o = JsonNode.Parse(row.ImportRow!.Tag.TagConfig)!.AsObject(); + o["address"]!.GetValue().ShouldBe("DB1.DBW5"); + } + + [Fact] + public void A_bad_enum_row_is_flagged_and_blocks_commit() + { + var csv = string.Join("\r\n", + "Name,DataType,Region,Address,ModbusDataType", + "t1,Int16,NotARegion,0,Int16"); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv); + var row = result.Rows.ShouldHaveSingleItem(); + row.Ok.ShouldBeFalse(); + row.Error!.ShouldContain("Region"); + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public void Duplicate_name_in_the_same_group_is_flagged_on_the_second_row() + { + var csv = string.Join("\r\n", + "Name,DataType,TagGroupPath,NodeId", + "dup,Int32,G1,ns=2;s=A", + "dup,Int32,G1,ns=2;s=B"); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv); + result.Rows[0].Ok.ShouldBeTrue(); + result.Rows[1].Ok.ShouldBeFalse(); + result.Rows[1].Error!.ShouldContain("Duplicate"); + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public void Group_path_prefix_is_prepended_to_each_row() + { + var csv = string.Join("\r\n", + "Name,DataType,TagGroupPath,NodeId", + "a,Int32,Sub,ns=2;s=A", + "b,Int32,,ns=2;s=B"); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv, groupPathPrefix: "Root"); + result.Rows[0].ImportRow!.TagGroupPath.ShouldBe("Root/Sub"); + result.Rows[1].ImportRow!.TagGroupPath.ShouldBe("Root"); + } + + // -------------------------------------------------------------------------------------- helpers + + private static void AssertRoundTrip(string driverType, IReadOnlyList tags) + { + var csv = RawTagCsvMapper.Export(driverType, tags); + var parsed = RawTagCsvMapper.Parse(driverType, csv); + + parsed.FatalError.ShouldBeNull(); + parsed.CanCommit.ShouldBeTrue($"every re-imported row should be valid; errors: " + + string.Join(" | ", parsed.Rows.Where(r => !r.Ok).Select(r => r.Error))); + + var imported = parsed.ToImportRows(); + imported.Count.ShouldBe(tags.Count); + + foreach (var original in tags) + { + var match = imported.SingleOrDefault(r => r.Tag.Name == original.Name); + match.ShouldNotBeNull($"tag '{original.Name}' vanished across the round-trip."); + + match!.TagGroupPath.ShouldBe(original.TagGroupPath, $"group path drift for '{original.Name}'."); + match.Tag.DataType.ShouldBe(original.DataType); + match.Tag.AccessLevel.ShouldBe(original.AccessLevel); + match.Tag.WriteIdempotent.ShouldBe(original.WriteIdempotent); + match.Tag.PollGroupId.ShouldBe(original.PollGroupId); + + Canonical(match.Tag.TagConfig).ShouldBe(Canonical(original.TagConfig), + $"TagConfig drift for '{original.Name}'."); + } + } + + // Canonical Modbus config via the real model, so the "expected" already carries the model's full key set. + private static string ModbusModel(string region, int address, string dataType, string byteOrder, bool? writable) + { + var m = ModbusTagConfigModel.FromJson("{}"); + m.Region = Enum.Parse(region); + m.Address = address; + m.DataType = Enum.Parse(dataType); + m.ByteOrder = Enum.Parse(byteOrder); + m.Writable = writable; + return m.ToJson(); + } + + // Order-independent, numeric-normalised JSON canonicaliser for semantic equality. + private static string Canonical(string json) + { + var node = JsonNode.Parse(json); + return Normalize(node)?.ToJsonString() ?? "null"; + } + + private static JsonNode? Normalize(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + { + var sorted = new JsonObject(); + foreach (var key in obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal)) + { + sorted[key] = Normalize(obj[key]?.DeepClone()); + } + + return sorted; + } + + case JsonArray arr: + { + var outArr = new JsonArray(); + foreach (var item in arr) + { + outArr.Add(Normalize(item?.DeepClone())); + } + + return outArr; + } + + case JsonValue val: + { + // Collapse numeric representations (int vs uint vs double 4 vs 4.0) to a canonical form. + if (val.GetValueKind() == JsonValueKind.Number && + double.TryParse(val.ToJsonString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var d)) + { + return JsonValue.Create(d); + } + + return val; + } + + default: + return node; + } + } +} From 844f93f64fe690a44a06f386770ac494b2b6f49b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 03:26:24 -0400 Subject: [PATCH 13/20] feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor Extract the 8 typed driver pages into embeddable DriverForm bodies (channel/protocol config) and add per-driver DeviceForm bodies (connection endpoint -> Device.DeviceConfig, v3 endpoint split). New DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device config transiently via DriverDeviceConfigMerger. Routed pages now host the form bodies and keep working. Round-trip + enum-serialization guard tests for the Modbus driver form + Modbus device model; retargeted the existing page-form serialization tests + the _jsonOpts converter guard to the forms. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Clusters/Drivers/AbCipDriverPage.razor | 278 +----------- .../Clusters/Drivers/AbLegacyDriverPage.razor | 229 +--------- .../Clusters/Drivers/FocasDriverPage.razor | 353 ++------------- .../Clusters/Drivers/GalaxyDriverPage.razor | 300 +------------ .../Clusters/Drivers/ModbusDriverPage.razor | 404 +----------------- .../Drivers/OpcUaClientDriverPage.razor | 396 ++--------------- .../Pages/Clusters/Drivers/S7DriverPage.razor | 207 ++------- .../Clusters/Drivers/TwinCATDriverPage.razor | 262 ++---------- .../Drivers/DeviceForms/AbCipDeviceForm.razor | 54 +++ .../Drivers/DeviceForms/AbCipDeviceModel.cs | 51 +++ .../DeviceForms/AbLegacyDeviceForm.razor | 54 +++ .../DeviceForms/AbLegacyDeviceModel.cs | 51 +++ .../Drivers/DeviceForms/FocasDeviceForm.razor | 54 +++ .../Drivers/DeviceForms/FocasDeviceModel.cs | 51 +++ .../DeviceForms/GalaxyDeviceForm.razor | 34 ++ .../Drivers/DeviceForms/GalaxyDeviceModel.cs | 29 ++ .../DeviceForms/ModbusDeviceForm.razor | 52 +++ .../Drivers/DeviceForms/ModbusDeviceModel.cs | 55 +++ .../DeviceForms/OpcUaClientDeviceForm.razor | 45 ++ .../DeviceForms/OpcUaClientDeviceModel.cs | 50 +++ .../Drivers/DeviceForms/S7DeviceForm.razor | 56 +++ .../Drivers/DeviceForms/S7DeviceModel.cs | 60 +++ .../DeviceForms/TwinCATDeviceForm.razor | 44 ++ .../Drivers/DeviceForms/TwinCATDeviceModel.cs | 45 ++ .../Shared/Drivers/DeviceModal.razor | 224 ++++++++++ .../Shared/Drivers/DriverConfigModal.razor | 201 +++++++++ .../Drivers/Forms/AbCipDriverForm.razor | 215 ++++++++++ .../Drivers/Forms/AbLegacyDriverForm.razor | 170 ++++++++ .../Drivers/Forms/FocasDriverForm.razor | 282 ++++++++++++ .../Drivers/Forms/GalaxyDriverForm.razor | 293 +++++++++++++ .../Drivers/Forms/ModbusDriverForm.razor | 399 +++++++++++++++++ .../Drivers/Forms/OpcUaClientDriverForm.razor | 367 ++++++++++++++++ .../Shared/Drivers/Forms/S7DriverForm.razor | 177 ++++++++ .../Drivers/Forms/TwinCATDriverForm.razor | 194 +++++++++ .../AbCipDriverPageFormSerializationTests.cs | 53 +-- ...bLegacyDriverPageFormSerializationTests.cs | 51 +-- .../DriverPageJsonConverterTests.cs | 64 +-- .../FocasDriverPageFormSerializationTests.cs | 54 +-- .../GalaxyDriverPageFormSerializationTests.cs | 13 +- ...aClientDriverPageFormSerializationTests.cs | 33 +- .../S7DriverPageFormSerializationTests.cs | 5 +- ...TwinCATDriverPageFormSerializationTests.cs | 59 +-- .../Uns/ModbusDeviceModelTests.cs | 70 +++ .../Uns/ModbusDriverFormModelTests.cs | 64 +++ 44 files changed, 3728 insertions(+), 2474 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbCipDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbCipDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbLegacyDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbLegacyDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/FocasDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/FocasDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/GalaxyDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/GalaxyDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/ModbusDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/ModbusDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/OpcUaClientDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/OpcUaClientDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/S7DeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/S7DeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/TwinCATDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/TwinCATDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor index cb379509..c554aa09 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -5,6 +5,7 @@ @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @@ -30,7 +31,7 @@ else if (!IsNew && _existing is null) } else { - + - + + + + + + + +} + +@code { + /// Whether the modal is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The device to edit; null β‡’ create a new device under . + [Parameter] public string? DeviceId { get; set; } + /// The owning driver instance (required for create; resolved from the device on edit). + [Parameter] public string? DriverInstanceId { get; set; } + /// The driver type (required for create; joined in from the device on edit). + [Parameter] public string? DriverType { get; set; } + + /// Raised after a successful create/update so the tree can refresh. + [Parameter] public EventCallback OnSaved { get; set; } + + private bool _isNew; + private string _driverType = ""; + private string? _resolvedDriverInstanceId; + private string _name = ""; + private bool _enabled = true; + private string _deviceConfigJson = "{}"; + private byte[] _rowVersion = []; + private string _parentDriverConfig = "{}"; + + private bool _busy; + private string? _loadError; + private string? _saveError; + private bool _openHandled; + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_openHandled) + { + _openHandled = true; + await LoadAsync(); + } + else if (!Visible) + { + _openHandled = false; + } + } + + private async Task LoadAsync() + { + _busy = false; _loadError = null; _saveError = null; + _isNew = string.IsNullOrEmpty(DeviceId); + + if (_isNew) + { + _resolvedDriverInstanceId = DriverInstanceId; + _driverType = DriverType ?? ""; + _name = "Device1"; + _enabled = true; + _deviceConfigJson = "{}"; + _rowVersion = []; + } + else + { + var dto = await Svc.LoadDeviceForEditAsync(DeviceId!); + if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; } + _resolvedDriverInstanceId = dto.DriverInstanceId; + _driverType = dto.DriverType; + _name = dto.Name; + _enabled = dto.Enabled; + _deviceConfigJson = string.IsNullOrWhiteSpace(dto.DeviceConfig) ? "{}" : dto.DeviceConfig; + _rowVersion = dto.RowVersion; + } + + // The parent driver's DriverConfig β€” folded with the (possibly unsaved) DeviceConfig for Test-connect. + if (!string.IsNullOrEmpty(_resolvedDriverInstanceId)) + { + var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId); + _parentDriverConfig = drv?.DriverConfig ?? "{}"; + } + } + + /// Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form β€” + /// same merge as LoadMergedProbeConfigAsync, so Test-connect works before the device is saved. + private string BuildMergedProbeConfig() + => DriverDeviceConfigMerger.Merge( + _parentDriverConfig, + new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) }, + Array.Empty()); + + private async Task SaveAsync() + { + _busy = true; _saveError = null; + try + { + UnsMutationResult result; + if (_isNew) + { + if (string.IsNullOrEmpty(_resolvedDriverInstanceId)) + { + _saveError = "No owning driver instance was supplied."; + return; + } + result = await Svc.CreateDeviceAsync(_resolvedDriverInstanceId, _name, _deviceConfigJson); + } + else + { + result = await Svc.UpdateDeviceAsync(DeviceId!, _name, _deviceConfigJson, _enabled, _rowVersion); + } + + if (!result.Ok) + { + _saveError = result.Error ?? "Save failed."; + return; + } + + await OnSaved.InvokeAsync(); + await SetVisibleAsync(false); + } + catch (Exception ex) { _saveError = ex.Message; } + finally { _busy = false; } + } + + private Task CloseAsync() => SetVisibleAsync(false); + + private async Task SetVisibleAsync(bool value) + { + Visible = value; + _openHandled = value; + await VisibleChanged.InvokeAsync(value); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor new file mode 100644 index 00000000..d2140099 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -0,0 +1,201 @@ +@* Create / edit modal for a raw-tree DriverInstance's channel/protocol config (endpoint lives on the + device β€” see DeviceModal). Dispatches to the right per-driver form body by DriverType; the form body + carries its own resilience section. The coordinator wires this into RawTree via @bind-Visible. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@inject IRawTreeService Svc + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The driver instance to edit; null β‡’ create a new driver. + [Parameter] public string? DriverInstanceId { get; set; } + + // --- Create-mode inputs (ignored on edit) --- + /// The owning cluster (required for create). + [Parameter] public string? ClusterId { get; set; } + /// The containing folder, or null for a cluster-root driver (create). + [Parameter] public string? FolderId { get; set; } + /// The driver type to create (required for create; immutable on edit). + [Parameter] public string? DriverType { get; set; } + + /// Raised after a successful create/update so the tree can refresh. + [Parameter] public EventCallback OnSaved { get; set; } + + private bool _isNew; + private string _driverType = ""; + private string _name = ""; + private string _driverConfigJson = "{}"; + private string? _resilienceConfig; + private byte[] _rowVersion = []; + + private bool _busy; + private string? _loadError; + private string? _saveError; + private bool _openHandled; + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_openHandled) + { + _openHandled = true; + await LoadAsync(); + } + else if (!Visible) + { + _openHandled = false; + } + } + + private async Task LoadAsync() + { + _busy = false; _loadError = null; _saveError = null; + _isNew = string.IsNullOrEmpty(DriverInstanceId); + + if (_isNew) + { + _driverType = DriverType ?? ""; + _name = ""; + _driverConfigJson = "{}"; + _resilienceConfig = null; + _rowVersion = []; + } + else + { + var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!); + if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; } + _driverType = dto.DriverType; + _name = dto.Name; + _driverConfigJson = string.IsNullOrWhiteSpace(dto.DriverConfig) ? "{}" : dto.DriverConfig; + _resilienceConfig = dto.ResilienceConfig; + _rowVersion = dto.RowVersion; + } + } + + private async Task SaveAsync() + { + _busy = true; _saveError = null; + try + { + UnsMutationResult result; + if (_isNew) + { + if (string.IsNullOrEmpty(ClusterId)) + { + _saveError = "No owning cluster was supplied."; + return; + } + result = await Svc.CreateDriverAsync(ClusterId, FolderId, _name, _driverType, _driverConfigJson); + } + else + { + result = await Svc.UpdateDriverAsync(DriverInstanceId!, _name, _driverConfigJson, _resilienceConfig, _rowVersion); + } + + if (!result.Ok) + { + _saveError = result.Error ?? "Save failed."; + return; + } + + await OnSaved.InvokeAsync(); + await SetVisibleAsync(false); + } + catch (Exception ex) { _saveError = ex.Message; } + finally { _busy = false; } + } + + private Task CloseAsync() => SetVisibleAsync(false); + + private async Task SetVisibleAsync(bool value) + { + Visible = value; + _openHandled = value; + await VisibleChanged.InvokeAsync(value); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor new file mode 100644 index 00000000..2a422476 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -0,0 +1,215 @@ +@* Embeddable AB CIP driver (protocol/operation) config form body. The Devices collection moved out to + AbCipDeviceForm in v3 (its devices are separate entities); this form preserves the driver's existing + Devices across a loadβ†’save and the merge reconstructs the array from the device rows at deploy/probe + time. Hosted by the routed AbCipDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the + parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.AbCip + +@* Operation timeout *@ +
+
Operation settings
+
+
+
+ + +
Default libplctag call timeout. Default 2 s.
+
+
+
+ + +
+
Walk the Logix symbol table and surface globals under Discovered/.
+
+
+
+ + +
+
Only enable when UDT member declaration order matches controller compiled layout.
+
+
+
+
+ +@* Alarm projection *@ +
+
Alarm projection
+
+
+
+
+ + +
+
Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.
+
+
+ + +
Default 1 s.
+
+
+
+
+ +@* Connectivity probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
Required when probe is enabled. Leave blank and an operator warning is logged.
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/operation; endpoints live on the devices). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + // Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints; + // the merge reconstructs the Devices array from the device rows at deploy/probe time). + private IReadOnlyList _preservedDevices = []; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions(); + _form = FormModel.FromOptions(opts); + _preservedDevices = opts.Devices; + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current protocol/operation config to camelCase JSON, carrying the preserved + /// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array + /// from the device rows at deploy/probe time). + public string GetConfigJson() + => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static AbCipDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. + // Collections (Devices) are preserved on the component and passed in on ToOptions(). + public sealed class FormModel + { + // Operation + public int TimeoutSeconds { get; set; } = 2; + public bool EnableControllerBrowse { get; set; } = false; + public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false; + + // Alarm projection + public bool EnableAlarmProjection { get; set; } = false; + public int AlarmPollIntervalSeconds { get; set; } = 1; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public string? ProbeTagPath { get; set; } + + // Admin UI probe timeout + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + public static FormModel FromOptions(AbCipDriverOptions o) => new() + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + EnableControllerBrowse = o.EnableControllerBrowse, + EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping, + EnableAlarmProjection = o.EnableAlarmProjection, + AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + ProbeTagPath = o.Probe.ProbeTagPath, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public AbCipDriverOptions ToOptions( + IReadOnlyList devices) => new() + { + Devices = devices, + Probe = new AbCipProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath, + }, + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + EnableControllerBrowse = EnableControllerBrowse, + EnableAlarmProjection = EnableAlarmProjection, + AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds), + EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor new file mode 100644 index 00000000..58ccebb3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -0,0 +1,170 @@ +@* Embeddable AB Legacy driver (channel/protocol) config form body. The Devices collection is authored + as separate v3 entities (the merge rebuilds the Devices array at deploy/probe time), so this form does + NOT edit it — the driver's existing devices are preserved verbatim. Hosted by the routed + AbLegacyDriverPage. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and + fires DriverConfigJsonChanged for @bind support. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy +@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies + +@* Operation settings *@ +
+
Operation settings
+
+
+
+ + +
Default read/write timeout. Default 2 s.
+
+
+
+
+ +@* Connectivity probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
PCCC file address to read for probe. Default S:0 (status file word 0).
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; devices live on separate v3 entities). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + // Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing + // Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time). + private IReadOnlyList _preservedDevices = []; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions(); + _form = FormModel.FromOptions(opts); + _preservedDevices = opts.Devices; + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON, preserving the driver's + /// existing Devices array (authored as separate v3 entities). + public string GetConfigJson() + => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static AbLegacyDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. + // Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). + public sealed class FormModel + { + // Operation + public int TimeoutSeconds { get; set; } = 2; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public string? ProbeAddress { get; set; } = "S:0"; + + // Admin UI probe timeout + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + // Persistence + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(AbLegacyDriverOptions o) => new() + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + ProbeAddress = o.Probe.ProbeAddress, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public AbLegacyDriverOptions ToOptions( + IReadOnlyList devices) => new() + { + Devices = devices, + Probe = new AbLegacyProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress, + }, + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor new file mode 100644 index 00000000..2cc1e0d4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor @@ -0,0 +1,282 @@ +@* Embeddable Fanuc FOCAS driver (channel/protocol) config form body. Endpoint fields (per-device + HostAddress/Series) moved to FocasDeviceForm in v3 (they live in the Device's DeviceConfig / the + multi-device Devices collection). Hosted by the routed FocasDriverPage and by DriverConfigModal. + Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires + DriverConfigJsonChanged for @bind support. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS + +@* Connection *@ +
+
Connection
+
+
+
+ + +
Per-operation timeout. Default 2 s.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ +@* Alarm projection *@ +
+
Alarm projection
+
+
+
+
+ + +
+
Surfaces FOCAS alarms via IAlarmSource.
+
+
+ + +
One cnc_rdalmmsg2 call per device per tick. Default 2 s.
+
+
+
+
+ +@* Handle recycle *@ +
+
Handle recycle
+
+
+
+
+ + +
+
Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.
+
+
+ + +
Typical: 30 min (shared pool) or 360 min (single client).
+
+
+
+
+ +@* Fixed tree *@ +
+
Fixed-node tree
+
+
+
+
+ + +
+
Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.
+
+
+ + +
cnc_rddynamic2 cadence per axis. Default 250 ms.
+
+
+ + +
Program/mode info cadence. 0 = disabled. Default 1 s.
+
+
+ + +
Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; per-device endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + // FOCAS is multi-device; the Devices collection is authored on the Raw tree, not on this form. + // Preserve whatever devices the persisted config carried so a driver-form load→save round-trip + // never drops them. + private IReadOnlyList _preservedDevices = []; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new FocasDriverOptions(); + _form = FormModel.FromOptions(opts); + _preservedDevices = opts.Devices; + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON, re-attaching the + /// preserved multi-device collection (authored on the Raw tree). + public string GetConfigJson() + => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static FocasDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. + // Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). + public sealed class FormModel + { + // Connection + public int TimeoutSeconds { get; set; } = 2; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int AdminProbeTimeoutSeconds { get; set; } = 10; + + // Alarm projection + public bool AlarmProjectionEnabled { get; set; } = false; + public int AlarmProjectionPollIntervalSeconds { get; set; } = 2; + + // Handle recycle + public bool HandleRecycleEnabled { get; set; } = false; + public int HandleRecycleIntervalMinutes { get; set; } = 60; + + // Fixed tree + public bool FixedTreeEnabled { get; set; } = false; + public int FixedTreePollIntervalMs { get; set; } = 250; + public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1; + public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30; + + // Common + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(FocasDriverOptions o) => new() + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + AlarmProjectionEnabled = o.AlarmProjection.Enabled, + AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds, + HandleRecycleEnabled = o.HandleRecycle.Enabled, + HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes, + FixedTreeEnabled = o.FixedTree.Enabled, + FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds, + FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds, + FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds, + }; + + public FocasDriverOptions ToOptions( + IReadOnlyList devices) => new() + { + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Probe = new FocasProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + AlarmProjection = new FocasAlarmProjectionOptions + { + Enabled = AlarmProjectionEnabled, + PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds), + }, + HandleRecycle = new FocasHandleRecycleOptions + { + Enabled = HandleRecycleEnabled, + Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes), + }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = FixedTreeEnabled, + PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs), + ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds), + TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds), + }, + Devices = devices, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor new file mode 100644 index 00000000..17468d7d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor @@ -0,0 +1,293 @@ +@* Embeddable AVEVA Galaxy driver config form body. Galaxy connects to a SINGLE mxaccessgw gateway + (nested Gateway/MxAccess/Repository/Reconnect records) — there is no flat per-device endpoint to split + out, so the whole connection stays here (GalaxyDeviceForm is informational only). Hosted by the routed + page + DriverConfigModal. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config + +@* mxaccessgw connection *@ +
+
mxaccessgw connection
+
+
+
+ + +
gRPC endpoint of the mxaccessgw process.
+
+
+ + +
Forms: env:NAME, file:PATH, dev:KEY. Cleartext is accepted but triggers a startup warning.
+
+
+
+ + +
+
+
+ + +
+
+ + +
Default 10 s.
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 0 (lifetime of driver).
+
+
+
+
+ +@* MXAccess *@ +
+
MXAccess
+
+
+
+ + +
Must be unique per OtOpcUa instance β€” redundancy pairs each get a distinct name.
+
+
+ + +
Default 1000 ms.
+
+
+ + +
0 = anonymous.
+
+
+ + +
Default 50000. Raise if events.dropped appears.
+
+
+
+
+ +@* Repository *@ +
+
Galaxy repository
+
+
+
+ + +
Default 5000 objects per page.
+
+
+
+ + +
+
Triggers address-space rebuild on Galaxy re-deploy.
+
+
+
+
+ +@* Reconnect *@ +
+
Reconnect backoff
+
+
+
+ + +
Default 500 ms.
+
+
+ + +
Default 30000 ms.
+
+
+
+ + +
+
+
+
+
+ +@* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 30.
+
+
+
+
+ + + +@code { + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + [Parameter] public string? ResilienceConfig { get; set; } + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? CreateDefaultOptions(); + _form = new FormModel { Galaxy = GalaxyFormModel.FromRecord(opts) }; + _lastParsed = DriverConfigJson; + } + } + + public string GetConfigJson() + => JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static GalaxyDriverOptions CreateDefaultOptions() => new( + Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"), + MxAccess: new GalaxyMxAccessOptions("OtOpcUa"), + Repository: new GalaxyRepositoryOptions(), + Reconnect: new GalaxyReconnectOptions()); + + private static GalaxyDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public GalaxyFormModel Galaxy { get; set; } = new(); + } + + /// Mutable flat mirror of and its nested records. + public sealed class GalaxyFormModel + { + // GalaxyGatewayOptions + public string GatewayEndpoint { get; set; } = "https://localhost:5001"; + public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY"; + public bool GatewayUseTls { get; set; } = true; + public string? GatewayCaCertificatePath { get; set; } + public int GatewayConnectTimeoutSeconds { get; set; } = 10; + public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30; + public int GatewayStreamTimeoutSeconds { get; set; } = 0; + + // GalaxyMxAccessOptions + public string MxClientName { get; set; } = "OtOpcUa"; + public int MxPublishingIntervalMs { get; set; } = 1000; + public int MxWriteUserId { get; set; } = 0; + public int MxEventPumpChannelCapacity { get; set; } = 50_000; + + // GalaxyRepositoryOptions + public int RepositoryDiscoverPageSize { get; set; } = 5000; + public bool RepositoryWatchDeployEvents { get; set; } = true; + + // GalaxyReconnectOptions + public int ReconnectInitialBackoffMs { get; set; } = 500; + public int ReconnectMaxBackoffMs { get; set; } = 30_000; + public bool ReconnectReplayOnSessionLost { get; set; } = true; + + // GalaxyDriverOptions top-level + public int ProbeTimeoutSeconds { get; set; } = 30; + + public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) + { + var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"); + var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa"); + var repo = r.Repository ?? new GalaxyRepositoryOptions(); + var rc = r.Reconnect ?? new GalaxyReconnectOptions(); + return new() + { + GatewayEndpoint = gw.Endpoint, + GatewayApiKeySecretRef = gw.ApiKeySecretRef, + GatewayUseTls = gw.UseTls, + GatewayCaCertificatePath = gw.CaCertificatePath, + GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds, + GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds, + GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds, + MxClientName = mx.ClientName, + MxPublishingIntervalMs = mx.PublishingIntervalMs, + MxWriteUserId = mx.WriteUserId, + MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity, + RepositoryDiscoverPageSize = repo.DiscoverPageSize, + RepositoryWatchDeployEvents = repo.WatchDeployEvents, + ReconnectInitialBackoffMs = rc.InitialBackoffMs, + ReconnectMaxBackoffMs = rc.MaxBackoffMs, + ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost, + ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, + }; + } + + public GalaxyDriverOptions ToRecord() => new( + Gateway: new GalaxyGatewayOptions( + Endpoint: GatewayEndpoint, + ApiKeySecretRef: GatewayApiKeySecretRef, + UseTls: GatewayUseTls, + CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath, + ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds, + DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds, + StreamTimeoutSeconds: GatewayStreamTimeoutSeconds), + MxAccess: new GalaxyMxAccessOptions( + ClientName: MxClientName, + PublishingIntervalMs: MxPublishingIntervalMs, + WriteUserId: MxWriteUserId, + EventPumpChannelCapacity: MxEventPumpChannelCapacity), + Repository: new GalaxyRepositoryOptions( + DiscoverPageSize: RepositoryDiscoverPageSize, + WatchDeployEvents: RepositoryWatchDeployEvents), + Reconnect: new GalaxyReconnectOptions( + InitialBackoffMs: ReconnectInitialBackoffMs, + MaxBackoffMs: ReconnectMaxBackoffMs, + ReplayOnSessionLost: ReconnectReplayOnSessionLost)) + { + ProbeTimeoutSeconds = ProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor new file mode 100644 index 00000000..da9036ba --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor @@ -0,0 +1,399 @@ +@* Embeddable Modbus driver (channel/protocol) config form body. Endpoint fields (Host/Port/UnitId) + moved to ModbusDeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed + ModbusDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.Modbus + +@* Family / transport-flags *@ +
+
Protocol
+
+
+
+ + +
Default 2 s.
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
Only used when Family = MELSEC.
+
+
+
+ + +
+
+
+
+
+ +@* Batch sizes *@ +
+
Batch sizes
+
+
+
+ + +
Spec max 125. Reduce for limited devices.
+
+
+ + +
Spec max 123.
+
+
+ + +
Spec max 2000.
+
+
+ + +
0 = no coalescing. Typical 5–32.
+
+
+
+
+ +@* Write options *@ +
+
Write options
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +@* Keep-alive *@ +
+
TCP keep-alive
+
+
+
+
+ + +
+
+
+ + +
Idle time before first probe. Default 30 s.
+
+
+ + +
Between probes once started. Default 10 s.
+
+
+ + +
Probes before declaring socket dead. Default 3.
+
+
+
+
+ +@* Reconnect backoff *@ +
+
Reconnect backoff
+
+
+
+ + +
0 = immediate retry.
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 2.0 (doubles each step).
+
+
+ + +
Proactive close after idle period. 0 = disabled.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
Zero-based. Default 0 (FC03 at register 0).
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+ + +
Interval to retry auto-prohibited coalesced ranges.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes β€” enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new ModbusDriverOptions(); + _form = FormModel.FromOptions(opts); + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON (endpoint excluded from the UI, + /// carried at harmless defaults; the device's DeviceConfig merges its endpoint up at deploy/probe time). + public string GetConfigJson() + => JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static ModbusDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model β€” retains Host/Port/UnitId at defaults (no UI; the device form authors the real + // endpoint into DeviceConfig, which merges up and overrides these at deploy/probe time). + public sealed class FormModel + { + // Transport endpoint (no UI β€” authored via the device form; kept at default so ToOptions is valid) + public string Host { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 502; + public int UnitId { get; set; } = 1; + public int TimeoutSeconds { get; set; } = 2; + + // Family + public ModbusFamily Family { get; set; } = ModbusFamily.Generic; + public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR; + + // Transport flags + public bool AutoReconnect { get; set; } = true; + public int IdleDisconnectTimeoutSeconds { get; set; } = 0; + + // Batch sizes (int; clamped to ushort on ToOptions) + public int MaxRegistersPerRead { get; set; } = 125; + public int MaxRegistersPerWrite { get; set; } = 123; + public int MaxCoilsPerRead { get; set; } = 2000; + public int MaxReadGap { get; set; } = 0; + + // Write options + public bool UseFC15ForSingleCoilWrites { get; set; } = false; + public bool UseFC16ForSingleRegisterWrites { get; set; } = false; + public bool DisableFC23 { get; set; } = false; + public bool WriteOnChangeOnly { get; set; } = false; + + // Keep-alive + public bool KeepAliveEnabled { get; set; } = true; + public int KeepAliveTimeSeconds { get; set; } = 30; + public int KeepAliveIntervalSeconds { get; set; } = 10; + public int KeepAliveRetryCount { get; set; } = 3; + + // Reconnect backoff + public int ReconnectInitialDelaySeconds { get; set; } = 0; + public int ReconnectMaxDelaySeconds { get; set; } = 30; + public double ReconnectBackoffMultiplier { get; set; } = 2.0; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int ProbeAddress { get; set; } = 0; + + // Auto-prohibit re-probe (0 = disabled) + public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0; + + // Admin UI probe timeout + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + public static FormModel FromOptions(ModbusDriverOptions o) => new() + { + Host = o.Host, + Port = o.Port, + UnitId = o.UnitId, + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + Family = o.Family, + MelsecSubFamily = o.MelsecSubFamily, + AutoReconnect = o.AutoReconnect, + IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0, + MaxRegistersPerRead = o.MaxRegistersPerRead, + MaxRegistersPerWrite = o.MaxRegistersPerWrite, + MaxCoilsPerRead = o.MaxCoilsPerRead, + MaxReadGap = o.MaxReadGap, + UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites, + UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites, + DisableFC23 = o.DisableFC23, + WriteOnChangeOnly = o.WriteOnChangeOnly, + KeepAliveEnabled = o.KeepAlive.Enabled, + KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds, + KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds, + KeepAliveRetryCount = o.KeepAlive.RetryCount, + ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds, + ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds, + ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + ProbeAddress = o.Probe.ProbeAddress, + AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue + ? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public ModbusDriverOptions ToOptions() => new() + { + Host = Host, + Port = Port, + UnitId = (byte)Math.Clamp(UnitId, 0, 255), + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Probe = new ModbusProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535), + }, + MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535), + MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535), + MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535), + UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites, + UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites, + DisableFC23 = DisableFC23, + AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0 + ? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null, + MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535), + Family = Family, + MelsecSubFamily = MelsecSubFamily, + WriteOnChangeOnly = WriteOnChangeOnly, + AutoReconnect = AutoReconnect, + KeepAlive = new ModbusKeepAliveOptions + { + Enabled = KeepAliveEnabled, + Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds), + Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds), + RetryCount = KeepAliveRetryCount, + }, + IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0 + ? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null, + Reconnect = new ModbusReconnectOptions + { + InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds), + MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds), + BackoffMultiplier = ReconnectBackoffMultiplier, + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor new file mode 100644 index 00000000..53f0611c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -0,0 +1,367 @@ +@* Embeddable OPC UA Client driver config form body. The single connection Endpoint URL moved to + OpcUaClientDeviceForm (Device.DeviceConfig) in v3; the EndpointUrls FAILOVER LIST stays here (it is a + driver-level policy). Hosted by the routed page + DriverConfigModal. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient + +@* Endpoint (single Endpoint URL moved to the device form) *@ +
+
Endpoint
+
+
+
+ + +
Restrict mirroring to a sub-tree.
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
Default 3 s β€” failover sweep budget.
+
+
+ + +
Default 10 s β€” steady-state reads/writes.
+
+
+ + +
Default 120 s.
+
+
+ + +
Default 5 s.
+
+
+ + +
Initial delay after session drop. Default 5 s.
+
+
+ + +
Default 10000.
+
+
+ + +
Default 10.
+
+
+
+
+ + + Endpoint URL (failover list β€” first reachable wins) + + + @e.Url + + + + +
Failover list. The per-device Endpoint URL (on the device) is used only when this list is empty.
+
+
+
+
+
+
+ +@* Security *@ +
+
Security
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+
+
+ +@* Authentication *@ +
+
Authentication
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+ @if (_form.OpcUa.AuthType == OpcUaAuthType.Username) + { +
+ + +
+
+ + +
+ } + @if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate) + { +
+ + +
+
+ + +
+ } +
+
+
+ +@* Namespace mapping *@ +
+
Namespace mapping
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.
+
+
+ +
@_unsMappingTableJson
+
Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.
+
+
+
+
+ +@* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 15.
+
+
+
+
+ + + +@code { + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + [Parameter] public string? ResilienceConfig { get; set; } + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private List _endpoints = []; + private string _unsMappingTableJson = "{}"; + private string? _lastParsed; + + protected override void OnParametersSet() + { + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new OpcUaClientDriverOptions(); + _form = new FormModel { OpcUa = OpcUaClientFormModel.FromRecord(opts) }; + _endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList(); + _unsMappingTableJson = JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts); + _lastParsed = DriverConfigJson; + } + } + + public string GetConfigJson() + => JsonSerializer.Serialize(_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static OpcUaClientDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public OpcUaClientFormModel OpcUa { get; set; } = new(); + } + + /// Mutable VM for a single failover endpoint URL row. + public sealed class EndpointUrlRow + { + public string Url { get; set; } = ""; + public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone(); + public static EndpointUrlRow FromUrl(string u) => new() { Url = u }; + public string ToUrl() => Url.Trim(); + + public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required."; + if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase)) + return "Endpoint URL must start with opc.tcp://"; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase)) + return $"Duplicate endpoint '{row.Url}'."; + return null; + } + } + + /// Mutable mirror of (int wrappers for TimeSpans). + /// EndpointUrl (single) has NO UI β€” the device form authors it; kept at default so ToRecord is valid. + public sealed class OpcUaClientFormModel + { + // Connection (EndpointUrl authored on the device β€” no UI here) + public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840"; + public string? BrowseRoot { get; set; } + public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient"; + public string SessionName { get; set; } = "OtOpcUa-Gateway"; + public bool AutoAcceptCertificates { get; set; } = false; + public int PerEndpointConnectTimeoutSeconds { get; set; } = 3; + public int TimeoutSeconds { get; set; } = 10; + public int SessionTimeoutSeconds { get; set; } = 120; + public int KeepAliveIntervalSeconds { get; set; } = 5; + public int ReconnectPeriodSeconds { get; set; } = 5; + public int MaxDiscoveredNodes { get; set; } = 10_000; + public int MaxBrowseDepth { get; set; } = 10; + + public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None; + public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None; + + public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous; + public string? Username { get; set; } + public string? Password { get; set; } + public string? UserCertificatePath { get; set; } + public string? UserCertificatePassword { get; set; } + + public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment; + + public int ProbeTimeoutSeconds { get; set; } = 15; + + internal IReadOnlyDictionary _unsMappingTable = new Dictionary(); + + public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new() + { + EndpointUrl = r.EndpointUrl, + BrowseRoot = r.BrowseRoot, + ApplicationUri = r.ApplicationUri, + SessionName = r.SessionName, + AutoAcceptCertificates = r.AutoAcceptCertificates, + PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds, + TimeoutSeconds = (int)r.Timeout.TotalSeconds, + SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds, + KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds, + ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds, + MaxDiscoveredNodes = r.MaxDiscoveredNodes, + MaxBrowseDepth = r.MaxBrowseDepth, + SecurityMode = r.SecurityMode, + SecurityPolicy = r.SecurityPolicy, + AuthType = r.AuthType, + Username = r.Username, + Password = r.Password, + UserCertificatePath = r.UserCertificatePath, + UserCertificatePassword = r.UserCertificatePassword, + TargetNamespaceKind = r.TargetNamespaceKind, + ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, + _unsMappingTable = r.UnsMappingTable, + }; + + public OpcUaClientDriverOptions ToRecord(IReadOnlyList endpointUrls) => new() + { + EndpointUrl = EndpointUrl, + EndpointUrls = endpointUrls, + BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot, + ApplicationUri = ApplicationUri, + SessionName = SessionName, + AutoAcceptCertificates = AutoAcceptCertificates, + PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds), + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds), + KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds), + ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds), + MaxDiscoveredNodes = MaxDiscoveredNodes, + MaxBrowseDepth = MaxBrowseDepth, + SecurityMode = SecurityMode, + SecurityPolicy = SecurityPolicy, + AuthType = AuthType, + Username = string.IsNullOrWhiteSpace(Username) ? null : Username, + Password = string.IsNullOrWhiteSpace(Password) ? null : Password, + UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath, + UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword, + TargetNamespaceKind = TargetNamespaceKind, + UnsMappingTable = _unsMappingTable, + ProbeTimeoutSeconds = ProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor new file mode 100644 index 00000000..d450f73b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor @@ -0,0 +1,177 @@ +@* Embeddable Siemens S7 driver (protocol) config form body. Endpoint fields (Host/Port/Rack/Slot) + moved to S7DeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed + S7DriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.S7 + +@* Connection *@ +
+
Connection
+
+
+
+ + +
+
+ + + @foreach (var v in Enum.GetValues()) + { + + } + +
Controls ISO-TSAP slot byte during handshake.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol; endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a protocol field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new S7DriverOptions(); + _form = FormModel.FromOptions(opts); + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current protocol config to camelCase JSON (endpoint excluded from the UI, + /// carried at harmless defaults; the device's DeviceConfig merges its endpoint up at deploy/probe time). + public string GetConfigJson() + => JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static S7DriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. + public sealed class FormModel + { + // Connection + public string Host { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 102; + public S7CpuType CpuType { get; set; } = S7CpuType.S71500; + public short Rack { get; set; } = 0; + public short Slot { get; set; } = 0; + public int TimeoutSeconds { get; set; } = 5; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + // Common + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(S7DriverOptions o) => new() + { + Host = o.Host, + Port = o.Port, + CpuType = o.CpuType, + Rack = o.Rack, + Slot = o.Slot, + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public S7DriverOptions ToOptions() => new() + { + Host = Host, + Port = Port, + CpuType = CpuType, + Rack = Rack, + Slot = Slot, + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Probe = new S7ProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor new file mode 100644 index 00000000..97c5828f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor @@ -0,0 +1,194 @@ +@* Embeddable TwinCAT driver (channel/protocol) config form body. Endpoint/device fields (AMS Net Id:port) + moved to TwinCATDeviceForm in v3 (they live in Device.DeviceConfig); this form never edits the Devices + collection — it preserves it verbatim across a load→save. Hosted by the routed TwinCATDriverPage and by + DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires + DriverConfigJsonChanged for @bind support. *@ +@using System.Text.Json +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT + +@* Options *@ +
+
Options
+
+
+
+ + +
Default 2 s per operation.
+
+
+
+ + +
+
Recommended. Disable for AMS routers with notification limits.
+
+
+
+ + +
+
Walks the symbol table via SymbolLoaderFactory at discovery. Default off.
+
+
+
+
+ + +
0 = push immediately. Increase to coalesce high-churn signals.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes β€” enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + // TwinCAT is multi-device: this form never edits the Devices collection β€” it preserves whatever was + // authored (via the device modal on the Raw tree) verbatim across a loadβ†’save. + private IReadOnlyList _preservedDevices = []; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var opts = TryDeserialize(DriverConfigJson) ?? new TwinCATDriverOptions(); + _form = FormModel.FromOptions(opts); + _preservedDevices = opts.Devices; + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON, preserving the Devices + /// collection (authored elsewhere) verbatim. + public string GetConfigJson() + => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static TwinCATDriverOptions? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model β€” all scalar properties settable for Blazor @bind-Value. + // The Devices collection is kept on the component and passed in on ToOptions(). + public sealed class FormModel + { + // Options + public int TimeoutSeconds { get; set; } = 2; + public bool UseNativeNotifications { get; set; } = true; + public bool EnableControllerBrowse { get; set; } = false; + public int NotificationMaxDelayMs { get; set; } = 0; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int AdminProbeTimeoutSeconds { get; set; } = 10; + + // Common + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(TwinCATDriverOptions o) => new() + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + UseNativeNotifications = o.UseNativeNotifications, + EnableControllerBrowse = o.EnableControllerBrowse, + NotificationMaxDelayMs = o.NotificationMaxDelayMs, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public TwinCATDriverOptions ToOptions( + IReadOnlyList devices) => new() + { + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + UseNativeNotifications = UseNativeNotifications, + EnableControllerBrowse = EnableControllerBrowse, + NotificationMaxDelayMs = NotificationMaxDelayMs, + Probe = new TwinCATProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + Devices = devices, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 5ae96fe8..3e171d59 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -79,59 +79,20 @@ public sealed class AbCipDriverPageFormSerializationTests back.ProbeTimeoutSeconds.ShouldBe(10); } - [Fact] - public void DeviceRow_round_trips_through_definition() - { - var row = new AbCipDriverPage.AbCipDeviceRow - { - HostAddress = "ab://10.0.0.1/1,0", PlcFamily = AbCipPlcFamily.CompactLogix, DeviceName = "PLC-A", - }; - var def = row.ToDefinition(); - var back = AbCipDriverPage.AbCipDeviceRow.FromDefinition(def); - - back.HostAddress.ShouldBe("ab://10.0.0.1/1,0"); - back.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix); - back.DeviceName.ShouldBe("PLC-A"); - } + // v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + AbCipDeviceRow retired β€” devices are now + // separate Device entities authored via the raw-tree DeviceModal (AbCipDeviceModel, HostAddress/PlcFamily + // into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config + // FormModel now lives in AbCipDriverForm; the multi-device Devices array it preserves is covered below. [Fact] - public void DeviceRow_preserves_unedited_fields() - { - var original = new AbCipDeviceOptions( - "ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-A", - AllowPacking: true, ConnectionSize: 4002); - var row = AbCipDriverPage.AbCipDeviceRow.FromDefinition(original); - row.HostAddress = "ab://10.0.0.2/1,0"; - - var back = row.ToDefinition(); - back.HostAddress.ShouldBe("ab://10.0.0.2/1,0"); - back.AllowPacking.ShouldBe(true); - back.ConnectionSize.ShouldBe(4002); - } - - // v3 Batch-1 migration: the pre-declared Tags editor (AbCipDriverPage.AbCipTagRow + - // AbCipDriverOptions.Tags) was retired β€” tags moved to the Raw tree (/raw, Batch 2). The former - // TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed. - // The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_* - // + the device-list serialization below, all carrying the AbCipPlcFamily enum) stays. - - [Fact] - public void ValidateDeviceRow_rejects_duplicate_host() - { - var rows = new List { new() { HostAddress = "ab://10.0.0.1/1,0" } }; - AbCipDriverPage.AbCipDeviceRow.ValidateRow(new() { HostAddress = "ab://10.0.0.1/1,0" }, rows, null) - .ShouldNotBeNull(); - } - - [Fact] - public void Device_list_survives_options_serialize_round_trip() + public void Device_list_survives_form_serialize_round_trip() { var devices = new List { new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"), new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"), }; - var opts = new AbCipDriverPage.FormModel().ToOptions(devices); + var opts = new AbCipDriverForm.FormModel().ToOptions(devices); var json = JsonSerializer.Serialize(opts, TestJsonOpts); var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; back.Devices.Count.ShouldBe(2); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index 973db1d1..5c22a9a1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; @@ -75,57 +75,20 @@ public sealed class AbLegacyDriverPageFormSerializationTests back.ProbeTimeoutSeconds.ShouldBe(10); } - [Fact] - public void DeviceRow_round_trips_through_definition() - { - var row = new AbLegacyDriverPage.AbLegacyDeviceRow - { - HostAddress = "10.0.0.10", PlcFamily = AbLegacyPlcFamily.MicroLogix, DeviceName = "PLC-A", - }; - var def = row.ToDefinition(); - var back = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(def); - - back.HostAddress.ShouldBe("10.0.0.10"); - back.PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix); - back.DeviceName.ShouldBe("PLC-A"); - } + // v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + AbLegacyDeviceRow retired β€” devices are now + // separate Device entities authored via the raw-tree DeviceModal (AbLegacyDeviceModel, HostAddress/PlcFamily + // into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config + // FormModel now lives in AbLegacyDriverForm; the multi-device Devices array it preserves is covered below. [Fact] - public void DeviceRow_preserves_unedited_fields() - { - var original = new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Plc5, "PLC-A"); - var row = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(original); - row.HostAddress = "10.0.0.20"; - - var back = row.ToDefinition(); - back.HostAddress.ShouldBe("10.0.0.20"); - back.PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5); - back.DeviceName.ShouldBe("PLC-A"); - } - - // v3 Batch-1 migration: the pre-declared Tags editor (AbLegacyDriverPage.AbLegacyTagRow + - // AbLegacyDriverOptions.Tags) was retired β€” tags moved to the Raw tree (/raw, Batch 2). The former - // TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed. - // The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_* - // + the device-list serialization below, all carrying the AbLegacyPlcFamily enum) stays. - - [Fact] - public void ValidateDeviceRow_rejects_duplicate_host() - { - var rows = new List { new() { HostAddress = "10.0.0.10" } }; - AbLegacyDriverPage.AbLegacyDeviceRow.ValidateRow(new() { HostAddress = "10.0.0.10" }, rows, null) - .ShouldNotBeNull(); - } - - [Fact] - public void Device_list_survives_options_serialize_round_trip() + public void Device_list_survives_form_serialize_round_trip() { var devices = new List { new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"), new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"), }; - var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices); + var opts = new AbLegacyDriverForm.FormModel().ToOptions(devices); var json = JsonSerializer.Serialize(opts, TestJsonOpts); var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; back.Devices.Count.ShouldBe(2); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs index 6726d347..2b05a9af 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs @@ -3,67 +3,69 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; /// /// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19. -/// Every *DriverPage serialised enum config fields (e.g. S7 CpuType, Modbus -/// DataType/Region, AbCip PlcFamily) as JSON numbers because its -/// private static _jsonOpts had no . The driver -/// factories, however, deserialise into string-typed DTOs (+ lenient ParseEnum) and -/// throw when binding a JSON number to a string? β€” so an AdminUI-authored -/// config that contained any enum field produced a blob the driver could not parse, faulting -/// the driver on deploy. The fix makes every page serialise enums as strings (matching the -/// factory + the long-correct OpcUaClient template). This test fails if any driver page loses -/// its string-enum converter again. +/// Every driver-config editor serialised enum config fields (e.g. S7 CpuType, Modbus +/// Family, AbCip PlcFamily) as JSON numbers when its private static +/// _jsonOpts had no . The driver factories, however, +/// deserialise into string-typed DTOs (+ lenient ParseEnum) and throw when binding a +/// JSON number to a string? β€” so an AdminUI-authored config that contained any enum field +/// produced a blob the driver could not parse, faulting the driver on deploy. The fix makes every +/// editor serialise enums as strings (matching the factory + the long-correct OpcUaClient template). +/// v3 Batch-2 (WP3): the config serializer moved from the routed *DriverPage into the +/// embeddable *DriverForm body (shared by the page + the raw-tree DriverConfigModal), so this +/// guard now reflects over the *DriverForm fleet. It fails if any driver form loses its +/// string-enum converter again. /// public sealed class DriverPageJsonConverterTests { - /// Every concrete *DriverPage in the AdminUI assembly that declares a + /// Every concrete *DriverForm in the AdminUI assembly that declares a /// _jsonOpts config serializer. - private static IReadOnlyList DriverPageTypes { get; } = - typeof(S7DriverPage).Assembly.GetTypes() - .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) + private static IReadOnlyList DriverFormTypes { get; } = + typeof(ModbusDriverForm).Assembly.GetTypes() + .Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract) .Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null) .OrderBy(t => t.Name) .ToList(); - /// xUnit theory source over the driver-page types discovered by reflection. - public static TheoryData DriverPagesWithJsonOpts() + /// xUnit theory source over the driver-form types discovered by reflection. + public static TheoryData DriverFormsWithJsonOpts() { var data = new TheoryData(); - foreach (var t in DriverPageTypes) + foreach (var t in DriverFormTypes) data.Add(t); return data; } - /// Verifies every driver page's config serializer registers a string-enum converter so + /// Verifies every driver form's config serializer registers a string-enum converter so /// enum config fields round-trip as strings (and the driver factory can parse the result). - /// A driver page component type discovered by reflection. + /// A driver form component type discovered by reflection. [Theory] - [MemberData(nameof(DriverPagesWithJsonOpts))] - public void Driver_page_json_options_register_string_enum_converter(Type pageType) + [MemberData(nameof(DriverFormsWithJsonOpts))] + public void Driver_form_json_options_register_string_enum_converter(Type formType) { - var field = pageType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static); + var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static); var opts = (JsonSerializerOptions)field!.GetValue(null)!; opts.Converters.OfType().ShouldNotBeEmpty( - $"{pageType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " + + $"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " + "enum config fields serialise as numbers and the string-typed driver factory throws on parse."); } - /// Enforces that EVERY concrete *DriverPage routes config serialization through a - /// _jsonOpts field β€” otherwise a new page that serialised config a different way would slip + /// Enforces that EVERY concrete *DriverForm routes config serialization through a + /// _jsonOpts field β€” otherwise a new form that serialised config a different way would slip /// past the converter guard above. Also a floor check so a rename can't silently shrink the set. [Fact] - public void Every_driver_page_uses_a_guarded_jsonOpts_serializer() + public void Every_driver_form_uses_a_guarded_jsonOpts_serializer() { - var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes() - .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) + var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes() + .Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract) .ToList(); - allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-page fleet"); - DriverPageTypes.Count.ShouldBe(allDriverPages.Count, - "every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); + allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet"); + DriverFormTypes.Count.ShouldBe(allDriverForms.Count, + "every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs index 607e83a7..78382bf9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -119,8 +119,7 @@ public sealed class FocasDriverPageFormSerializationTests }, }; - var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .FocasDriverPage.FormModel.FromOptions(opts); + var form = FocasDriverForm.FormModel.FromOptions(opts); var roundTripped = form.ToOptions([]); roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); @@ -138,57 +137,20 @@ public sealed class FocasDriverPageFormSerializationTests roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45)); } - [Fact] - public void DeviceRow_round_trips_through_definition() - { - var row = new FocasDriverPage.FocasDeviceRow - { - HostAddress = "192.168.0.10:8193", Series = FocasCncSeries.Thirty_i, DeviceName = "CNC1", - }; - var def = row.ToDefinition(); - var back = FocasDriverPage.FocasDeviceRow.FromDefinition(def); - - back.HostAddress.ShouldBe("192.168.0.10:8193"); - back.Series.ShouldBe(FocasCncSeries.Thirty_i); - back.DeviceName.ShouldBe("CNC1"); - } + // v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + FocasDeviceRow retired β€” devices are now + // separate Device entities authored via the raw-tree DeviceModal (FocasDeviceModel, HostAddress/Series + // into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config + // FormModel now lives in FocasDriverForm; the multi-device Devices array it preserves is covered below. [Fact] - public void DeviceRow_preserves_unedited_fields() - { - var original = new FocasDeviceOptions("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i); - var row = FocasDriverPage.FocasDeviceRow.FromDefinition(original); - row.HostAddress = "192.168.0.20:8193"; - - var back = row.ToDefinition(); - back.HostAddress.ShouldBe("192.168.0.20:8193"); - back.DeviceName.ShouldBe("CNC1"); - back.Series.ShouldBe(FocasCncSeries.Thirty_i); - } - - // v3 Batch-1 migration: the pre-declared Tags editor (FocasDriverPage.FocasTagRow + - // FocasDriverOptions.Tags) was retired β€” tags moved to the Raw tree (/raw, Batch 2). The former - // TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed. - // The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_* - // + the device-list serialization below, all carrying the FocasCncSeries enum) stays. - - [Fact] - public void ValidateDeviceRow_rejects_duplicate_host() - { - var rows = new List { new() { HostAddress = "192.168.0.10:8193" } }; - FocasDriverPage.FocasDeviceRow.ValidateRow(new() { HostAddress = "192.168.0.10:8193" }, rows, null) - .ShouldNotBeNull(); - } - - [Fact] - public void Device_list_survives_options_serialize_round_trip() + public void Device_list_survives_form_serialize_round_trip() { var devices = new List { new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i), new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F), }; - var opts = new FocasDriverPage.FormModel().ToOptions(devices); + var opts = new FocasDriverForm.FormModel().ToOptions(devices); var json = JsonSerializer.Serialize(opts, TestJsonOpts); var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; back.Devices.Count.ShouldBe(2); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs index 001fdcc8..8fce86f7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs @@ -3,13 +3,14 @@ using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; public sealed class GalaxyDriverPageFormSerializationTests { - // Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive). + // Matches GalaxyDriverForm._jsonOpts (camelCase, no PropertyNameCaseInsensitive). private static readonly JsonSerializerOptions _opts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -142,7 +143,7 @@ public sealed class GalaxyDriverPageFormSerializationTests var driverOpts = JsonSerializer.Deserialize(seededJson, _pageOpts); driverOpts.ShouldNotBeNull(); - var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!); + var form = GalaxyDriverForm.GalaxyFormModel.FromRecord(driverOpts!); // Assert REAL seeded values β€” not defaults. form.GatewayEndpoint.ShouldBe("http://10.100.0.48:5120"); @@ -155,7 +156,7 @@ public sealed class GalaxyDriverPageFormSerializationTests /// /// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all) - /// must not throw β€” must + /// must not throw β€” must /// null-coalesce the missing section to its default value. /// [Fact] @@ -175,7 +176,7 @@ public sealed class GalaxyDriverPageFormSerializationTests driverOpts.ShouldNotBeNull(); // FromRecord must not throw even though Reconnect (and other sections) is null. - var form = Should.NotThrow(() => GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!)); + var form = Should.NotThrow(() => GalaxyDriverForm.GalaxyFormModel.FromRecord(driverOpts!)); // Omitted Reconnect section falls back to GalaxyReconnectOptions() defaults. var defaultRc = new GalaxyReconnectOptions(); @@ -185,7 +186,7 @@ public sealed class GalaxyDriverPageFormSerializationTests } /// - /// Confirms that still + /// Confirms that still /// round-trips correctly when all nested records are populated (non-regressed path). /// [Fact] @@ -216,7 +217,7 @@ public sealed class GalaxyDriverPageFormSerializationTests ProbeTimeoutSeconds = 20, }; - var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(original); + var form = GalaxyDriverForm.GalaxyFormModel.FromRecord(original); form.GatewayEndpoint.ShouldBe("https://gw.example.com:5001"); form.GatewayApiKeySecretRef.ShouldBe("env:MY_KEY"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs index 60920f4d..5b97d79e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -124,7 +125,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests ProbeTimeoutSeconds = 25, }; - var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original); + var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original); var result = form.ToRecord(endpointUrls); result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840"); @@ -159,7 +160,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests [Fact] public void EndpointUrlRow_FromUrl_ToUrl_Trims() { - var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 "); + var row = OpcUaClientDriverForm.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 "); row.Url.ShouldBe(" opc.tcp://plc:4840 "); row.ToUrl().ShouldBe("opc.tcp://plc:4840"); @@ -168,10 +169,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests [Fact] public void EndpointUrlRow_ValidateRow_RejectsBlank() { - var all = new List(); - var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " }; + var all = new List(); + var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " " }; - var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); + var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldBe("URL is required."); } @@ -179,10 +180,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests [Fact] public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme() { - var all = new List(); - var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" }; + var all = new List(); + var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "http://plc:4840" }; - var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); + var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldBe("Endpoint URL must start with opc.tcp://"); } @@ -190,15 +191,15 @@ public sealed class OpcUaClientDriverPageFormSerializationTests [Fact] public void EndpointUrlRow_ValidateRow_RejectsDuplicate() { - var all = new List + var all = new List { new() { Url = "opc.tcp://primary:4840" }, new() { Url = "opc.tcp://backup:4840" }, }; // Adding a new row (editIndex null) duplicating the first β€” case-insensitive, whitespace-insensitive. - var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " }; + var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " }; - var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); + var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldNotBeNull(); error.ShouldContain("Duplicate endpoint"); @@ -207,15 +208,15 @@ public sealed class OpcUaClientDriverPageFormSerializationTests [Fact] public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace() { - var all = new List + var all = new List { new() { Url = "opc.tcp://primary:4840" }, new() { Url = "opc.tcp://backup:4840" }, }; // Editing index 0 and keeping the same URL must not flag itself as a duplicate. - var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" }; + var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "opc.tcp://primary:4840" }; - var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0); + var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, 0); error.ShouldBeNull(); } @@ -228,7 +229,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests var endpointUrls = new List { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" }; var rows = endpointUrls - .Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl) + .Select(OpcUaClientDriverForm.EndpointUrlRow.FromUrl) .ToList(); var roundTripped = rows.Select(r => r.ToUrl()).ToList(); @@ -237,7 +238,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests roundTripped[1].ShouldBe("opc.tcp://secondary:4840"); roundTripped[2].ShouldBe("opc.tcp://tertiary:4840"); - var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()); + var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()); var result = form.ToRecord(roundTripped); result.EndpointUrls.Count.ShouldBe(3); result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs index 69f11487..977571b4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs @@ -3,13 +3,14 @@ using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; // v3 Batch-1 migration: the S7 driver page dropped its pre-declared Tags editor β€” tags now live in // the Raw tree (/raw, Batch 2), and S7DriverOptions.Tags (the typed S7TagDefinition list) + -// S7DriverPage.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and +// S7DriverForm.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and // tag-serialization tests (S7TagRow_*, TagList_SerializeRoundTrip_PreservesTags, and the tag legs of // the round-trip tests) were removed. The connection/protocol-field + enum-serialization round-trip // coverage (CpuType, S7DataType) is retained below unchanged. @@ -91,7 +92,7 @@ public sealed class S7DriverPageFormSerializationTests ProbeTimeoutSeconds = 20, }; - var form = S7DriverPage.FormModel.FromOptions(opts); + var form = S7DriverForm.FormModel.FromOptions(opts); var roundTripped = form.ToOptions(); roundTripped.Host.ShouldBe("192.168.1.50"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs index 2a3ad37d..2664b942 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -80,8 +81,7 @@ public sealed class TwinCATDriverPageFormSerializationTests ProbeTimeoutSeconds = 15, }; - var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.FormModel.FromOptions(opts); + var form = TwinCATDriverForm.FormModel.FromOptions(opts); var roundTripped = form.ToOptions([]); roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); @@ -94,60 +94,15 @@ public sealed class TwinCATDriverPageFormSerializationTests roundTripped.ProbeTimeoutSeconds.ShouldBe(15); } - [Fact] - public void DeviceRow_RoundTrip_PreservesEditableFields() - { - var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1"); - - var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def); - var back = row.ToDefinition(); - - back.HostAddress.ShouldBe("192.168.0.1.1.1:851"); - back.DeviceName.ShouldBe("PLC1"); - } - - [Fact] - public void DeviceRow_CarriesThroughUneditedSourceFields() - { - // Edit only DeviceName; HostAddress on the source must survive the round-trip via _source. - var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original"); - - var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def); - row.DeviceName = "Renamed"; - var back = row.ToDefinition(); - - back.HostAddress.ShouldBe("10.0.0.5.1.1:851"); - back.DeviceName.ShouldBe("Renamed"); - } - - [Fact] - public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress() - { - var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851")); - var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" }; - - var all = new[] { existing, dup }; - var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1); - - error.ShouldNotBeNull(); - error.ShouldContain("Duplicate"); - } - - // v3 Batch-1 migration: the pre-declared Tags editor (TwinCATDriverPage.TwinCATTagRow + - // TwinCATDriverOptions.Tags) was retired β€” tags moved to the Raw tree (/raw, Batch 2). The former - // TagRow_* tests and the tag leg of FormModel_ToOptions_* were removed. The multi-device Devices - // editor is RETAINED, so its coverage (DeviceRow_* + the device-list serialization below) stays. + // v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + TwinCATDeviceRow retired β€” devices are now + // separate Device entities authored via the raw-tree DeviceModal (TwinCATDeviceModel, HostAddress into + // DeviceConfig). The former DeviceRow_* tests were removed. The channel-config FormModel now lives in + // TwinCATDriverForm; the multi-device Devices array it preserves is covered below. [Fact] public void FormModel_ToOptions_SerializesDeviceList() { - var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers - .TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions()); + var form = TwinCATDriverForm.FormModel.FromOptions(new TwinCATDriverOptions()); var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs new file mode 100644 index 00000000..e5609a8a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs @@ -0,0 +1,70 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Round-trip guard for the Modbus device endpoint model β€” the gate-critical DeviceConfig +/// surface. Endpoint keys MUST be PascalCase (Host/Port/UnitId) so the merged probe config binds via +/// the runtime's case-insensitive deserializer, matching the seed and Wave-B service tests. +/// +public sealed class ModbusDeviceModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = ModbusDeviceModel.FromJson(json); + + m.Host.ShouldBe("127.0.0.1"); + m.Port.ShouldBe(502); + m.UnitId.ShouldBe(1); + } + + [Fact] + public void Round_trip_preserves_endpoint_fields() + { + var m = new ModbusDeviceModel { Host = "10.100.0.35", Port = 5020, UnitId = 7 }; + + var json = m.ToJson(); + var m2 = ModbusDeviceModel.FromJson(json); + + m2.Host.ShouldBe("10.100.0.35"); + m2.Port.ShouldBe(5020); + m2.UnitId.ShouldBe(7); + } + + [Fact] + public void ToJson_emits_PascalCase_endpoint_keys() + { + var json = new ModbusDeviceModel { Host = "10.1.2.3", Port = 5020, UnitId = 1 }.ToJson(); + + // Runtime binds ModbusDriverOptions.Host/Port/UnitId from these exact PascalCase keys. + json.ShouldContain("\"Host\":\"10.1.2.3\""); + json.ShouldContain("\"Port\":5020"); + json.ShouldContain("\"UnitId\":1"); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = ModbusDeviceModel + .FromJson("""{"Host":"10.1.2.3","Port":5020,"UnitId":1,"customKey":"keepme"}""") + .ToJson(); + + json.ShouldContain("customKey"); + json.ShouldContain("keepme"); + json.ShouldContain("\"Host\":\"10.1.2.3\""); + } + + [Fact] + public void Validate_flags_blank_host() + { + new ModbusDeviceModel { Host = "" }.Validate().ShouldNotBeNull(); + new ModbusDeviceModel { Host = "127.0.0.1" }.Validate().ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs new file mode 100644 index 00000000..c7e47597 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Round-trip + enum-serialization guard for the Modbus driver (channel/protocol) form model. +/// Reproduces the historical driver-enum-serialization bug: enums MUST serialize as their name strings +/// (JsonStringEnumConverter) with camelCase keys, or the runtime factory faults on the numeric form. +/// +public sealed class ModbusDriverFormModelTests +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + [Fact] + public void Round_trip_preserves_channel_fields_and_enum() + { + var form = new ModbusDriverForm.FormModel + { + Family = ModbusFamily.MELSEC, + MelsecSubFamily = MelsecFamily.Q_L_iQR, + TimeoutSeconds = 4, + MaxRegistersPerRead = 100, + WriteOnChangeOnly = true, + KeepAliveRetryCount = 5, + ReconnectBackoffMultiplier = 3.0, + ProbeAddress = 40, + }; + + var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts); + var back = ModbusDriverForm.FormModel.FromOptions( + JsonSerializer.Deserialize(json, JsonOpts)!); + + back.Family.ShouldBe(ModbusFamily.MELSEC); + back.MelsecSubFamily.ShouldBe(MelsecFamily.Q_L_iQR); + back.TimeoutSeconds.ShouldBe(4); + back.MaxRegistersPerRead.ShouldBe(100); + back.WriteOnChangeOnly.ShouldBeTrue(); + back.KeepAliveRetryCount.ShouldBe(5); + back.ReconnectBackoffMultiplier.ShouldBe(3.0); + back.ProbeAddress.ShouldBe(40); + } + + [Fact] + public void Serialized_config_uses_camelCase_keys_and_enum_names() + { + var form = new ModbusDriverForm.FormModel { Family = ModbusFamily.MELSEC }; + + var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts); + + json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key + json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form + } +} From aef9ef84525429aa711ca43363fcec00fd0ee868 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 03:38:32 -0400 Subject: [PATCH 14/20] =?UTF-8?q?feat(adminui):=20B2=20Wave-B=20integratio?= =?UTF-8?q?n=20=E2=80=94=20wire=20real=20/raw=20modals=20+=20dialogs=20+?= =?UTF-8?q?=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace RawTree's 22 stub handlers with the real Wave-B modals and 3 new small dialogs, plus post-mutation subtree refresh: - New dialogs: RawNameDialog (create/rename, RawPaths.ValidateSegment inline), RawConfirmDialog (deletes), RawDriverTypeDialog (New driver type+name picker, incl. Calculation). - Configure driver/device -> DriverConfigModal/DeviceModal (edit); New device -> DeviceModal (create); Edit tag -> RawTagModal; Manual entry / CSV import/export -> the Raw*Modal surfaces; New folder/tag-group/group + New driver + Rename + Delete + Toggle -> IRawTreeService directly via the dialogs. - Refresh: reload the container after create-under, the parent after item-level ops (parent found by searching the loaded tree; robust, non-throwing). - Rename warnings + blocked-delete errors surfaced via the repurposed RawStubModal message surface. Browse device stays a placeholder (Wave C). Driver-level 'Test connect' menu item removed (test-connect now lives on the device modal). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Shared/Raw/RawConfirmDialog.razor | 73 +++ .../Shared/Raw/RawDriverTypeDialog.razor | 105 +++ .../Components/Shared/Raw/RawNameDialog.razor | 88 +++ .../Components/Shared/Raw/RawTree.razor | 601 ++++++++++++++++-- 4 files changed, 805 insertions(+), 62 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor new file mode 100644 index 00000000..2d8ccda4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor @@ -0,0 +1,73 @@ +@* Reusable confirm dialog for the /raw destructive operations (delete folder / driver / device / + tag-group / tag). Raises OnConfirm when the operator confirms; the caller runs the delete service + call and surfaces any UnsMutationResult.Error (a blocked delete names its blocker) via a follow-up + message. Markup mirrors the other /raw modal shells. *@ + +@if (Visible) +{ + + +} + +@code { + /// Whether the dialog is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The dialog title (e.g. "Delete driver"). + [Parameter] public string Title { get; set; } = "Confirm"; + + /// The confirmation body message. + [Parameter] public string Message { get; set; } = ""; + + /// The confirm-button label. + [Parameter] public string ConfirmLabel { get; set; } = "Delete"; + + /// Raised when the operator confirms. The dialog awaits it, then self-closes. + [Parameter] public EventCallback OnConfirm { get; set; } + + private bool _busy; + + private async Task Confirm() + { + _busy = true; + try + { + await OnConfirm.InvokeAsync(); + } + finally + { + _busy = false; + } + await Close(); + } + + private Task Cancel() => Close(); + + private async Task Close() + { + Visible = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor new file mode 100644 index 00000000..e43dc14a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -0,0 +1,105 @@ +@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller + creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via + the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now + (its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other + /raw modal shells. *@ +@using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions + +@if (Visible) +{ + + +} + +@code { + /// Whether the dialog is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after. + [Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; } + + // Label β†’ DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is + // authorable now though its factory arrives in Wave C. + private static readonly (string Label, string Value)[] Types = + [ + ("Modbus", DriverTypeNames.Modbus), + ("S7", DriverTypeNames.S7), + ("AbCip", DriverTypeNames.AbCip), + ("AbLegacy", DriverTypeNames.AbLegacy), + ("TwinCAT", DriverTypeNames.TwinCAT), + ("FOCAS", DriverTypeNames.FOCAS), + ("OpcUaClient", DriverTypeNames.OpcUaClient), + ("Galaxy", DriverTypeNames.Galaxy), + ("Calculation", "Calculation"), + ]; + + private string _type = DriverTypeNames.Modbus; + private string _name = ""; + private string? _error; + private bool _open; // seed once per open; preserve edits across unrelated re-renders + + protected override void OnParametersSet() + { + if (!Visible) { _open = false; return; } + if (_open) { return; } + _open = true; + _type = DriverTypeNames.Modbus; + _name = ""; + _error = null; + } + + private async Task Submit() + { + var err = RawPaths.ValidateSegment(_name); + if (err is not null) { _error = err; return; } + await OnSubmit.InvokeAsync((_type, _name)); + await Close(); + } + + private Task Cancel() => Close(); + + private async Task Close() + { + Visible = false; + _open = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor new file mode 100644 index 00000000..c7743808 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor @@ -0,0 +1,88 @@ +@* Reusable name-input dialog for the /raw create + rename operations (New folder / New tag-group / + New group / Rename folder-driver-device-tag-group). Inline-validates the input as a RawPath segment + (RawPaths.ValidateSegment) before raising OnSubmit; a valid submit self-closes the dialog and the + caller runs the create/rename service call + surfaces any server-side error/warnings. Markup mirrors + the other /raw modal shells (RawStubModal). *@ +@using ZB.MOM.WW.OtOpcUa.Commons.Types + +@if (Visible) +{ + + +} + +@code { + /// Whether the dialog is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The dialog title (e.g. "New folder", "Rename driver"). + [Parameter] public string Title { get; set; } = "Name"; + + /// The initial value seeded into the input on open (blank for create, current name for rename). + [Parameter] public string Value { get; set; } = ""; + + /// Raised with the validated name when the operator confirms. The dialog self-closes after. + [Parameter] public EventCallback OnSubmit { get; set; } + + private string _value = ""; + private string? _error; + private bool _open; // seed the input once per open; preserve typing across unrelated re-renders + + protected override void OnParametersSet() + { + if (!Visible) { _open = false; return; } + if (_open) { return; } + _open = true; + _value = Value ?? ""; + _error = null; + } + + private async Task Submit() + { + var err = RawPaths.ValidateSegment(_value); + if (err is not null) { _error = err; return; } + await OnSubmit.InvokeAsync(_value); + await Close(); + } + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") { await Submit(); } + } + + private Task Cancel() => Close(); + + private async Task Close() + { + Visible = false; + _open = false; + await VisibleChanged.InvokeAsync(false); + } +} 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 ed14b6d6..8508711b 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 @@ -5,13 +5,21 @@ expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends the returned level into node.Children and flips Loaded/Loading, showing a spinner in flight and an error row on failure. Per-node context menus (right-click surface - + "β‹―" fallback) are built per RawNodeKind; every action opens a placeholder - (RawStubModal) in this wave β€” Wave B/C replaces each named handler's body with the - real Configure / Device / TagModal / CSV / Browse modal. This component is a single - instance that recurses via the RenderNode RenderFragment, so it owns one stub modal - and one injected service for the whole tree. *@ + + "β‹―" fallback) are built per RawNodeKind. + + Wave-B integration (this pass): every named handler now opens the REAL modal/dialog + (Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService + directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs + built alongside β€” RawNameDialog / RawConfirmDialog / RawDriverTypeDialog β€” and + refreshes the affected subtree afterwards (reload the container for create-under + ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C + owns it). The former RawStubModal is repurposed here as the shared message surface + for delete-blockers, rename warnings, and the Browse "coming soon" note. This + component is a single instance that recurses via the RenderNode RenderFragment, so it + owns one set of modals + one injected service for the whole tree. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @inject IRawTreeService Svc @foreach (var root in Roots) @@ -19,18 +27,105 @@ @RenderNode(root, 0) } - +@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@ + + + + + + + + + + + + + + + + + + +@* Shared message surface (repurposed RawStubModal) β€” delete blockers, rename warnings, Browse note. *@ + @code { /// The top-level Raw nodes to render (the enterprise roots from LoadRootsAsync). [Parameter, EditorRequired] public IReadOnlyList Roots { get; set; } = default!; - // --- Stub-placeholder state (one instance for the whole recursive tree) --- - private bool _stubVisible; - private string _stubTitle = ""; - private string _stubMessage = ""; - private string? _stubNodeName; + /// + /// Optional safety valve: raised when a mutation's affected node has no in-tree parent to reload + /// (only reachable for a cluster-less top-level node). Every menu-bearing node's parent is in the + /// tree today, so this is normally a no-op; the page may wire it to reload roots if that ever changes. + /// + [Parameter] public EventCallback OnRootsChanged { get; set; } + + // --- Configure-driver modal (edit) --- + private bool _driverCfgVisible; + private string? _driverCfgId; + private RawNode? _driverCfgNode; + + // --- Device modal (create + edit) --- + private bool _deviceVisible; + private string? _deviceEditId; + private string? _deviceCreateDriverId; + private string? _deviceDriverType; + private RawNode? _deviceNode; + private bool _deviceIsCreate; + + // --- Edit-tag modal --- + private bool _tagVisible; + private string? _tagId; + private string? _tagDriverType; + private RawNode? _tagNode; + + // --- Manual tag-entry modal --- + private bool _manualVisible; + private string _manualDeviceId = ""; + private string? _manualTagGroupId; + private string _manualDriverType = ""; + private RawNode? _manualNode; + + // --- CSV import modal --- + private bool _csvImportVisible; + private string _csvImportDeviceId = ""; + private string? _csvImportTagGroupId; + private string _csvImportDriverType = ""; + private RawNode? _csvImportNode; + + // --- CSV export modal (read-only, no refresh) --- + private bool _csvExportVisible; + private string _csvExportDeviceId = ""; + private string? _csvExportTagGroupId; + private string _csvExportDriverType = ""; + + // --- Name dialog (New folder/group, Rename) --- + private bool _nameVisible; + private string _nameTitle = ""; + private string _nameInitial = ""; + private Func? _nameSubmit; + + // --- Confirm dialog (deletes) --- + private bool _confirmVisible; + private string _confirmTitle = ""; + private string _confirmMessage = ""; + private Func? _confirmAction; + + // --- Driver-type dialog (New driver) --- + private bool _driverTypeVisible; + private Func<(string DriverType, string Name), Task>? _driverTypeSubmit; + + // --- Shared message surface (repurposed RawStubModal): delete blockers, rename warnings, Browse note --- + private bool _msgVisible; + private string _msgTitle = ""; + private string _msgMessage = ""; + private string? _msgNodeName; // --------------------------------------------------------------------------- // Row rendering @@ -194,7 +289,6 @@ private List DriverMenu(RawNode node) => new() { new() { Label = "Configure", Icon = "βš™", OnClick = () => OnConfigureDriver(node) }, - new() { Label = "Test connect", Icon = "πŸ”Ž", OnClick = () => OnTestConnect(node) }, new() { Label = "New device", Icon = "πŸ“Ÿ", OnClick = () => OnNewDevice(node) }, ContextMenuItem.Separator(), new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "β–Ά", @@ -239,63 +333,446 @@ }; // --------------------------------------------------------------------------- - // Named action handlers β€” the Wave B/C replacement seam. Each opens a placeholder - // today; a later wave swaps the body for the real modal invocation. + // Named action handlers β€” each opens the real modal/dialog or calls the service + // directly, then refreshes the affected subtree. Only OnBrowseDevice is a + // placeholder (Wave C owns device browse). // --------------------------------------------------------------------------- - // Folder / Cluster - private Task OnNewFolder(RawNode node) => ShowStub("New folder", node); - private Task OnNewDriver(RawNode node) => ShowStub("New driver", node); - private Task OnRenameFolder(RawNode node) => ShowStub("Rename folder", node); - private Task OnDeleteFolder(RawNode node) => ShowStub("Delete folder", node); + // --- Folder / Cluster --- - // Driver - private Task OnConfigureDriver(RawNode node) => ShowStub("Configure driver", node); - private Task OnTestConnect(RawNode node) => ShowStub("Test connect", node); - private Task OnNewDevice(RawNode node) => ShowStub("New device", node); - private Task OnToggleDriverEnabled(RawNode node) => ShowStub(node.Enabled ? "Disable driver" : "Enable driver", node); - private Task OnRenameDriver(RawNode node) => ShowStub("Rename driver", node); - private Task OnDeleteDriver(RawNode node) => ShowStub("Delete driver", node); - - // Device - private Task OnConfigureDevice(RawNode node) => ShowStub("Configure device", node); - private Task OnNewTagGroup(RawNode node) => ShowStub("New tag-group", node); - private Task OnDeleteDevice(RawNode node) => ShowStub("Delete device", node); - - // Add-tags submenu (shared by Device + TagGroup) - private Task OnAddManualTags(RawNode node) => ShowStub("Add tags β€” manual entry", node); - private Task OnImportCsv(RawNode node) => ShowStub("Import tags CSV", node); - private Task OnExportCsv(RawNode node) => ShowStub("Export tags CSV", node); - private Task OnBrowseDevice(RawNode node) => ShowStub("Browse device", node); - - // TagGroup - private Task OnNewGroup(RawNode node) => ShowStub("New group", node); - private Task OnRenameTagGroup(RawNode node) => ShowStub("Rename tag-group", node); - private Task OnDeleteTagGroup(RawNode node) => ShowStub("Delete tag-group", node); - - // Tag - private Task OnEditTag(RawNode node) => ShowStub("Edit tag", node); - private Task OnDeleteTag(RawNode node) => ShowStub("Delete tag", node); - - // --------------------------------------------------------------------------- - // Placeholder plumbing (removed once every handler above has its real modal). - // --------------------------------------------------------------------------- - - private Task ShowStub(string action, RawNode node) + // New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder). + private Task OnNewFolder(RawNode node) { - _stubTitle = action; - _stubMessage = $"{action} β€” coming in a later wave."; - _stubNodeName = node.DisplayName; - _stubVisible = true; + var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId; + var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null; + ShowNameDialog("New folder", "", async name => + { + var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name); + if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; } + await ReloadNodeAsync(node); + }); + return Task.CompletedTask; + } + + // New driver: pick type + name, then create a minimal driver ("{}") β€” configure it afterward. + private Task OnNewDriver(RawNode node) + { + var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId; + var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null; + ShowDriverTypeDialog(async choice => + { + var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}"); + if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; } + await ReloadNodeAsync(node); + }); + return Task.CompletedTask; + } + + private Task OnRenameFolder(RawNode node) + { + ShowNameDialog("Rename folder", node.DisplayName, async name => + await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node)); + return Task.CompletedTask; + } + + private Task OnDeleteFolder(RawNode node) + { + ShowConfirm("Delete folder", $"Delete folder β€œ{node.DisplayName}”?", async () => + await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node)); + return Task.CompletedTask; + } + + // --- Driver --- + + private Task OnConfigureDriver(RawNode node) + { + _driverCfgId = node.EntityId; + _driverCfgNode = node; + _driverCfgVisible = true; StateHasChanged(); return Task.CompletedTask; } - private void CloseStub() + private Task OnNewDevice(RawNode node) { - _stubVisible = false; - _stubTitle = ""; - _stubMessage = ""; - _stubNodeName = null; + _deviceIsCreate = true; + _deviceEditId = null; + _deviceCreateDriverId = node.EntityId; + _deviceDriverType = node.DriverType; + _deviceNode = node; + _deviceVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private async Task OnToggleDriverEnabled(RawNode node) + { + var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion); + if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; } + await ReloadParentAsync(node); + } + + private Task OnRenameDriver(RawNode node) + { + ShowNameDialog("Rename driver", node.DisplayName, async name => + await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node)); + return Task.CompletedTask; + } + + private Task OnDeleteDriver(RawNode node) + { + ShowConfirm("Delete driver", $"Delete driver β€œ{node.DisplayName}” and its empty devices?", async () => + await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node)); + return Task.CompletedTask; + } + + // --- Device --- + + private Task OnConfigureDevice(RawNode node) + { + _deviceIsCreate = false; + _deviceEditId = node.EntityId; + _deviceCreateDriverId = null; + _deviceDriverType = node.DriverType; + _deviceNode = node; + _deviceVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private Task OnNewTagGroup(RawNode node) + { + // node is a Device β€” its root tag-groups. + ShowNameDialog("New tag-group", "", async name => + { + var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name); + if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; } + await ReloadNodeAsync(node); + }); + return Task.CompletedTask; + } + + private Task OnDeleteDevice(RawNode node) + { + ShowConfirm("Delete device", $"Delete device β€œ{node.DisplayName}”?", async () => + await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node)); + return Task.CompletedTask; + } + + // --- Add-tags surfaces (shared by Device + TagGroup) --- + + private Task OnAddManualTags(RawNode node) + { + // Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up. + _manualDeviceId = FindAncestorDevice(node)?.EntityId ?? ""; + _manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null; + _manualDriverType = node.DriverType ?? ""; + _manualNode = node; + _manualVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private Task OnImportCsv(RawNode node) + { + // Device node β†’ DeviceId; TagGroup node β†’ TagGroupId (the modal self-resolves its device). + if (node.Kind == RawNodeKind.Device) + { + _csvImportDeviceId = node.EntityId ?? ""; + _csvImportTagGroupId = null; + } + else + { + _csvImportDeviceId = ""; + _csvImportTagGroupId = node.EntityId; + } + _csvImportDriverType = node.DriverType ?? ""; + _csvImportNode = node; + _csvImportVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private Task OnExportCsv(RawNode node) + { + if (node.Kind == RawNodeKind.Device) + { + _csvExportDeviceId = node.EntityId ?? ""; + _csvExportTagGroupId = null; + } + else + { + _csvExportDeviceId = ""; + _csvExportTagGroupId = node.EntityId; + } + _csvExportDriverType = node.DriverType ?? ""; + _csvExportVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + // Browse device stays a placeholder β€” Wave C (WP6) wires the discovery-browser modal. + private Task OnBrowseDevice(RawNode node) + { + ShowMessage("Browse device", "Device browse arrives in the browse wave (Wave C).", node.DisplayName); + return Task.CompletedTask; + } + + // --- TagGroup --- + + private Task OnNewGroup(RawNode node) + { + // node is a TagGroup β€” create a sub-group under it (needs its owning device id). + var deviceId = FindAncestorDevice(node)?.EntityId ?? ""; + ShowNameDialog("New group", "", async name => + { + var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name); + if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; } + await ReloadNodeAsync(node); + }); + return Task.CompletedTask; + } + + private Task OnRenameTagGroup(RawNode node) + { + ShowNameDialog("Rename tag-group", node.DisplayName, async name => + await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node)); + return Task.CompletedTask; + } + + private Task OnDeleteTagGroup(RawNode node) + { + ShowConfirm("Delete tag-group", $"Delete tag-group β€œ{node.DisplayName}”?", async () => + await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node)); + return Task.CompletedTask; + } + + // --- Tag --- + + private Task OnEditTag(RawNode node) + { + _tagId = node.EntityId; + _tagDriverType = node.DriverType; + _tagNode = node; + _tagVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private Task OnDeleteTag(RawNode node) + { + ShowConfirm("Delete tag", $"Delete tag β€œ{node.DisplayName}”?", async () => + await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node)); + return Task.CompletedTask; + } + + // --------------------------------------------------------------------------- + // Modal OnSaved callbacks β€” refresh the affected subtree. + // --------------------------------------------------------------------------- + + private async Task DriverCfgSaved() + { + if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); } + } + + private async Task DeviceSaved() + { + if (_deviceNode is null) { return; } + // Create β†’ reload the driver container; edit β†’ reload the device's parent driver. + if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); } + else { await ReloadParentAsync(_deviceNode); } + } + + private async Task TagSaved() + { + if (_tagNode is not null) { await ReloadParentAsync(_tagNode); } + } + + private async Task ManualSaved() + { + if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); } + } + + private async Task CsvImportSaved() + { + if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); } + } + + // --------------------------------------------------------------------------- + // Rename / delete outcome surfacing. + // --------------------------------------------------------------------------- + + private async Task AfterRename(RawRenameResult res, RawNode node) + { + if (res.Ok) { await ReloadParentAsync(node); } + + if (!res.Ok) + { + ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName); + } + else if (res.Warnings.Count > 0) + { + ShowMessage("Renamed β€” with warnings", string.Join(" β€’ ", res.Warnings), node.DisplayName); + } + } + + private async Task AfterDelete(UnsMutationResult res, RawNode node) + { + if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; } + await ReloadParentAsync(node); + } + + // --------------------------------------------------------------------------- + // Dialog show-helpers (one instance each; a pending delegate carries the op). + // --------------------------------------------------------------------------- + + private void ShowNameDialog(string title, string initial, Func submit) + { + _nameTitle = title; + _nameInitial = initial; + _nameSubmit = submit; + _nameVisible = true; + StateHasChanged(); + } + + private async Task NameSubmitted(string name) + { + var submit = _nameSubmit; + if (submit is not null) { await submit(name); } + } + + private void ShowConfirm(string title, string message, Func action) + { + _confirmTitle = title; + _confirmMessage = message; + _confirmAction = action; + _confirmVisible = true; + StateHasChanged(); + } + + private async Task Confirmed() + { + var action = _confirmAction; + if (action is not null) { await action(); } + } + + private void ShowDriverTypeDialog(Func<(string DriverType, string Name), Task> submit) + { + _driverTypeSubmit = submit; + _driverTypeVisible = true; + StateHasChanged(); + } + + private async Task DriverTypeSubmitted((string DriverType, string Name) choice) + { + var submit = _driverTypeSubmit; + if (submit is not null) { await submit(choice); } + } + + // --------------------------------------------------------------------------- + // Shared message surface (repurposed RawStubModal). + // --------------------------------------------------------------------------- + + private void ShowMessage(string title, string message, string? nodeName) + { + _msgTitle = title; + _msgMessage = message; + _msgNodeName = nodeName; + _msgVisible = true; + StateHasChanged(); + } + + private void CloseMessage() + { + _msgVisible = false; + _msgTitle = ""; + _msgMessage = ""; + _msgNodeName = null; + } + + // --------------------------------------------------------------------------- + // Tree refresh + parent lookup. + // --------------------------------------------------------------------------- + + /// + /// Reloads a container node's children in place (create-under refresh): clears its loaded level and + /// re-runs the lazy load, keeping it expanded. A refresh failure is captured on the node, never thrown. + /// + private async Task ReloadNodeAsync(RawNode container) + { + try + { + container.Expanded = true; + container.Loaded = false; + container.Children.Clear(); + container.Error = null; + + if (container.HasLazyChildren) + { + container.Loading = true; + StateHasChanged(); + var children = await Svc.LoadChildrenAsync(container); + container.Children.Clear(); + container.Children.AddRange(children); + container.ChildCount = container.Children.Count; + container.Loaded = true; + } + } + catch (Exception ex) + { + container.Error = ex.Message; + } + finally + { + container.Loading = false; + } + StateHasChanged(); + } + + /// + /// Reloads the parent container of an item-level node (rename/delete/toggle/edit refresh) so a changed + /// name / removed row / muted styling is reflected while preserving surrounding expansion state. Every + /// menu-bearing node's parent is in the loaded tree; the OnRootsChanged fallback is a belt-and-braces + /// path for a would-be cluster-less top-level node. + /// + private async Task ReloadParentAsync(RawNode node) + { + var parent = FindParent(node); + if (parent is not null) { await ReloadNodeAsync(parent); } + else { await OnRootsChanged.InvokeAsync(); } + } + + /// Finds a node's parent by searching the currently-loaded tree; null for a root. + private RawNode? FindParent(RawNode target) + { + foreach (var root in Roots) + { + var found = FindParentIn(root, target); + if (found is not null) { return found; } + } + return null; + } + + private static RawNode? FindParentIn(RawNode node, RawNode target) + { + foreach (var child in node.Children) + { + if (ReferenceEquals(child, target)) { return node; } + var deeper = FindParentIn(child, target); + if (deeper is not null) { return deeper; } + } + return null; + } + + /// Walks up from (inclusive) to the nearest Device node, or null. + private RawNode? FindAncestorDevice(RawNode node) + { + var current = node; + while (current is not null) + { + if (current.Kind == RawNodeKind.Device) { return current; } + current = FindParent(current); + } + return null; } } From ae9f906f525b4e6fabe13fbb67861a1204ae931b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 03:56:19 -0400 Subject: [PATCH 15/20] review(b2-waveB): strip endpoint keys from driver-form config (H1) so DeviceConfig is sole endpoint source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer H1 (v3 regression): the endpointβ†’DeviceConfig split ADDED the endpoint to DeviceConfig but the driver forms still serialized it into DriverConfig at defaults. Single-device worked only by an STJ case-insensitive last-key-wins accident; a 2nd device on a single-top-level-Host driver (Modbus/S7) silently reverted to the driver-form default (host=127.0.0.1). Fix: Modbus/S7/OpcUaClient driver-form GetConfigJson strips the endpoint keys (host/port/unitId; host/port/rack/slot; scalar endpointUrl) from the serialized channel config, leaving DeviceConfig as the sole source (OpcUaClient keeps its endpointUrls failover list, which is driver-level policy). Enum-serialization verified CLEAN fleet-wide; ImportTagsAsync all-or-nothing verified; RawTree wiring sound. Deferred (documented in PR): H2 pre-existing Modbus/S7 form TimeSpan vs factory *Ms-int DTO mismatch (authored durations dropped β€” predates v3); M1 CSV export drops non-columned alarm sub-keys; M2 blank flag column clobbers a base-JSON isHistorized/isArray; M3 OpcUaClient device/driver endpoint precedence; L1 unused LoadMergedProbeConfigAsync; L5 refresh collapses sibling expansion. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Shared/Drivers/Forms/ModbusDriverForm.razor | 17 ++++++++++++++--- .../Drivers/Forms/OpcUaClientDriverForm.razor | 11 ++++++++++- .../Shared/Drivers/Forms/S7DriverForm.razor | 16 +++++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor index da9036ba..f0959d1a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor @@ -3,6 +3,7 @@ ModbusDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@ @using System.Text.Json +@using System.Text.Json.Nodes @using System.Text.Json.Serialization @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Driver.Modbus @@ -241,10 +242,20 @@ } } - /// Serializes the current channel/protocol config to camelCase JSON (endpoint excluded from the UI, - /// carried at harmless defaults; the device's DeviceConfig merges its endpoint up at deploy/probe time). + // v3: the endpoint lives on the Device (DeviceConfig), not the driver. Strip the endpoint keys from + // the serialized channel config so DeviceConfig is the SOLE endpoint source β€” otherwise a stale + // driver-level default (host=127.0.0.1) is carried in DriverConfig and shadows the device on any + // driver that reads a single top-level Host, silently, as soon as a 2nd device exists (reviewer H1). + private static readonly string[] EndpointKeys = ["host", "port", "unitId"]; + + /// Serializes the current channel/protocol config to camelCase JSON, with the endpoint keys + /// removed (the device's DeviceConfig is the sole endpoint source, merged up at deploy/probe time). public string GetConfigJson() - => JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + { + var node = JsonSerializer.SerializeToNode(_form.ToOptions(), _jsonOpts)!.AsObject(); + foreach (var key in EndpointKeys) node.Remove(key); + return node.ToJsonString(_jsonOpts); + } private async Task EmitAsync() { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index 53f0611c..1dace2bb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -2,6 +2,7 @@ OpcUaClientDeviceForm (Device.DeviceConfig) in v3; the EndpointUrls FAILOVER LIST stays here (it is a driver-level policy). Hosted by the routed page + DriverConfigModal. *@ @using System.Text.Json +@using System.Text.Json.Nodes @using System.Text.Json.Serialization @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient @@ -232,7 +233,15 @@ } public string GetConfigJson() - => JsonSerializer.Serialize(_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts); + { + // v3 (reviewer H1): the convenience scalar endpoint lives on the Device (DeviceConfig). Strip the + // scalar "endpointUrl" so the device is its sole source; the "endpointUrls" failover list stays + // here (it is a driver-level policy and wins over the scalar at runtime when populated). + var node = JsonSerializer.SerializeToNode( + _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts)!.AsObject(); + node.Remove("endpointUrl"); + return node.ToJsonString(_jsonOpts); + } private async Task EmitAsync() { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor index d450f73b..da936e66 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor @@ -3,6 +3,7 @@ S7DriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@ @using System.Text.Json +@using System.Text.Json.Nodes @using System.Text.Json.Serialization @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.Driver.S7 @@ -98,10 +99,19 @@ } } - /// Serializes the current protocol config to camelCase JSON (endpoint excluded from the UI, - /// carried at harmless defaults; the device's DeviceConfig merges its endpoint up at deploy/probe time). + // v3: the endpoint lives on the Device (DeviceConfig). Strip the endpoint keys so DeviceConfig is the + // sole endpoint source β€” a stale driver-level default would otherwise shadow the device on the + // single-top-level-Host read path as soon as a 2nd device exists (reviewer H1). + private static readonly string[] EndpointKeys = ["host", "port", "rack", "slot"]; + + /// Serializes the current protocol config to camelCase JSON, with the endpoint keys removed + /// (the device's DeviceConfig is the sole endpoint source, merged up at deploy/probe time). public string GetConfigJson() - => JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + { + var node = JsonSerializer.SerializeToNode(_form.ToOptions(), _jsonOpts)!.AsObject(); + foreach (var key in EndpointKeys) node.Remove(key); + return node.ToJsonString(_jsonOpts); + } private async Task EmitAsync() { From 1f434499429ffde7e25ede92bbdfc0d1ed6ffdee Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 04:14:27 -0400 Subject: [PATCH 16/20] =?UTF-8?q?feat(adminui):=20B2-WP6=20browse=20re-tar?= =?UTF-8?q?get=20=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"); + } +} From 53d222e0f76469c0699573fad65f02e68a41f0b1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 04:29:37 -0400 Subject: [PATCH 17/20] =?UTF-8?q?feat(driver-calc):=20B2-WP7=20Calculation?= =?UTF-8?q?=20driver=20=E2=80=94=20IDependencyConsumer=20+=20triggers=20+?= =?UTF-8?q?=20deploy=20cycle=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live values) plus the host seam that feeds it: - IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation. - CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate + dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission, Good recovery; reuses the T0-4 CalculationEvaluator. - DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver, registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged → OnDependencyValue; re-registers on every apply, torn down with the driver. - Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test csproj references the driver so the bin-scan discovers the factory. - DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's delivered TagConfig so the host-blind driver can compile it. - DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges). - Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer, read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Validation/DraftSnapshot.cs | 4 + .../Validation/DraftSnapshotFactory.cs | 1 + .../Validation/DraftValidator.cs | 99 +++++ .../ZB.MOM.WW.OtOpcUa.Configuration.csproj | 10 +- .../DriverTypeNames.cs | 4 + .../IDependencyConsumer.cs | 44 ++ .../CalculationDriver.cs | 384 ++++++++++++++++++ .../CalculationDriverFactoryExtensions.cs | 96 +++++ .../CalculationDriverOptions.cs | 20 + .../CalculationDriverProbe.cs | 45 ++ .../CalculationTagDefinition.cs | 85 ++++ .../CalculationTimerScheduler.cs | 110 +++++ ...B.MOM.WW.OtOpcUa.Driver.Calculation.csproj | 1 + .../Drivers/DriverFactoryBootstrap.cs | 13 +- .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + .../Drivers/DeploymentArtifact.cs | 50 +++ .../Drivers/DriverHostActor.cs | 39 ++ .../DependencyConsumerMuxAdapter.cs | 71 ++++ .../DraftValidatorCalculationTests.cs | 156 +++++++ ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 1 + .../CalculationDriverTests.cs | 187 +++++++++ .../CalculationTagDefinitionTests.cs | 55 +++ .../Drivers/CalculationDependencyFlowTests.cs | 82 ++++ .../ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj | 3 + 24 files changed, 1558 insertions(+), 3 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDependencyConsumer.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverOptions.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTimerScheduler.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCalculationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationTagDefinitionTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs index 9fa9dad7..4bed38ba 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs @@ -55,6 +55,10 @@ public sealed class DraftSnapshot public IReadOnlyList VirtualTags { get; init; } = []; /// Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set. public IReadOnlyList ScriptedAlarms { get; init; } = []; + + /// User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives + /// the WP7 Calculation gates: scriptId existence + the calc→calc dependency-cycle check. + public IReadOnlyList