using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Unit tests for — the ADR-001 Task B builder that /// produces the full-path index consumed by /// in its indexed mode. /// [Trait("Category", "Unit")] public sealed class ScopePathIndexBuilderTests { [Fact] public void Build_emits_full_hierarchy_for_well_formed_content() { var index = ScopePathIndexBuilder.Build("c1", "ns-eq", Content( areas: [Area("area1")], lines: [Line("line1", "area1")], equipment: [Equip("eq1", "line1")], tags: [TagRow("tag1", "eq1", tagConfig: "Eq1/Speed")])); index.Count.ShouldBe(1); var scope = index["Eq1/Speed"]; scope.ClusterId.ShouldBe("c1"); scope.NamespaceId.ShouldBe("ns-eq"); scope.UnsAreaId.ShouldBe("area1"); scope.UnsLineId.ShouldBe("line1"); scope.EquipmentId.ShouldBe("eq1"); scope.TagId.ShouldBe("Eq1/Speed"); scope.Kind.ShouldBe(NodeHierarchyKind.Equipment); } [Fact] public void Build_skips_tags_with_null_EquipmentId() { // SystemPlatform-namespace tags (decision #110) — the cluster-only resolver // fallback handles them; no index entry needed. var index = ScopePathIndexBuilder.Build("c1", "ns-sp", Content( tags: [TagRow("t", equipmentId: null, tagConfig: "Galaxy.Object.Attr")])); index.Count.ShouldBe(0); } [Fact] public void Build_skips_tags_with_broken_Equipment_FK() { // Tag references a missing Equipment row. sp_ValidateDraft should have caught this // at publish; builder skips rather than crashes so startup stays bootable. var index = ScopePathIndexBuilder.Build("c1", "ns", Content( areas: [Area("area1")], lines: [Line("line1", "area1")], tags: [TagRow("t", "missing-eq", "missing/Speed")])); index.Count.ShouldBe(0); } [Fact] public void Build_skips_equipment_with_broken_line_FK() { var index = ScopePathIndexBuilder.Build("c1", "ns", Content( areas: [Area("area1")], lines: [], // no lines — equipment's UnsLineId misses equipment: [Equip("eq1", "missing")], tags: [TagRow("t", "eq1", "E/S")])); index.Count.ShouldBe(0); } [Fact] public void Build_throws_on_duplicate_TagConfig() { var ex = Should.Throw(() => ScopePathIndexBuilder.Build("c1", "ns", Content( areas: [Area("area1")], lines: [Line("line1", "area1")], equipment: [Equip("eq1", "line1")], tags: [ TagRow("t1", "eq1", "E/DUP"), TagRow("t2", "eq1", "E/DUP"), ]))); ex.Message.ShouldContain("Duplicate"); ex.Message.ShouldContain("E/DUP"); } [Fact] public void Resolver_with_index_returns_full_path_scope() { var index = ScopePathIndexBuilder.Build("c1", "ns", Content( areas: [Area("area1")], lines: [Line("line1", "area1")], equipment: [Equip("eq1", "line1")], tags: [TagRow("t", "eq1", "E/Speed")])); var resolver = new NodeScopeResolver("c1", index); var resolved = resolver.Resolve("E/Speed"); resolved.UnsAreaId.ShouldBe("area1"); resolved.UnsLineId.ShouldBe("line1"); resolved.EquipmentId.ShouldBe("eq1"); // Un-indexed ref falls through to cluster-only scope — pre-ADR-001 behaviour preserved. var fallback = resolver.Resolve("Galaxy.Object.Attr"); fallback.ClusterId.ShouldBe("c1"); fallback.TagId.ShouldBe("Galaxy.Object.Attr"); fallback.UnsAreaId.ShouldBeNull(); } // ---- fixture helpers --------------------------------------------------- private static EquipmentNamespaceContent Content( IReadOnlyList? areas = null, IReadOnlyList? lines = null, IReadOnlyList? equipment = null, IReadOnlyList? tags = null) => new(areas ?? [], lines ?? [], equipment ?? [], tags ?? []); private static UnsArea Area(string id) => new() { UnsAreaId = id, ClusterId = "c1", Name = $"Area {id}", }; private static UnsLine Line(string id, string areaId) => new() { UnsLineId = id, UnsAreaId = areaId, Name = $"Line {id}", }; private static Equipment Equip(string id, string lineId) => new() { EquipmentId = id, UnsLineId = lineId, DriverInstanceId = "drv", Name = $"Eq {id}", MachineCode = $"M{id}", ZTag = id, }; private static Tag TagRow(string id, string? equipmentId, string tagConfig) => new() { TagId = id, EquipmentId = equipmentId, DriverInstanceId = "drv", Name = id, DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = tagConfig, }; }