From 76cffe1f491269c537c89c1c6492ce450f967355 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:58:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20B2=20Wave-B=20service=20prelud?= =?UTF-8?q?e=20=E2=80=94=20tag=20CRUD/import=20+=20driver/device=20config?= =?UTF-8?q?=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(); + } +}