using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must /// not latch Degraded; and a good write must not overwrite a Degraded the read path just recorded /// with Healthy (the batch read path is worst-wins for exactly this reason). A good write proves the device /// answered, so it advances LastSuccessfulRead without touching the state. /// [Trait("Category", "Unit")] public sealed class AbCipWriteHealthTests { private const string Device = "ab://10.0.0.5/1,0"; private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override Task WriteAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("socket reset"); } private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync( Func? make = null) { var factory = new FakeAbCipTagFactory { Customise = make ?? (p => p.TagName == "Broken" ? new FakeAbCipTag(p) { ThrowOnRead = true } : new RealEncodingTag(p) { Value = 0 }), }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = AbCipRawTags.From( new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt), new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int), new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "abcip-write-health", factory); await drv.InitializeAsync("{}", CancellationToken.None); return (drv, factory); } /// A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16). private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = type switch { AbCipDataType.Int => Convert.ToInt16(value), _ => Convert.ToInt32(value), }; } /// Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent. [Fact] public async Task A_client_conversion_error_writes_no_health() { var (drv, factory) = await BuildAsync(); var before = drv.GetHealth(); var r = await drv.WriteAsync( [ new WriteRequest("D", "not a number"), // FormatException new WriteRequest("D", new object()), // InvalidCastException new WriteRequest("I", 1_000_000), // OverflowException ], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); drv.GetHealth().ShouldBe(before); factory.Tags["D"].WriteCount.ShouldBe(0); factory.Tags["I"].WriteCount.ShouldBe(0); } /// A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead. [Fact] public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy() { var (drv, _) = await BuildAsync(); await drv.ReadAsync(["Broken"], CancellationToken.None); var degraded = drv.GetHealth(); degraded.State.ShouldBe(DriverState.Degraded); await Task.Delay(20, TestContext.Current.CancellationToken); var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); var after = drv.GetHealth(); after.State.ShouldBe(DriverState.Degraded); after.LastError.ShouldBe(degraded.LastError); after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered } /// Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead. [Fact] public async Task A_write_transport_fault_still_degrades() { var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p)); var before = drv.GetHealth(); var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); var after = drv.GetHealth(); after.State.ShouldBe(DriverState.Degraded); after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); factory.Tags["D"].Disposed.ShouldBeTrue(); } /// M3: a good write clears a Degraded that a WRITE comms fault recorded. [Fact] public async Task A_good_write_clears_a_write_side_Degraded() { var fail = true; var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p)); (await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode .ShouldBe(AbCipStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); fail = false; // the evicted runtime is re-created healthy (await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode .ShouldBe(AbCipStatusMapper.Good); drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().LastError.ShouldBeNull(); } /// /// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good /// write still clears it. /// [Fact] public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded() { var fail = true; var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p)); for (var round = 0; round < 20; round++) { fail = true; await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None)))); drv.GetHealth().State.ShouldBe(DriverState.Degraded); fail = false; (await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode .ShouldBe(AbCipStatusMapper.Good); drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}"); } } /// /// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a /// good write still clears it. /// [Fact] public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned() { var fail = true; var (drv, _) = await BuildAsync(p => p.TagName == "Broken" ? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound } : fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p)); await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Degraded); (await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); fail = false; await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } }