test(focas): wire-level cases for fixed-tree health and the legacy alarm layout (#740 review I2)
Against focas-mock: - H15: a local TCP relay in front of the shared mock takes "the CNC" away and brings it back without stopping the fixture. A FIXED-only driver goes Degraded with LastSuccessfulRead frozen and its value Bad, then returns to Healthy with Good values (the positive control). - H20: the mock answers cnc_rdalmmsg2 in the 80-byte legacy layout. Nineteen alarms (1,520 B = 20 x 76) now decode as the 19 seeded alarms; the old length test gives 20, verified red. A 3-alarm reply is the control. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+206
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout)
|
||||
/// against the focas-mock fixture.
|
||||
/// </summary>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendHealthAndLayoutTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
/// <summary>Initializes a new instance of the test class with the FOCAS simulation fixture.</summary>
|
||||
/// <param name="fx">The FOCAS simulation fixture.</param>
|
||||
public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
/// <summary>
|
||||
/// A local TCP relay in front of the shared mock, so ONE test can take "the CNC" away and bring it back
|
||||
/// without stopping a fixture other suites use: <see cref="Down"/> closes every relayed session and stops
|
||||
/// listening (new connects are refused); <see cref="Up"/> listens again on the same port.
|
||||
/// </summary>
|
||||
private sealed class CncRelay : IAsyncDisposable
|
||||
{
|
||||
private readonly string _targetHost;
|
||||
private readonly int _targetPort;
|
||||
private readonly List<TcpClient> _live = [];
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public CncRelay(string targetHost, int targetPort)
|
||||
{
|
||||
_targetHost = targetHost;
|
||||
_targetPort = targetPort;
|
||||
Port = Listen(0);
|
||||
}
|
||||
|
||||
public int Port { get; private set; }
|
||||
|
||||
private int Listen(int port)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_listener.Start();
|
||||
var ct = _cts.Token;
|
||||
var listener = _listener;
|
||||
_ = Task.Run(() => AcceptLoopAsync(listener, ct), CancellationToken.None);
|
||||
return ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
TcpClient inbound;
|
||||
try { inbound = await listener.AcceptTcpClientAsync(ct); }
|
||||
catch { return; }
|
||||
var outbound = new TcpClient();
|
||||
try { await outbound.ConnectAsync(_targetHost, _targetPort, ct); }
|
||||
catch { inbound.Dispose(); outbound.Dispose(); continue; }
|
||||
lock (_live) { _live.Add(inbound); _live.Add(outbound); }
|
||||
_ = Pump(inbound, outbound, ct);
|
||||
_ = Pump(outbound, inbound, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Pump(TcpClient from, TcpClient to, CancellationToken ct)
|
||||
{
|
||||
try { await from.GetStream().CopyToAsync(to.GetStream(), ct); }
|
||||
catch { /* the relay was taken down, or a side closed */ }
|
||||
finally { from.Dispose(); to.Dispose(); }
|
||||
}
|
||||
|
||||
public void Down()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_listener?.Stop();
|
||||
lock (_live)
|
||||
{
|
||||
foreach (var c in _live) c.Dispose();
|
||||
_live.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void Up() => Listen(Port);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Down();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H15 over the wire: a driver serving only <c>FIXED:</c> tags is Healthy with a moving LastSuccessfulRead
|
||||
/// while its CNC answers; when the CNC disappears it degrades with LastSuccessfulRead frozen (the #527
|
||||
/// watchdog's conjunction); and it recovers to Healthy when the CNC returns (positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Fixed_only_driver_degrades_on_cnc_loss_and_recovers_over_the_wire()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
|
||||
await using var relay = new CncRelay(_fx.Host, _fx.Port);
|
||||
var host = $"focas://127.0.0.1:{relay.Port}";
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(host)],
|
||||
RawTags = FocasRawTags.Entries(
|
||||
new FocasTagDefinition("Series", host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)),
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
PollInterval = TimeSpan.FromMilliseconds(50),
|
||||
ProgramPollInterval = TimeSpan.Zero,
|
||||
TimerPollInterval = TimeSpan.Zero,
|
||||
},
|
||||
}, driverInstanceId: "wire-fixed-health", clientFactory: new WireFocasClientFactory())
|
||||
{
|
||||
FixedTreeStaleBudgetOverride = TimeSpan.FromMilliseconds(500),
|
||||
};
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
await WaitFor(() => drv.GetDeviceState(host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(10));
|
||||
var first = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await WaitFor(() => drv.GetHealth().LastSuccessfulRead > first, TimeSpan.FromSeconds(5));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
relay.Down();
|
||||
await WaitFor(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(15));
|
||||
var frozen = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "nothing answered, so LastSuccessfulRead stays frozen");
|
||||
(await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError,
|
||||
"the stale fixed-tree value reads Bad on the same budget that degrades the driver");
|
||||
|
||||
// Positive control: the CNC returns ⇒ Healthy again, LastSuccessfulRead moves. The connect backoff
|
||||
// (1 s doubling) bounds how soon the loop re-dials.
|
||||
relay.Up();
|
||||
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(40));
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
|
||||
(await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H20 over the wire: the mock answers <c>cnc_rdalmmsg2</c> in the 80-byte LEGACY layout. Nineteen active
|
||||
/// alarms make a 1,520-byte payload — also exactly 20 × 76 — which the old length test decoded as twenty
|
||||
/// garbage vendor records. Positive control: the unambiguous 3-alarm reply decodes the same alarms.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Nineteen_legacy_alarms_decode_as_nineteen_over_the_wire()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
|
||||
var client = new WireFocasClientFactory().Create();
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(FocasHostAddress.TryParse(_fx.DeviceUri)!, TimeSpan.FromSeconds(2), ct);
|
||||
|
||||
await _fx.PatchStateAsync(new { alarms = Alarms(3) }, ct);
|
||||
var three = await client.ReadAlarmsAsync(ct);
|
||||
three.Count.ShouldBe(3);
|
||||
three[2].AlarmNumber.ShouldBe(402);
|
||||
|
||||
await _fx.PatchStateAsync(new { alarms = Alarms(19) }, ct);
|
||||
var nineteen = await client.ReadAlarmsAsync(ct);
|
||||
nineteen.Count.ShouldBe(19, "a 1,520-byte legacy reply is 19 records, not 20 vendor ones");
|
||||
nineteen.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(400, 19));
|
||||
nineteen[18].Message.ShouldBe("SERVO ALARM 418");
|
||||
nineteen[18].Axis.ShouldBe((short)(1 + (18 % 4)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
}
|
||||
|
||||
static object[] Alarms(int n) => [.. Enumerable.Range(0, n)
|
||||
.Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })];
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
|
||||
await Task.Delay(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user