feat(uns): equipment-bound virtual-tag CRUD

This commit is contained in:
Joseph Doherty
2026-06-08 13:11:12 -04:00
parent 77024f87da
commit d8fba02a5e
4 changed files with 504 additions and 0 deletions
@@ -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)");
}
}