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