Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs
T
Joseph Doherty de54b23b1a 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
2026-10-22 14:44:42 -04:00

207 lines
8.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}