b7b754fd98
A TC2 global is named with ONE leading dot (.OPC_HEARTBEAT) and the PLC requires it (SYM_HNDBYNAME answers 0x710 without it). TryParse rejected the dot, so the driver fell back to the raw string and lost bit-index parsing: .X.WORD.3 went to ADS verbatim instead of reading .X.WORD and extracting bit 3. The dot now stays on the root segment and round-trips through ToAdsSymbolName; .., ., .[1] and .9x stay invalid.
195 lines
7.5 KiB
C#
195 lines
7.5 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 TwinCATSymbolPathTests
|
|
{
|
|
/// <summary>Verifies that a single segment global variable parses correctly.</summary>
|
|
[Fact]
|
|
public void Single_segment_global_variable_parses()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("Counter");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Single().Name.ShouldBe("Counter");
|
|
p.ToAdsSymbolName().ShouldBe("Counter");
|
|
}
|
|
|
|
/// <summary>Verifies that a POU dot-separated variable parses correctly.</summary>
|
|
[Fact]
|
|
public void POU_dot_variable_parses()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("MAIN.bStart");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Select(s => s.Name).ShouldBe(["MAIN", "bStart"]);
|
|
p.ToAdsSymbolName().ShouldBe("MAIN.bStart");
|
|
}
|
|
|
|
/// <summary>Verifies that a GVL reference parses correctly.</summary>
|
|
[Fact]
|
|
public void GVL_reference_parses()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("GVL.Counter");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Select(s => s.Name).ShouldBe(["GVL", "Counter"]);
|
|
p.ToAdsSymbolName().ShouldBe("GVL.Counter");
|
|
}
|
|
|
|
/// <summary>Verifies that structured member access is split into segments.</summary>
|
|
[Fact]
|
|
public void Structured_member_access_splits()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("Motor1.Status.Running");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Status", "Running"]);
|
|
}
|
|
|
|
/// <summary>Verifies that array subscripts parse correctly.</summary>
|
|
[Fact]
|
|
public void Array_subscript_parses()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("Data[5]");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Single().Subscripts.ShouldBe([5]);
|
|
p.ToAdsSymbolName().ShouldBe("Data[5]");
|
|
}
|
|
|
|
/// <summary>Verifies that multi-dimensional array subscripts parse correctly.</summary>
|
|
[Fact]
|
|
public void Multi_dim_array_subscript_parses()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("Matrix[1,2]");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Single().Subscripts.ShouldBe([1, 2]);
|
|
}
|
|
|
|
/// <summary>Verifies that bit access is captured as a bit index.</summary>
|
|
[Fact]
|
|
public void Bit_access_captured_as_bit_index()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("Flags.3");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Single().Name.ShouldBe("Flags");
|
|
p.BitIndex.ShouldBe(3);
|
|
p.ToAdsSymbolName().ShouldBe("Flags.3");
|
|
}
|
|
|
|
/// <summary>Verifies that bit access works after a member path.</summary>
|
|
[Fact]
|
|
public void Bit_access_after_member_path()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("GVL.Status.7");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Select(s => s.Name).ShouldBe(["GVL", "Status"]);
|
|
p.BitIndex.ShouldBe(7);
|
|
}
|
|
|
|
/// <summary>Verifies that combined scope, member, subscript, and bit access parses correctly.</summary>
|
|
[Fact]
|
|
public void Combined_scope_member_subscript_bit()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse("MAIN.Motors[0].Status.5");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Select(s => s.Name).ShouldBe(["MAIN", "Motors", "Status"]);
|
|
p.Segments[1].Subscripts.ShouldBe([0]);
|
|
p.BitIndex.ShouldBe(5);
|
|
p.ToAdsSymbolName().ShouldBe("MAIN.Motors[0].Status.5");
|
|
}
|
|
|
|
/// <summary>Verifies that invalid symbol path shapes return null.</summary>
|
|
/// <param name="input">The invalid symbol path input to test.</param>
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(".")] // a bare dot names nothing
|
|
[InlineData("..Motor")] // TwinCAT 2 globals carry ONE leading dot, never two
|
|
[InlineData(".[1]")] // leading dot then no identifier
|
|
[InlineData(".9x")] // leading dot then an ident starting with a digit
|
|
[InlineData(".x.")] // leading dot, trailing dot
|
|
[InlineData(" . x")] // whitespace between the dot and the identifier
|
|
[InlineData("Motor.")] // trailing dot
|
|
[InlineData("Motor.[0]")] // empty segment
|
|
[InlineData("1bad")] // ident starts with digit
|
|
[InlineData("Bad Name")] // space in ident
|
|
[InlineData("Motor[]")] // empty subscript
|
|
[InlineData("Motor[-1]")] // negative subscript
|
|
[InlineData("Motor[a]")] // non-numeric subscript
|
|
[InlineData("Motor[")] // unbalanced bracket
|
|
[InlineData("Flags.32")] // bit out of range (treated as ident → invalid shape)
|
|
public void Invalid_shapes_return_null(string? input)
|
|
{
|
|
TwinCATSymbolPath.TryParse(input).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that underscore-prefixed identifiers are accepted.</summary>
|
|
[Fact]
|
|
public void Underscore_prefix_idents_accepted()
|
|
{
|
|
TwinCATSymbolPath.TryParse("_internal_var")!.Segments.Single().Name.ShouldBe("_internal_var");
|
|
}
|
|
|
|
/// <summary>Verifies that ToAdsSymbolName roundtrips correctly for various symbol formats.</summary>
|
|
[Fact]
|
|
public void ToAdsSymbolName_roundtrips()
|
|
{
|
|
var cases = new[]
|
|
{
|
|
"Counter",
|
|
"MAIN.bStart",
|
|
"GVL.Counter",
|
|
"Motor1.Status.Running",
|
|
"Data[5]",
|
|
"Matrix[1,2]",
|
|
"Flags.3",
|
|
"MAIN.Motors[0].Status.5",
|
|
};
|
|
foreach (var c in cases)
|
|
{
|
|
var parsed = TwinCATSymbolPath.TryParse(c);
|
|
parsed.ShouldNotBeNull(c);
|
|
parsed.ToAdsSymbolName().ShouldBe(c);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// TwinCAT 2 names a GLOBAL variable with ONE leading dot (<c>.OPC_HEARTBEAT</c>) and the PLC
|
|
/// requires it: on a real TC 2.11 CX (Elma cleaner Z33856, 2026-09-24) <c>SYM_HNDBYNAME</c>
|
|
/// answered a handle for <c>.OPC_HEARTBEAT</c> and 0x710 (symbol not found) for
|
|
/// <c>OPC_HEARTBEAT</c>. The parser rejected the dot, so a TC2 global fell back to the raw
|
|
/// string — losing bit-index parsing (<c>.X.WORD.3</c> was sent to ADS verbatim).
|
|
/// Ported from ignitionoee driver 0.19.1.
|
|
/// </summary>
|
|
/// <param name="input">A TwinCAT 2 global path as the PLC names it.</param>
|
|
[Theory]
|
|
[InlineData(".x")]
|
|
[InlineData(".OPC_HEARTBEAT")]
|
|
[InlineData(".OPC_MACHINEDATA.PROCESSSTATUS")]
|
|
[InlineData(".ARRCARRIERDATA_V[1].SWORKORDER")]
|
|
[InlineData(".OPC_LOGDATA.STATIONDATA[5].TEMPERATUREMAXIMUM")]
|
|
public void TwinCAT2_leading_dot_global_parses_and_keeps_its_dot(string input)
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse(input);
|
|
p.ShouldNotBeNull(input);
|
|
p.Segments[0].Name.ShouldStartWith(".");
|
|
p.ToAdsSymbolName().ShouldBe(input);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The leading dot belongs to the ROOT segment only, so bit access on a TC2 global still parses:
|
|
/// the parent word is read as <c>.OPC_STATUS.WORD</c> and bit 3 is extracted locally.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TwinCAT2_global_bit_access_parses_with_the_dot_on_the_root_segment()
|
|
{
|
|
var p = TwinCATSymbolPath.TryParse(".OPC_STATUS.WORD.3");
|
|
p.ShouldNotBeNull();
|
|
p.Segments.Select(s => s.Name).ShouldBe([".OPC_STATUS", "WORD"]);
|
|
p.BitIndex.ShouldBe(3);
|
|
(p with { BitIndex = null }).ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD");
|
|
p.ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD.3");
|
|
}
|
|
}
|