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 38de64b7..2916da49 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -185,4 +185,48 @@ public interface IUnsTreeService /// A token to cancel the operation. /// Success, a concurrency failure, or a delete-failed failure. Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Loads the scripts eligible to back a virtual tag, ordered by name. Each is projected to a + /// (ScriptId, Display) pair where Display is "{Name} ({Language})". + /// + /// A token to cancel the load. + /// The scripts projected to (ScriptId, Display) pairs. + Task> LoadScriptsAsync(CancellationToken ct = default); + + /// + /// 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 + /// VirtualTagId, or on a name already used on the equipment. + /// + /// The owning equipment. + /// The operator-editable virtual-tag fields. + /// A token to cancel the operation. + /// Success, or one of the guard failures. + Task CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default); + + /// + /// Updates an equipment-bound virtual tag's name, data type, script binding, triggers, historize, + /// and enabled flags. The owning EquipmentId 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 + /// . + /// + /// The virtual tag to update. + /// The new operator-editable virtual-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 UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes a virtual tag. A missing row is treated as success (already gone). Uses last-write-wins + /// optimistic concurrency on . + /// + /// The virtual 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 DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default); } 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 7b3bc51f..1bef2435 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -643,6 +643,174 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } + /// + public async Task> 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(); + } + + /// + public async Task 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); + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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}."); + } + } + + /// + /// 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 null when the input is valid. + /// + 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; + } + /// Returns true if parses as a well-formed JSON document. private static bool IsValidJson(string json) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/VirtualTagInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/VirtualTagInput.cs new file mode 100644 index 00000000..fed11be7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/VirtualTagInput.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// 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 EquipmentId is supplied separately and never changes on update. A virtual tag +/// must have at least one evaluation trigger: or a non-null +/// (which, when set, must be at least 50 ms). +/// +/// Stable unique virtual-tag id; only honoured on create (immutable thereafter). +/// Virtual-tag display name; unique within the owning equipment. +/// OPC UA built-in type name (Boolean / Int32 / Float / etc.). +/// The script that computes this tag's value; must be chosen. +/// Re-evaluate when any referenced input tag changes. +/// Optional periodic re-evaluation cadence in ms; null = no timer, otherwise >= 50. +/// Whether this tag's values should be historized. +/// Whether this virtual tag is spawned in deployments. +public sealed record VirtualTagInput( + string VirtualTagId, + string Name, + string DataType, + string ScriptId, + bool ChangeTriggered, + int? TimerIntervalMs, + bool Historize, + bool Enabled); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs new file mode 100644 index 00000000..d7fafe1c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs @@ -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; + +/// +/// Verifies the equipment-bound VirtualTag CRUD mutations on , 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. +/// +/// +/// The EF InMemory provider enforces neither RowVersion concurrency nor the DbContext CHECK +/// constraints, so the DbUpdateConcurrencyException branches are not exercised here by design, +/// and the service's own trigger/timer guards are what protect the data in these tests. +/// +[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); + } + + /// + /// Seeds an area→line→equipment path (equipment id EQ-1) plus a single script + /// (SCRIPT-1, language CSharp) so the create/update paths have a valid script to bind. + /// + 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 ----- + + /// A valid change-triggered virtual tag persists with EquipmentId set. + [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(); + } + + /// Creating a virtual tag for an equipment id that does not exist is blocked. + [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(); + } + + /// A virtual tag with neither change-trigger nor a timer is blocked. + [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(); + } + + /// A virtual tag with a timer below the 50 ms minimum is blocked. + [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(); + } + + /// Creating a virtual tag with a VirtualTagId that already exists is blocked. + [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."); + } + + /// Creating a virtual tag whose Name already exists on the same equipment is blocked. + [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 ----- + + /// Updating a virtual tag changes its mutable fields and keeps EquipmentId. + [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"); + } + + /// Updating a virtual tag that no longer exists returns the row-gone error. + [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 ----- + + /// Deleting a virtual tag removes the row. + [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(); + } + + /// Deleting a virtual tag that is already gone is a no-op success. + [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 ----- + + /// The script loader returns the seeded scripts projected to (id, "Name (Language)"). + [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)"); + } +}