fix(driver-focas): resolve Low code-review findings (Driver.FOCAS-007,008,009,010,011)
- Driver.FOCAS-007: optional ILogger<FocasDriver> + alarm-projection logger; log Debug around every formerly-empty catch (probe / shutdown / fixed-tree / recycle / alarms-read / projection). - Driver.FOCAS-008: cache the parsed FocasAddress per tag at InitializeAsync; Read/WriteAsync look it up instead of re-parsing on every call. - Driver.FOCAS-009: ProbeLoopAsync now wraps client.ProbeAsync in a linked CTS honouring Probe.Timeout so a hung CNC socket can't block past the configured limit. - Driver.FOCAS-010: FocasOperationModeExtensions.ToText delegates to FocasOpMode.ToText — single canonical op-mode label surface. - Driver.FOCAS-011: FocasAlarmType constants are typed short to match the cnc_rdalmmsg2 wire field and the projection switch arms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for Driver.FOCAS-007 — the driver previously swallowed every
|
||||
/// exception in its poll / probe / recycle / fixed-tree loops with no logging at all,
|
||||
/// leaving operators blind when a CNC was silently failing every tick.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasLoggingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_accepts_an_ILogger()
|
||||
{
|
||||
// Constructor signature must allow an ILogger<FocasDriver> so the host can wire one
|
||||
// through Microsoft.Extensions.DependencyInjection. The driver code project already
|
||||
// references Microsoft.Extensions.Logging.Abstractions.
|
||||
var logger = new CapturingLogger<FocasDriver>();
|
||||
var drv = new FocasDriver(
|
||||
new FocasDriverOptions { Probe = new FocasProbeOptions { Enabled = false } },
|
||||
"drv-1",
|
||||
new FakeFocasClientFactory(),
|
||||
logger);
|
||||
|
||||
drv.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeLoop_logs_when_an_exception_is_swallowed()
|
||||
{
|
||||
var logger = new CapturingLogger<FocasDriver>();
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient
|
||||
{
|
||||
// Make ProbeAsync throw — the probe loop swallows it but must log.
|
||||
ThrowOnConnect = false,
|
||||
ProbeResult = true, // not used because the underlying probe path throws
|
||||
},
|
||||
};
|
||||
// Force the probe to throw by making the client throw on connect.
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
ThrowOnConnect = true,
|
||||
Exception = new InvalidOperationException("simulated probe failure"),
|
||||
};
|
||||
|
||||
var drv = new FocasDriver(
|
||||
new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
Timeout = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
},
|
||||
"drv-log",
|
||||
factory,
|
||||
logger);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Give the probe loop one tick or two to log.
|
||||
await Task.Delay(250);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
// We expect at least one log entry at Debug / Warning that mentions the simulated
|
||||
// failure or the probe loop. Without logging there's literally no record on a wedged
|
||||
// CNC — exactly the gap the finding called out.
|
||||
logger.Entries.ShouldNotBeEmpty();
|
||||
logger.Entries.Any(e => e.Message.Contains("probe", StringComparison.OrdinalIgnoreCase)
|
||||
|| (e.Exception?.Message.Contains("simulated probe failure") ?? false))
|
||||
.ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception");
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the Low-severity code-review findings:
|
||||
/// <list type="bullet">
|
||||
/// <item>Driver.FOCAS-008 — parsed FocasAddress is cached at init, not re-parsed per read/write</item>
|
||||
/// <item>Driver.FOCAS-009 — Probe.Timeout is actually applied around ProbeAsync</item>
|
||||
/// <item>Driver.FOCAS-010 — operation-mode → text mapping is consolidated</item>
|
||||
/// <item>Driver.FOCAS-011 — FocasAlarmType constants are typed as short</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[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<FocasAddress>();
|
||||
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<FocasAddress>();
|
||||
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<FocasAddress> 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<uint> 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<bool> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
cancelledSignal.TrySetResult();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user