feat(adminui): B2 Wave-B service prelude — tag CRUD/import + driver/device config + merged probe
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -29,6 +29,48 @@ public readonly record struct RawTagEditDto(
|
||||
string TagConfig,
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a <c>DriverInstance</c> — the read side of the Wave-B (WP3)
|
||||
/// "Configure driver" modal. <c>DriverType</c> is included so the modal can dispatch to the correct
|
||||
/// typed driver-config editor; it is immutable, so <see cref="IRawTreeService.UpdateDriverAsync"/> does
|
||||
/// not change it.
|
||||
/// </summary>
|
||||
/// <param name="DriverInstanceId">The driver instance's stable logical id.</param>
|
||||
/// <param name="Name">The driver instance name (a RawPath segment).</param>
|
||||
/// <param name="DriverType">The immutable driver-type string (see <c>DriverTypeNames</c>).</param>
|
||||
/// <param name="DriverConfig">The schemaless per-driver-type <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <param name="ResilienceConfig">Optional per-instance resilience-pipeline overrides JSON, or null.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token.</param>
|
||||
public sealed record RawDriverEditDto(
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DriverType,
|
||||
string DriverConfig,
|
||||
string? ResilienceConfig,
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a <c>Device</c> — the read side of the Wave-B (WP3) "Configure
|
||||
/// device" modal. <c>DriverType</c> is joined in from the parent <c>DriverInstance</c> so the modal can
|
||||
/// dispatch to the correct typed device-config editor (endpoint/connection settings now live in
|
||||
/// <c>DeviceConfig</c>).
|
||||
/// </summary>
|
||||
/// <param name="DeviceId">The device's stable logical id.</param>
|
||||
/// <param name="DriverInstanceId">The owning driver instance's logical id.</param>
|
||||
/// <param name="Name">The device name (a RawPath segment).</param>
|
||||
/// <param name="DeviceConfig">The schemaless per-driver-type <c>DeviceConfig</c> JSON blob (host/endpoint + per-device settings).</param>
|
||||
/// <param name="Enabled">Whether the device is enabled.</param>
|
||||
/// <param name="DriverType">The parent driver's type string, joined in for editor dispatch.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token.</param>
|
||||
public sealed record RawDeviceEditDto(
|
||||
string DeviceId,
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DeviceConfig,
|
||||
bool Enabled,
|
||||
string DriverType,
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Loads and mutates the v3 Raw project tree (<c>/raw</c>) — the cluster-rooted
|
||||
/// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config
|
||||
@@ -251,4 +293,105 @@ public interface IRawTreeService
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The tag's editable projection, or null when the tag does not exist.</returns>
|
||||
Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw <c>Tag</c> under a device (<paramref name="tagGroupId"/> null) or a tag group. The
|
||||
/// name is validated as a RawPath segment and must be unique among its siblings on the same
|
||||
/// <c>(DeviceId, TagGroupId)</c>; the <see cref="RawTagInput.TagConfig"/> is validated for JSON
|
||||
/// well-formedness. On success the result's <c>CreatedId</c> carries the new tag's logical id.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The owning device's logical id.</param>
|
||||
/// <param name="tagGroupId">The containing tag group's logical id, or null for a device-root tag.</param>
|
||||
/// <param name="input">The tag's editable fields (identity is the RawPath — no TagId here).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateTagAsync(string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a raw <c>Tag</c>'s editable fields. The new name is re-validated as a RawPath segment and
|
||||
/// sibling-unique on the tag's existing <c>(DeviceId, TagGroupId)</c> excluding itself; the
|
||||
/// <see cref="RawTagInput.TagConfig"/> is validated for JSON well-formedness. Last-write-wins on
|
||||
/// <paramref name="rowVersion"/>. The tag's <c>DeviceId</c>/<c>TagGroupId</c> (its identity location)
|
||||
/// are preserved — moving a tag is not this method's job.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="input">The tag's edited fields.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded tag.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Commits a batch of raw tags under a device — the WP5 CSV-import path. Each row's
|
||||
/// <c>TagGroupPath</c> is a <c>/</c>-separated tag-group path under the device whose segments are
|
||||
/// auto-created as nested <c>TagGroup</c>s (reusing existing ones). Per-row validation errors (bad
|
||||
/// name segment, malformed <c>TagConfig</c> JSON, a duplicate name within the device+group) are
|
||||
/// collected, not thrown. The import is <b>all-or-nothing</b>: if any row errors, nothing is inserted
|
||||
/// and the errors are returned; otherwise all tags (and any newly-needed groups) are committed in one
|
||||
/// <c>SaveChanges</c>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The target device's logical id.</param>
|
||||
/// <param name="rows">The rows to import.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The import outcome: the inserted count on success, or the per-row errors with a 0 count.</returns>
|
||||
Task<RawTagImportResult> ImportTagsAsync(string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a driver instance's editable fields as a detached projection for the WP3 "Configure driver"
|
||||
/// modal. <c>DriverType</c> is included for typed-editor dispatch (it is immutable).
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The driver's editable projection, or null when it does not exist.</returns>
|
||||
Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="RenameDriverAsync"/>);
|
||||
/// last-write-wins on <paramref name="rowVersion"/>. <c>DriverType</c> is immutable and is not changed.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="name">The (possibly changed) driver name.</param>
|
||||
/// <param name="driverConfigJson">The new <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <param name="resilienceConfig">The new resilience-config JSON, or null/blank to clear.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a device's editable fields as a detached projection for the WP3 "Configure device" modal.
|
||||
/// <c>DriverType</c> is joined in from the parent driver so the modal can dispatch to the correct typed
|
||||
/// device-config editor.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The device's editable projection, or null when it does not exist.</returns>
|
||||
Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <paramref name="rowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="name">The (possibly changed) device name.</param>
|
||||
/// <param name="deviceConfigJson">The new <c>DeviceConfig</c> JSON blob (endpoint + per-device settings).</param>
|
||||
/// <param name="enabled">The new enabled state.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> UpdateDeviceAsync(string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the single merged config blob the driver-probe (test-connect) path binds from for one device:
|
||||
/// the parent driver's <c>DriverConfig</c> folded together with this device's <c>DeviceConfig</c> (via
|
||||
/// <c>DriverDeviceConfigMerger</c>, endpoint keys merging up from <c>DeviceConfig</c>). Used by the
|
||||
/// "Test connect" button inside the WP3 driver/device modals now that the endpoint lives in
|
||||
/// <c>DeviceConfig</c>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device to probe.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The parent driver's type + the merged config JSON, or null when the device/driver cannot be resolved.</returns>
|
||||
Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter object carrying the operator-editable fields for a raw <c>Tag</c> 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 <c>TagId</c> here: it is generated on create and never carried in the input.
|
||||
/// Whitespace-only <see cref="PollGroupId"/> collapses to <c>null</c>; <see cref="TagConfig"/> is
|
||||
/// validated for JSON well-formedness by the service (blank collapses to <c>{}</c>).
|
||||
/// </summary>
|
||||
/// <param name="Name">The tag name (a RawPath segment).</param>
|
||||
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
|
||||
/// <param name="AccessLevel">Tag-level OPC UA access baseline.</param>
|
||||
/// <param name="WriteIdempotent">Whether writes to this tag are safe to retry.</param>
|
||||
/// <param name="PollGroupId">Optional poll-group batching key; whitespace/empty collapses to <c>null</c>.</param>
|
||||
/// <param name="TagConfig">Schemaless per-driver-type JSON config; validated for JSON well-formedness.</param>
|
||||
public sealed record RawTagInput(
|
||||
string Name,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
bool WriteIdempotent,
|
||||
string? PollGroupId,
|
||||
string TagConfig);
|
||||
|
||||
/// <summary>
|
||||
/// One row of the WP5 CSV import commit. <see cref="TagGroupPath"/> is a <c>/</c>-separated tag-group
|
||||
/// path under the target device (its segments are auto-created as nested <c>TagGroup</c>s, reusing any
|
||||
/// that already exist); <c>null</c>/blank places the tag directly under the device. <see cref="Tag"/>
|
||||
/// carries the tag's editable fields.
|
||||
/// </summary>
|
||||
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for the device root.</param>
|
||||
/// <param name="Tag">The tag's editable fields.</param>
|
||||
public sealed record RawTagImportRow(string? TagGroupPath, RawTagInput Tag);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="IRawTreeService.ImportTagsAsync"/>. The import is all-or-nothing: on any
|
||||
/// per-row validation error nothing is inserted, <see cref="Inserted"/> is <c>0</c>, and
|
||||
/// <see cref="Errors"/> carries the operator-facing per-row messages. On success <see cref="Errors"/>
|
||||
/// is empty and <see cref="Inserted"/> is the number of tags committed.
|
||||
/// </summary>
|
||||
/// <param name="Inserted">The number of tags inserted (0 when any row errored).</param>
|
||||
/// <param name="Errors">The per-row validation errors; empty on success.</param>
|
||||
public sealed record RawTagImportResult(int Inserted, IReadOnlyList<string> Errors);
|
||||
@@ -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<OtOpcUaConfigDbContext> dbF
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Tag create / update (WP4) + CSV import (WP5)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RawTagImportResult> ImportTagsAsync(
|
||||
string deviceId, IReadOnlyList<RawTagImportRow> 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<TagGroup>();
|
||||
var pendingTags = new List<Tag>();
|
||||
var errors = new List<string>();
|
||||
|
||||
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<string>());
|
||||
}
|
||||
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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <c>/</c>-separated tag-group path under a device to a group id, auto-creating any
|
||||
/// missing nested <see cref="TagGroup"/> (added to <paramref name="pendingGroups"/> and
|
||||
/// <paramref name="groupLookup"/> so a repeated path reuses the same group). Returns null on success
|
||||
/// with <paramref name="groupId"/> set (null = the device root for a blank path), or a friendly error.
|
||||
/// </summary>
|
||||
private static string? ResolvePendingGroupPath(
|
||||
string deviceId, string? groupPath,
|
||||
Dictionary<(string? Parent, string Name), string> groupLookup,
|
||||
List<TagGroup> 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")
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RawDriverEditDto?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RawDeviceEditDto?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<RawTagEntry>());
|
||||
|
||||
return (driver.DriverType, merged);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -808,6 +1175,57 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
/// <summary>Generates a system id of the form <c>{prefix}-{12 hex}</c> (≤ the 64-char id limit).</summary>
|
||||
private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}";
|
||||
|
||||
/// <summary>Collapses a whitespace-only optional string to <c>null</c>.</summary>
|
||||
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a <c>TagConfig</c> blob for JSON well-formedness (blank ⇒ <c>{}</c>). Returns a friendly
|
||||
/// error when the blob is not parseable JSON; otherwise null with <paramref name="normalized"/> set.
|
||||
/// </summary>
|
||||
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.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Ordinal (case-sensitive) equality for a nullable-group + name tag identity key.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Commits a rename, translating a concurrency clash into a friendly failure.</summary>
|
||||
private static async Task<RawRenameResult> SaveRenameAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<string> warnings, string noun, CancellationToken ct)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the B2 Wave-B service prelude added to <see cref="RawTreeService"/> — the tag CRUD/import path
|
||||
/// (WP4 manual entry + WP5 CSV import) and the driver/device config + merged-probe projections (WP3
|
||||
/// modals). Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture.
|
||||
/// <para>
|
||||
/// As with the WP1 suite, the EF InMemory provider does not enforce <c>RowVersion</c> tokens, so the
|
||||
/// optimistic-concurrency failure is exercised via the "row vanished under a stale handle" path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<OtOpcUaConfigDbContext, byte[]> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user