diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs new file mode 100644 index 00000000..30d39394 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs @@ -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; + +/// +/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout) +/// against the focas-mock fixture. +/// +[Collection(FocasSimCollection.Name)] +public sealed class WireBackendHealthAndLayoutTests +{ + private readonly FocasSimFixture _fx; + + /// Initializes a new instance of the test class with the FOCAS simulation fixture. + /// The FOCAS simulation fixture. + public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx; + + /// + /// 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: closes every relayed session and stops + /// listening (new connects are refused); listens again on the same port. + /// + private sealed class CncRelay : IAsyncDisposable + { + private readonly string _targetHost; + private readonly int _targetPort; + private readonly List _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; + } + } + + /// + /// H15 over the wire: a driver serving only FIXED: 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). + /// + [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); + } + } + + /// + /// H20 over the wire: the mock answers cnc_rdalmmsg2 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. + /// + [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() }, 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 condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); + await Task.Delay(25); + } + } +}