c2c3c52274
The AbCip controller browse could never be verified end to end: libplctag's ab_server answers CIP service 0x55 (Get Instance Attribute List) on the Symbol Object class with "unsupported", so the `@tags` walk had no backend. #468's own definition of done offered a sim that serves `@tags` as an alternative to real hardware; this is that. The fixture now applies a vendored patch teaching ab_server to serve the Symbol Object listing, with partial-transfer paging. Correctness is checked against libplctag's own `list_tags_logix` rather than against our decoder — our decoder is the thing under test, so its agreement would prove nothing. The clone is also pinned to a concrete tag: the Dockerfile called `--branch release` "pinned" while tracking a branch that moves. That immediately exposed a real defect. CipSymbolObjectDecoder advanced to an even byte boundary after each symbol name, but entries are packed with no padding — libplctag walks them by sizeof(entry) + string_len, and a captured reply confirms it. Any odd-length name therefore desynchronised every entry behind it, and odd-length names are ordinary. In the live browse below, `Motor_Run` is 9 bytes and first on the wire, so the old decoder would have shown that one tag and silently dropped the other eight. Every existing multi-entry test passed throughout, because the test builder padded exactly as the decoder did: the two agreed with each other and neither agreed with a controller. The builder no longer pads, and the layout is pinned by a captured reply (CipSymbolObjectWireCaptureTests) instead of by a second statement of the assumption. Restoring the alignment step reddens exactly the three new tests and none of the 29 others. Live gate on docker-dev, all three of #468's criteria: - populated tree — all nine seeded tags render as leaves, in wire order - refresh re-captures - leaf commit — Pump_1_Speed + TestDINT saved with their CIP tag paths The gate also surfaced two things it was not asked about: browse-commit drops the discovered data type, so a browsed REAL commits as a DINT and the driver decodes it as one (filed as #536), and Test Connect fails against this fixture regardless of the device because it probes `@raw_cpu_type` (documented). TwinCAT and FOCAS still have no browsable backend; #468's AbCip half is closed, theirs is not. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
255 lines
10 KiB
C#
255 lines
10 KiB
C#
using System.Buffers.Binary;
|
||
using System.Text;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||
|
||
[Trait("Category", "Unit")]
|
||
public sealed class CipSymbolObjectDecoderTests
|
||
{
|
||
/// <summary>
|
||
/// Build one Symbol Object entry in the byte layout
|
||
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len]</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// There is no trailing pad. This builder used to round the entry up to an even length,
|
||
/// which is what let the padding defect survive: the builder and the decoder shared one
|
||
/// wrong assumption, so every multi-entry test agreed with itself and none of them agreed
|
||
/// with a controller. <see cref="CipSymbolObjectWireCaptureTests"/> pins the real layout.
|
||
/// </remarks>
|
||
private static byte[] BuildEntry(
|
||
uint instanceId,
|
||
ushort symbolType,
|
||
ushort elementLength,
|
||
(uint, uint, uint) arrayDims,
|
||
string name)
|
||
{
|
||
var nameBytes = Encoding.ASCII.GetBytes(name);
|
||
var nameLen = nameBytes.Length;
|
||
var totalLen = 22 + nameLen;
|
||
|
||
var buf = new byte[totalLen];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
|
||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
|
||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
|
||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
|
||
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
|
||
return buf;
|
||
}
|
||
|
||
private static byte[] Concat(params byte[][] chunks)
|
||
{
|
||
var total = chunks.Sum(c => c.Length);
|
||
var result = new byte[total];
|
||
var pos = 0;
|
||
foreach (var c in chunks)
|
||
{
|
||
Buffer.BlockCopy(c, 0, result, pos, c.Length);
|
||
pos += c.Length;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>Verifies that a single DInt entry decodes correctly.</summary>
|
||
[Fact]
|
||
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
|
||
{
|
||
var bytes = BuildEntry(
|
||
instanceId: 42,
|
||
symbolType: 0xC4,
|
||
elementLength: 4,
|
||
arrayDims: (0, 0, 0),
|
||
name: "Counter");
|
||
|
||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||
|
||
tags.Count.ShouldBe(1);
|
||
tags[0].Name.ShouldBe("Counter");
|
||
tags[0].ProgramScope.ShouldBeNull();
|
||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||
tags[0].IsSystemTag.ShouldBeFalse();
|
||
}
|
||
|
||
/// <summary>Verifies that all known atomic type codes map to correct data types.</summary>
|
||
/// <param name="typeCode">The CIP type code to map.</param>
|
||
/// <param name="expected">The expected AbCipDataType result.</param>
|
||
[Theory]
|
||
[InlineData((byte)0xC1, AbCipDataType.Bool)]
|
||
[InlineData((byte)0xC2, AbCipDataType.SInt)]
|
||
[InlineData((byte)0xC3, AbCipDataType.Int)]
|
||
[InlineData((byte)0xC4, AbCipDataType.DInt)]
|
||
[InlineData((byte)0xC5, AbCipDataType.LInt)]
|
||
[InlineData((byte)0xC6, AbCipDataType.USInt)]
|
||
[InlineData((byte)0xC7, AbCipDataType.UInt)]
|
||
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
|
||
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
|
||
[InlineData((byte)0xCA, AbCipDataType.Real)]
|
||
[InlineData((byte)0xCB, AbCipDataType.LReal)]
|
||
[InlineData((byte)0xD0, AbCipDataType.String)]
|
||
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
|
||
{
|
||
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
|
||
}
|
||
|
||
/// <summary>Verifies that unknown type codes return null for opaque handling.</summary>
|
||
[Fact]
|
||
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
|
||
{
|
||
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verifies that the struct flag overrides the type code AND that the lower 12 bits are
|
||
/// surfaced as the CIP template instance id (used by discovery to fetch the UDT's Template
|
||
/// Object). This is the plumbing that makes top-level discovered-UDT expansion functional.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Struct_flag_yields_Structure_and_surfaces_template_instance_id()
|
||
{
|
||
// 0x8000 (struct flag) | 0x0234 (template instance id in the lower 12 bits)
|
||
var bytes = BuildEntry(
|
||
instanceId: 5,
|
||
symbolType: 0x8000 | 0x0234,
|
||
elementLength: 16,
|
||
arrayDims: (0, 0, 0),
|
||
name: "Motor1");
|
||
|
||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||
tag.TemplateInstanceId.ShouldBe(0x0234u);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verifies that an atomic (non-struct) symbol carries no template instance id — only struct
|
||
/// symbols repurpose the lower 12 bits as a template id.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Atomic_symbol_has_no_template_instance_id()
|
||
{
|
||
var bytes = BuildEntry(
|
||
instanceId: 7,
|
||
symbolType: 0xC4, // DINT — atomic, not a struct
|
||
elementLength: 4,
|
||
arrayDims: (0, 0, 0),
|
||
name: "Counter");
|
||
|
||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||
tag.TemplateInstanceId.ShouldBeNull();
|
||
}
|
||
|
||
/// <summary>Verifies that system flag surfaces as IsSystemTag true.</summary>
|
||
[Fact]
|
||
public void System_flag_surfaces_as_IsSystemTag_true()
|
||
{
|
||
var bytes = BuildEntry(
|
||
instanceId: 99,
|
||
symbolType: 0x1000 | 0xC4, // system flag + DINT
|
||
elementLength: 4,
|
||
arrayDims: (0, 0, 0),
|
||
name: "__Reserved_1");
|
||
|
||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||
tag.IsSystemTag.ShouldBeTrue();
|
||
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||
}
|
||
|
||
/// <summary>Verifies that program scope names split correctly into prefix and name.</summary>
|
||
[Fact]
|
||
public void Program_scope_name_splits_prefix_into_ProgramScope()
|
||
{
|
||
var bytes = BuildEntry(
|
||
instanceId: 1,
|
||
symbolType: 0xC4,
|
||
elementLength: 4,
|
||
arrayDims: (0, 0, 0),
|
||
name: "Program:MainProgram.StepIndex");
|
||
|
||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||
tag.ProgramScope.ShouldBe("MainProgram");
|
||
tag.Name.ShouldBe("StepIndex");
|
||
}
|
||
|
||
/// <summary>Verifies that multiple entries decode in wire order.</summary>
|
||
[Fact]
|
||
public void Multiple_entries_decode_in_wire_order()
|
||
{
|
||
var bytes = Concat(
|
||
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abcd"),
|
||
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi"));
|
||
|
||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||
tags.Count.ShouldBe(2);
|
||
tags[0].Name.ShouldBe("Abcd");
|
||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||
tags[1].Name.ShouldBe("Pi");
|
||
tags[1].DataType.ShouldBe(AbCipDataType.Real);
|
||
}
|
||
|
||
/// <summary>
|
||
/// An odd-length name leaves the following entry on an odd offset, and the decoder must
|
||
/// read it there.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This is the case Gitea #468 could not reach: every prior multi-entry test used a builder
|
||
/// that padded exactly as the decoder did, so the pair agreed with each other and neither
|
||
/// agreed with a controller. Odd-length names are not exotic — <c>TestArray</c>,
|
||
/// <c>Motor_Run</c> and <c>Line1_Count</c> are all odd, and one of them is enough to lose
|
||
/// every tag discovered after it.
|
||
/// </remarks>
|
||
[Fact]
|
||
public void An_entry_after_an_odd_length_name_still_decodes()
|
||
{
|
||
var bytes = Concat(
|
||
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // 3 bytes — next entry starts at 25
|
||
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi"),
|
||
BuildEntry(3, 0xC1, 1, (0, 0, 0), "Motor_Run")); // 9 bytes — and again after this one
|
||
|
||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||
|
||
tags.Select(t => t.Name).ShouldBe(["Abc", "Pi", "Motor_Run"]);
|
||
tags.Select(t => t.DataType)
|
||
.ShouldBe([AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.Bool]);
|
||
}
|
||
|
||
/// <summary>Verifies that truncated buffers stop decoding gracefully.</summary>
|
||
[Fact]
|
||
public void Truncated_buffer_stops_decoding_gracefully()
|
||
{
|
||
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
|
||
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
|
||
var truncated = full.Take(full.Length - 5).ToArray();
|
||
|
||
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
|
||
}
|
||
|
||
/// <summary>Verifies that empty buffers yield no tags.</summary>
|
||
[Fact]
|
||
public void Empty_buffer_yields_no_tags()
|
||
{
|
||
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
|
||
}
|
||
|
||
/// <summary>Verifies that SplitProgramScope handles all valid name shapes.</summary>
|
||
/// <param name="input">The name string to split.</param>
|
||
/// <param name="expectedScope">The expected program scope prefix, if any.</param>
|
||
/// <param name="expectedName">The expected name after splitting.</param>
|
||
[Theory]
|
||
[InlineData("Counter", null, "Counter")]
|
||
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
|
||
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
|
||
[InlineData("Program:", null, "Program:")] // malformed — no dot
|
||
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
|
||
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
|
||
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
|
||
{
|
||
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
|
||
scope.ShouldBe(expectedScope);
|
||
name.ShouldBe(expectedName);
|
||
}
|
||
}
|