Files
Joseph Doherty 1f7ab80b81 fix(s7): a refused span falls back per member and is not regrouped (R07:P2)
A span's block is the union of its members' ranges, so one member that runs
past the DB's real end (or over-declares a STRING) made the CPU refuse the
whole block, and every neighbour within it was Bad on every poll. The
planner's stated invariant (a grouped read cannot fault where per-tag reads
would succeed) was false; the multi-var path already fell back, spans did not.

- A refused multi-member span hands its members back; they are read one at a
  time for that pass, bypassing the multi-var pass.
- _noGroup remembers the members refused on their own (or every member when
  none was) and keeps them out of later plans, so the rest group again. It is
  cleared by every initialize publish. Non-refusal span failures are
  unchanged (every member stamped).
- The once-per-tag refusal Warning memo (R07:H4) is now also cleared by the
  publish, and shutdown resets the write-side Degraded owner, as documented.
- Live gate: MW10 + MD254 (2 bytes past the 256-byte M area) merged into one
  refused block; MW10 keeps reading 4097 on every pass.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:30:12 -04:00

50 lines
2.3 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:P2 over real S7comm. The fixture's M area is 256 bytes. <c>MW10</c> is seeded
/// (4097); <c>MD254</c> runs two bytes past the end of the area. With the gap budget opened wide
/// the planner merges them into ONE block <c>MB10..258</c>, which the CPU refuses — before the fix
/// that made the perfectly readable <c>MW10</c> Bad (and the driver Degraded) on every poll.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500SpanRefusalTests(Snap7ServerFixture sim)
{
/// <summary>The good neighbour of an over-running member keeps reading its seeded value, every pass.</summary>
[Fact]
public async Task A_member_past_the_area_end_does_not_spoil_its_span_neighbour()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = new S7DriverOptions
{
Host = sim.Host, Port = sim.Port, CpuType = S7CpuType.S71500,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
// Wide enough that MW10 and MD254 land in one planned span.
SpanReadMaxGapBytes = 256,
SpanReadMaxSpanBytes = 256,
RawTags = S7RawTags.Entries(
new S7TagDefinition("Mk/Word10", "MW10", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Mk/PastEnd", "MD254", S7DataType.Int32, Writable: false)),
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-span-refusal-live");
await drv.InitializeAsync("{}", ct);
for (var pass = 0; pass < 3; pass++)
{
var r = await drv.ReadAsync(["Mk/Word10", "Mk/PastEnd"], ct);
r[0].StatusCode.ShouldBe(0u, $"pass {pass}: the in-range neighbour must be served");
Convert.ToInt32(r[0].Value).ShouldBe(4097);
r[1].StatusCode.ShouldNotBe(0u, "the over-running member is refused on its own");
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
}
}
}