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:
Joseph Doherty
2026-05-23 07:45:38 -04:00
parent f7e3e9885e
commit 6575c6e5f6
8 changed files with 522 additions and 64 deletions

View File

@@ -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() { }
}
}
}