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,
};
}