diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs index af19b52f..7b540f01 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs @@ -287,4 +287,85 @@ public sealed class ScriptTagCatalogTests (await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull(); (await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull(); } + + // ----------------------------------------------------------------------- + // GetEquipmentRelativeLeavesAsync — DB-backed tests + // ----------------------------------------------------------------------- + // Seeded paths and their leaf derivation: + // TAG-EQ → "Motor.Speed" → leaf "Speed" + // TAG-SP → "DelmiaReceiver_001.DownloadPath" → leaf "DownloadPath" + // VTAG-1 → "Computed" → NO dot → excluded + // ----------------------------------------------------------------------- + + /// A null filter returns one leaf per dot-bearing path; the no-dot virtual-tag path is excluded. + [Fact] + public async Task GetEquipmentRelativeLeaves_no_filter_returns_dot_bearing_leaves_only() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default); + + // Leaves derived from the two dot-bearing paths in Seed(). + leaves.ShouldContain("Speed"); // from "Motor.Speed" + leaves.ShouldContain("DownloadPath"); // from "DelmiaReceiver_001.DownloadPath" + + // Virtual tag "Computed" has no dot — must be excluded. + leaves.ShouldNotContain("Computed"); + } + + /// A prefix filter narrows to leaves that start with the given string (e.g. "Sp" → "Speed", not "DownloadPath"). + [Fact] + public async Task GetEquipmentRelativeLeaves_prefix_filter_narrows_to_matching_leaves() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + var leaves = await catalog.GetEquipmentRelativeLeavesAsync("Sp", default); + + leaves.ShouldContain("Speed"); + leaves.ShouldNotContain("DownloadPath"); + leaves.ShouldAllBe(l => l.StartsWith("Sp", StringComparison.OrdinalIgnoreCase)); + } + + /// The prefix filter is case-insensitive: "sp" (lowercase) still matches the leaf "Speed". + [Fact] + public async Task GetEquipmentRelativeLeaves_prefix_filter_is_case_insensitive() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + var leaves = await catalog.GetEquipmentRelativeLeavesAsync("sp", default); + + leaves.ShouldContain("Speed"); + } + + /// Two paths sharing the same leaf after different object prefixes produce one distinct entry. + [Fact] + public async Task GetEquipmentRelativeLeaves_duplicate_leaves_are_deduplicated() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + // Add a second equipment tag whose FullName produces the same leaf "Speed" as TAG-EQ. + using (var db = new OtOpcUaConfigDbContext(opts)) + { + db.Tags.Add(new Tag + { + TagId = "TAG-EQ2", + DriverInstanceId = "DRV-1", + EquipmentId = "EQ-1", + Name = "Speed2", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"Conveyor.Speed\"}", + }); + db.SaveChanges(); + } + + var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default); + + // "Speed" must appear exactly once despite being produced by two different paths. + leaves.Count(l => string.Equals(l, "Speed", StringComparison.Ordinal)).ShouldBe(1); + } }