diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
index 3d310b7d..38de64b7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
@@ -136,4 +136,53 @@ public interface IUnsTreeService
/// A token to cancel the operation.
/// Success, a concurrency failure, or a delete-failed failure.
Task DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's
+ /// cluster (Equipment.UnsLine → UnsArea.ClusterId) whose namespace is Equipment-kind
+ /// (decision #110 — tree tags are equipment-bound). Ordered by DriverInstanceId. Returns
+ /// an empty list when the equipment cannot be resolved to a cluster.
+ ///
+ /// The equipment whose candidate drivers to load.
+ /// A token to cancel the load.
+ /// The eligible drivers projected to (DriverInstanceId, Display) pairs.
+ Task> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
+
+ ///
+ /// Creates a new equipment-bound tag. FolderPath is always null (decision #110 —
+ /// the tree only edits equipment-bound tags). Fails on a duplicate TagId, invalid
+ /// TagConfig JSON, an unknown driver, a driver whose namespace is not Equipment-kind, a
+ /// driver in a different cluster than the equipment (decision #122), or a name already used on
+ /// the equipment. Whitespace-only PollGroupId collapses to null.
+ ///
+ /// The owning equipment.
+ /// The operator-editable tag fields.
+ /// A token to cancel the operation.
+ /// Success, or one of the guard failures.
+ Task CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
+
+ ///
+ /// Updates an equipment-bound tag's driver binding, name, data type, access level, write-retry
+ /// flag, poll group, and config. The owning EquipmentId and the null
+ /// FolderPath are preserved. Re-runs the JSON-validity, namespace-kind, and decision-#122
+ /// cluster guards against the tag's existing equipment, and enforces name uniqueness on that
+ /// equipment excluding this tag. Uses last-write-wins optimistic concurrency on
+ /// .
+ ///
+ /// The tag to update.
+ /// The new operator-editable tag fields.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a missing-row failure, a guard failure, or a concurrency failure.
+ Task UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
+ /// optimistic concurrency on .
+ ///
+ /// The tag to delete.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a concurrency failure, or a delete-failed failure.
+ Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagInput.cs
new file mode 100644
index 00000000..0c969109
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagInput.cs
@@ -0,0 +1,28 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+///
+/// Parameter object carrying the operator-editable fields for an equipment-bound Tag create or
+/// update via the UNS tree. Tree tags always bind to an equipment (FolderPath stays
+/// null), so the SystemPlatform/FolderPath branch from the legacy cluster-scoped page does
+/// not apply here. Optional string fields that arrive whitespace-only are collapsed to null
+/// by the service.
+///
+/// Stable unique tag id; only honoured on create (immutable thereafter).
+/// Tag display name; unique within the owning equipment.
+/// The bound driver; must resolve to an Equipment-kind namespace in the equipment's cluster.
+/// OPC UA built-in type name (Boolean / Int32 / Float / etc.).
+/// Tag-level OPC UA access baseline.
+/// Whether writes are safe to retry (decisions #44–45).
+/// Optional poll-group batching key; whitespace/empty collapses to null.
+/// Schemaless per-driver-type JSON config; validated for JSON well-formedness.
+public sealed record TagInput(
+ string TagId,
+ string Name,
+ string DriverInstanceId,
+ string DataType,
+ TagAccessLevel AccessLevel,
+ bool WriteIdempotent,
+ string? PollGroupId,
+ string TagConfig);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
index 85c1b1a6..a6a5e183 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -474,6 +475,249 @@ public sealed class UnsTreeService(IDbContextFactory dbF
}
}
+ ///
+ public async Task> LoadTagDriversForEquipmentAsync(
+ string equipmentId,
+ CancellationToken ct = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
+ if (equipmentCluster is null)
+ {
+ return Array.Empty<(string, string)>();
+ }
+
+ // Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
+ var equipmentNamespaceIds = await db.Namespaces
+ .Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
+ .Select(n => n.NamespaceId)
+ .ToListAsync(ct);
+
+ var drivers = await db.DriverInstances
+ .Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
+ .OrderBy(d => d.DriverInstanceId)
+ .Select(d => new { d.DriverInstanceId, d.Name })
+ .ToListAsync(ct);
+
+ return drivers
+ .Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}"))
+ .ToList();
+ }
+
+ ///
+ public async Task CreateTagAsync(
+ string equipmentId,
+ TagInput input,
+ CancellationToken ct = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
+ {
+ return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
+ }
+
+ if (!IsValidJson(input.TagConfig))
+ {
+ return new UnsMutationResult(false, "TagConfig is not valid JSON.");
+ }
+
+ var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
+ var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
+ if (guard is not null)
+ {
+ return guard.Value;
+ }
+
+ if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
+ {
+ return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
+ }
+
+ db.Tags.Add(new Tag
+ {
+ TagId = input.TagId,
+ DriverInstanceId = input.DriverInstanceId,
+ EquipmentId = equipmentId,
+ Name = input.Name,
+ FolderPath = null,
+ DataType = input.DataType,
+ AccessLevel = input.AccessLevel,
+ WriteIdempotent = input.WriteIdempotent,
+ PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId,
+ TagConfig = input.TagConfig,
+ });
+ await db.SaveChangesAsync(ct);
+ return new UnsMutationResult(true, null);
+ }
+
+ ///
+ public async Task UpdateTagAsync(
+ string tagId,
+ TagInput input,
+ byte[] rowVersion,
+ CancellationToken ct = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
+ if (entity is null)
+ {
+ return new UnsMutationResult(false, "Row no longer exists.");
+ }
+
+ db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
+
+ if (!IsValidJson(input.TagConfig))
+ {
+ return new UnsMutationResult(false, "TagConfig is not valid JSON.");
+ }
+
+ var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
+ var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
+ if (guard is not null)
+ {
+ return guard.Value;
+ }
+
+ if (await db.Tags.AnyAsync(
+ t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
+ ct))
+ {
+ return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
+ }
+
+ // EquipmentId and FolderPath (null) are preserved — tree tags are always equipment-bound.
+ entity.DriverInstanceId = input.DriverInstanceId;
+ entity.Name = input.Name;
+ entity.DataType = input.DataType;
+ entity.AccessLevel = input.AccessLevel;
+ entity.WriteIdempotent = input.WriteIdempotent;
+ entity.PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId;
+ entity.TagConfig = input.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.");
+ }
+ }
+
+ ///
+ public async Task DeleteTagAsync(
+ string tagId,
+ byte[] rowVersion,
+ CancellationToken ct = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
+ if (entity is null)
+ {
+ return new UnsMutationResult(true, null);
+ }
+
+ db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
+ db.Tags.Remove(entity);
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ return new UnsMutationResult(true, null);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ return new UnsMutationResult(false, "Another user changed this tag while you were viewing it.");
+ }
+ catch (Exception ex)
+ {
+ return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
+ }
+ }
+
+ /// Returns true if parses as a well-formed JSON document.
+ private static bool IsValidJson(string json)
+ {
+ try
+ {
+ using var _ = System.Text.Json.JsonDocument.Parse(json);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Resolves an equipment to its cluster via Equipment.UnsLineId → UnsLine.UnsAreaId →
+ /// UnsArea.ClusterId. Returns null when the equipment, its line, or its area cannot be
+ /// resolved.
+ ///
+ private static async Task ResolveEquipmentClusterAsync(
+ OtOpcUaConfigDbContext db,
+ string? equipmentId,
+ CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(equipmentId))
+ {
+ return null;
+ }
+
+ var equipment = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
+ if (equipment is null)
+ {
+ return null;
+ }
+
+ var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == equipment.UnsLineId, ct);
+ if (line is null)
+ {
+ return null;
+ }
+
+ var area = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
+ return area?.ClusterId;
+ }
+
+ ///
+ /// Validates a tree tag's driver binding: the driver must exist, live in an Equipment-kind
+ /// namespace (decision #110), and be in the same cluster as the owning equipment (decision #122).
+ /// Returns null when the binding is allowed, or a populated failure otherwise.
+ ///
+ private static async Task CheckTagDriverGuardAsync(
+ OtOpcUaConfigDbContext db,
+ string driverInstanceId,
+ string? equipmentCluster,
+ CancellationToken ct)
+ {
+ var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
+ if (driver is null)
+ {
+ return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
+ }
+
+ var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.NamespaceId == driver.NamespaceId, ct);
+ if (ns?.Kind != NamespaceKind.Equipment)
+ {
+ return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not in an Equipment-kind namespace.");
+ }
+
+ if (driver.ClusterId != equipmentCluster)
+ {
+ return new UnsMutationResult(
+ false,
+ $"Driver '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}' (decision #122).");
+ }
+
+ return null;
+ }
+
///
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// Policy:
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagTests.cs
new file mode 100644
index 00000000..eb8f9d6d
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagTests.cs
@@ -0,0 +1,358 @@
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Verifies the equipment-bound Tag CRUD mutations on , including
+/// TagConfig JSON validity, the namespace-kind guard (tree tags must bind to an Equipment-kind
+/// namespace), the decision-#122 driver-cluster guard, duplicate-id / duplicate-name guards, and
+/// the driver-candidate loader scoped to the equipment's cluster.
+///
+///
+/// The EF InMemory provider does not enforce RowVersion concurrency, so the
+/// DbUpdateConcurrencyException branches are not exercised here by design.
+///
+[Trait("Category", "Unit")]
+public sealed class UnsTreeServiceTagTests
+{
+ private static (UnsTreeService Service, string DbName) Fresh()
+ {
+ var dbName = $"uns-tag-{Guid.NewGuid():N}";
+ return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
+ }
+
+ ///
+ /// Seeds an area→line→equipment path in . The equipment id is
+ /// always EQ-1. Optionally seeds an Equipment-kind driver (DRV-EQ) in the equipment's
+ /// cluster, a SystemPlatform-kind driver (DRV-SP) in the equipment's cluster, and an
+ /// Equipment-kind driver (DRV-OTHER) in .
+ ///
+ private static void SeedHierarchyAndDrivers(
+ string dbName,
+ string equipmentCluster,
+ bool seedEquipmentDriver = false,
+ bool seedSystemPlatformDriver = false,
+ string? otherCluster = null)
+ {
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = equipmentCluster, Name = "a" });
+ db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
+ db.Equipment.Add(new Equipment
+ {
+ EquipmentId = "EQ-1",
+ EquipmentUuid = Guid.NewGuid(),
+ UnsLineId = "LINE-1",
+ Name = "machine-1",
+ MachineCode = "machine_001",
+ });
+
+ // Equipment-kind namespace in the equipment's cluster.
+ db.Namespaces.Add(new Namespace
+ {
+ NamespaceId = "NS-EQ",
+ ClusterId = equipmentCluster,
+ Kind = NamespaceKind.Equipment,
+ NamespaceUri = "urn:zb:eq",
+ });
+ // SystemPlatform-kind namespace in the equipment's cluster.
+ db.Namespaces.Add(new Namespace
+ {
+ NamespaceId = "NS-SP",
+ ClusterId = equipmentCluster,
+ Kind = NamespaceKind.SystemPlatform,
+ NamespaceUri = "urn:zb:sp",
+ });
+
+ if (seedEquipmentDriver)
+ {
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = "DRV-EQ",
+ ClusterId = equipmentCluster,
+ NamespaceId = "NS-EQ",
+ Name = "equipment driver",
+ DriverType = "ModbusTcp",
+ DriverConfig = "{}",
+ });
+ }
+
+ if (seedSystemPlatformDriver)
+ {
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = "DRV-SP",
+ ClusterId = equipmentCluster,
+ NamespaceId = "NS-SP",
+ Name = "galaxy driver",
+ DriverType = "Galaxy",
+ DriverConfig = "{}",
+ });
+ }
+
+ if (otherCluster is not null)
+ {
+ // Equipment-kind namespace + driver in a different cluster.
+ db.Namespaces.Add(new Namespace
+ {
+ NamespaceId = "NS-OTHER",
+ ClusterId = otherCluster,
+ Kind = NamespaceKind.Equipment,
+ NamespaceUri = "urn:zb:other",
+ });
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = "DRV-OTHER",
+ ClusterId = otherCluster,
+ NamespaceId = "NS-OTHER",
+ Name = "other-cluster driver",
+ DriverType = "ModbusTcp",
+ DriverConfig = "{}",
+ });
+ }
+
+ db.SaveChanges();
+ }
+
+ private static TagInput Input(
+ string tagId,
+ string name,
+ string driverInstanceId,
+ string tagConfig = "{}") =>
+ new(tagId, name, driverInstanceId, DataType: "Float",
+ AccessLevel: TagAccessLevel.Read, WriteIdempotent: false,
+ PollGroupId: null, TagConfig: tagConfig);
+
+ // ----- CreateTag -----
+
+ /// A valid equipment-bound tag persists with EquipmentId set and FolderPath null.
+ [Fact]
+ public async Task CreateTag_equipment_bound_persists()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+
+ var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ var tag = db.Tags.Single(t => t.TagId == "TAG-1");
+ tag.EquipmentId.ShouldBe("EQ-1");
+ tag.FolderPath.ShouldBeNull();
+ tag.DriverInstanceId.ShouldBe("DRV-EQ");
+ tag.Name.ShouldBe("speed");
+ tag.DataType.ShouldBe("Float");
+ }
+
+ /// A tag with invalid TagConfig JSON is blocked.
+ [Fact]
+ public async Task CreateTag_invalid_json_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+
+ var result = await service.CreateTagAsync(
+ "EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: "{ not json"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("TagConfig is not valid JSON.");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
+ }
+
+ /// Binding a tag to a driver in a different cluster than the equipment is blocked (#122).
+ [Fact]
+ public async Task CreateTag_driver_in_other_cluster_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", otherCluster: "SITE-A");
+
+ var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-OTHER"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldNotBeNull();
+ result.Error.ShouldContain("decision #122");
+ result.Error.ShouldContain("DRV-OTHER");
+ result.Error.ShouldContain("SITE-A");
+ result.Error.ShouldContain("MAIN");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
+ }
+
+ /// Binding a tree tag to a driver in a SystemPlatform-kind namespace is blocked.
+ [Fact]
+ public async Task CreateTag_driver_systemplatform_namespace_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedSystemPlatformDriver: true);
+
+ var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-SP"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldNotBeNull();
+ result.Error.ShouldContain("DRV-SP");
+ result.Error.ShouldContain("Equipment-kind namespace");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
+ }
+
+ /// Creating a tag with a TagId that already exists is blocked.
+ [Fact]
+ public async Task CreateTag_duplicate_tagid_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+ await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
+
+ var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "another", "DRV-EQ"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("Tag 'TAG-1' already exists.");
+ }
+
+ /// Creating a tag whose Name already exists on the same equipment is blocked.
+ [Fact]
+ public async Task CreateTag_duplicate_name_on_equipment_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+ await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
+
+ var result = await service.CreateTagAsync("EQ-1", Input("TAG-2", "speed", "DRV-EQ"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("A tag named 'speed' already exists on this equipment.");
+ }
+
+ // ----- UpdateTag -----
+
+ /// Updating a tag changes its mutable fields and keeps EquipmentId / FolderPath.
+ [Fact]
+ public async Task UpdateTag_changes_fields()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+ await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
+
+ byte[] rv;
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ rv = db.Tags.Single(t => t.TagId == "TAG-1").RowVersion;
+ }
+
+ var updated = new TagInput("TAG-1", "renamed", "DRV-EQ", DataType: "Int32",
+ AccessLevel: TagAccessLevel.ReadWrite, WriteIdempotent: true,
+ PollGroupId: " ", TagConfig: """{ "register": 40001 }""");
+
+ var result = await service.UpdateTagAsync("TAG-1", updated, rv);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ var after = verify.Tags.Single(t => t.TagId == "TAG-1");
+ after.Name.ShouldBe("renamed");
+ after.DataType.ShouldBe("Int32");
+ after.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
+ after.WriteIdempotent.ShouldBeTrue();
+ after.PollGroupId.ShouldBeNull(); // whitespace collapses to null
+ after.EquipmentId.ShouldBe("EQ-1");
+ after.FolderPath.ShouldBeNull();
+ }
+
+ /// Updating a tag that no longer exists returns the row-gone error.
+ [Fact]
+ public async Task UpdateTag_missing_row_returns_error()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+
+ var result = await service.UpdateTagAsync("TAG-nope", Input("TAG-nope", "x", "DRV-EQ"), []);
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("Row no longer exists.");
+ }
+
+ // ----- DeleteTag -----
+
+ /// Deleting a tag removes the row.
+ [Fact]
+ public async Task DeleteTag_removes_row()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+ await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
+
+ byte[] rv;
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ rv = db.Tags.Single(t => t.TagId == "TAG-1").RowVersion;
+ }
+
+ var result = await service.DeleteTagAsync("TAG-1", rv);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ verify.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
+ }
+
+ /// Deleting a tag that is already gone is a no-op success.
+ [Fact]
+ public async Task DeleteTag_already_gone_returns_ok()
+ {
+ var (service, _) = Fresh();
+
+ var result = await service.DeleteTagAsync("TAG-ghost", []);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+ }
+
+ // ----- LoadTagDriversForEquipmentAsync -----
+
+ ///
+ /// The driver loader returns only Equipment-kind drivers in the equipment's cluster — excluding
+ /// SystemPlatform-kind drivers in the same cluster and Equipment-kind drivers in other clusters.
+ ///
+ [Fact]
+ public async Task LoadTagDriversForEquipment_returns_only_equipment_kind_drivers_in_cluster()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(
+ dbName,
+ equipmentCluster: "MAIN",
+ seedEquipmentDriver: true,
+ seedSystemPlatformDriver: true,
+ otherCluster: "SITE-A");
+
+ var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-1");
+
+ drivers.Count.ShouldBe(1);
+ drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
+ drivers[0].Display.ShouldContain("DRV-EQ");
+ drivers[0].Display.ShouldContain("equipment driver");
+ }
+
+ /// An unresolvable equipment yields an empty driver list.
+ [Fact]
+ public async Task LoadTagDriversForEquipment_unresolvable_equipment_returns_empty()
+ {
+ var (service, dbName) = Fresh();
+ SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
+
+ var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-nope");
+
+ drivers.ShouldBeEmpty();
+ }
+}