using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
///
/// Live end-to-end proof of the STAB-1 reconnect path against the real python-snap7
/// S7-1500 profile: a running survives a full PLC bounce (container
/// stop/start) and resumes reading without a redeploy — the exact failure the
/// arch-review flagged (a transient drop permanently killed the driver until redeploy).
///
///
///
/// Double-gated so it never runs destructively by accident:
///
/// - — the sim must be reachable; and
/// - S7_RECONNECT_BOUNCE_CMD — the shell command that bounces the PLC/container
/// (e.g. ssh dohertj2@10.100.0.35 docker restart otopcua-python-snap7-s7_1500).
/// Absent ⇒ the test skips, because bouncing the shared sim would disrupt other suites.
///
/// There is no per-test container hook in the harness, so the bounce is delegated to this
/// operator-supplied command — the automation the plan called a "manual recipe", captured in
/// code so it is reproducible.
///
///
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Category", "Reconnect")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500ReconnectTests(Snap7ServerFixture sim)
{
private const string BounceCmdEnvVar = "S7_RECONNECT_BOUNCE_CMD";
///
/// Reads a seeded tag Good, bounces the PLC out-of-band, observes the read fail (Degraded),
/// then asserts the driver reopens on its own and the tag returns to Good — no redeploy.
///
[Fact]
public async Task Driver_recovers_reads_after_a_live_PLC_bounce_without_redeploy()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var bounceCmd = Environment.GetEnvironmentVariable(BounceCmdEnvVar);
if (string.IsNullOrWhiteSpace(bounceCmd))
Assert.Skip($"Set {BounceCmdEnvVar} to the command that bounces the S7 sim to run this destructive test.");
var ct = TestContext.Current.CancellationToken;
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-reconnect-live");
await drv.InitializeAsync("{}", ct);
// Baseline — a Good read against the live sim.
(await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Bounce the PLC out-of-band (stop → start), then confirm the driver both NOTICES the drop
// (at least one non-Good read) and RECOVERS to Good on its own within a bounded window.
await RunBounceCommandAsync(bounceCmd!, ct);
var sawOutage = false;
var recovered = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
while (DateTime.UtcNow < deadline)
{
var status = (await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode;
if (status != 0u) sawOutage = true;
else if (sawOutage) { recovered = true; break; }
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
}
sawOutage.ShouldBeTrue("the driver should have observed the PLC outage as a failed read");
recovered.ShouldBeTrue("the driver should have reopened the connection and resumed Good reads without a redeploy");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static async Task RunBounceCommandAsync(string command, CancellationToken ct)
{
// Run through the login shell so an SSH/docker one-liner in the env var works verbatim.
using var proc = new Process
{
StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
},
};
proc.Start();
await proc.WaitForExitAsync(ct);
proc.ExitCode.ShouldBe(0, $"bounce command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
// Give the container a moment to release the port before the first post-bounce read.
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}