Closes task #123 (partial — builder semantics unit-tested; production wiring is the new task #133). ScopePathIndexBuilder + NodeScopeResolver indexed mode already exist — they produce a full Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag scope from the published generation's config rows. What was missing: unit coverage of the Build semantics (the only consumers were compile-time references) + explicit acknowledgement in the readiness doc that the gate/resolver aren't yet wired into Program.cs. Tests — 6 cases in ScopePathIndexBuilderTests.cs: - Well-formed content emits full hierarchy. - Tags with null EquipmentId skipped (SystemPlatform-namespace fallback). - Tags with broken Equipment FK skipped (publish-time validation should have caught; builder is defensive). - Equipment with broken Line FK skipped. - Duplicate TagConfig throws InvalidOperationException. - Resolver with index returns full-path scope; un-indexed ref falls through to cluster-only scope (pre-ADR-001 behaviour preserved). Server.Tests 277 → 283. Critical follow-up (task #133): Program.cs still constructs OpcUaApplicationHost WITHOUT authzGate or scopeResolver, so all six dispatch-layer gates (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) are currently inert in production. Wiring them up — load NodeAcl + EquipmentNamespaceContent at bootstrap, construct gate + resolver, pass into OpcUaApplicationHost, rebind on generation refresh — is the last Phase 6.2 GA blocker. Docs: v2-release-readiness.md Phase 6.2 Stream C hardening list marks the scope-resolution bullet struck-through with a close-out note that calls out the gate-inert-in-production gap + task #133. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
149 lines
5.2 KiB
C#
149 lines
5.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="ScopePathIndexBuilder"/> — the ADR-001 Task B builder that
|
|
/// produces the full-path <see cref="NodeScope"/> index consumed by
|
|
/// <see cref="NodeScopeResolver"/> in its indexed mode.
|
|
/// </summary>
|
|
[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<InvalidOperationException>(() =>
|
|
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<UnsArea>? areas = null,
|
|
IReadOnlyList<UnsLine>? lines = null,
|
|
IReadOnlyList<Equipment>? equipment = null,
|
|
IReadOnlyList<Tag>? 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,
|
|
};
|
|
}
|