Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpDispatchFaultTests.cs
T
Joseph Doherty b8202e1147
v2-ci / unit-tests (push) Has been cancelled
test: remaining xUnit1051/1031/1030, CS8602/8604, CS0618, CS0219, CS0649, NU1510 fixes (lmxopcua#725)
Awaited deadline oracles get the runner token; sync-over-async tests made async;
nullable child models asserted via ShouldNotBeNull() return values; obsolete SDK
ctors replaced with the telemetry overloads; dead locals/fixture and the redundant
DI PackageReference removed. No suppressions.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:22:34 -05:00

97 lines
4.2 KiB
C#

using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Review 2026-10-21 G-10: a dispatch-loop exit left the read loop draining into a channel
/// nobody read, with health Healthy and nothing telling the supervisor — a clean termination
/// evading the watchdog. The exit now reports a stream fault. The fault is injected through
/// the pump's handle factory, the one dispatch-side seam that runs outside the per-handler
/// isolation.
/// </summary>
public sealed class EventPumpDispatchFaultTests
{
private const int WaitMs = 10_000;
private sealed class ChannelSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken) => Task.CompletedTask;
public async IAsyncEnumerable<MxEvent> StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return ev;
}
}
public ValueTask EmitAsync(int handle) => _events.Writer.WriteAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = handle,
Value = new MxValue { DoubleValue = 1 },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
}
/// <summary>A dispatch-loop exception reports a stream fault carrying that exception.</summary>
[Fact]
public async Task Dispatch_loop_exit_reports_a_stream_fault()
{
var subscriber = new ChannelSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank01.Level", 7)]);
var fault = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry,
handleFactory: _ => throw new InvalidDataException("synthetic dispatch failure"),
channelCapacity: 4, clientName: "DispatchFault",
onStreamFault: ex => fault.TrySetResult(ex));
pump.Start();
await subscriber.EmitAsync(7);
(await Task.WhenAny(fault.Task, Task.Delay(WaitMs, TestContext.Current.CancellationToken))).ShouldBe(fault.Task,
"a dispatch-loop exit must be reported as a stream fault");
(await fault.Task).ShouldBeOfType<InvalidDataException>();
}
/// <summary>Positive control: the same event with a working handle factory dispatches and reports no fault.</summary>
[Fact]
public async Task Same_event_with_a_working_factory_dispatches_without_a_fault()
{
var subscriber = new ChannelSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank01.Level", 7)]);
var faulted = false;
var delivered = new TaskCompletionSource<DataChangeEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 4, clientName: "DispatchFault",
onStreamFault: _ => faulted = true);
pump.OnDataChange += (_, e) => delivered.TrySetResult(e);
pump.Start();
await subscriber.EmitAsync(7);
(await Task.WhenAny(delivered.Task, Task.Delay(WaitMs, TestContext.Current.CancellationToken))).ShouldBe(delivered.Task);
(await delivered.Task).FullReference.ShouldBe("Tank01.Level");
faulted.ShouldBeFalse();
}
}