Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs
T

200 lines
9.9 KiB
C#

using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>
/// Offline pins for the differential's press-safety rules (Task 18 review r1). No controller is touched:
/// the "endpoint" is a loopback listener that accepts and immediately hangs up.
/// </summary>
[Collection(DifferentialCollection.Name)]
public sealed class DifferentialSafetyTests
{
[Fact]
public async Task A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect()
{
await using var endpoint = HangUpListener.Start();
var target = Target(endpoint.Port);
IReadOnlyList<SelectedTag> selection =
[
new("A", AbCipDataType.DInt, 1, false, "scalar"),
new("B", AbCipDataType.DInt, 1, false, "scalar"),
new("C", AbCipDataType.DInt, 1, false, "scalar"),
];
var ex = await Should.ThrowAsync<DifferentialAbortedException>(
() => DifferentialRun.RunNativePhaseAsync(target, selection, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("native phase at browse");
// Long enough for a throttled (≥ 1 s) reconnect to have reached the listener, had one been allowed.
await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken);
endpoint.Connections.ShouldBe(1);
Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored after the phase");
}
[Fact]
public async Task A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read()
{
await using var endpoint = HangUpListener.Start();
var target = Target(endpoint.Port);
var ex = await Should.ThrowAsync<DifferentialAbortedException>(
() => DifferentialRun.RunLibplctagPhaseAsync(target, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("libplctag phase at");
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
// libplctag's own session layer may redial inside its one anchor-create timeout; the harness cannot
// reach below that. Recorded, and bounded: one anchor create, never one per tag.
TestContext.Current.TestOutputHelper?.WriteLine($"libplctag connects before the abort: {endpoint.Connections}");
endpoint.Connections.ShouldBeInRange(1, 2);
}
[Fact]
public void The_default_selection_keeps_arrays_and_udt_members_at_any_cap()
{
var browsed = new TransportObservations();
for (var i = 0; i < 300; i++) browsed.Browse.Add(new AbCipDiscoveredTag($"S{i}", null, AbCipDataType.DInt, false));
for (var i = 0; i < 30; i++)
browsed.Browse.Add(new AbCipDiscoveredTag($"A{i}", null, AbCipDataType.Real, false, ElementCount: 4, IsArray: true));
browsed.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
browsed.Shapes[7] = new AbCipUdtShape("Udt", 400,
[.. Enumerable.Range(0, 25).Select(i => new AbCipUdtMember($"M{i}", i * 4, AbCipDataType.DInt, 1))]);
var at200 = DifferentialRun.Select(Target(1) with { MaxTags = 200 }, browsed);
at200.Count.ShouldBe(200);
at200.Count(t => t.Origin == "array").ShouldBe(20);
at200.Count(t => t.Origin == "udt member").ShouldBe(20);
at200.Count(t => t.Origin == "scalar").ShouldBe(160);
var at25 = DifferentialRun.Select(Target(1) with { MaxTags = 25 }, browsed);
at25.Count.ShouldBe(25);
at25.ShouldContain(t => t.Origin == "array");
at25.ShouldContain(t => t.Origin == "udt member");
at25.ShouldContain(t => t.Origin == "scalar");
DifferentialRun.SelectionCoverageProblems(Target(1) with { MaxTags = 25 }, browsed, at25).ShouldBeEmpty();
// The coverage check is not vacuous: a selection of scalars only is flagged for both kinds.
DifferentialRun.SelectionCoverageProblems(Target(1), browsed, [.. at200.Where(t => t.Origin == "scalar")])
.Count.ShouldBe(2);
}
[Fact]
public void Template_parity_fails_when_vacuous_or_undecodable()
{
var lib = new TransportObservations();
var native = new TransportObservations();
lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("vacuous");
lib.Shapes[7] = null;
native.Shapes[7] = null;
DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("undecodable");
var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]);
lib.Shapes[7] = shape;
native.Shapes[7] = shape with { Members = [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)] };
DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty();
}
[Fact]
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
{
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar,
DifferentialTarget.LatencyTagsVar, DifferentialTarget.LatencyPassesVar, DifferentialTarget.LatencyPassGapVar);
try
{
Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1");
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "501");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTagsVar);
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "500");
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, "9999");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTemplatesVar);
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, null);
var t =DifferentialTarget.FromEnvironment()!;
t.MaxTags.ShouldBe(500);
t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates);
t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs);
t.LatencyTags.ShouldBe(100);
t.LatencyPasses.ShouldBe(30);
t.LatencyPassGapMs.ShouldBe(200);
// Latency knobs (Task 21): the same fail-fast rule, with tighter ceilings.
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "201");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200");
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50");
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassGapVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "0");
var lat = DifferentialTarget.FromEnvironment()!;
(lat.LatencyTags, lat.LatencyPasses, lat.LatencyPassGapMs).ShouldBe((200, 50, 0));
}
finally
{
foreach (var (k, v) in saved) Environment.SetEnvironmentVariable(k, v);
}
}
private static DifferentialTarget Target(int port) =>
new("127.0.0.1", port, "1,0", AbCipPlcFamily.CompactLogix, null, 200, CountOpens: false, MaxTemplates: 50, PaceMs: 0);
private static (string, string?)[] Snapshot(params string[] names) =>
[.. names.Select(n => (n, Environment.GetEnvironmentVariable(n)))];
/// <summary>Accepts every connection and closes it at once, counting them.</summary>
private sealed class HangUpListener : IAsyncDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
private int _connections;
private HangUpListener()
{
_listener.Start();
_loop = Task.Run(AcceptLoopAsync);
}
public static HangUpListener Start() => new();
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public int Connections => Volatile.Read(ref _connections);
private async Task AcceptLoopAsync()
{
try
{
while (!_stop.IsCancellationRequested)
{
using var socket = await _listener.AcceptSocketAsync(_stop.Token);
Interlocked.Increment(ref _connections);
socket.Close();
}
}
catch (OperationCanceledException) { }
catch (SocketException) { }
catch (ObjectDisposedException) { }
}
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
_listener.Stop();
try { await _loop; } catch { /* stopped */ }
_stop.Dispose();
}
}
}