77bc010ba9
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the equipment page's Tags tab (v3 reference-only equipment): columns are effective name, computed raw path, inherited datatype/access (read-only), display-name override (editable), and remove. The retired equipment-authored-tag editor (TagModal.razor) is deleted; the VirtualTag and ScriptedAlarm flows are untouched. - "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw usage is byte-unchanged (PickerMode defaults false). - UnsTreeService (+ IUnsTreeService) gain the reference mutations: LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver), AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync, SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync / LoadDescendantTagIdsAsync. - Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered): AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update mutations now call CheckAsync before persisting and surface a collision as the failure. WP2 supplies the implementation + DI registration. - Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput, the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the dead decision-#122 driver-cluster guard. Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers); Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI suite green (639 passed, 3 skipped); full solution builds 0 errors. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
315 lines
13 KiB
C#
315 lines
13 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
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.Tests.Uns;
|
|
|
|
/// <summary>
|
|
/// Verifies the v3 Batch-3 UNS reference-only equipment mutations on <see cref="UnsTreeService"/>:
|
|
/// adding references (with computed RawPath + inherited datatype/access), removing them, setting the
|
|
/// display-name override, the cross-cluster rejection, the same-tag / same-name duplicate rejections,
|
|
/// and the <see cref="IEffectiveNameGuard"/>-consumed collision rejection (via a configurable fake).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The EF InMemory provider enforces neither <c>RowVersion</c> concurrency nor the filtered-unique
|
|
/// indexes, so the service's own pre-checks are what protect the data in these tests.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class UnsTreeServiceReferenceTests
|
|
{
|
|
private const string EquipmentId = "EQ-1";
|
|
|
|
/// <summary>A configurable <see cref="IEffectiveNameGuard"/>: returns <see cref="Result"/> from every check.</summary>
|
|
private sealed class FakeGuard : IEffectiveNameGuard
|
|
{
|
|
public string? Result { get; set; }
|
|
|
|
public string? LastProposedName { get; private set; }
|
|
|
|
public Task<string?> CheckAsync(
|
|
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
|
|
string? excludeSourceId, CancellationToken ct = default)
|
|
{
|
|
LastProposedName = proposedEffectiveName;
|
|
return Task.FromResult(Result);
|
|
}
|
|
}
|
|
|
|
private static OtOpcUaConfigDbContext Db(string name) =>
|
|
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
|
|
|
private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => UnsTreeTestDb.Factory(name);
|
|
|
|
/// <summary>
|
|
/// Seeds two clusters (MAIN, SITE-A), each with driver→device→tags, plus an equipment (<c>EQ-1</c>)
|
|
/// under MAIN. MAIN tags: <c>MAIN/dev1/speed</c> (Float, Read) and <c>MAIN/dev1/running</c>
|
|
/// (Boolean, ReadWrite), with <c>speed</c> also nested under a group <c>grp</c> variant. SITE-A tag:
|
|
/// <c>OTHER/dev2/temp</c> (for the cross-cluster rejection).
|
|
/// </summary>
|
|
private static void Seed(string name)
|
|
{
|
|
using var db = Db(name);
|
|
|
|
db.ServerClusters.Add(new ServerCluster { ClusterId = "MAIN", Name = "Main", Enterprise = "zb", Site = "s1", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
|
|
db.ServerClusters.Add(new ServerCluster { ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "s2", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
|
|
|
|
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "assembly" });
|
|
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "line-a" });
|
|
db.Equipment.Add(new Equipment { EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-1", Name = "machine-1", MachineCode = "mc_1" });
|
|
|
|
// MAIN raw topology.
|
|
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-1", ClusterId = "MAIN", Name = "MAIN", DriverType = "Modbus", DriverConfig = "{}" });
|
|
db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
|
|
db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", Name = "grp" });
|
|
db.Tags.Add(new Tag { TagId = "TAG-speed", DeviceId = "DEV-1", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
|
|
db.Tags.Add(new Tag { TagId = "TAG-running", DeviceId = "DEV-1", Name = "running", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" });
|
|
db.Tags.Add(new Tag { TagId = "TAG-grouped", DeviceId = "DEV-1", TagGroupId = "TG-1", Name = "nested", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
|
|
|
|
// SITE-A raw topology (for the cross-cluster rejection).
|
|
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-2", ClusterId = "SITE-A", Name = "OTHER", DriverType = "Modbus", DriverConfig = "{}" });
|
|
db.Devices.Add(new Device { DeviceId = "DEV-2", DriverInstanceId = "DRV-2", Name = "dev2", DeviceConfig = "{}" });
|
|
db.Tags.Add(new Tag { TagId = "TAG-other", DeviceId = "DEV-2", Name = "temp", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
|
|
|
|
db.SaveChanges();
|
|
}
|
|
|
|
// ----- AddReferences -----
|
|
|
|
/// <summary>Adding two raw tags persists two UnsTagReference rows for the equipment.</summary>
|
|
[Fact]
|
|
public async Task AddReferences_persists_rows()
|
|
{
|
|
var name = $"ref-add-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
|
|
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-running"]);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Error.ShouldBeNull();
|
|
|
|
using var db = Db(name);
|
|
var refs = db.UnsTagReferences.Where(r => r.EquipmentId == EquipmentId).ToList();
|
|
refs.Count.ShouldBe(2);
|
|
refs.Select(r => r.TagId).ShouldBe(new[] { "TAG-speed", "TAG-running" }, ignoreOrder: true);
|
|
refs.All(r => r.DisplayNameOverride == null).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Loaded reference rows carry the computed RawPath and inherited (raw) datatype/access.</summary>
|
|
[Fact]
|
|
public async Task LoadReferences_projects_rawpath_and_inherited_type()
|
|
{
|
|
var name = $"ref-load-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-grouped"]);
|
|
|
|
var rows = await service.LoadReferencesForEquipmentAsync(EquipmentId);
|
|
|
|
rows.Count.ShouldBe(2);
|
|
|
|
var speed = rows.Single(r => r.RawPath == "MAIN/dev1/speed");
|
|
speed.EffectiveName.ShouldBe("speed");
|
|
speed.DataType.ShouldBe("Float");
|
|
speed.AccessLevel.ShouldBe(TagAccessLevel.Read);
|
|
|
|
var nested = rows.Single(r => r.EffectiveName == "nested");
|
|
nested.RawPath.ShouldBe("MAIN/dev1/grp/nested");
|
|
nested.DataType.ShouldBe("Int32");
|
|
}
|
|
|
|
/// <summary>A raw tag whose cluster differs from the equipment's is rejected (cross-cluster).</summary>
|
|
[Fact]
|
|
public async Task AddReferences_cross_cluster_rejected()
|
|
{
|
|
var name = $"ref-xcluster-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
|
|
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-other"]);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
result.Error.ShouldContain("cross-cluster");
|
|
|
|
using var db = Db(name);
|
|
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Re-referencing an already-referenced raw tag is rejected.</summary>
|
|
[Fact]
|
|
public async Task AddReferences_duplicate_tag_rejected()
|
|
{
|
|
var name = $"ref-dup-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
|
|
|
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
result.Error.ShouldContain("already referenced");
|
|
|
|
using var db = Db(name);
|
|
db.UnsTagReferences.Count(r => r.EquipmentId == EquipmentId).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>A guard-reported collision (fake) becomes the mutation's readable failure; nothing is added.</summary>
|
|
[Fact]
|
|
public async Task AddReferences_guard_collision_rejected()
|
|
{
|
|
var name = $"ref-guard-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var guard = new FakeGuard { Result = "'speed' already exists on equipment 'machine-1'." };
|
|
var service = new UnsTreeService(Factory(name), guard);
|
|
|
|
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldBe("'speed' already exists on equipment 'machine-1'.");
|
|
guard.LastProposedName.ShouldBe("speed");
|
|
|
|
using var db = Db(name);
|
|
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
|
}
|
|
|
|
// ----- SetReferenceOverride -----
|
|
|
|
/// <summary>Setting an override changes the effective name; clearing it falls back to the raw name.</summary>
|
|
[Fact]
|
|
public async Task SetReferenceOverride_sets_and_clears()
|
|
{
|
|
var name = $"ref-override-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
|
|
|
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
|
|
|
var set = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "line-speed", row.RowVersion);
|
|
set.Ok.ShouldBeTrue();
|
|
|
|
var afterSet = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
|
afterSet.EffectiveName.ShouldBe("line-speed");
|
|
afterSet.DisplayNameOverride.ShouldBe("line-speed");
|
|
afterSet.RawPath.ShouldBe("MAIN/dev1/speed"); // raw path is unchanged by an override
|
|
|
|
var clear = await service.SetReferenceOverrideAsync(afterSet.UnsTagReferenceId, " ", afterSet.RowVersion);
|
|
clear.Ok.ShouldBeTrue();
|
|
|
|
var afterClear = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
|
afterClear.EffectiveName.ShouldBe("speed");
|
|
afterClear.DisplayNameOverride.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Setting an override that the guard reports as colliding is rejected.</summary>
|
|
[Fact]
|
|
public async Task SetReferenceOverride_guard_collision_rejected()
|
|
{
|
|
var name = $"ref-override-guard-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var guard = new FakeGuard();
|
|
var service = new UnsTreeService(Factory(name), guard);
|
|
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
|
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
|
|
|
guard.Result = "collides with a virtual tag.";
|
|
var result = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "running", row.RowVersion);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldBe("collides with a virtual tag.");
|
|
guard.LastProposedName.ShouldBe("running");
|
|
}
|
|
|
|
// ----- RemoveReference -----
|
|
|
|
/// <summary>Removing a reference deletes the row; a missing row is a no-op success.</summary>
|
|
[Fact]
|
|
public async Task RemoveReference_removes_row()
|
|
{
|
|
var name = $"ref-remove-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
|
|
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
|
|
|
|
var result = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
|
|
result.Ok.ShouldBeTrue();
|
|
|
|
using var db = Db(name);
|
|
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
|
|
|
// Already gone → idempotent success.
|
|
var again = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
|
|
again.Ok.ShouldBeTrue();
|
|
}
|
|
|
|
// ----- Picker support -----
|
|
|
|
/// <summary>The picker root is the equipment's own cluster node (structurally cluster-scoped).</summary>
|
|
[Fact]
|
|
public async Task LoadReferencePickerRoot_returns_cluster_node()
|
|
{
|
|
var name = $"ref-picker-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
|
|
var root = await service.LoadReferencePickerRootAsync(EquipmentId);
|
|
|
|
root.ShouldNotBeNull();
|
|
root!.Kind.ShouldBe(RawNodeKind.Cluster);
|
|
root.EntityId.ShouldBe("MAIN");
|
|
root.ClusterId.ShouldBe("MAIN");
|
|
root.HasLazyChildren.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Select-all under a device returns every tag id in the device subtree.</summary>
|
|
[Fact]
|
|
public async Task LoadDescendantTagIds_device_returns_subtree_tags()
|
|
{
|
|
var name = $"ref-descend-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
|
|
var deviceNode = new RawNode
|
|
{
|
|
Kind = RawNodeKind.Device,
|
|
Key = "dev:DEV-1",
|
|
DisplayName = "dev1",
|
|
ClusterId = "MAIN",
|
|
EntityId = "DEV-1",
|
|
};
|
|
|
|
var ids = await service.LoadDescendantTagIdsAsync(deviceNode);
|
|
|
|
ids.ShouldBe(new[] { "TAG-speed", "TAG-running", "TAG-grouped" }, ignoreOrder: true);
|
|
}
|
|
|
|
/// <summary>Select-all under a tag-group returns only the tags in that group's subtree.</summary>
|
|
[Fact]
|
|
public async Task LoadDescendantTagIds_group_returns_group_tags()
|
|
{
|
|
var name = $"ref-descend-grp-{Guid.NewGuid():N}";
|
|
Seed(name);
|
|
var service = new UnsTreeService(Factory(name));
|
|
|
|
var groupNode = new RawNode
|
|
{
|
|
Kind = RawNodeKind.TagGroup,
|
|
Key = "grp:TG-1",
|
|
DisplayName = "grp",
|
|
ClusterId = "MAIN",
|
|
EntityId = "TG-1",
|
|
};
|
|
|
|
var ids = await service.LoadDescendantTagIdsAsync(groupNode);
|
|
|
|
ids.ShouldBe(new[] { "TAG-grouped" });
|
|
}
|
|
}
|