diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index a4e92493..17fe3e8d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -173,6 +173,7 @@ public sealed class RawTreeService(IDbContextFactory dbF var tags = await db.Tags.AsNoTracking() .Where(t => t.DeviceId == deviceId && t.TagGroupId == null) .OrderBy(t => t.Name) + .Select(t => new TagRow(t.TagId, t.Name, t.RowVersion)) .ToListAsync(ct); var nodes = new List(groups.Count + tags.Count); @@ -194,6 +195,7 @@ public sealed class RawTreeService(IDbContextFactory dbF var tags = await db.Tags.AsNoTracking() .Where(t => t.TagGroupId == groupId) .OrderBy(t => t.Name) + .Select(t => new TagRow(t.TagId, t.Name, t.RowVersion)) .ToListAsync(ct); var nodes = new List(subGroups.Count + tags.Count); @@ -299,7 +301,13 @@ public sealed class RawTreeService(IDbContextFactory dbF }).ToList(); } - private static RawNode BuildTagNode(RawNode parent, Tag t) => new() + /// + /// The only columns a tree row needs from a . Projecting to this keeps the + /// TagConfig nvarchar(max) JSON blob out of the tree's SELECT — it is never rendered. + /// + private sealed record TagRow(string TagId, string Name, byte[] RowVersion); + + private static RawNode BuildTagNode(RawNode parent, TagRow t) => new() { Kind = RawNodeKind.Tag, Key = $"tag:{t.TagId}", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs index 4657e5a8..c7afdcd5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs @@ -102,6 +102,41 @@ public sealed class RawTreeServiceTests (await service.LoadChildrenAsync(tags[0])).ShouldBeEmpty(); // leaf } + /// + /// Tag rows are read through a narrow projection that leaves the TagConfig JSON blob in the + /// database, so this pins that the three fields a tree row actually renders — identity, name and the + /// concurrency token the mutation handles round-trip — still arrive intact, from both tag loaders + /// (a tag under a group, and a tag hanging directly off a device). + /// + [Fact] + public async Task LoadChildren_yields_tag_rows_carrying_id_name_and_row_version() + { + var (service, dbName) = Seeded(); + var cluster = (await service.LoadRootsAsync()).Single().Children.Single(); + + // Group-child loader: two tags under "Motors". + var folder = (await service.LoadChildrenAsync(cluster)).Single(n => n.Kind == RawNodeKind.Folder); + var driver = (await service.LoadChildrenAsync(folder)).ShouldHaveSingleItem(); + var device = (await service.LoadChildrenAsync(driver)).ShouldHaveSingleItem(); + var group = (await service.LoadChildrenAsync(device)).ShouldHaveSingleItem(); + + var plain = (await service.LoadChildrenAsync(group)).Single(t => t.EntityId == RawTreeTestDb.PlainTagId); + plain.DisplayName.ShouldBe("state"); + plain.RowVersion.ShouldBe( + RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion)); + + // Device-child loader: the cluster-root device's own tag. + var rootDriver = (await service.LoadChildrenAsync(cluster)).Single(n => n.Kind == RawNodeKind.Driver); + var rootDevice = (await service.LoadChildrenAsync(rootDriver)).ShouldHaveSingleItem(); + + var referenced = (await service.LoadChildrenAsync(rootDevice)) + .Single(t => t.EntityId == RawTreeTestDb.ReferencedTagId); + referenced.Kind.ShouldBe(RawNodeKind.Tag); + referenced.DisplayName.ShouldBe("temperature"); + referenced.RowVersion.ShouldBe( + RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.ReferencedTagId).RowVersion)); + } + // -------------------------------------------------------------------------------- Create/happy [Fact]