b815446940
Both `IDriver` members had no production call site. Their only consumer was `MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and `GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which had not been true since v3, even before #522 removed the poller. Owner decision on #525 was (a) delete rather than (b) build the process-wide watchdog that would consume them. The driver-side implementations are in git history and were mostly reusable; what was always missing is the trigger, and a member nobody calls is how the misleading doc-comment survived this long. Removed from `IDriver`, all 12 driver implementations, and every test stub — plus the support code that existed only to feed them, which is the part a member-only deletion would have left behind as dead weight: - MTConnect's three per-entry byte-weight constants, its cached `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run on every cache fill to populate a number with no remaining reader. - OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it. - MQTT's `ApproxBytesPerAuthoredTag`. Two behavioural notes, neither a change in what the software does: - `AbCipTemplateCache` now has no production reset path. Its only caller was AB CIP's flush, which itself had no caller — so nothing changes except how visible the fact is. Recorded on the type, along with the correction that its comment's claim about a device `ReinitializeAsync` also flushing it was never true. A UDT redefined on a live controller keeps its stale shape until the driver instance is rebuilt. - MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an Agent restart. That is now `internal` rather than `private` so the tests which used the flush purely to empty the cache drive the real path instead — they now cover a case that actually occurs in production. Several unrelated doc comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are repointed to it. Tests: the ones that existed only to assert on the removed members are deleted (3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a footprint of 0 before the flush and 0 after with no discovery in between, so it would have passed against a flush that did nothing at all. Full suite green except 7 failures verified identical on master before and after (4 AbLegacy integration with the compose profile down, 3 Host.IntegrationTests), checked by name rather than assumed. Docs corrected where they described this as live: `DriverLifecycle.md` (which carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and `v2/driver-stability.md` — whose "polls this every 30 s" + per-driver `DriverConfig` memory budget were never built as written and are now banner-ed as design intent rather than description. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
190 lines
9.2 KiB
C#
190 lines
9.2 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression tests for the Medium code-review findings resolved in the 2026-05-22
|
|
/// batch:
|
|
/// <list type="bullet">
|
|
/// <item>Driver.OpcUaClient-009 — WriteAsync timeout maps to BadTimeout, not BadCommunicationError</item>
|
|
/// <item>Driver.OpcUaClient-010 — OPC UA Byte type maps to UInt16, not Int16</item>
|
|
/// <item>Driver.OpcUaClient-012 — AutoAcceptCertificates emits a startup warning</item>
|
|
/// <item>Driver.OpcUaClient-015 — key pure-logic paths exposed for future test expansion</item>
|
|
/// </list>
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientMediumFindingsRegressionTests
|
|
{
|
|
// ---- Driver.OpcUaClient-009 ----
|
|
|
|
/// <summary>Verifies that WriteAsync without session returns BadCommunicationError, not BadTimeout.</summary>
|
|
[Fact]
|
|
public async Task WriteAsync_without_session_returns_BadCommunicationError_not_BadTimeout()
|
|
{
|
|
// The session-null branch fires *before* the wire request — definitely-did-not-happen
|
|
// so BadCommunicationError (not BadTimeout) is correct.
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-write-009");
|
|
// No InitializeAsync → Session is null, but RequireSession throws before the gate.
|
|
// The test asserting pre-init throw is in OpcUaClientReadWriteTests; here we only
|
|
// check the mapping constants are distinct.
|
|
const uint BadComm = 0x80050000u;
|
|
const uint BadTimeout = 0x800A0000u;
|
|
BadComm.ShouldNotBe(BadTimeout, "the two status codes must be distinct for downstream disambiguation");
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient-010 ----
|
|
|
|
/// <summary>Verifies that Byte data type maps to UInt16, not Int16.</summary>
|
|
[Fact]
|
|
public void MapUpstreamDataType_Byte_maps_to_UInt16_unsigned_family()
|
|
{
|
|
// Byte is unsigned 0-255; it must not map to the signed Int16 bucket.
|
|
var result = OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.Byte));
|
|
result.ShouldBe(DriverDataType.UInt16);
|
|
result.ShouldNotBe(DriverDataType.Int16, "Byte is unsigned; Int16 is signed — wrong family");
|
|
}
|
|
|
|
/// <summary>Verifies that SByte data type maps to Int16 signed family.</summary>
|
|
[Fact]
|
|
public void MapUpstreamDataType_SByte_still_maps_to_Int16_signed_family()
|
|
{
|
|
// SByte is signed 8-bit; the signed widen to Int16 is correct (no narrower signed type).
|
|
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.SByte))
|
|
.ShouldBe(DriverDataType.Int16);
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient-012 ----
|
|
|
|
/// <summary>Verifies that AutoAcceptCertificates defaults to false.</summary>
|
|
[Fact]
|
|
public void AutoAcceptCertificates_default_is_false_secure_by_default()
|
|
{
|
|
new OpcUaClientDriverOptions().AutoAcceptCertificates.ShouldBeFalse(
|
|
"production default must reject untrusted server certs to prevent MITM");
|
|
}
|
|
|
|
/// <summary>Verifies that InitializeAsync with AutoAccept emits a warning log.</summary>
|
|
[Fact]
|
|
public async Task InitializeAsync_AutoAccept_emits_warning_log()
|
|
{
|
|
// Connect will fail (no server at port 1) but the warning must fire before the
|
|
// connection attempt so it is captured even on a fast-fail path.
|
|
var logger = new CapturingLogger();
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
|
Timeout = TimeSpan.FromMilliseconds(300),
|
|
AutoAcceptCertificates = true,
|
|
// Equipment is the default TargetNamespaceKind; it requires a mapping table to
|
|
// pass ValidateNamespaceKind, which runs before BuildApplicationConfigurationAsync
|
|
// where the warning is emitted.
|
|
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-autocert", logger);
|
|
|
|
try { await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); }
|
|
catch { /* expected — no server at port 1 */ }
|
|
|
|
logger.Entries.ShouldContain(e =>
|
|
e.Level == LogLevel.Warning &&
|
|
e.Message.Contains("AutoAcceptCertificates=true"),
|
|
"a prominent warning must be logged every time AutoAcceptCertificates is enabled");
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient-013 ----
|
|
//
|
|
// Three tests lived here covering GetMemoryFootprint + FlushOptionalCachesAsync. Both members
|
|
// were deleted from IDriver in Gitea #525 as unconsumed surface, and these went with them. Worth
|
|
// noting one was vacuous anyway: it asserted a footprint of 0 before the flush and 0 after, with
|
|
// no discovery in between, so it would have passed against a flush that did nothing at all.
|
|
|
|
// ---- Driver.OpcUaClient-015: pure-logic paths ----
|
|
|
|
/// <summary>Verifies that MapSeverity thresholds match OPC UA AC Part 9 guidance.</summary>
|
|
[Fact]
|
|
public void MapSeverity_thresholds_match_opcua_ac_part9_guidance()
|
|
{
|
|
// 1-200 Low, 201-500 Medium, 501-800 High, 801-1000 Critical
|
|
OpcUaClientDriver.MapSeverity(1).ShouldBe(AlarmSeverity.Low);
|
|
OpcUaClientDriver.MapSeverity(200).ShouldBe(AlarmSeverity.Low);
|
|
OpcUaClientDriver.MapSeverity(201).ShouldBe(AlarmSeverity.Medium);
|
|
OpcUaClientDriver.MapSeverity(500).ShouldBe(AlarmSeverity.Medium);
|
|
OpcUaClientDriver.MapSeverity(501).ShouldBe(AlarmSeverity.High);
|
|
OpcUaClientDriver.MapSeverity(800).ShouldBe(AlarmSeverity.High);
|
|
OpcUaClientDriver.MapSeverity(801).ShouldBe(AlarmSeverity.Critical);
|
|
OpcUaClientDriver.MapSeverity(1000).ShouldBe(AlarmSeverity.Critical);
|
|
}
|
|
|
|
/// <summary>Verifies that driver health starts Unknown and is accessible before init.</summary>
|
|
[Fact]
|
|
public void Driver_health_starts_Unknown_and_is_accessible_before_init()
|
|
{
|
|
// Guards the pre-init health contract for the failed-reconnect-to-Faulted path:
|
|
// callers that read health before InitializeAsync must see Unknown, not a garbage value.
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-health-015");
|
|
var h = drv.GetHealth();
|
|
h.State.ShouldBe(DriverState.Unknown);
|
|
h.LastError.ShouldBeNull();
|
|
h.LastSuccessfulRead.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that driver Session is null before init and RequireSession throws.</summary>
|
|
[Fact]
|
|
public void Driver_Session_is_null_before_init_so_RequireSession_throws()
|
|
{
|
|
// Exercises the early-exit guard in ReadAsync/WriteAsync/DiscoverAsync: they all
|
|
// call RequireSession() before touching the gate so callers get a clear error.
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sess-015");
|
|
drv.Session.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that GetHostStatuses returns single entry keyed to configured endpoint.</summary>
|
|
[Fact]
|
|
public void GetHostStatuses_returns_single_entry_keyed_to_configured_endpoint()
|
|
{
|
|
// IHostConnectivityProbe contract: the status list is always non-empty and
|
|
// keyed by the configured endpoint URL (falls back to EndpointUrl pre-init).
|
|
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.test:4840" };
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-probe-015");
|
|
var statuses = drv.GetHostStatuses();
|
|
statuses.Count.ShouldBe(1);
|
|
statuses[0].HostName.ShouldBe("opc.tcp://plc.test:4840");
|
|
statuses[0].State.ShouldBe(HostState.Unknown);
|
|
}
|
|
|
|
// ---- Helper ----
|
|
|
|
private sealed class CapturingLogger : ILogger<OpcUaClientDriver>
|
|
{
|
|
/// <summary>Gets the captured log entries.</summary>
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
|
|
/// <summary>Begins a logical scope.</summary>
|
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
|
/// <param name="state">The state to scope.</param>
|
|
/// <returns>An IDisposable representing the scope.</returns>
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
/// <summary>Checks if logging is enabled for the specified level.</summary>
|
|
/// <param name="logLevel">The log level.</param>
|
|
/// <returns>True if logging is enabled.</returns>
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <summary>Logs a message.</summary>
|
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
|
/// <param name="logLevel">The log level.</param>
|
|
/// <param name="eventId">The event ID.</param>
|
|
/// <param name="state">The state.</param>
|
|
/// <param name="exception">The exception.</param>
|
|
/// <param name="formatter">The formatter function.</param>
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
|
|
Exception? exception, Func<TState, Exception?, string> formatter)
|
|
{
|
|
Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
}
|
|
}
|