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; /// /// Verifies the v3 Batch-3 UNS reference-only equipment mutations on : /// 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 -consumed collision rejection (via a configurable fake). /// /// /// The EF InMemory provider enforces neither RowVersion concurrency nor the filtered-unique /// indexes, so the service's own pre-checks are what protect the data in these tests. /// [Trait("Category", "Unit")] public sealed class UnsTreeServiceReferenceTests { private const string EquipmentId = "EQ-1"; /// A configurable : returns from every check. private sealed class FakeGuard : IEffectiveNameGuard { public string? Result { get; set; } public string? LastProposedName { get; private set; } public Task 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().UseInMemoryDatabase(name).Options); private static IDbContextFactory Factory(string name) => UnsTreeTestDb.Factory(name); /// /// Seeds two clusters (MAIN, SITE-A), each with driver→device→tags, plus an equipment (EQ-1) /// under MAIN. MAIN tags: MAIN/dev1/speed (Float, Read) and MAIN/dev1/running /// (Boolean, ReadWrite), with speed also nested under a group grp variant. SITE-A tag: /// OTHER/dev2/temp (for the cross-cluster rejection). /// 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 ----- /// Adding two raw tags persists two UnsTagReference rows for the equipment. [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(); } /// Loaded reference rows carry the computed RawPath and inherited (raw) datatype/access. [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"); } /// A raw tag whose cluster differs from the equipment's is rejected (cross-cluster). [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(); } /// Re-referencing an already-referenced raw tag is rejected. [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); } /// A guard-reported collision (fake) becomes the mutation's readable failure; nothing is added. [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 ----- /// Setting an override changes the effective name; clearing it falls back to the raw name. [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(); } /// Setting an override that the guard reports as colliding is rejected. [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 ----- /// Removing a reference deletes the row; a missing row is a no-op success. [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 ----- /// The picker root is the equipment's own cluster node (structurally cluster-scoped). [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(); } /// Select-all under a device returns every tag id in the device subtree. [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); } /// Select-all under a tag-group returns only the tags in that group's subtree. [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" }); } }