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();
+ }
+}