feat(uns): equipment-bound tag CRUD with namespace + cluster guards
This commit is contained in:
@@ -136,4 +136,53 @@ public interface IUnsTreeService
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's
|
||||
/// cluster (<c>Equipment.UnsLine → UnsArea.ClusterId</c>) whose namespace is Equipment-kind
|
||||
/// (decision #110 — tree tags are equipment-bound). Ordered by <c>DriverInstanceId</c>. Returns
|
||||
/// an empty list when the equipment cannot be resolved to a cluster.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
|
||||
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
|
||||
/// the tree only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
|
||||
/// <c>TagConfig</c> 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 <c>PollGroupId</c> collapses to <c>null</c>.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The owning equipment.</param>
|
||||
/// <param name="input">The operator-editable tag fields.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or one of the guard failures.</returns>
|
||||
Task<UnsMutationResult> CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an equipment-bound tag's driver binding, name, data type, access level, write-retry
|
||||
/// flag, poll group, and config. The owning <c>EquipmentId</c> and the <c>null</c>
|
||||
/// <c>FolderPath</c> 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
|
||||
/// <see cref="Configuration.Entities.Tag.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag to update.</param>
|
||||
/// <param name="input">The new operator-editable tag fields.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag to delete.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>FolderPath</c> stays
|
||||
/// <c>null</c>), 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 <c>null</c>
|
||||
/// by the service.
|
||||
/// </summary>
|
||||
/// <param name="TagId">Stable unique tag id; only honoured on create (immutable thereafter).</param>
|
||||
/// <param name="Name">Tag display name; unique within the owning equipment.</param>
|
||||
/// <param name="DriverInstanceId">The bound driver; must resolve to an Equipment-kind namespace in the equipment's cluster.</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 are safe to retry (decisions #44–45).</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 TagInput(
|
||||
string TagId,
|
||||
string Name,
|
||||
string DriverInstanceId,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
bool WriteIdempotent,
|
||||
string? PollGroupId,
|
||||
string TagConfig);
|
||||
@@ -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<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> 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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.</summary>
|
||||
private static bool IsValidJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var _ = System.Text.Json.JsonDocument.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
|
||||
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
|
||||
/// resolved.
|
||||
/// </summary>
|
||||
private static async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>null</c> when the binding is allowed, or a populated failure otherwise.
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Policy:
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the equipment-bound Tag CRUD mutations on <see cref="UnsTreeService"/>, 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
|
||||
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an area→line→equipment path in <paramref name="equipmentCluster"/>. The equipment id is
|
||||
/// always <c>EQ-1</c>. Optionally seeds an Equipment-kind driver (<c>DRV-EQ</c>) in the equipment's
|
||||
/// cluster, a SystemPlatform-kind driver (<c>DRV-SP</c>) in the equipment's cluster, and an
|
||||
/// Equipment-kind driver (<c>DRV-OTHER</c>) in <paramref name="otherCluster"/>.
|
||||
/// </summary>
|
||||
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 -----
|
||||
|
||||
/// <summary>A valid equipment-bound tag persists with EquipmentId set and FolderPath null.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>A tag with invalid TagConfig JSON is blocked.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Binding a tag to a driver in a different cluster than the equipment is blocked (#122).</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Binding a tree tag to a driver in a SystemPlatform-kind namespace is blocked.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Creating a tag with a TagId that already exists is blocked.</summary>
|
||||
[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.");
|
||||
}
|
||||
|
||||
/// <summary>Creating a tag whose Name already exists on the same equipment is blocked.</summary>
|
||||
[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 -----
|
||||
|
||||
/// <summary>Updating a tag changes its mutable fields and keeps EquipmentId / FolderPath.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Updating a tag that no longer exists returns the row-gone error.</summary>
|
||||
[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 -----
|
||||
|
||||
/// <summary>Deleting a tag removes the row.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Deleting a tag that is already gone is a no-op success.</summary>
|
||||
[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 -----
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>An unresolvable equipment yields an empty driver list.</summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user