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);
+ }
}