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;
}
}
}
}