fbcb521d1b
Strict width+kind eligibility for sum-batched scalars (others read singly); latch only on ADS 1793/1794, split 1792/1797/1798 without latching; 32 KiB per-command budget and 500-item ceiling; short sum response handled per item. Diagnostics: opt-in diagnosticsEnabled (default off), reuse the probe's ReadState, sample after the host-state transition, JitterMs renamed HeadroomMs, compact per-device line so many devices fit the 16-entry cap, unavailable-symbol latch cleared on reconnect. AdminUI: min/max on sum-read max, diagnostics checkbox. Pure planner extracted and unit-tested. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
108 lines
5.3 KiB
C#
108 lines
5.3 KiB
C#
using Shouldly;
|
|
using TwinCAT.Ads;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
/// <summary>The pure sum-read decisions (Gitea #645) — the part of the wire path a fake client cannot reach.</summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATSumReadPlannerTests
|
|
{
|
|
private static TwinCATPlcSymbolShape Plc(string name, int size, bool isFloat = false) =>
|
|
new(TwinCATPlcKind.Scalar, size, isFloat, name);
|
|
|
|
/// <summary>Verifies the strict width + kind rule: only an exact match rides a sum command, the lenient
|
|
/// CheckRead pairs (value-converted by Beckhoff on a single read) are read singly instead.</summary>
|
|
[Theory]
|
|
[InlineData(TwinCATDataType.DInt, "DINT", 4, false, true)]
|
|
[InlineData(TwinCATDataType.DInt, "UDINT", 4, false, true)] // same width + kind: tolerated
|
|
[InlineData(TwinCATDataType.Real, "REAL", 4, true, true)]
|
|
[InlineData(TwinCATDataType.Bool, "BOOL", 1, false, true)]
|
|
[InlineData(TwinCATDataType.DInt, "INT", 2, false, false)] // would over-read into the neighbour
|
|
[InlineData(TwinCATDataType.Real, "LREAL", 8, true, false)] // half a double
|
|
[InlineData(TwinCATDataType.Real, "DINT", 4, false, false)] // bit pattern reinterpreted
|
|
[InlineData(TwinCATDataType.DInt, "REAL", 4, true, false)]
|
|
[InlineData(TwinCATDataType.Int, "DINT", 4, false, false)] // truncation
|
|
public void Scalar_eligibility_needs_exact_width_and_kind(
|
|
TwinCATDataType declared, string plcType, int plcSize, bool plcFloat, bool eligible)
|
|
{
|
|
var shape = Plc(plcType, plcSize, plcFloat);
|
|
|
|
TwinCATSumReadPlanner.ScalarEligible(declared, shape).ShouldBe(eligible);
|
|
// The mismatching pairs are exactly the ones the lenient single-read check lets through.
|
|
if (!eligible) TwinCATTypeGuard.CheckRead(declared, shape).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies a string tag over a numeric symbol is not eligible.</summary>
|
|
[Fact]
|
|
public void A_string_tag_over_a_numeric_symbol_is_not_eligible() =>
|
|
TwinCATSumReadPlanner.ScalarEligible(TwinCATDataType.String, Plc("DINT", 4)).ShouldBeFalse();
|
|
|
|
/// <summary>Verifies only service / index-group errors latch; request-size errors split without latching.</summary>
|
|
[Theory]
|
|
[InlineData(1793u, true, false)]
|
|
[InlineData(1794u, true, false)]
|
|
[InlineData(1792u, false, true)]
|
|
[InlineData(1797u, false, true)]
|
|
[InlineData(1798u, false, true)]
|
|
[InlineData(1795u, false, false)]
|
|
[InlineData(6u, false, false)]
|
|
[InlineData(1861u, false, false)]
|
|
[InlineData(1808u, false, false)]
|
|
public void Overall_ads_codes_classify(uint code, bool refusal, bool rejected)
|
|
{
|
|
TwinCATSumReadPlanner.IsRefusal(code).ShouldBe(refusal);
|
|
TwinCATSumReadPlanner.IsRequestRejected(code).ShouldBe(rejected);
|
|
}
|
|
|
|
/// <summary>Verifies the byte budget splits a poll and gives an oversized item a group of its own.</summary>
|
|
[Fact]
|
|
public void Partition_respects_the_byte_budget()
|
|
{
|
|
// cost = length + 4: 104, 104, 104 | 1004 (own group) | 14
|
|
var groups = TwinCATSumReadPlanner.Partition([100, 100, 100, 1000, 10], 330);
|
|
|
|
groups.Select(g => g.ToArray()).ToArray().ShouldBe([[0, 1, 2], [3], [4]]);
|
|
}
|
|
|
|
/// <summary>Verifies an empty poll partitions to nothing.</summary>
|
|
[Fact]
|
|
public void Partition_of_nothing_is_empty() =>
|
|
TwinCATSumReadPlanner.Partition([], 1000).ShouldBeEmpty();
|
|
|
|
/// <summary>Verifies a Good block decodes to a scalar / an array.</summary>
|
|
[Fact]
|
|
public void Decode_returns_scalar_and_array_values()
|
|
{
|
|
byte[] four = [0x2A, 0, 0, 0];
|
|
var scalar = TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, AdsErrorCode.NoError, four,
|
|
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError);
|
|
scalar.ShouldBe((42, TwinCATStatusMapper.Good));
|
|
|
|
byte[] arr = [1, 0, 2, 0];
|
|
var array = TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.Int, AdsErrorCode.NoError, arr,
|
|
new TwinCATArrayLayout(2, 2), true, false, TwinCATStatusMapper.MapAdsError);
|
|
array.status.ShouldBe(TwinCATStatusMapper.Good);
|
|
array.value.ShouldBe(new short[] { 1, 2 });
|
|
}
|
|
|
|
/// <summary>Verifies a per-item ADS error maps to that item.</summary>
|
|
[Fact]
|
|
public void Decode_maps_a_sub_error() =>
|
|
TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, (AdsErrorCode)1808, null,
|
|
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError)
|
|
.ShouldBe((null, TwinCATStatusMapper.BadNodeIdUnknown));
|
|
|
|
/// <summary>Verifies a short or missing block is a size error for that item, never an uncaught throw.</summary>
|
|
[Fact]
|
|
public void Decode_of_a_short_or_missing_block_is_a_per_item_size_error()
|
|
{
|
|
var expected = (default(object), TwinCATStatusMapper.MapAdsError(1797));
|
|
TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, AdsErrorCode.NoError, new byte[] { 1, 2 },
|
|
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError).ShouldBe(expected);
|
|
TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, AdsErrorCode.NoError, null,
|
|
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError).ShouldBe(expected);
|
|
}
|
|
}
|