796 lines
42 KiB
C#
796 lines
42 KiB
C#
using S7.Net;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using S7NetDataType = global::S7.Net.DataType;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// Falsification gate for span-grouped S7 reads. Every tag must decode to the SAME value, the
|
|
/// same CLR box type and the same status code whether it was served by a wire request of its
|
|
/// own (the pre-existing per-tag path, reachable by setting
|
|
/// <c>SpanReadMaxGapBytes = -1</c>) or by a block read shared with its neighbours.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The two drivers read the same in-memory PLC image through the same fake, so any
|
|
/// difference between the two result sets is a defect in the grouping path — there is no
|
|
/// other variable. Grouping is proven to have actually engaged by
|
|
/// <see cref="Grouping_issues_far_fewer_wire_requests_than_there_are_tags"/>: without that
|
|
/// assertion this whole file would pass vacuously if the planner ever produced only
|
|
/// leftovers, because "leftover" IS the per-tag path.
|
|
/// </para>
|
|
/// <para>
|
|
/// The fake serves <c>ReadBytesAsync</c> (block reads — the span path, plus the wide /
|
|
/// array / Timer-Counter tags that always read blocks) and <c>ReadAsync(string)</c> (the
|
|
/// narrow per-tag path) from ONE byte image per <c>(area, DB)</c>, decoding the latter
|
|
/// exactly as S7.Net does: <c>DBX</c> boxes a <c>bool</c>, <c>DBB</c> a <c>byte</c>,
|
|
/// <c>DBW</c> a <c>ushort</c>, <c>DBD</c> a <c>uint</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7SpanReadParityTests
|
|
{
|
|
/// <summary>OPC UA <c>BadDeviceFailure</c> — what a device-layer read fault stamps.</summary>
|
|
private const uint StatusBadDeviceFailure = 0x808B0000u;
|
|
/// <summary>OPC UA <c>BadNodeIdUnknown</c> — what an unresolvable reference stamps.</summary>
|
|
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
|
/// <summary>
|
|
/// OPC UA <c>BadCommunicationError</c> — what the driver's catch-all failure mapping stamps,
|
|
/// which is where a malformed multi-var answer (an <c>InvalidDataException</c>) lands.
|
|
/// </summary>
|
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
|
|
|
// ── The mixed tag set ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A deliberately awkward batch: two Bools sharing one byte (bit 0 and bit 7), a Byte, a
|
|
/// word pair, three dwords behind a small hole, a wide Int64, an S7 STRING, a UInt16 array,
|
|
/// a far-away word past the gap budget, a second AREA, and a Counter (whose offset is a
|
|
/// device number, so it can never join a byte span).
|
|
/// </summary>
|
|
private static IReadOnlyList<RawTagEntry> MixedTags() => S7RawTags.Entries(
|
|
new S7TagDefinition("Bit0", "DB1.DBX0.0", S7DataType.Bool, Writable: false),
|
|
new S7TagDefinition("Bit7", "DB1.DBX0.7", S7DataType.Bool, Writable: false),
|
|
new S7TagDefinition("Byte1", "DB1.DBB1", S7DataType.Byte, Writable: false),
|
|
new S7TagDefinition("U16", "DB1.DBW2", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("I16", "DB1.DBW4", S7DataType.Int16, Writable: false),
|
|
new S7TagDefinition("U32", "DB1.DBD8", S7DataType.UInt32, Writable: false),
|
|
new S7TagDefinition("I32", "DB1.DBD12", S7DataType.Int32, Writable: false),
|
|
new S7TagDefinition("F32", "DB1.DBD16", S7DataType.Float32, Writable: false),
|
|
new S7TagDefinition("I64", "DB1.DBB24", S7DataType.Int64, Writable: false),
|
|
new S7TagDefinition("Str", "DB1.DBB36", S7DataType.String, Writable: false, StringLength: 10),
|
|
new S7TagDefinition("Arr", "DB1.DBW60", S7DataType.UInt16, Writable: false, ArrayCount: 4),
|
|
new S7TagDefinition("Far", "DB1.DBW200", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("Mem", "MW0", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false));
|
|
|
|
/// <summary>Read references, in the order the batch presents them.</summary>
|
|
private static readonly string[] MixedRefs =
|
|
[
|
|
"Bit0", "Bit7", "Byte1", "U16", "I16", "U32", "I32", "F32",
|
|
"Str", "I64", "Arr", "Far", "Mem", "Cnt",
|
|
];
|
|
|
|
/// <summary>Only the narrow, byte-addressed DB1 tags — every one of them lands in one span.</summary>
|
|
private static IReadOnlyList<RawTagEntry> NarrowTags() => S7RawTags.Entries(
|
|
new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: false));
|
|
|
|
/// <summary>
|
|
/// <paramref name="count"/> narrow words spaced 8 bytes apart. With a gap budget below 6
|
|
/// none of them can merge, so every one becomes a single-member span the driver demotes to
|
|
/// the leftover path — which is exactly the population the multi-var batch serves.
|
|
/// </summary>
|
|
/// <param name="count">How many scattered words to author.</param>
|
|
/// <returns>The raw-tag entries.</returns>
|
|
private static IReadOnlyList<RawTagEntry> ScatteredWords(int count) => S7RawTags.Entries(
|
|
[.. Enumerable.Range(0, count).Select(i =>
|
|
new S7TagDefinition($"S{i}", $"DB1.DBW{i * 8}", S7DataType.UInt16, Writable: false))]);
|
|
|
|
/// <summary>References for <see cref="ScatteredWords"/>, in authoring order.</summary>
|
|
/// <param name="count">How many scattered words the batch reads.</param>
|
|
/// <returns>The reference names.</returns>
|
|
private static string[] ScatteredRefs(int count) => [.. Enumerable.Range(0, count).Select(i => $"S{i}")];
|
|
|
|
/// <summary>
|
|
/// One of every narrow access width, spaced far enough apart that each is its own
|
|
/// single-member span — so the whole set reaches the multi-var pass and every box type
|
|
/// (<c>bool</c> / <c>byte</c> / <c>ushort</c> / <c>short</c> / <c>uint</c> / <c>int</c> /
|
|
/// <c>float</c>) is exercised through it.
|
|
/// </summary>
|
|
private static IReadOnlyList<RawTagEntry> ScatteredWidths() => S7RawTags.Entries(
|
|
new S7TagDefinition("WBit", "DB1.DBX0.3", S7DataType.Bool, Writable: false),
|
|
new S7TagDefinition("WByte", "DB1.DBB8", S7DataType.Byte, Writable: false),
|
|
new S7TagDefinition("WU16", "DB1.DBW16", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("WI16", "DB1.DBW24", S7DataType.Int16, Writable: false),
|
|
new S7TagDefinition("WU32", "DB1.DBD32", S7DataType.UInt32, Writable: false),
|
|
new S7TagDefinition("WI32", "DB1.DBD40", S7DataType.Int32, Writable: false),
|
|
new S7TagDefinition("WF32", "DB1.DBD48", S7DataType.Float32, Writable: false),
|
|
// A second AREA in the same batch — a multi-var request carries each item's own area/DB,
|
|
// which is precisely what a block read cannot do.
|
|
new S7TagDefinition("WMem", "MW0", S7DataType.UInt16, Writable: false));
|
|
|
|
/// <summary>References for <see cref="ScatteredWidths"/>, in authoring order.</summary>
|
|
private static readonly string[] ScatteredWidthRefs =
|
|
["WBit", "WByte", "WU16", "WI16", "WU32", "WI32", "WF32", "WMem"];
|
|
|
|
// ── Parity ───────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// THE gate: span-grouped reads and per-tag reads produce identical snapshots — same
|
|
/// value, same box type, same status — for every tag in the mixed batch.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Span_grouped_reads_match_the_per_tag_path_snapshot_for_snapshot()
|
|
{
|
|
var images = Images();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
|
|
var grouping = new ImageS7PlcFactory(images);
|
|
var perTag = new ImageS7PlcFactory(images);
|
|
|
|
using var groupedDriver = new S7Driver(Options(MixedTags(), maxGap: 16), "s7-grouped", grouping);
|
|
using var perTagDriver = new S7Driver(Options(MixedTags(), maxGap: -1), "s7-per-tag", perTag);
|
|
await groupedDriver.InitializeAsync("{}", ct);
|
|
await perTagDriver.InitializeAsync("{}", ct);
|
|
|
|
var grouped = await groupedDriver.ReadAsync(MixedRefs, ct);
|
|
var individual = await perTagDriver.ReadAsync(MixedRefs, ct);
|
|
|
|
grouped.Count.ShouldBe(MixedRefs.Length);
|
|
for (var i = 0; i < MixedRefs.Length; i++)
|
|
{
|
|
var name = MixedRefs[i];
|
|
grouped[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'");
|
|
grouped[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good");
|
|
grouped[i].Value.ShouldNotBeNull($"'{name}' returned no value");
|
|
grouped[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'");
|
|
grouped[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'");
|
|
}
|
|
|
|
// The per-tag control really did take the per-tag path (no grouping happened there): its
|
|
// only block reads are the four tags that ALWAYS read blocks (Int64, String, array, Counter).
|
|
perTag.ReadBytesCalls.ShouldBe(4);
|
|
perTag.StringReadCalls.ShouldBe(10);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proves grouping actually engaged. Without this the parity test above would pass
|
|
/// vacuously if the planner degenerated to all-leftovers, since a leftover IS a per-tag
|
|
/// read.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 14 tags collapse to 3 wire requests: ONE block read covering the eleven DB1 tags from
|
|
/// byte 0 to byte 68, the Counter's own unavoidable block read, and ONE multi-var request
|
|
/// carrying the two tags a span can't help — the far DB1 word (past the gap budget) and the
|
|
/// lone Memory word, which is in a different AREA entirely. Neither is worth a block read of
|
|
/// its own, and batching them together is something only a multi-var request can do.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Grouping_issues_far_fewer_wire_requests_than_there_are_tags()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images());
|
|
using var driver = new S7Driver(Options(MixedTags(), maxGap: 16), "s7-wire-count", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
_ = await driver.ReadAsync(MixedRefs, ct);
|
|
|
|
(factory.ReadBytesCalls + factory.StringReadCalls + factory.MultiVarCalls)
|
|
.ShouldBeLessThan(MixedRefs.Length);
|
|
factory.ReadBytesCalls.ShouldBeLessThan(MixedRefs.Length);
|
|
factory.ReadBytesCalls.ShouldBe(2);
|
|
// NO per-address reads at all: the nine narrow tags inside the big span are served by its
|
|
// block read, and the two that ended up alone in a span of their own are batched together
|
|
// into a single multi-var request.
|
|
factory.StringReadCalls.ShouldBe(0);
|
|
factory.MultiVarCalls.ShouldBe(1);
|
|
}
|
|
|
|
// ── Failure parity ───────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A failed span stamps EVERY one of its members with exactly the status the per-tag path
|
|
/// would have produced for that exception, and degrades health identically. A span read is
|
|
/// one wire request serving many tags, so a fault that used to affect one tag now affects
|
|
/// all of them — they must all be told the same thing.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine
|
|
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded.
|
|
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
|
|
|
var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
|
|
var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
|
|
|
|
using var groupedDriver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-grouped-fail", groupingFactory);
|
|
using var perTagDriver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-per-tag-fail", perTagFactory);
|
|
await groupedDriver.InitializeAsync("{}", ct);
|
|
await perTagDriver.InitializeAsync("{}", ct);
|
|
|
|
string[] refs = ["A", "B", "C"];
|
|
var grouped = await groupedDriver.ReadAsync(refs, ct);
|
|
var individual = await perTagDriver.ReadAsync(refs, ct);
|
|
|
|
// Every member of the failed span — not just the first — is stamped Bad.
|
|
grouped.Count.ShouldBe(3);
|
|
grouped.ShouldAllBe(s => s.StatusCode == StatusBadDeviceFailure);
|
|
grouped.ShouldAllBe(s => s.Value == null);
|
|
grouped.Select(s => s.StatusCode).ShouldBe(individual.Select(s => s.StatusCode));
|
|
|
|
groupedDriver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
groupedDriver.GetHealth().State.ShouldBe(perTagDriver.GetHealth().State);
|
|
|
|
// One failed block read, not three — the span path fails once for the whole group.
|
|
groupingFactory.ReadBytesCalls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unresolved references still stamp <c>BadNodeIdUnknown</c> and never enter a span, and
|
|
/// the resolvable tags around them still read Good.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_unresolved_reference_is_still_BadNodeIdUnknown_under_grouping()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images());
|
|
using var driver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-unknown", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
var results = await driver.ReadAsync(["A", "NoSuchTag", "C"], ct);
|
|
|
|
results[0].StatusCode.ShouldBe(0u);
|
|
results[1].StatusCode.ShouldBe(StatusBadNodeIdUnknown);
|
|
results[1].Value.ShouldBeNull();
|
|
results[2].StatusCode.ShouldBe(0u);
|
|
}
|
|
|
|
// ── Multi-var leftovers ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The batchable leftovers ride ONE multi-var request per chunk of
|
|
/// <c>MultiVarReadMaxItems</c> and issue no per-address reads at all. 13 scattered words
|
|
/// that the span planner cannot merge used to cost 13 wire requests; they now cost 2.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Batchable_leftovers_ride_multi_var_requests_instead_of_one_read_each()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images());
|
|
using var driver = new S7Driver(Options(ScatteredWords(13), maxGap: 2), "s7-multivar", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
var results = await driver.ReadAsync(ScatteredRefs(13), ct);
|
|
|
|
results.ShouldAllBe(s => s.StatusCode == 0u);
|
|
results.ShouldAllBe(s => s.Value != null);
|
|
// ⌈13 / 12⌉ = 2 requests, and NOT ONE per-address read — the whole point of the batch.
|
|
factory.MultiVarCalls.ShouldBe(2);
|
|
factory.StringReadCalls.ShouldBe(0);
|
|
factory.ReadBytesCalls.ShouldBe(0);
|
|
// The chunking is a real split, not one oversized request plus an empty one.
|
|
factory.MultiVarItems.Select(c => c.Count).ShouldBe([12, 1]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// THE multi-var gate: a tag served by a multi-var request decodes to the same value, the
|
|
/// same CLR box type and the same status as the per-tag path reading the same image.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Multi_var_leftovers_match_the_per_tag_path_snapshot_for_snapshot()
|
|
{
|
|
var images = Images();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
|
|
var batching = new ImageS7PlcFactory(images);
|
|
var perTag = new ImageS7PlcFactory(images);
|
|
|
|
using var batchingDriver = new S7Driver(Options(ScatteredWidths(), maxGap: 2), "s7-mv-parity", batching);
|
|
using var perTagDriver = new S7Driver(Options(ScatteredWidths(), maxGap: -1), "s7-mv-control", perTag);
|
|
await batchingDriver.InitializeAsync("{}", ct);
|
|
await perTagDriver.InitializeAsync("{}", ct);
|
|
|
|
var batched = await batchingDriver.ReadAsync(ScatteredWidthRefs, ct);
|
|
var individual = await perTagDriver.ReadAsync(ScatteredWidthRefs, ct);
|
|
|
|
for (var i = 0; i < ScatteredWidthRefs.Length; i++)
|
|
{
|
|
var name = ScatteredWidthRefs[i];
|
|
batched[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good");
|
|
batched[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'");
|
|
batched[i].Value.ShouldNotBeNull($"'{name}' returned no value");
|
|
batched[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'");
|
|
batched[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'");
|
|
}
|
|
|
|
// Proves the batching side really took the multi-var path (otherwise this file's parity
|
|
// claim would be vacuous), and the control side really did not.
|
|
batching.MultiVarCalls.ShouldBe(1);
|
|
batching.StringReadCalls.ShouldBe(0);
|
|
perTag.MultiVarCalls.ShouldBe(0);
|
|
perTag.StringReadCalls.ShouldBe(ScatteredWidthRefs.Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A multi-var request that faults falls back to the per-tag path for that chunk's tags and
|
|
/// still serves them Good. Batching is an optimisation: it must never be able to turn a tag
|
|
/// Bad that reading it one at a time would have served.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_failing_multi_var_chunk_falls_back_to_per_tag_reads_and_still_serves_good()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images())
|
|
{
|
|
// A device-layer fault — the shape that WOULD stamp BadDeviceFailure had it come from
|
|
// the per-tag path. Through the multi-var pass it must cost nothing but a retry.
|
|
MultiVarFault = new PlcException(ErrorCode.ReadData, "S7 CPU rejected the multi-var request"),
|
|
};
|
|
using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-fallback", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
var results = await driver.ReadAsync(ScatteredRefs(5), ct);
|
|
|
|
results.ShouldAllBe(s => s.StatusCode == 0u);
|
|
results.ShouldAllBe(s => s.Value != null);
|
|
factory.MultiVarCalls.ShouldBe(1);
|
|
// Every tag of the failed chunk was re-read individually.
|
|
factory.StringReadCalls.ShouldBe(5);
|
|
// And the failed batch attempt did NOT degrade the driver — nothing actually failed to read.
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tags the per-tag path owns — Timer and Counter, whose offsets are device numbers rather
|
|
/// than byte addresses — are never handed to a multi-var request, even though they share the
|
|
/// leftover list with the narrow scalars that are.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Timer_and_counter_leftovers_never_enter_a_multi_var_request()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var tags = S7RawTags.Entries(
|
|
new S7TagDefinition("Tmr", "T2", S7DataType.Float64, Writable: false),
|
|
new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false),
|
|
new S7TagDefinition("N0", "DB1.DBW0", S7DataType.UInt16, Writable: false),
|
|
new S7TagDefinition("N1", "DB1.DBW100", S7DataType.UInt16, Writable: false));
|
|
var factory = new ImageS7PlcFactory(Images());
|
|
using var driver = new S7Driver(Options(tags, maxGap: 2), "s7-mv-partition", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
var results = await driver.ReadAsync(["Tmr", "Cnt", "N0", "N1"], ct);
|
|
|
|
results.ShouldAllBe(s => s.StatusCode == 0u);
|
|
results[0].Value.ShouldBeOfType<double>();
|
|
results[1].Value.ShouldBeOfType<int>();
|
|
|
|
// One multi-var request carrying ONLY the two narrow words; the Timer and the Counter kept
|
|
// their own block reads.
|
|
factory.MultiVarCalls.ShouldBe(1);
|
|
factory.MultiVarItems[0].Count.ShouldBe(2);
|
|
factory.MultiVarItems[0].ShouldAllBe(d => d.DataType == S7NetDataType.DataBlock);
|
|
factory.ReadBytesCalls.ShouldBe(2);
|
|
}
|
|
|
|
// ── Malformed multi-var answers ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// ONE undecodable item in an otherwise-good multi-var chunk spoils only its own tag: its
|
|
/// chunk-mates keep the values the batch already carried, and NOBODY is re-read individually.
|
|
/// A per-ITEM decode fault is not a chunk fault — the whole-chunk fallback exists for a
|
|
/// failed REQUEST, and firing it here would re-read tags that already have good values.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Health ends <c>Healthy</c> rather than <c>Degraded</c> because the decode loop stamps
|
|
/// health per item in chunk order, so the good item after the bad one re-stamps it. That is
|
|
/// the same last-writer-wins behaviour the per-tag loop has always had; it is pinned here so
|
|
/// a change to it is a deliberate one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task One_undecodable_multi_var_item_stamps_only_its_own_tag_Bad()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var images = Images();
|
|
var factory = new ImageS7PlcFactory(images) { NullItemIndex = 1 };
|
|
using var driver = new S7Driver(Options(ScatteredWords(3), maxGap: 2), "s7-mv-item-fault", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
var results = await driver.ReadAsync(ScatteredRefs(3), ct);
|
|
|
|
// The one item the CPU answered with nothing.
|
|
results[1].StatusCode.ShouldBe(StatusBadCommunicationError);
|
|
results[1].Value.ShouldBeNull();
|
|
|
|
// Its chunk-mates are untouched, values and all (S{i} lives at DB1.DBW{i * 8}).
|
|
var db1 = images[(S7NetDataType.DataBlock, 1)];
|
|
static ushort Word(byte[] image, int at) => (ushort)((image[at] << 8) | image[at + 1]);
|
|
results[0].StatusCode.ShouldBe(0u);
|
|
results[0].Value.ShouldBe(Word(db1, 0));
|
|
results[2].StatusCode.ShouldBe(0u);
|
|
results[2].Value.ShouldBe(Word(db1, 16));
|
|
|
|
// One batch, and NO per-tag fallback at all — not even for the tag that failed to decode,
|
|
// which would have failed identically on the per-tag path.
|
|
factory.MultiVarCalls.ShouldBe(1);
|
|
factory.StringReadCalls.ShouldBe(0);
|
|
factory.ReadBytesCalls.ShouldBe(0);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An answer of the wrong SHAPE — one item short — cannot be mapped back onto the request's
|
|
/// indexes, so the driver refuses to guess which tag got which value and falls the whole
|
|
/// chunk back to the per-tag path, which still serves every tag Good.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_short_multi_var_answer_falls_back_to_per_tag_reads_and_still_serves_good()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images()) { ShortAnswer = true };
|
|
using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-shape", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
|
|
var results = await driver.ReadAsync(ScatteredRefs(5), ct);
|
|
|
|
results.ShouldAllBe(s => s.StatusCode == 0u);
|
|
results.ShouldAllBe(s => s.Value != null);
|
|
factory.MultiVarCalls.ShouldBe(1);
|
|
// Every tag of the rejected chunk was re-read individually — including the four the short
|
|
// answer DID carry, because a mis-shaped answer says nothing trustworthy about any of them.
|
|
factory.StringReadCalls.ShouldBe(5);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
// ── Cancellation mid-read (STAB-15a) ─────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Cancellation observed while a SPAN's block read is in flight abandons a half-read
|
|
/// ISO-on-TCP response on the single gated stream, so the handle can't be reused: the OCE
|
|
/// must propagate (the caller cancelled — nobody consumes the batch) AND the next read must
|
|
/// open a fresh connection. The per-tag path's sibling of this is pinned by
|
|
/// <c>S7DriverReconnectTests.Cancelled_read_marks_handle_dead_and_next_read_reopens</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_cancelled_span_block_read_marks_the_handle_dead_and_the_next_read_reopens()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images()) { HangReadBytes = true };
|
|
using var driver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-span-cancel", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
factory.CreatedPlcCount.ShouldBe(1);
|
|
|
|
string[] refs = ["A", "B", "C"];
|
|
using var cts = new CancellationTokenSource();
|
|
var reading = driver.ReadAsync(refs, cts.Token);
|
|
await factory.ReadBytesEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
|
await cts.CancelAsync();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(async () => await reading);
|
|
|
|
// The reopen is the observable half of the contract: the desynced handle was DISPOSED — not
|
|
// merely leaked behind a second connection — and the next read builds a new one.
|
|
factory.HangReadBytes = false;
|
|
var recovered = await driver.ReadAsync(refs, ct);
|
|
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
|
factory.CreatedPlcCount.ShouldBe(2);
|
|
factory.Plcs[0].Disposed.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same contract on the MULTI-VAR leftover path: a cancelled batch request propagates
|
|
/// the OCE and marks the handle dead, so the next read reopens. Distinct from the per-chunk
|
|
/// fallback — cancellation is the one request fault that must NOT be retried per tag.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_cancelled_multi_var_read_marks_the_handle_dead_and_the_next_read_reopens()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ImageS7PlcFactory(Images()) { HangMultiVar = true };
|
|
using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-cancel", factory);
|
|
await driver.InitializeAsync("{}", ct);
|
|
factory.CreatedPlcCount.ShouldBe(1);
|
|
|
|
var refs = ScatteredRefs(5);
|
|
using var cts = new CancellationTokenSource();
|
|
var reading = driver.ReadAsync(refs, cts.Token);
|
|
await factory.MultiVarEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
|
await cts.CancelAsync();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(async () => await reading);
|
|
|
|
factory.HangMultiVar = false;
|
|
var recovered = await driver.ReadAsync(refs, ct);
|
|
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
|
factory.CreatedPlcCount.ShouldBe(2);
|
|
factory.Plcs[0].Disposed.ShouldBeTrue();
|
|
}
|
|
|
|
// ── Configurability ──────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The grouping knobs must be reachable from a deployed driver config. <c>ParseOptions</c>
|
|
/// maps an explicit DTO rather than deserialising <see cref="S7DriverOptions"/>, so a knob
|
|
/// that is not mapped there is silently pinned to its default forever — which would leave
|
|
/// the documented "-1 disables grouping" escape hatch unreachable in production.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_span_read_knobs_bind_from_driver_config_json()
|
|
{
|
|
var configured = S7DriverFactoryExtensions.ParseOptions(
|
|
"s7-cfg",
|
|
"""{"host":"10.0.0.1","spanReadMaxGapBytes":-1,"spanReadMaxSpanBytes":64}""");
|
|
|
|
configured.SpanReadMaxGapBytes.ShouldBe(-1);
|
|
configured.SpanReadMaxSpanBytes.ShouldBe(64);
|
|
|
|
// Omitted keys keep the shipped defaults rather than collapsing to zero.
|
|
var defaulted = S7DriverFactoryExtensions.ParseOptions("s7-cfg", """{"host":"10.0.0.1"}""");
|
|
defaulted.SpanReadMaxGapBytes.ShouldBe(S7DriverOptions.DefaultSpanReadMaxGapBytes);
|
|
defaulted.SpanReadMaxSpanBytes.ShouldBe(S7DriverOptions.DefaultSpanReadMaxSpanBytes);
|
|
}
|
|
|
|
// ── Fixture ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Driver options over the given tags with the span-grouping knobs under test.</summary>
|
|
private static S7DriverOptions Options(IReadOnlyList<RawTagEntry> tags, int maxGap) => new()
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromMilliseconds(250),
|
|
Probe = new S7ProbeOptions { Enabled = false },
|
|
SpanReadMaxGapBytes = maxGap,
|
|
SpanReadMaxSpanBytes = 220,
|
|
RawTags = tags,
|
|
};
|
|
|
|
/// <summary>
|
|
/// The in-memory PLC images the fake serves, one byte array per <c>(area, DB)</c>. Filled
|
|
/// with a deterministic non-repeating pattern so no tag reads a vacuous all-zero value, then
|
|
/// seeded with a well-formed S7 STRING field at DB1 byte 36 (<c>[maxLen][curLen][chars…]</c>,
|
|
/// which <c>S7String.FromByteArray</c> validates).
|
|
/// </summary>
|
|
private static Dictionary<(S7NetDataType Area, int Db), byte[]> Images()
|
|
{
|
|
var db1 = new byte[256];
|
|
for (var i = 0; i < db1.Length; i++) db1[i] = (byte)((i * 37 + 11) & 0xFF);
|
|
|
|
db1[36] = 10; // declared max length
|
|
db1[37] = 5; // current length
|
|
var chars = "HELLO"u8.ToArray();
|
|
chars.CopyTo(db1, 38);
|
|
for (var i = 38 + chars.Length; i < 36 + 12; i++) db1[i] = 0x20; // reserved padding
|
|
|
|
var mem = new byte[64];
|
|
for (var i = 0; i < mem.Length; i++) mem[i] = (byte)((i * 53 + 7) & 0xFF);
|
|
|
|
var counters = new byte[64];
|
|
for (var i = 0; i < counters.Length; i++) counters[i] = (byte)((i * 29 + 3) & 0xFF);
|
|
|
|
var timers = new byte[64];
|
|
for (var i = 0; i < timers.Length; i++) timers[i] = (byte)((i * 17 + 5) & 0xFF);
|
|
|
|
return new Dictionary<(S7NetDataType, int), byte[]>
|
|
{
|
|
[(S7NetDataType.DataBlock, 1)] = db1,
|
|
[(S7NetDataType.Memory, 0)] = mem,
|
|
[(S7NetDataType.Counter, 0)] = counters,
|
|
[(S7NetDataType.Timer, 0)] = timers,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake <see cref="IS7PlcFactory"/> serving both read shapes from one shared byte image per
|
|
/// <c>(area, DB)</c>, and counting each shape so a test can prove which path ran. Counters
|
|
/// live on the FACTORY (not the connection) so a reconnect cannot reset them.
|
|
/// </summary>
|
|
private sealed class ImageS7PlcFactory(Dictionary<(S7NetDataType Area, int Db), byte[]> images) : IS7PlcFactory
|
|
{
|
|
/// <summary>The shared per-area byte images both read shapes decode from.</summary>
|
|
public Dictionary<(S7NetDataType Area, int Db), byte[]> Images { get; } = images;
|
|
|
|
/// <summary>Number of block reads issued — one per planned span, plus the always-block tags.</summary>
|
|
public int ReadBytesCalls;
|
|
|
|
/// <summary>Number of per-address string reads issued — the un-grouped narrow path.</summary>
|
|
public int StringReadCalls;
|
|
|
|
/// <summary>Number of multi-var requests issued — one per leftover chunk.</summary>
|
|
public int MultiVarCalls;
|
|
|
|
/// <summary>The items of every multi-var request, in issue order, so a test can assert what was batched.</summary>
|
|
public List<IReadOnlyList<global::S7.Net.Types.DataItem>> MultiVarItems { get; } = new();
|
|
|
|
/// <summary>When set, every read throws this instead of serving the image.</summary>
|
|
public Exception? ReadFault { get; init; }
|
|
|
|
/// <summary>When set, only the MULTI-VAR read throws this — the per-tag fallback still serves the image.</summary>
|
|
public Exception? MultiVarFault { get; init; }
|
|
|
|
/// <summary>
|
|
/// When true, <c>ReadBytesAsync</c> hangs until its token is cancelled, so cancellation is
|
|
/// the only way out of a block read (STAB-15a). Settable so a test can turn it back off
|
|
/// after observing the cancellation and let the reopened handle read normally.
|
|
/// </summary>
|
|
public bool HangReadBytes { get; set; }
|
|
|
|
/// <summary>When true, <c>ReadMultipleVarsAsync</c> hangs until its token is cancelled (STAB-15a).</summary>
|
|
public bool HangMultiVar { get; set; }
|
|
|
|
/// <summary>Completed the moment a hanging <c>ReadBytesAsync</c> is entered, so a test cancels a read that is genuinely in flight.</summary>
|
|
public TaskCompletionSource ReadBytesEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
/// <summary>Completed the moment a hanging <c>ReadMultipleVarsAsync</c> is entered.</summary>
|
|
public TaskCompletionSource MultiVarEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
/// <summary>
|
|
/// When set, the multi-var answer comes back the right shape but with THIS item's
|
|
/// <c>Value</c> left null — the per-item decode fault, which must spoil only that tag.
|
|
/// </summary>
|
|
public int? NullItemIndex { get; init; }
|
|
|
|
/// <summary>
|
|
/// When true, the multi-var answer comes back ONE item short — a shape the driver cannot
|
|
/// map back onto the request's indexes, so it must reject the whole chunk.
|
|
/// </summary>
|
|
public bool ShortAnswer { get; init; }
|
|
|
|
/// <summary>How many connections this factory has been asked to build — the reopen observable.</summary>
|
|
public int CreatedPlcCount;
|
|
|
|
/// <summary>
|
|
/// Every connection this factory built, in build order, so a test can assert what became
|
|
/// of the STALE one — a driver that leaked the old handle and merely opened a second
|
|
/// would satisfy <see cref="CreatedPlcCount"/> alone.
|
|
/// </summary>
|
|
public List<ImageS7Plc> Plcs { get; } = new();
|
|
|
|
/// <inheritdoc />
|
|
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
|
{
|
|
Interlocked.Increment(ref CreatedPlcCount);
|
|
var plc = new ImageS7Plc(this);
|
|
lock (Plcs) Plcs.Add(plc);
|
|
return plc;
|
|
}
|
|
|
|
public sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc
|
|
{
|
|
/// <inheritdoc />
|
|
public bool IsConnected { get; private set; }
|
|
|
|
/// <summary>True once the driver has disposed this handle — the discard half of a reopen.</summary>
|
|
public bool Disposed { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
|
|
|
|
/// <inheritdoc />
|
|
public void Close() => IsConnected = false;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
|
|
{
|
|
// Counted BEFORE the injected fault so a test can assert how many block reads were
|
|
// ATTEMPTED — a failure test that counted only successes would read zero.
|
|
Interlocked.Increment(ref owner.ReadBytesCalls);
|
|
if (owner.HangReadBytes)
|
|
{
|
|
// Signal first, then hang: the test cancels only once the read is genuinely in
|
|
// flight, so it exercises cancel-mid-I/O rather than racing a timer.
|
|
owner.ReadBytesEntered.TrySetResult();
|
|
await Task.Delay(System.Threading.Timeout.Infinite, ct).ConfigureAwait(false);
|
|
}
|
|
if (owner.ReadFault is { } fault) throw fault;
|
|
var image = owner.Images[(area, db)];
|
|
if (startByteAdr + count > image.Length)
|
|
{
|
|
throw new PlcException(ErrorCode.ReadData,
|
|
$"fake image overrun: {area} db{db} @{startByteAdr}+{count} exceeds {image.Length}");
|
|
}
|
|
return image[startByteAdr..(startByteAdr + count)];
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Decodes each item from the same byte image the other two read shapes serve, boxing
|
|
/// exactly as S7.Net's <c>ParseBytes</c> does for <c>Count == 1</c>: <c>Bit</c> a
|
|
/// <c>bool</c> at <c>BitAdr</c>, <c>Byte</c> a <c>byte</c>, <c>Word</c> a
|
|
/// <c>ushort</c>, <c>DWord</c> a <c>uint</c>. Like the real library it populates each
|
|
/// item's <c>Value</c> in place and hands the same list back.
|
|
/// </remarks>
|
|
public async Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
|
|
List<global::S7.Net.Types.DataItem> items, CancellationToken ct)
|
|
{
|
|
// Counted (and captured) BEFORE the injected fault so a fallback test can assert the
|
|
// batch was ATTEMPTED — counting only successes would read zero.
|
|
Interlocked.Increment(ref owner.MultiVarCalls);
|
|
lock (owner.MultiVarItems) owner.MultiVarItems.Add([.. items]);
|
|
if (owner.HangMultiVar)
|
|
{
|
|
owner.MultiVarEntered.TrySetResult();
|
|
await Task.Delay(System.Threading.Timeout.Infinite, ct).ConfigureAwait(false);
|
|
}
|
|
if (owner.MultiVarFault is { } mvFault) throw mvFault;
|
|
if (owner.ReadFault is { } fault) throw fault;
|
|
|
|
foreach (var item in items)
|
|
{
|
|
var image = owner.Images[(item.DataType, item.DB)];
|
|
var at = item.StartByteAdr;
|
|
item.Value = item.VarType switch
|
|
{
|
|
VarType.Bit => (object)((image[at] & (1 << item.BitAdr)) != 0),
|
|
VarType.Byte => image[at],
|
|
VarType.Word => (ushort)((image[at] << 8) | image[at + 1]),
|
|
VarType.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16)
|
|
| ((uint)image[at + 2] << 8) | image[at + 3],
|
|
_ => throw new NotSupportedException($"fake cannot serve VarType {item.VarType}"),
|
|
};
|
|
}
|
|
|
|
// The two malformed-answer shapes, injected AFTER decoding so the answer is as good
|
|
// as any other batch's apart from the one defect under test.
|
|
if (owner.NullItemIndex is { } nullAt) items[nullAt].Value = null;
|
|
if (owner.ShortAnswer) return items.GetRange(0, items.Count - 1);
|
|
|
|
return items;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<object?> ReadAsync(string address, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref owner.StringReadCalls);
|
|
if (owner.ReadFault is { } fault) throw fault;
|
|
var addr = S7AddressParser.Parse(address);
|
|
var image = owner.Images[(ToS7NetArea(addr.Area), addr.DbNumber)];
|
|
var at = addr.ByteOffset;
|
|
|
|
// Box exactly as S7.Net's string read does — the size suffix picks the type.
|
|
return Task.FromResult<object?>(addr.Size switch
|
|
{
|
|
S7Size.Bit => (object)((image[at] & (1 << addr.BitOffset)) != 0),
|
|
S7Size.Byte => image[at],
|
|
S7Size.Word => (ushort)((image[at] << 8) | image[at + 1]),
|
|
S7Size.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16)
|
|
| ((uint)image[at + 2] << 8) | image[at + 3],
|
|
_ => throw new NotSupportedException($"fake cannot serve size {addr.Size}"),
|
|
});
|
|
}
|
|
|
|
/// <summary>Mirrors the driver's own area mapping so both read shapes hit the same image.</summary>
|
|
private static S7NetDataType ToS7NetArea(S7Area area) => area switch
|
|
{
|
|
S7Area.DataBlock => S7NetDataType.DataBlock,
|
|
S7Area.Memory => S7NetDataType.Memory,
|
|
S7Area.Input => S7NetDataType.Input,
|
|
S7Area.Output => S7NetDataType.Output,
|
|
S7Area.Timer => S7NetDataType.Timer,
|
|
S7Area.Counter => S7NetDataType.Counter,
|
|
_ => throw new NotSupportedException($"fake cannot map area {area}"),
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public Task WriteAsync(string address, object value, CancellationToken ct) => Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
IsConnected = false;
|
|
Disposed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|