using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
///
/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
/// : a bridge (DHRIO / 1761-NET-ENI) in front of a dead DH+ node answers every
/// PCCC request with STS 0x30 "remote node host missing". That is NOT the device answering, so the driver must treat
/// it as a comms fault — Degraded, LastSuccessfulRead not advanced — or the comms-loss watchdog never fires
/// for it. The positive control is a live device refusing a tag (STS 0x60, command protection): per-tag Bad, Healthy,
/// LastSuccessfulRead advanced.
///
[Trait("Category", "Unit")]
public sealed class NativePcccBridgeStatusHealthTests
{
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
/// A fake whose Execute PCCC envelope itself is refused with CIP general status .
private static FakeEipServer CipEnvelopeServer(byte general)
{
var server = new FakeEipServer { ZeroReplyContext = true };
server.Handler = req => FakeEipServer.Command(req) == Ab.Transport.Eip.EncapCommand.SendRRData
? FakeEipServer.RRReply(req, [0xCB, 0x00, general, 0x00])
: null;
return server;
}
/// A PCCC fake that answers every request with one fixed STS.
private static FakeEipServer StsServer(byte sts)
{
var server = new FakeEipServer { ZeroReplyContext = true };
server.Handler = req =>
{
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
var cip = FakeEipServer.RequestCip(req);
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
return FakeEipServer.RRReply(req, [.. head, sts, cip[15], cip[16]]);
};
return server;
}
///
/// A manual clock the driver stamps health from (lmxopcua#768). Init stamps ; each test
/// moves it to before reading, so an "advanced" LastSuccessfulRead is provably
/// the read's stamp (it used to be DateTime.UtcNow twice microseconds apart, equal ~70% of runs on a
/// coarse clock, lmxopcua#767).
///
private static readonly DateTime InitTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime ReadTime = InitTime.AddSeconds(1);
#pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap)
private static async Task DriverAsync(FakeEipServer server, Func clock)
{
var host = $"ab://127.0.0.1:{server.Port}/1,0";
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("T", host, "N7:0", AbLegacyDataType.Int)]),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-pccc-bridge", new NativeAbLegacyTagFactory(new DeviceSessionPool()))
{
Clock = clock,
};
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return driver;
}
/// STS 0x30 from a bridge: a comms fault, never evidence the device answered.
[Fact]
public async Task A_dead_node_behind_a_bridge_degrades_and_does_not_advance_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
await using var server = StsServer(0x30);
var now = InitTime;
var driver = await DriverAsync(server, () => now);
var before = driver.GetHealth().LastSuccessfulRead;
before.ShouldBe(InitTime);
now = ReadTime;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(before);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
/// Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.
[Fact]
public async Task A_live_device_refusing_the_tag_stays_Healthy_and_advances_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
await using var server = StsServer(0x60);
var now = InitTime;
var driver = await DriverAsync(server, () => now);
var before = driver.GetHealth().LastSuccessfulRead;
before.ShouldBe(InitTime);
now = ReadTime;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldBe(ReadTime);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
///
/// Review I2: a refusal that came OFF THE WIRE — the device (or the bridge's Execute PCCC object) answered with
/// CIP 0x08 / 0x1C / 0x20 on the envelope, or PCCC STS 0x10 — is an answered refusal: per-tag BadNotSupported,
/// Healthy, LastSuccessfulRead advanced. It must not land in the "refused before the wire" neutral class
/// (which advances nothing). The local-refusal counterpart stays neutral (NativeAbLegacyRefusedWriteTests).
///
/// The CIP general status of the envelope reply, or 0 for a PCCC STS 0x10 reply.
[Theory]
[InlineData((byte)0x08)]
[InlineData((byte)0x1C)]
[InlineData((byte)0x20)]
[InlineData((byte)0x00)]
public async Task A_wire_Unsupported_reply_is_an_answered_refusal(byte general)
{
var ct = TestContext.Current.CancellationToken;
await using var server = general == 0 ? StsServer(0x10) : CipEnvelopeServer(general);
var now = InitTime;
var driver = await DriverAsync(server, () => now);
var before = driver.GetHealth().LastSuccessfulRead;
before.ShouldBe(InitTime);
now = ReadTime;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldBe(ReadTime);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
#pragma warning restore OTOPCUA0001
}