feat(uns): equipment-bound virtual-tag CRUD
This commit is contained in:
@@ -185,4 +185,48 @@ 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> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the scripts eligible to back a virtual tag, ordered by name. Each is projected to a
|
||||
/// <c>(ScriptId, Display)</c> pair where <c>Display</c> is <c>"{Name} ({Language})"</c>.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The scripts projected to <c>(ScriptId, Display)</c> pairs.</returns>
|
||||
Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment-bound virtual tag (plan decision #2 — virtual tags are always scoped
|
||||
/// to an equipment). Fails if the equipment does not exist, if no script is chosen, if neither a
|
||||
/// change trigger nor a timer is set, if the timer is below the 50 ms minimum, on a duplicate
|
||||
/// <c>VirtualTagId</c>, or on a name already used on the equipment.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The owning equipment.</param>
|
||||
/// <param name="input">The operator-editable virtual-tag fields.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or one of the guard failures.</returns>
|
||||
Task<UnsMutationResult> CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an equipment-bound virtual tag's name, data type, script binding, triggers, historize,
|
||||
/// and enabled flags. The owning <c>EquipmentId</c> is preserved. Re-runs the script-chosen,
|
||||
/// change-or-timer, and 50 ms timer-minimum guards, and enforces name uniqueness on the tag's
|
||||
/// existing equipment excluding this virtual tag. Uses last-write-wins optimistic concurrency on
|
||||
/// <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="virtualTagId">The virtual tag to update.</param>
|
||||
/// <param name="input">The new operator-editable virtual-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> UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a virtual tag. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="virtualTagId">The virtual 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> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -643,6 +643,174 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var scripts = await db.Scripts
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.Select(s => new { s.ScriptId, s.Name, s.Language })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return scripts
|
||||
.Select(s => (s.ScriptId, Display: $"{s.Name} ({s.Language})"))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateVirtualTagAsync(
|
||||
string equipmentId,
|
||||
VirtualTagInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
|
||||
}
|
||||
|
||||
var guard = CheckVirtualTagRules(input);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
if (await db.VirtualTags.AnyAsync(v => v.VirtualTagId == input.VirtualTagId, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"VirtualTag '{input.VirtualTagId}' already exists.");
|
||||
}
|
||||
|
||||
if (await db.VirtualTags.AnyAsync(v => v.EquipmentId == equipmentId && v.Name == input.Name, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
{
|
||||
VirtualTagId = input.VirtualTagId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = input.Name,
|
||||
DataType = input.DataType,
|
||||
ScriptId = input.ScriptId,
|
||||
ChangeTriggered = input.ChangeTriggered,
|
||||
TimerIntervalMs = input.TimerIntervalMs,
|
||||
Historize = input.Historize,
|
||||
Enabled = input.Enabled,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> UpdateVirtualTagAsync(
|
||||
string virtualTagId,
|
||||
VirtualTagInput input,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == virtualTagId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(false, "Row no longer exists.");
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
var guard = CheckVirtualTagRules(input);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
if (await db.VirtualTags.AnyAsync(
|
||||
v => v.EquipmentId == entity.EquipmentId && v.Name == input.Name && v.VirtualTagId != virtualTagId,
|
||||
ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// EquipmentId is preserved — virtual tags are always equipment-bound (plan decision #2).
|
||||
entity.Name = input.Name;
|
||||
entity.DataType = input.DataType;
|
||||
entity.ScriptId = input.ScriptId;
|
||||
entity.ChangeTriggered = input.ChangeTriggered;
|
||||
entity.TimerIntervalMs = input.TimerIntervalMs;
|
||||
entity.Historize = input.Historize;
|
||||
entity.Enabled = input.Enabled;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this virtual tag while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> DeleteVirtualTagAsync(
|
||||
string virtualTagId,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == virtualTagId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.VirtualTags.Remove(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this virtual tag while you were viewing it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a virtual tag's script binding and trigger configuration (mirrors the DbContext CHECK
|
||||
/// constraints): a script must be chosen, at least one of change-trigger / timer must be set, and a
|
||||
/// set timer must be at least 50 ms. Returns <c>null</c> when the input is valid.
|
||||
/// </summary>
|
||||
private static UnsMutationResult? CheckVirtualTagRules(VirtualTagInput input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input.ScriptId))
|
||||
{
|
||||
return new UnsMutationResult(false, "Pick a script.");
|
||||
}
|
||||
|
||||
if (!input.ChangeTriggered && input.TimerIntervalMs is null)
|
||||
{
|
||||
return new UnsMutationResult(false, "Pick at least one trigger — change or timer.");
|
||||
}
|
||||
|
||||
if (input.TimerIntervalMs is not null && input.TimerIntervalMs < 50)
|
||||
{
|
||||
return new UnsMutationResult(false, "TimerIntervalMs must be at least 50 ms.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.</summary>
|
||||
private static bool IsValidJson(string json)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter object carrying the operator-editable fields for an equipment-bound VirtualTag create
|
||||
/// or update via the UNS tree. Virtual tags are always scoped to an equipment (plan decision #2), so
|
||||
/// the owning <c>EquipmentId</c> is supplied separately and never changes on update. A virtual tag
|
||||
/// must have at least one evaluation trigger: <see cref="ChangeTriggered"/> or a non-null
|
||||
/// <see cref="TimerIntervalMs"/> (which, when set, must be at least 50 ms).
|
||||
/// </summary>
|
||||
/// <param name="VirtualTagId">Stable unique virtual-tag id; only honoured on create (immutable thereafter).</param>
|
||||
/// <param name="Name">Virtual-tag display name; unique within the owning equipment.</param>
|
||||
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
|
||||
/// <param name="ScriptId">The script that computes this tag's value; must be chosen.</param>
|
||||
/// <param name="ChangeTriggered">Re-evaluate when any referenced input tag changes.</param>
|
||||
/// <param name="TimerIntervalMs">Optional periodic re-evaluation cadence in ms; <c>null</c> = no timer, otherwise >= 50.</param>
|
||||
/// <param name="Historize">Whether this tag's values should be historized.</param>
|
||||
/// <param name="Enabled">Whether this virtual tag is spawned in deployments.</param>
|
||||
public sealed record VirtualTagInput(
|
||||
string VirtualTagId,
|
||||
string Name,
|
||||
string DataType,
|
||||
string ScriptId,
|
||||
bool ChangeTriggered,
|
||||
int? TimerIntervalMs,
|
||||
bool Historize,
|
||||
bool Enabled);
|
||||
@@ -0,0 +1,266 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the equipment-bound VirtualTag CRUD mutations on <see cref="UnsTreeService"/>, including
|
||||
/// the equipment-existence guard, the script-chosen guard, the change-or-timer trigger rule, the
|
||||
/// 50 ms timer minimum, duplicate-id / duplicate-name guards, and the script-candidate loader.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The EF InMemory provider enforces neither <c>RowVersion</c> concurrency nor the DbContext CHECK
|
||||
/// constraints, so the <c>DbUpdateConcurrencyException</c> branches are not exercised here by design,
|
||||
/// and the service's own trigger/timer guards are what protect the data in these tests.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceVirtualTagTests
|
||||
{
|
||||
private static (UnsTreeService Service, string DbName) Fresh()
|
||||
{
|
||||
var dbName = $"uns-vtag-{Guid.NewGuid():N}";
|
||||
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an area→line→equipment path (equipment id <c>EQ-1</c>) plus a single script
|
||||
/// (<c>SCRIPT-1</c>, language CSharp) so the create/update paths have a valid script to bind.
|
||||
/// </summary>
|
||||
private static void SeedEquipmentAndScript(string dbName)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", 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",
|
||||
});
|
||||
db.Scripts.Add(new Script
|
||||
{
|
||||
ScriptId = "SCRIPT-1",
|
||||
Name = "compute speed",
|
||||
SourceCode = "return 1;",
|
||||
SourceHash = "hash-1",
|
||||
Language = "CSharp",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static VirtualTagInput Input(
|
||||
string virtualTagId,
|
||||
string name,
|
||||
string scriptId = "SCRIPT-1",
|
||||
bool changeTriggered = true,
|
||||
int? timerIntervalMs = null) =>
|
||||
new(virtualTagId, name, DataType: "Double", scriptId,
|
||||
changeTriggered, timerIntervalMs, Historize: false, Enabled: true);
|
||||
|
||||
// ----- CreateVirtualTag -----
|
||||
|
||||
/// <summary>A valid change-triggered virtual tag persists with EquipmentId set.</summary>
|
||||
[Fact]
|
||||
public async Task CreateVirtualTag_persists()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var vtag = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1");
|
||||
vtag.EquipmentId.ShouldBe("EQ-1");
|
||||
vtag.Name.ShouldBe("computed");
|
||||
vtag.DataType.ShouldBe("Double");
|
||||
vtag.ScriptId.ShouldBe("SCRIPT-1");
|
||||
vtag.ChangeTriggered.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Creating a virtual tag for an equipment id that does not exist is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateVirtualTag_equipment_not_found_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-NOPE", Input("VTAG-1", "computed"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Equipment 'EQ-NOPE' not found.");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A virtual tag with neither change-trigger nor a timer is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateVirtualTag_no_trigger_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
|
||||
var result = await service.CreateVirtualTagAsync(
|
||||
"EQ-1", Input("VTAG-1", "computed", changeTriggered: false, timerIntervalMs: null));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Pick at least one trigger — change or timer.");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A virtual tag with a timer below the 50 ms minimum is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateVirtualTag_timer_below_50_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
|
||||
var result = await service.CreateVirtualTagAsync(
|
||||
"EQ-1", Input("VTAG-1", "computed", changeTriggered: false, timerIntervalMs: 10));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("TimerIntervalMs must be at least 50 ms.");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Creating a virtual tag with a VirtualTagId that already exists is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateVirtualTag_duplicate_id_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "another"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("VirtualTag 'VTAG-1' already exists.");
|
||||
}
|
||||
|
||||
/// <summary>Creating a virtual tag whose Name already exists on the same equipment is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateVirtualTag_duplicate_name_on_equipment_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-2", "computed"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("A virtual tag named 'computed' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// ----- UpdateVirtualTag -----
|
||||
|
||||
/// <summary>Updating a virtual tag changes its mutable fields and keeps EquipmentId.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateVirtualTag_changes_fields()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
|
||||
}
|
||||
|
||||
var updated = new VirtualTagInput("VTAG-1", "renamed", DataType: "Int32", ScriptId: "SCRIPT-1",
|
||||
ChangeTriggered: false, TimerIntervalMs: 250, Historize: true, Enabled: false);
|
||||
|
||||
var result = await service.UpdateVirtualTagAsync("VTAG-1", updated, rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var after = verify.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1");
|
||||
after.Name.ShouldBe("renamed");
|
||||
after.DataType.ShouldBe("Int32");
|
||||
after.ChangeTriggered.ShouldBeFalse();
|
||||
after.TimerIntervalMs.ShouldBe(250);
|
||||
after.Historize.ShouldBeTrue();
|
||||
after.Enabled.ShouldBeFalse();
|
||||
after.EquipmentId.ShouldBe("EQ-1");
|
||||
}
|
||||
|
||||
/// <summary>Updating a virtual tag that no longer exists returns the row-gone error.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateVirtualTag_missing_row_returns_error()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
|
||||
var result = await service.UpdateVirtualTagAsync("VTAG-nope", Input("VTAG-nope", "x"), []);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Row no longer exists.");
|
||||
}
|
||||
|
||||
// ----- DeleteVirtualTag -----
|
||||
|
||||
/// <summary>Deleting a virtual tag removes the row.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteVirtualTag_removes_row()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
|
||||
}
|
||||
|
||||
var result = await service.DeleteVirtualTagAsync("VTAG-1", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Deleting a virtual tag that is already gone is a no-op success.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteVirtualTag_already_gone_returns_ok()
|
||||
{
|
||||
var (service, _) = Fresh();
|
||||
|
||||
var result = await service.DeleteVirtualTagAsync("VTAG-ghost", []);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- LoadScriptsAsync -----
|
||||
|
||||
/// <summary>The script loader returns the seeded scripts projected to (id, "Name (Language)").</summary>
|
||||
[Fact]
|
||||
public async Task LoadScripts_returns_scripts()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedEquipmentAndScript(dbName);
|
||||
|
||||
var scripts = await service.LoadScriptsAsync();
|
||||
|
||||
scripts.Count.ShouldBe(1);
|
||||
scripts[0].ScriptId.ShouldBe("SCRIPT-1");
|
||||
scripts[0].Display.ShouldBe("compute speed (CSharp)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user