fdd6b332fe
- ITwinCATClient.BrowseSymbolsAsync XML doc updated: states the implementation now
expands struct/UDT/FB symbols into atomic member leaves via TwinCATSymbolExpander;
callers receive only atomic/array leaves with full InstancePaths, never struct containers.
- AdsSymbolNode: cache IsStruct, Mapped, Children, ReadOnly as readonly fields computed
once in the ctor so repeated property access during recursive expansion doesn't
re-materialize or re-invoke MapSymbolType/IsSymbolWritable.
- BrowseSymbolsAsync: add operator-gated live risk note next to SymbolsLoadMode.Flat
warning that a real TC3 target may not populate SubSymbols in Flat mode, with
guidance to switch to VirtualTree if members don't surface — do not change mode now.
- TwinCATSymbolExpanderTests: simplify confusing `new string('.', 0)` no-op to `""`.
163 lines
6.8 KiB
C#
163 lines
6.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATSymbolExpanderTests
|
|
{
|
|
/// <summary>
|
|
/// A struct with atomic members + a nested struct expands to exactly the atomic leaves, each
|
|
/// carrying its full dotted InstancePath and mapped atomic type. The struct nodes themselves
|
|
/// contribute no variable.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Struct_expands_to_atomic_member_leaves_with_full_paths()
|
|
{
|
|
// MAIN.Motor1 : struct { Speed: REAL, Running: BOOL, Status: struct { Code: DINT } }
|
|
var motor = Struct("MAIN.Motor1",
|
|
Atomic("MAIN.Motor1.Speed", TwinCATDataType.Real),
|
|
Atomic("MAIN.Motor1.Running", TwinCATDataType.Bool),
|
|
Struct("MAIN.Motor1.Status",
|
|
Atomic("MAIN.Motor1.Status.Code", TwinCATDataType.DInt)));
|
|
|
|
var leaves = TwinCATSymbolExpander.ExpandLeaves([motor]).ToList();
|
|
|
|
leaves.Select(l => l.InstancePath).ShouldBe(
|
|
["MAIN.Motor1.Speed", "MAIN.Motor1.Running", "MAIN.Motor1.Status.Code"]);
|
|
leaves.Single(l => l.InstancePath == "MAIN.Motor1.Speed").DataType.ShouldBe(TwinCATDataType.Real);
|
|
leaves.Single(l => l.InstancePath == "MAIN.Motor1.Running").DataType.ShouldBe(TwinCATDataType.Bool);
|
|
leaves.Single(l => l.InstancePath == "MAIN.Motor1.Status.Code").DataType.ShouldBe(TwinCATDataType.DInt);
|
|
// No struct container is ever emitted as a leaf.
|
|
leaves.ShouldNotContain(l => l.InstancePath == "MAIN.Motor1");
|
|
leaves.ShouldNotContain(l => l.InstancePath == "MAIN.Motor1.Status");
|
|
}
|
|
|
|
/// <summary>A top-level atomic symbol yields itself unchanged.</summary>
|
|
[Fact]
|
|
public void Top_level_atomic_yields_itself()
|
|
{
|
|
var counter = Atomic("MAIN.Counter", TwinCATDataType.DInt);
|
|
|
|
var leaves = TwinCATSymbolExpander.ExpandLeaves([counter]).ToList();
|
|
|
|
leaves.Count.ShouldBe(1);
|
|
leaves[0].InstancePath.ShouldBe("MAIN.Counter");
|
|
leaves[0].DataType.ShouldBe(TwinCATDataType.DInt);
|
|
}
|
|
|
|
/// <summary>The atomic leaf carries its mapped array length through unchanged (1-D array element).</summary>
|
|
[Fact]
|
|
public void Array_leaf_carries_element_type_and_length()
|
|
{
|
|
var arr = new FakeNode("GVL.Data", IsStruct: false,
|
|
Mapped: (TwinCATDataType.Int, 4), Children: [], ReadOnly: false);
|
|
|
|
var leaf = TwinCATSymbolExpander.ExpandLeaves([arr]).Single();
|
|
|
|
leaf.InstancePath.ShouldBe("GVL.Data");
|
|
leaf.DataType.ShouldBe(TwinCATDataType.Int);
|
|
leaf.ArrayLength.ShouldBe(4);
|
|
}
|
|
|
|
/// <summary>The read-only flag is propagated onto the emitted discovered symbol.</summary>
|
|
[Fact]
|
|
public void ReadOnly_flag_propagates()
|
|
{
|
|
var ro = new FakeNode("MAIN.Status", IsStruct: false,
|
|
Mapped: (TwinCATDataType.DInt, null), Children: [], ReadOnly: true);
|
|
|
|
TwinCATSymbolExpander.ExpandLeaves([ro]).Single().ReadOnly.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>An unsupported atomic leaf (mapped Type == null, e.g. a pointer) is dropped.</summary>
|
|
[Fact]
|
|
public void Unsupported_atomic_leaf_is_dropped()
|
|
{
|
|
var pointer = new FakeNode("MAIN.pData", IsStruct: false,
|
|
Mapped: (null, null), Children: [], ReadOnly: false);
|
|
var ok = Atomic("MAIN.Counter", TwinCATDataType.DInt);
|
|
|
|
var leaves = TwinCATSymbolExpander.ExpandLeaves([pointer, ok]).ToList();
|
|
|
|
leaves.Select(l => l.InstancePath).ShouldBe(["MAIN.Counter"]);
|
|
}
|
|
|
|
/// <summary>An empty struct (no members) yields nothing — there is no addressable atomic.</summary>
|
|
[Fact]
|
|
public void Empty_struct_yields_nothing()
|
|
{
|
|
var empty = Struct("MAIN.Empty");
|
|
|
|
TwinCATSymbolExpander.ExpandLeaves([empty]).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A member nested beyond <see cref="TwinCATSymbolExpander.MaxDepth"/> is dropped; members at
|
|
/// or above the floor still surface.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Node_beyond_MaxDepth_is_dropped()
|
|
{
|
|
// Build a chain of nested structs: root (depth 0) → struct → struct → ... with a single
|
|
// atomic leaf at the very bottom. The leaf sits at depth == number-of-struct-levels.
|
|
// Place the leaf at depth MaxDepth (== one past the deepest descended level) so it is dropped,
|
|
// and a sibling leaf at a shallow depth so we prove the walk itself still works.
|
|
ITwinCATSymbolNode deep = Atomic("RootLeaf.Deep", TwinCATDataType.DInt);
|
|
// Wrap the atomic in MaxDepth struct levels so the atomic ends up at depth == MaxDepth.
|
|
for (var i = 0; i < TwinCATSymbolExpander.MaxDepth; i++)
|
|
deep = Struct($"Level{i}", deep);
|
|
|
|
// Shallow leaf reachable at depth 1.
|
|
var shallow = Struct("Shallow", Atomic("Shallow.Ok", TwinCATDataType.Int));
|
|
|
|
var leaves = TwinCATSymbolExpander.ExpandLeaves([deep, shallow]).ToList();
|
|
|
|
leaves.Select(l => l.InstancePath).ShouldBe(["Shallow.Ok"]);
|
|
}
|
|
|
|
/// <summary>A node at exactly the deepest descended level is still emitted (boundary check).</summary>
|
|
[Fact]
|
|
public void Node_at_max_depth_boundary_is_emitted()
|
|
{
|
|
// Nest the atomic under (MaxDepth - 1) struct levels so it sits at depth == MaxDepth - 1,
|
|
// which is the last level the walker still descends.
|
|
ITwinCATSymbolNode node = Atomic("Deep.Boundary", TwinCATDataType.DInt);
|
|
for (var i = 0; i < TwinCATSymbolExpander.MaxDepth - 1; i++)
|
|
node = Struct($"L{i}", node);
|
|
|
|
var leaves = TwinCATSymbolExpander.ExpandLeaves([node]).ToList();
|
|
|
|
leaves.Select(l => l.InstancePath).ShouldBe(["Deep.Boundary"]);
|
|
}
|
|
|
|
/// <summary>A duplicate InstancePath (Flat-vs-tree double-listing) is emitted exactly once.</summary>
|
|
[Fact]
|
|
public void Duplicate_InstancePath_is_emitted_once()
|
|
{
|
|
var a = Atomic("MAIN.Counter", TwinCATDataType.DInt);
|
|
var b = Atomic("MAIN.Counter", TwinCATDataType.DInt);
|
|
|
|
var leaves = TwinCATSymbolExpander.ExpandLeaves([a, b]).ToList();
|
|
|
|
leaves.Count.ShouldBe(1);
|
|
leaves[0].InstancePath.ShouldBe("MAIN.Counter");
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
private static FakeNode Atomic(string path, TwinCATDataType type) =>
|
|
new(path, IsStruct: false, Mapped: (type, null), Children: [], ReadOnly: false);
|
|
|
|
private static FakeNode Struct(string path, params ITwinCATSymbolNode[] children) =>
|
|
new(path, IsStruct: true, Mapped: (null, null), Children: children, ReadOnly: false);
|
|
|
|
private sealed record FakeNode(
|
|
string InstancePath,
|
|
bool IsStruct,
|
|
(TwinCATDataType? Type, int? ArrayLength) Mapped,
|
|
IReadOnlyList<ITwinCATSymbolNode> Children,
|
|
bool ReadOnly) : ITwinCATSymbolNode;
|
|
}
|