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
318 lines
16 KiB
C#
318 lines
16 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class GenericDriverNodeManagerTests
|
|
{
|
|
/// <summary>
|
|
/// BuildAddressSpaceAsync walks the driver's discovery through the caller's builder. Every
|
|
/// variable marked with MarkAsAlarmCondition captures its sink in the node manager; later,
|
|
/// IAlarmSource.OnAlarmEvent payloads are routed by SourceNodeId to the matching sink.
|
|
/// This is the plumbing that PR 16's concrete OPC UA builder will use to update the actual
|
|
/// AlarmConditionState nodes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Alarm_events_are_routed_to_the_sink_registered_for_the_matching_source_node_id()
|
|
{
|
|
var driver = new FakeDriver();
|
|
var builder = new RecordingBuilder();
|
|
using var nm = new GenericDriverNodeManager(driver);
|
|
|
|
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
|
|
|
builder.Alarms.Count.ShouldBe(2);
|
|
nm.TrackedAlarmSources.Count.ShouldBe(2);
|
|
|
|
// Simulate the driver raising a transition for one of the alarms.
|
|
var args = new AlarmEventArgs(
|
|
SubscriptionHandle: new FakeHandle("s1"),
|
|
SourceNodeId: "Tank.HiHi",
|
|
ConditionId: "cond-1",
|
|
AlarmType: "Tank.HiHi",
|
|
Message: "Level exceeded",
|
|
Severity: AlarmSeverity.High,
|
|
SourceTimestampUtc: DateTime.UtcNow);
|
|
driver.RaiseAlarm(args);
|
|
|
|
builder.Alarms["Tank.HiHi"].Received.Count.ShouldBe(1);
|
|
builder.Alarms["Tank.HiHi"].Received[0].Message.ShouldBe("Level exceeded");
|
|
// The other alarm sink never received a payload — fan-out is tag-scoped.
|
|
builder.Alarms["Heater.OverTemp"].Received.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Verifies that non-alarm variables do not register sinks in the alarm tracker.</summary>
|
|
[Fact]
|
|
public async Task Non_alarm_variables_do_not_register_sinks()
|
|
{
|
|
var driver = new FakeDriver();
|
|
var builder = new RecordingBuilder();
|
|
using var nm = new GenericDriverNodeManager(driver);
|
|
|
|
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
|
|
|
// FakeDriver registers 2 alarm-bearing variables + 1 plain variable.
|
|
nm.TrackedAlarmSources.ShouldNotContain("Tank.Level"); // the plain one
|
|
}
|
|
|
|
/// <summary>Verifies that alarm events with unknown source node IDs are silently dropped.</summary>
|
|
[Fact]
|
|
public async Task Unknown_source_node_id_is_dropped_silently()
|
|
{
|
|
var driver = new FakeDriver();
|
|
var builder = new RecordingBuilder();
|
|
using var nm = new GenericDriverNodeManager(driver);
|
|
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
|
|
|
driver.RaiseAlarm(new AlarmEventArgs(
|
|
new FakeHandle("s1"), "Unknown.Source", "c", "t", "m", AlarmSeverity.Low, DateTime.UtcNow));
|
|
|
|
builder.Alarms.Values.All(s => s.Received.Count == 0).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies that disposing the node manager unsubscribes from alarm events.</summary>
|
|
[Fact]
|
|
public async Task Dispose_unsubscribes_from_OnAlarmEvent()
|
|
{
|
|
var driver = new FakeDriver();
|
|
var builder = new RecordingBuilder();
|
|
var nm = new GenericDriverNodeManager(driver);
|
|
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
|
|
|
nm.Dispose();
|
|
|
|
driver.RaiseAlarm(new AlarmEventArgs(
|
|
new FakeHandle("s1"), "Tank.HiHi", "c", "t", "m", AlarmSeverity.Low, DateTime.UtcNow));
|
|
|
|
// No sink should have received it — the forwarder was detached.
|
|
builder.Alarms["Tank.HiHi"].Received.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core-006 regression: a second call to BuildAddressSpaceAsync (e.g. on Galaxy redeploy)
|
|
/// must unsubscribe the old alarm forwarder and clear the sink registry before re-walking,
|
|
/// so alarm transitions are not delivered twice.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Second_BuildAddressSpaceAsync_Does_Not_Double_Fire_Alarms()
|
|
{
|
|
var driver = new FakeDriver();
|
|
var builder1 = new RecordingBuilder();
|
|
var builder2 = new RecordingBuilder();
|
|
using var nm = new GenericDriverNodeManager(driver);
|
|
|
|
await nm.BuildAddressSpaceAsync(builder1, CancellationToken.None);
|
|
await nm.BuildAddressSpaceAsync(builder2, CancellationToken.None); // redeploy
|
|
|
|
driver.RaiseAlarm(new AlarmEventArgs(
|
|
new FakeHandle("s1"), "Tank.HiHi", "c", "t", "m", AlarmSeverity.High, DateTime.UtcNow));
|
|
|
|
// Only the second builder's sink should have received the event.
|
|
builder2.Alarms["Tank.HiHi"].Received.Count.ShouldBe(1,
|
|
"second BuildAddressSpaceAsync must replace the subscription — not add to it");
|
|
|
|
// The first builder's sink should NOT have received it (old forwarder was detached).
|
|
builder1.Alarms.TryGetValue("Tank.HiHi", out var oldSink);
|
|
(oldSink?.Received.Count ?? 0).ShouldBe(0,
|
|
"the original alarm forwarder must be unsubscribed on the second build");
|
|
}
|
|
|
|
/// <summary>Verifies that a second call to BuildAddressSpaceAsync clears the old sink registry.</summary>
|
|
[Fact]
|
|
public async Task Second_BuildAddressSpaceAsync_Clears_Old_Sink_Registry()
|
|
{
|
|
var driver = new FakeDriver();
|
|
using var nm = new GenericDriverNodeManager(driver);
|
|
|
|
await nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None);
|
|
var countAfterFirst = nm.TrackedAlarmSources.Count;
|
|
await nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None);
|
|
var countAfterSecond = nm.TrackedAlarmSources.Count;
|
|
|
|
countAfterFirst.ShouldBe(2, "FakeDriver registers 2 alarm sources");
|
|
countAfterSecond.ShouldBe(2, "second build must re-register exactly the same sources, not accumulate");
|
|
}
|
|
|
|
/// <summary>Verifies that calling BuildAddressSpaceAsync after disposal throws ObjectDisposedException.</summary>
|
|
[Fact]
|
|
public async Task BuildAddressSpaceAsync_After_Dispose_Throws_ObjectDisposedException()
|
|
{
|
|
var driver = new FakeDriver();
|
|
var nm = new GenericDriverNodeManager(driver);
|
|
nm.Dispose();
|
|
|
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
|
nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core-008 regression: the XML doc states exception isolation is the caller's
|
|
/// responsibility — exceptions from <see cref="ITagDiscovery.DiscoverAsync"/> must propagate
|
|
/// out of <c>BuildAddressSpaceAsync</c> unhandled so the Server layer's per-driver try/catch
|
|
/// (<c>OpcUaApplicationHost.PopulateAddressSpaces</c>) can mark the subtree Faulted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BuildAddressSpaceAsync_Propagates_Discovery_Exceptions_To_Caller()
|
|
{
|
|
var driver = new ThrowingDiscoveryDriver();
|
|
using var nm = new GenericDriverNodeManager(driver);
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None));
|
|
ex.Message.ShouldBe("discovery boom",
|
|
"exceptions from DiscoverAsync must propagate unhandled — exception isolation is the caller's responsibility (e.g. OpcUaApplicationHost)");
|
|
}
|
|
|
|
/// <summary>Driver whose DiscoverAsync throws — exercises the exception-isolation boundary.</summary>
|
|
private sealed class ThrowingDiscoveryDriver : IDriver, ITagDiscovery
|
|
{
|
|
/// <summary>Gets the driver instance identifier.</summary>
|
|
public string DriverInstanceId => "throwing";
|
|
/// <summary>Gets the driver type name.</summary>
|
|
public string DriverType => "Throwing";
|
|
|
|
/// <summary>Initializes the driver with configuration.</summary>
|
|
/// <param name="_">Configuration JSON (unused in test double).</param>
|
|
/// <param name="__">Cancellation token (unused in test double).</param>
|
|
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
|
/// <summary>Reinitializes the driver with new configuration.</summary>
|
|
/// <param name="_">Configuration JSON (unused in test double).</param>
|
|
/// <param name="__">Cancellation token (unused in test double).</param>
|
|
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
|
/// <summary>Shuts down the driver.</summary>
|
|
/// <param name="_">Cancellation token (unused in test double).</param>
|
|
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
|
|
/// <summary>Gets the current health status of the driver.</summary>
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
|
|
|
/// <summary>Discovers the address space by throwing an exception.</summary>
|
|
/// <param name="builder">The builder used to construct the address space.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
|
=> throw new InvalidOperationException("discovery boom");
|
|
}
|
|
|
|
// --- test doubles ---
|
|
|
|
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
|
|
{
|
|
/// <summary>Gets the driver instance identifier.</summary>
|
|
public string DriverInstanceId => "fake";
|
|
/// <summary>Gets the driver type name.</summary>
|
|
public string DriverType => "Fake";
|
|
/// <summary>Occurs when an alarm event is raised.</summary>
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
/// <summary>Initializes the driver with configuration.</summary>
|
|
/// <param name="driverConfigJson">Configuration JSON.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
/// <summary>Reinitializes the driver with new configuration.</summary>
|
|
/// <param name="driverConfigJson">Configuration JSON.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
/// <summary>Shuts down the driver.</summary>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
/// <summary>Gets the current health status of the driver.</summary>
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
|
|
/// <summary>Discovers the address space and registers alarm conditions.</summary>
|
|
/// <param name="builder">The builder used to construct the address space.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
|
{
|
|
var folder = builder.Folder("Tank", "Tank");
|
|
var lvl = folder.Variable("Level", "Level", new DriverAttributeInfo(
|
|
"Tank.Level", DriverDataType.Float64, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
|
var hiHi = folder.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
|
"Tank.HiHi", DriverDataType.Boolean, false, null, SecurityClassification.FreeAccess, false, IsAlarm: true));
|
|
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo("Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
|
|
|
var heater = builder.Folder("Heater", "Heater");
|
|
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
|
"Heater.OverTemp", DriverDataType.Boolean, false, null, SecurityClassification.FreeAccess, false, IsAlarm: true));
|
|
ot.MarkAsAlarmCondition(new AlarmConditionInfo("Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Raises an alarm event with the given arguments.</summary>
|
|
/// <param name="args">The alarm event arguments.</param>
|
|
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
|
|
|
/// <summary>Subscribes to alarm events.</summary>
|
|
/// <param name="_">Tag references to subscribe to (unused in test double).</param>
|
|
/// <param name="__">Cancellation token (unused in test double).</param>
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> _, CancellationToken __)
|
|
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
|
/// <summary>Unsubscribes from alarm events.</summary>
|
|
/// <param name="_">The subscription handle (unused in test double).</param>
|
|
/// <param name="__">Cancellation token (unused in test double).</param>
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
|
|
/// <summary>Acknowledges alarm notifications.</summary>
|
|
/// <param name="_">Alarm acknowledgement requests (unused in test double).</param>
|
|
/// <param name="__">Cancellation token (unused in test double).</param>
|
|
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __) => Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Test double for IAlarmSubscriptionHandle.</summary>
|
|
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
|
{
|
|
/// <summary>Gets the diagnostic identifier for this subscription.</summary>
|
|
public string DiagnosticId { get; } = diagnosticId;
|
|
}
|
|
|
|
/// <summary>Test double for IAddressSpaceBuilder that records alarm sinks.</summary>
|
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
|
{
|
|
/// <summary>Gets the map of alarm sources to their sinks.</summary>
|
|
public Dictionary<string, RecordingSink> Alarms { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Creates a folder in the address space.</summary>
|
|
/// <param name="_">The contained name (unused in test double).</param>
|
|
/// <param name="__">The display name (unused in test double).</param>
|
|
public IAddressSpaceBuilder Folder(string _, string __) => this;
|
|
|
|
/// <summary>Creates a variable in the address space.</summary>
|
|
/// <param name="_">The contained name (unused in test double).</param>
|
|
/// <param name="__">The display name (unused in test double).</param>
|
|
/// <param name="info">The driver attribute information.</param>
|
|
public IVariableHandle Variable(string _, string __, DriverAttributeInfo info)
|
|
=> new Handle(info.FullName, Alarms);
|
|
|
|
/// <summary>Adds a property to the current variable.</summary>
|
|
/// <param name="_">The property name (unused in test double).</param>
|
|
/// <param name="__">The data type (unused in test double).</param>
|
|
/// <param name="___">The initial value (unused in test double).</param>
|
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
|
|
|
/// <summary>Test double for IVariableHandle.</summary>
|
|
public sealed class Handle(string fullRef, Dictionary<string, RecordingSink> alarms) : IVariableHandle
|
|
{
|
|
/// <summary>Gets the full reference name for this variable.</summary>
|
|
public string FullReference { get; } = fullRef;
|
|
/// <summary>Marks this variable as an alarm condition and registers its sink.</summary>
|
|
/// <param name="_">The alarm condition info (unused in test double).</param>
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _)
|
|
{
|
|
var sink = new RecordingSink();
|
|
alarms[FullReference] = sink;
|
|
return sink;
|
|
}
|
|
}
|
|
|
|
/// <summary>Test double for IAlarmConditionSink that records transitions.</summary>
|
|
public sealed class RecordingSink : IAlarmConditionSink
|
|
{
|
|
/// <summary>Gets the list of alarm transitions received by this sink.</summary>
|
|
public List<AlarmEventArgs> Received { get; } = new();
|
|
/// <summary>Records an alarm transition.</summary>
|
|
/// <param name="args">The alarm event arguments.</param>
|
|
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
|
|
}
|
|
}
|
|
}
|