using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Regression coverage for the Low-severity code-review findings: /// /// Driver.FOCAS-008 — parsed FocasAddress is cached at init, not re-parsed per read/write /// Driver.FOCAS-009 — Probe.Timeout is actually applied around ProbeAsync /// Driver.FOCAS-010 — operation-mode → text mapping is consolidated /// Driver.FOCAS-011 — FocasAlarmType constants are typed as short /// /// [Trait("Category", "Unit")] public sealed class FocasLowFindingsTests { // ---- Driver.FOCAS-008 — parsed FocasAddress cached at init ---- [Fact] public async Task ReadAsync_uses_cached_FocasAddress_when_tag_definition_has_a_malformed_address_after_init() { // After InitializeAsync succeeds with a well-formed address, the driver must rely on the // cached parse — *not* re-parse `FocasTagDefinition.Address` on every read. We can prove // the cache is used by stuffing a malformed Address onto a tag *post-init* through the // internal cache surface — but that's brittle. Instead, prove no re-parse by counting: // we monkey-patch the FakeFocasClient.ReadAsync to capture the FocasAddress reference // it receives and assert it's the *same* instance across two consecutive reads. var captured = new List(); var factory = new FakeFocasClientFactory { Customise = () => new CapturingFakeFocasClient(captured) { Values = { ["R100"] = (sbyte)1 }, }, }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); captured.Count.ShouldBe(2); ReferenceEquals(captured[0], captured[1]) .ShouldBeTrue("ReadAsync must reuse the FocasAddress parsed at init, not re-parse per read"); } [Fact] public async Task WriteAsync_uses_cached_FocasAddress_too() { var captured = new List(); var factory = new FakeFocasClientFactory { Customise = () => new CapturingFakeFocasClient(captured) { WriteStatuses = { ["R100"] = FocasStatusMapper.Good }, }, }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [ new FocasTagDefinition( "X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true), ], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync( [new WriteRequest("X", (sbyte)1)], CancellationToken.None); await drv.WriteAsync( [new WriteRequest("X", (sbyte)2)], CancellationToken.None); captured.Count.ShouldBe(2); ReferenceEquals(captured[0], captured[1]) .ShouldBeTrue("WriteAsync must reuse the FocasAddress parsed at init"); } // ---- Driver.FOCAS-009 — Probe.Timeout applies to ProbeAsync ---- [Fact] public async Task ProbeLoop_cancels_a_slow_ProbeAsync_at_Probe_Timeout() { // The probe loop must apply Probe.Timeout — a hung CNC socket should be cancelled at the // configured timeout rather than blocking until the OS TCP timeout. We prove the timeout // is applied by making ProbeAsync wait indefinitely and asserting it observes // cancellation before the normal probe Interval would tick again. var hangSignal = new TaskCompletionSource(); var factory = new FakeFocasClientFactory { Customise = () => new HangingProbeFakeClient(hangSignal), }; var probeTimeout = TimeSpan.FromMilliseconds(100); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromSeconds(10), Timeout = probeTimeout, }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Wait up to a generous bound for the probe to observe cancellation. Without the // timeout fix, this never completes (Probe runs forever). var cancelled = await Task.WhenAny( hangSignal.Task, Task.Delay(TimeSpan.FromSeconds(2))) == hangSignal.Task; await drv.ShutdownAsync(CancellationToken.None); cancelled.ShouldBeTrue( "ProbeAsync must be cancelled at Probe.Timeout when it does not complete; otherwise a hung CNC blocks the probe loop indefinitely"); } // ---- Driver.FOCAS-010 — operation-mode → text mapping is consolidated ---- [Theory] [InlineData(0, "MDI")] [InlineData(1, "AUTO")] [InlineData(2, "TJOG")] // canonical FocasOpMode label, matches both surfaces post-fix [InlineData(3, "EDIT")] [InlineData(4, "HANDLE")] [InlineData(5, "JOG")] [InlineData(6, "TEACH_IN_HANDLE")] [InlineData(7, "REFERENCE")] [InlineData(8, "REMOTE")] [InlineData(9, "TEST")] public void OpMode_ToText_yields_the_same_label_in_both_namespaces(int code, string expected) { // Driver fixed-tree path (FocasOpMode.ToText) and wire layer (FocasOperationModeExtensions.ToText) // must yield the same canonical label so dashboard rendering doesn't vary by code path. FocasOpMode.ToText(code).ShouldBe(expected); ((FocasOperationMode)(short)code).ToText().ShouldBe(expected); } [Fact] public void OpMode_ToText_fallback_label_is_consistent() { // Unknown codes must fall back to the same shape from both call sites — previously // FocasOpMode used "Mode{n}" while FocasOperationModeExtensions used the bare number. const int unknown = 99; FocasOpMode.ToText(unknown).ShouldBe( ((FocasOperationMode)(short)unknown).ToText(), "unknown-mode fallback must agree across both surfaces"); } // ---- Driver.FOCAS-011 — FocasAlarmType constants typed as short ---- [Fact] public void FocasAlarmType_constants_are_typed_short() { // The downstream switches in FocasAlarmProjection.MapAlarmType / MapSeverity take a short. // Declaring the constants as int (the old shape) compiled by accident because the values // fit short range; making them short makes the type match the wire width. // We assert this at runtime via reflection so the test fails if a future contributor // demotes them back to int. var fields = typeof(FocasAlarmType).GetFields( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); foreach (var f in fields) { f.FieldType.ShouldBe(typeof(short), $"FocasAlarmType.{f.Name} must be typed `short` so it matches the wire field width " + "and the FocasAlarmProjection switch arm types"); } } // ---- helpers ---- private sealed class CapturingFakeFocasClient(List captured) : FakeFocasClient { public override Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { captured.Add(address); return base.ReadAsync(address, type, ct); } public override Task WriteAsync( FocasAddress address, FocasDataType type, object? value, CancellationToken ct) { captured.Add(address); return base.WriteAsync(address, type, value, ct); } } private sealed class HangingProbeFakeClient(TaskCompletionSource cancelledSignal) : FakeFocasClient { public override async Task ProbeAsync(CancellationToken ct) { try { await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false); return true; } catch (OperationCanceledException) { cancelledSignal.TrySetResult(); throw; } } } }