Resolve Worker.Tests-008..015 code-review findings
Worker.Tests-008: moved the misplaced WorkerLogRedactor test out of VariantConverterTests into Bootstrap/WorkerLogRedactorTests. Worker.Tests-009: renamed 46 snake_case alarm-test methods to PascalCase Method_Scenario_Expectation. Worker.Tests-010: replaced a weak Assert.Contains with an exact assertion against the real diagnostic message and corrected the XML doc. Worker.Tests-011: renamed and re-documented a cancellation test that overstated what it proved. Worker.Tests-012: added an oversized-frame (MessageTooLarge) test; renamed the mislabeled zero-length-payload test. Worker.Tests-013: removed the fixed-100ms ThrowIfCompletedAsync helper; the caller now races runTask deterministically. Worker.Tests-014: consolidated duplicated test fakes/helpers (FakeRuntimeSession, NoopComApartmentInitializer, NoopEventSink, frame helpers) into a shared TestSupport namespace. Worker.Tests-015: added MxAccessEventQueue coverage for drain-all (maxEvents 0), empty-queue drain, and enqueue-after-fault. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,4 +32,16 @@ public sealed class WorkerLogRedactorTests
|
||||
Assert.Equal("[redacted]", redacted["api_key"]);
|
||||
Assert.Equal("session-1", redacted["session_id"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="WorkerLogRedactor.RedactValue"/> redacts individual
|
||||
/// credential-bearing fields before they reach a log sink.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RedactValue_WithCredentialBearingFieldNames_ReturnsRedactedValue()
|
||||
{
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Bootstrap;
|
||||
using MxGateway.Worker.Conversion;
|
||||
using ProtobufTimestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||
|
||||
@@ -192,15 +191,6 @@ public sealed class VariantConverterTests
|
||||
Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.ArrayValue.RawDiagnostic);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that credential-bearing fields are redacted before logging.</summary>
|
||||
[Fact]
|
||||
public void Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging()
|
||||
{
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret"));
|
||||
}
|
||||
|
||||
/// <summary>Fake unsupported variant type for testing unknown type handling.</summary>
|
||||
private sealed class UnsupportedVariant
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.ProtocolVersion++;
|
||||
using MemoryStream stream = new(CreateFrame(envelope));
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
@@ -55,7 +55,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.SessionId = "different-session";
|
||||
using MemoryStream stream = new(CreateFrame(envelope));
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
@@ -65,9 +65,15 @@ public sealed class WorkerFrameProtocolTests
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed length throws error.</summary>
|
||||
/// <summary>
|
||||
/// Verifies that a frame whose length prefix is zero is rejected before the
|
||||
/// payload buffer is allocated. <c>docs/WorkerFrameProtocol.md</c> states the
|
||||
/// reader rejects zero-length payloads as a malformed-length error. The
|
||||
/// length prefix is the leading four bytes of the stream, so a four-zero-byte
|
||||
/// stream is exactly a frame declaring a zero-length payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMalformedLength_ThrowsMalformedLength()
|
||||
public async Task ReadAsync_WithZeroLengthPayload_ThrowsMalformedLength()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new(new byte[sizeof(uint)]);
|
||||
@@ -80,12 +86,40 @@ public sealed class WorkerFrameProtocolTests
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a frame whose length prefix exceeds the configured maximum
|
||||
/// is rejected before the payload buffer is allocated. <c>docs/WorkerFrameProtocol.md</c>
|
||||
/// states the reader rejects oversized payloads as a message-too-large error.
|
||||
/// A small maximum is configured so the rejection is asserted without
|
||||
/// allocating a multi-megabyte buffer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithPayloadAboveConfiguredMaximum_ThrowsMessageTooLarge()
|
||||
{
|
||||
const int maxMessageBytes = 64;
|
||||
WorkerFrameProtocolOptions options = new(
|
||||
SessionId,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce,
|
||||
maxMessageBytes);
|
||||
byte[] frame = new byte[sizeof(uint)];
|
||||
WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, maxMessageBytes + 1);
|
||||
using MemoryStream stream = new(frame);
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed payload throws invalid envelope error.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new(CreateFrame(new byte[] { 0x80 }));
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(new byte[] { 0x80 }));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
@@ -175,27 +209,4 @@ public sealed class WorkerFrameProtocolTests
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateFrame(IMessage message)
|
||||
{
|
||||
return CreateFrame(message.ToByteArray());
|
||||
}
|
||||
|
||||
private static byte[] CreateFrame(byte[] payload)
|
||||
{
|
||||
byte[] frame = new byte[sizeof(uint) + payload.Length];
|
||||
WriteUInt32LittleEndian(frame, (uint)payload.Length);
|
||||
payload.CopyTo(frame, sizeof(uint));
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static void WriteUInt32LittleEndian(
|
||||
byte[] buffer,
|
||||
uint value)
|
||||
{
|
||||
buffer[0] = (byte)value;
|
||||
buffer[1] = (byte)(value >> 8);
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
buffer[3] = (byte)(value >> 24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
@@ -9,8 +8,7 @@ using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Bootstrap;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -213,100 +211,4 @@ public sealed class WorkerPipeClientTests
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||
{
|
||||
/// <summary>Starts the worker session.</summary>
|
||||
/// <param name="sessionId">Session ID.</param>
|
||||
/// <param name="workerProcessId">Worker process ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready response.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new WorkerReady
|
||||
{
|
||||
WorkerProcessId = workerProcessId,
|
||||
MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId,
|
||||
MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid,
|
||||
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispatches a command to STA thread.</summary>
|
||||
/// <param name="command">The command.</param>
|
||||
/// <returns>Command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
return Task.FromResult(new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Captures current runtime heartbeat snapshot.</summary>
|
||||
/// <returns>Heartbeat snapshot.</returns>
|
||||
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||
{
|
||||
return new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Drains queued events.</summary>
|
||||
/// <param name="maxEvents">Maximum events to drain.</param>
|
||||
/// <returns>Drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
return Array.Empty<WorkerEvent>();
|
||||
}
|
||||
|
||||
/// <summary>Drains pending fault if any.</summary>
|
||||
/// <returns>Fault or null.</returns>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Cancels a command by correlation ID.</summary>
|
||||
/// <param name="correlationId">Command correlation ID.</param>
|
||||
/// <returns>True if cancelled.</returns>
|
||||
public bool CancelCommand(string correlationId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Requests graceful shutdown.</summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Shuts down gracefully within timeout.</summary>
|
||||
/// <param name="timeout">Shutdown timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Shutdown result.</returns>
|
||||
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||
}
|
||||
|
||||
/// <summary>Disposes resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -110,7 +111,7 @@ public sealed class WorkerPipeSessionTests
|
||||
public async Task CompleteStartupHandshakeAsync_WithMalformedFrame_WritesWorkerFault()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream inbound = new(CreateFrame(new byte[] { 0x80 }));
|
||||
using MemoryStream inbound = new(WorkerFrameTestHelpers.CreateFrame(new byte[] { 0x80 }));
|
||||
using MemoryStream outbound = new();
|
||||
WorkerPipeSession session = CreateSession(inbound, outbound, options);
|
||||
bool initialized = false;
|
||||
@@ -181,12 +182,24 @@ public sealed class WorkerPipeSessionTests
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
await ThrowIfCompletedAsync(runTask);
|
||||
|
||||
WorkerEnvelope heartbeat = await ReadUntilAsync(
|
||||
// Deterministic race: read the first heartbeat while watching runTask.
|
||||
// A faulted RunAsync would complete the run task first; if it wins the
|
||||
// race the test fails immediately with the underlying fault instead of
|
||||
// waiting out an arbitrary fixed delay.
|
||||
Task<WorkerEnvelope> heartbeatTask = ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerHeartbeat,
|
||||
cancellation.Token);
|
||||
Task winner = await Task.WhenAny(runTask, heartbeatTask);
|
||||
if (winner == runTask)
|
||||
{
|
||||
// Surface the RunAsync fault (or assert it did not exit early).
|
||||
await runTask;
|
||||
Assert.Fail("RunAsync completed before the first heartbeat was received.");
|
||||
}
|
||||
|
||||
WorkerEnvelope heartbeat = await heartbeatTask;
|
||||
|
||||
Assert.Equal(WorkerState.ExecutingCommand, heartbeat.WorkerHeartbeat.State);
|
||||
Assert.Equal(1234, heartbeat.WorkerHeartbeat.WorkerProcessId);
|
||||
@@ -761,15 +774,6 @@ public sealed class WorkerPipeSessionTests
|
||||
await runTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ThrowIfCompletedAsync(Task task)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100));
|
||||
if (task.IsCompleted)
|
||||
{
|
||||
await task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads frames until one matching the expected body type is found.</summary>
|
||||
/// <param name="reader">Frame reader.</param>
|
||||
/// <param name="expectedBody">Expected body case.</param>
|
||||
@@ -825,25 +829,6 @@ public sealed class WorkerPipeSessionTests
|
||||
return envelopes.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreateFrame(byte[] payload)
|
||||
{
|
||||
byte[] frame = new byte[sizeof(uint) + payload.Length];
|
||||
WriteUInt32LittleEndian(frame, (uint)payload.Length);
|
||||
payload.CopyTo(frame, sizeof(uint));
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static void WriteUInt32LittleEndian(
|
||||
byte[] buffer,
|
||||
uint value)
|
||||
{
|
||||
buffer[0] = (byte)value;
|
||||
buffer[1] = (byte)(value >> 8);
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
buffer[3] = (byte)(value >> 24);
|
||||
}
|
||||
|
||||
private sealed class RecordingWorkerLogger : MxGateway.Worker.Bootstrap.IWorkerLogger
|
||||
{
|
||||
private readonly object gate = new();
|
||||
@@ -907,204 +892,6 @@ public sealed class WorkerPipeSessionTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||
{
|
||||
private readonly ManualResetEventSlim releaseDispatch = new(false);
|
||||
private readonly object gate = new();
|
||||
private readonly Queue<WorkerEvent> events = new();
|
||||
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty);
|
||||
|
||||
/// <summary>Gets the event signaled when dispatch begins.</summary>
|
||||
public ManualResetEventSlim DispatchStarted { get; } = new(false);
|
||||
|
||||
/// <summary>Blocks dispatch execution until explicitly released.</summary>
|
||||
public bool BlockDispatch { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether to throw an exception after dispatch is released.</summary>
|
||||
public bool ThrowAfterDispatchReleased { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException.</summary>
|
||||
public bool ThrowTimeoutOnShutdown { get; set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether Dispose was called.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>Starts the worker session with the given session ID and process ID.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="workerProcessId">The worker process ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready response.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new WorkerReady
|
||||
{
|
||||
WorkerProcessId = workerProcessId,
|
||||
MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId,
|
||||
MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid,
|
||||
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispatches a command to the STA thread.</summary>
|
||||
/// <param name="command">The command to dispatch.</param>
|
||||
/// <returns>The command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
return Task.Run(
|
||||
() =>
|
||||
{
|
||||
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
command.CorrelationId));
|
||||
DispatchStarted.Set();
|
||||
|
||||
if (BlockDispatch)
|
||||
{
|
||||
releaseDispatch.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty));
|
||||
|
||||
if (ThrowAfterDispatchReleased)
|
||||
{
|
||||
throw new InvalidOperationException("Command failed after shutdown started.");
|
||||
}
|
||||
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Captures current heartbeat snapshot.</summary>
|
||||
/// <returns>Current runtime heartbeat snapshot.</returns>
|
||||
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains queued events up to the specified limit.</summary>
|
||||
/// <param name="maxEvents">Maximum events to drain; 0 drains all.</param>
|
||||
/// <returns>The drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
int drainCount = maxEvents == 0
|
||||
? events.Count
|
||||
: Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
|
||||
List<WorkerEvent> drained = new(drainCount);
|
||||
for (int index = 0; index < drainCount; index++)
|
||||
{
|
||||
drained.Add(events.Dequeue());
|
||||
}
|
||||
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains a pending fault if any.</summary>
|
||||
/// <returns>Pending fault or null.</returns>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Cancels command by correlation ID.</summary>
|
||||
/// <param name="correlationId">The command correlation ID.</param>
|
||||
/// <returns>True if cancelled; false otherwise.</returns>
|
||||
public bool CancelCommand(string correlationId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Requests graceful shutdown.</summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
}
|
||||
|
||||
/// <summary>Shuts down gracefully within the specified timeout.</summary>
|
||||
/// <param name="timeout">Shutdown timeout period.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Shutdown result.</returns>
|
||||
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
if (ThrowTimeoutOnShutdown)
|
||||
{
|
||||
return Task.FromException<MxAccessShutdownResult>(
|
||||
new TimeoutException("Simulated graceful shutdown timeout."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||
}
|
||||
|
||||
/// <summary>Releases a blocked dispatch.</summary>
|
||||
public void ReleaseDispatch()
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
}
|
||||
|
||||
/// <summary>Sets the current heartbeat snapshot.</summary>
|
||||
/// <param name="value">The snapshot to set.</param>
|
||||
public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
snapshot = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enqueues a worker event to be drained.</summary>
|
||||
/// <param name="workerEvent">The event to enqueue.</param>
|
||||
public void EnqueueEvent(WorkerEvent workerEvent)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
events.Enqueue(workerEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
releaseDispatch.Set();
|
||||
releaseDispatch.Dispose();
|
||||
DispatchStarted.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PipePair : IDisposable
|
||||
{
|
||||
private readonly NamedPipeServerStream gatewayStream;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
@@ -22,7 +23,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
private const string CorrelationId = "C";
|
||||
|
||||
[Fact]
|
||||
public void SubscribeAlarms_routes_to_handler_and_returns_ok()
|
||||
public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler();
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
@@ -46,7 +47,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeAlarms_without_handler_returns_invalid_request()
|
||||
public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest()
|
||||
{
|
||||
MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null);
|
||||
|
||||
@@ -67,7 +68,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeAlarms_with_empty_expression_returns_invalid_request()
|
||||
public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest()
|
||||
{
|
||||
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
|
||||
|
||||
@@ -88,7 +89,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_routes_native_status_into_hresult_and_payload()
|
||||
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
@@ -121,7 +122,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_with_invalid_guid_returns_invalid_request()
|
||||
public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest()
|
||||
{
|
||||
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
|
||||
|
||||
@@ -142,7 +143,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_with_nonzero_native_status_carries_diagnostic()
|
||||
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 };
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
@@ -165,7 +166,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcknowledgeAlarmByName_routes_tuple_to_handler()
|
||||
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
@@ -198,7 +199,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcknowledgeAlarmByName_with_empty_name_returns_invalid_request()
|
||||
public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest()
|
||||
{
|
||||
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
|
||||
|
||||
@@ -221,7 +222,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryActiveAlarms_returns_payload_with_snapshots()
|
||||
public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
@@ -253,7 +254,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsubscribeAlarms_routes_to_handler()
|
||||
public void UnsubscribeAlarms_WithHandler_RoutesToHandler()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler();
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
@@ -273,7 +274,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsubscribeAlarms_without_handler_is_ok_noop()
|
||||
public void UnsubscribeAlarms_WithoutHandler_IsOkNoop()
|
||||
{
|
||||
MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null);
|
||||
|
||||
@@ -291,7 +292,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_handler_throw_returns_mxaccess_failure()
|
||||
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true };
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
@@ -357,7 +358,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
{
|
||||
new object(),
|
||||
new NullMxAccessServer(),
|
||||
new NullEventSink(),
|
||||
new NoopEventSink(),
|
||||
new MxAccessHandleRegistry(),
|
||||
System.Environment.CurrentManagedThreadId,
|
||||
});
|
||||
@@ -386,12 +387,6 @@ public sealed class AlarmCommandExecutorTests
|
||||
public int ArchestrAUserToId(string userName) => 0;
|
||||
}
|
||||
|
||||
private sealed class NullEventSink : IMxAccessEventSink
|
||||
{
|
||||
public void Attach(object mxAccessComObject, string sessionId) { }
|
||||
public void Detach() { }
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmHandler : IAlarmCommandHandler
|
||||
{
|
||||
public string? LastSubscription { get; private set; }
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace MxGateway.Worker.Tests.MxAccess;
|
||||
public sealed class AlarmCommandHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Subscribe_creates_consumer_and_calls_subscribe()
|
||||
public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer();
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
@@ -27,7 +27,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Second_subscribe_without_unsubscribe_throws()
|
||||
public void Subscribe_WhenAlreadySubscribed_Throws()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer();
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
@@ -40,7 +40,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_disposes_consumer_when_underlying_subscribe_throws()
|
||||
public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true };
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
@@ -54,7 +54,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_disposes_consumer_and_clears_state()
|
||||
public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer();
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
@@ -69,7 +69,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_without_prior_subscribe_is_noop()
|
||||
public void Unsubscribe_WithoutPriorSubscribe_IsNoop()
|
||||
{
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
@@ -79,7 +79,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_forwards_to_consumer_with_full_operator_identity()
|
||||
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
@@ -96,7 +96,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_before_subscribe_throws_invalid_op()
|
||||
public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation()
|
||||
{
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
@@ -107,7 +107,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryActive_returns_mapped_proto_snapshots()
|
||||
public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer
|
||||
{
|
||||
@@ -138,7 +138,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryActive_filters_by_prefix()
|
||||
public void QueryActive_WithPrefix_FiltersByPrefix()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer
|
||||
{
|
||||
@@ -160,7 +160,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_unsubscribes_and_disposes_consumer()
|
||||
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer();
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class AlarmDispatcherTests
|
||||
private const string SessionId = "session-001";
|
||||
|
||||
[Fact]
|
||||
public void TransitionEvent_lands_in_queue_with_mapped_fields()
|
||||
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
@@ -64,7 +64,7 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consecutive_unchanged_state_does_not_emit_a_transition()
|
||||
public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition()
|
||||
{
|
||||
// Mapper.MapTransition returns Unspecified when the state didn't
|
||||
// change; the dispatcher should drop the event before queueing.
|
||||
@@ -94,7 +94,7 @@ public sealed class AlarmDispatcherTests
|
||||
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
|
||||
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
|
||||
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
|
||||
public void Transition_kind_follows_state_table(
|
||||
public void MapTransition_ForEachStatePair_FollowsStateTable(
|
||||
MxAlarmStateKind previous,
|
||||
MxAlarmStateKind current,
|
||||
AlarmTransitionKind expected)
|
||||
@@ -123,7 +123,7 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_forwards_to_consumer()
|
||||
public void Subscribe_WhenInvoked_ForwardsToConsumer()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
@@ -136,7 +136,7 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_forwards_to_consumer_with_full_operator_identity()
|
||||
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
consumer.AcknowledgeReturn = 0;
|
||||
@@ -159,7 +159,7 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcknowledgeByName_forwards_to_consumer_with_full_tuple()
|
||||
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
@@ -185,7 +185,7 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotActiveAlarms_maps_records_to_protos()
|
||||
public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
|
||||
@@ -233,7 +233,7 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_unsubscribes_handler_and_disposes_consumer()
|
||||
public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace MxGateway.Worker.Tests.MxAccess;
|
||||
public sealed class AlarmRecordTransitionMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComposeFullReference_uses_provider_bang_group_dot_name_format()
|
||||
public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat()
|
||||
{
|
||||
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
providerName: "GalaxyAlarmProvider",
|
||||
@@ -25,7 +25,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeFullReference_drops_provider_when_empty()
|
||||
public void ComposeFullReference_WithEmptyProvider_DropsProvider()
|
||||
{
|
||||
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
providerName: null, groupName: "Tank01", alarmName: "Level.HiHi");
|
||||
@@ -33,7 +33,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeFullReference_drops_group_when_empty()
|
||||
public void ComposeFullReference_WithEmptyGroup_DropsGroup()
|
||||
{
|
||||
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
providerName: "GalaxyAlarmProvider", groupName: null, alarmName: "GlobalAlarm");
|
||||
@@ -41,7 +41,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeFullReference_returns_alarm_name_when_provider_and_group_empty()
|
||||
public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName()
|
||||
{
|
||||
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
providerName: null, groupName: null, alarmName: "Bare");
|
||||
@@ -58,7 +58,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
[InlineData("UNKNOWN", MxAlarmStateKind.Unspecified)]
|
||||
[InlineData("", MxAlarmStateKind.Unspecified)]
|
||||
[InlineData(null, MxAlarmStateKind.Unspecified)]
|
||||
public void ParseStateKind_decodes_state_strings(string? input, MxAlarmStateKind expected)
|
||||
public void ParseStateKind_ForEachStateString_DecodesStateKind(string? input, MxAlarmStateKind expected)
|
||||
{
|
||||
Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input));
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Unspecified)]
|
||||
// Current=Unspecified → Unspecified.
|
||||
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.Unspecified, AlarmTransitionKind.Unspecified)]
|
||||
public void MapTransition_decides_proto_kind(
|
||||
public void MapTransition_ForEachStatePair_DecidesProtoKind(
|
||||
MxAlarmStateKind previous,
|
||||
MxAlarmStateKind current,
|
||||
AlarmTransitionKind expected)
|
||||
@@ -92,7 +92,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTransitionTimestampUtc_assembles_utc_from_xml_fields()
|
||||
public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc()
|
||||
{
|
||||
// Captured payload from probe (2026-05-01): EDT producer, GMTOFFSET=240, DSTADJUST=0.
|
||||
// Local 13:26:14.709 + 240 minutes (4h) = 17:26:14.709 UTC.
|
||||
@@ -110,7 +110,7 @@ public sealed class AlarmRecordTransitionMapperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTransitionTimestampUtc_returns_min_value_on_unparseable_inputs()
|
||||
public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue()
|
||||
{
|
||||
Assert.Equal(DateTime.MinValue,
|
||||
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(null, null, 0, 0));
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
@@ -1102,35 +1103,4 @@ public sealed class MxAccessCommandExecutorTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>No-operation event sink for testing.</summary>
|
||||
private sealed class NoopEventSink : IMxAccessEventSink
|
||||
{
|
||||
/// <summary>Attaches to a MXAccess COM object (no-op in test).</summary>
|
||||
/// <param name="mxAccessComObject">The MXAccess COM object to attach to.</param>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
public void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Detaches from the MXAccess COM object (no-op in test).</summary>
|
||||
public void Detach()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>No-operation STA apartment initializer for testing.</summary>
|
||||
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
/// <summary>Initializes the STA apartment (no-op in test).</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Uninitializes the STA apartment (no-op in test).</summary>
|
||||
public void Uninitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,53 @@ public sealed class MxAccessEventQueueTests
|
||||
Assert.Equal(1, queue.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Drain with maxEvents 0 drains every queued event.</summary>
|
||||
[Fact]
|
||||
public void Drain_WithZeroMaxEvents_DrainsAllEvents()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11));
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12));
|
||||
|
||||
IReadOnlyList<WorkerEvent> drained = queue.Drain(maxEvents: 0);
|
||||
|
||||
Assert.Equal(3, drained.Count);
|
||||
Assert.Equal(new[] { 10, 11, 12 }, new[]
|
||||
{
|
||||
drained[0].Event.ItemHandle,
|
||||
drained[1].Event.ItemHandle,
|
||||
drained[2].Event.ItemHandle,
|
||||
});
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that draining an empty queue returns an empty list.</summary>
|
||||
[Fact]
|
||||
public void Drain_WhenQueueIsEmpty_ReturnsEmptyList()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
|
||||
Assert.Empty(queue.Drain(maxEvents: 0));
|
||||
Assert.Empty(queue.Drain(maxEvents: 5));
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Enqueue is rejected after a fault is recorded manually.</summary>
|
||||
[Fact]
|
||||
public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
queue.RecordFault(new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
});
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)));
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded.</summary>
|
||||
[Fact]
|
||||
public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents()
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
@@ -223,10 +224,12 @@ public sealed class MxAccessStaSessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gap 1: Verifies that when MxAccessStaSession is created with the default
|
||||
/// parameterless constructor (no alarm factory), SubscribeAlarms returns
|
||||
/// InvalidRequest with "alarm consumer not configured" diagnostic.
|
||||
/// This validates the baseline before the fix.
|
||||
/// Gap 1: Verifies that when MxAccessStaSession is created without an alarm
|
||||
/// command handler factory, SubscribeAlarms returns InvalidRequest with the
|
||||
/// exact "SubscribeAlarms requires an alarm command handler; the worker was
|
||||
/// constructed without one." diagnostic. The full phrase is asserted so the
|
||||
/// test fails if the diagnostic regresses to a misleading message that still
|
||||
/// happens to contain the word "alarm".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest()
|
||||
@@ -254,7 +257,9 @@ public sealed class MxAccessStaSessionTests
|
||||
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||
Assert.Contains("alarm", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(
|
||||
"SubscribeAlarms requires an alarm command handler; the worker was constructed without one.",
|
||||
reply.DiagnosticMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -411,26 +416,6 @@ public sealed class MxAccessStaSessionTests
|
||||
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Noop STA COM apartment initializer for testing.
|
||||
/// </summary>
|
||||
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the COM apartment (no-op).
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uninitializes the COM apartment (no-op).
|
||||
/// </summary>
|
||||
public void Uninitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake alarm command handler that records calls and tracks poll thread.
|
||||
/// </summary>
|
||||
|
||||
@@ -35,21 +35,21 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_returns_empty_dictionary_for_empty_payload()
|
||||
public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
|
||||
{
|
||||
var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
|
||||
Assert.Empty(records);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_returns_empty_dictionary_for_null_or_whitespace()
|
||||
public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
|
||||
{
|
||||
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
|
||||
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_decodes_single_active_alarm_record()
|
||||
public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
|
||||
{
|
||||
var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_silently_drops_records_with_invalid_guids()
|
||||
public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
|
||||
{
|
||||
string xml = SingleAlarmActiveXml.Replace(
|
||||
"<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
|
||||
@@ -85,7 +85,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
[Theory]
|
||||
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
||||
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
|
||||
public void TryParseHexGuid_handles_dashless_32_char_hex(string hex, string expected)
|
||||
public void TryParseHexGuid_WithDashless32CharHex_Parses(string hex, string expected)
|
||||
{
|
||||
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
||||
Assert.Equal(new Guid(expected), guid);
|
||||
@@ -93,7 +93,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
||||
public void TryParseHexGuid_accepts_canonical_dashed_form(string canonical)
|
||||
public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
|
||||
{
|
||||
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
|
||||
Assert.Equal(new Guid(canonical), guid);
|
||||
@@ -106,7 +106,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
[InlineData("nope")]
|
||||
[InlineData("0123456789ABCDEF")] // too short
|
||||
[InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
|
||||
public void TryParseHexGuid_rejects_invalid_input(string? hex)
|
||||
public void TryParseHexGuid_WithInvalidInput_Rejects(string? hex)
|
||||
{
|
||||
Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
||||
Assert.Equal(Guid.Empty, guid);
|
||||
@@ -120,7 +120,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
/// callback must not exist on the type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WnWrapAlarmConsumer_has_no_internal_timer_field()
|
||||
public void WnWrapAlarmConsumer_ByReflection_HasNoInternalTimerField()
|
||||
{
|
||||
FieldInfo[] fields = typeof(WnWrapAlarmConsumer)
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
@@ -138,7 +138,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
/// footgun structurally unreachable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WnWrapAlarmConsumer_exposes_no_poll_interval_constructor_parameter()
|
||||
public void WnWrapAlarmConsumer_ByReflection_ExposesNoPollIntervalConstructorParameter()
|
||||
{
|
||||
foreach (ConstructorInfo constructor in typeof(WnWrapAlarmConsumer)
|
||||
.GetConstructors(BindingFlags.Instance | BindingFlags.Public))
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Sta;
|
||||
|
||||
@@ -87,10 +88,16 @@ public sealed class StaCommandDispatcherTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cancellation after execution starts still returns the reply once execution completes.
|
||||
/// Verifies cancellation cannot abort a command already executing on the STA:
|
||||
/// once the executor has started, cancelling the token is a no-op and the
|
||||
/// command still runs to completion and returns its normal reply. This
|
||||
/// matches <c>gateway.md</c>: cancellation "cannot safely abort an in-flight
|
||||
/// COM call on the STA". The test does not — and cannot — distinguish "cancel
|
||||
/// observed and ignored" from "cancel never checked"; it only proves the
|
||||
/// in-flight command is not aborted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply()
|
||||
public async Task DispatchAsync_WhenCanceledWhileExecuting_DoesNotAbortInFlightCommand()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
@@ -341,20 +348,4 @@ public sealed class StaCommandDispatcherTests
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op COM apartment initializer for testing.
|
||||
/// </summary>
|
||||
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Uninitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
|
||||
namespace MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Single configurable <see cref="IWorkerRuntimeSession"/> test double shared by
|
||||
/// the IPC tests. Replaces the two independent (and previously diverged)
|
||||
/// <c>FakeRuntimeSession</c> copies in WorkerPipeSessionTests and
|
||||
/// WorkerPipeClientTests: one supported dispatch blocking and event enqueue, the
|
||||
/// other did not. This consolidated double supports every configuration both
|
||||
/// call sites needed, so a minimal caller simply leaves the options unset.
|
||||
/// </summary>
|
||||
internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||
{
|
||||
private readonly ManualResetEventSlim releaseDispatch = new(false);
|
||||
private readonly object gate = new();
|
||||
private readonly Queue<WorkerEvent> events = new();
|
||||
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty);
|
||||
|
||||
/// <summary>Gets the event signaled when dispatch begins.</summary>
|
||||
public ManualResetEventSlim DispatchStarted { get; } = new(false);
|
||||
|
||||
/// <summary>Blocks dispatch execution until explicitly released.</summary>
|
||||
public bool BlockDispatch { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether to throw an exception after dispatch is released.</summary>
|
||||
public bool ThrowAfterDispatchReleased { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException.</summary>
|
||||
public bool ThrowTimeoutOnShutdown { get; set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether Dispose was called.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>Starts the worker session with the given session ID and process ID.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="workerProcessId">The worker process ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready response.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new WorkerReady
|
||||
{
|
||||
WorkerProcessId = workerProcessId,
|
||||
MxaccessProgid = MxAccessInteropInfo.ProgId,
|
||||
MxaccessClsid = MxAccessInteropInfo.Clsid,
|
||||
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispatches a command to the STA thread.</summary>
|
||||
/// <param name="command">The command to dispatch.</param>
|
||||
/// <returns>The command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
return Task.Run(
|
||||
() =>
|
||||
{
|
||||
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
command.CorrelationId));
|
||||
DispatchStarted.Set();
|
||||
|
||||
if (BlockDispatch)
|
||||
{
|
||||
releaseDispatch.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty));
|
||||
|
||||
if (ThrowAfterDispatchReleased)
|
||||
{
|
||||
throw new InvalidOperationException("Command failed after shutdown started.");
|
||||
}
|
||||
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Captures current heartbeat snapshot.</summary>
|
||||
/// <returns>Current runtime heartbeat snapshot.</returns>
|
||||
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains queued events up to the specified limit.</summary>
|
||||
/// <param name="maxEvents">Maximum events to drain; 0 drains all.</param>
|
||||
/// <returns>The drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
int drainCount = maxEvents == 0
|
||||
? events.Count
|
||||
: Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
|
||||
List<WorkerEvent> drained = new(drainCount);
|
||||
for (int index = 0; index < drainCount; index++)
|
||||
{
|
||||
drained.Add(events.Dequeue());
|
||||
}
|
||||
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains a pending fault if any.</summary>
|
||||
/// <returns>Pending fault or null.</returns>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Cancels command by correlation ID.</summary>
|
||||
/// <param name="correlationId">The command correlation ID.</param>
|
||||
/// <returns>True if cancelled; false otherwise.</returns>
|
||||
public bool CancelCommand(string correlationId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Requests graceful shutdown.</summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
}
|
||||
|
||||
/// <summary>Shuts down gracefully within the specified timeout.</summary>
|
||||
/// <param name="timeout">Shutdown timeout period.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Shutdown result.</returns>
|
||||
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
if (ThrowTimeoutOnShutdown)
|
||||
{
|
||||
return Task.FromException<MxAccessShutdownResult>(
|
||||
new TimeoutException("Simulated graceful shutdown timeout."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||
}
|
||||
|
||||
/// <summary>Releases a blocked dispatch.</summary>
|
||||
public void ReleaseDispatch()
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
}
|
||||
|
||||
/// <summary>Sets the current heartbeat snapshot.</summary>
|
||||
/// <param name="value">The snapshot to set.</param>
|
||||
public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
snapshot = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enqueues a worker event to be drained.</summary>
|
||||
/// <param name="workerEvent">The event to enqueue.</param>
|
||||
public void EnqueueEvent(WorkerEvent workerEvent)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
events.Enqueue(workerEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
releaseDispatch.Set();
|
||||
releaseDispatch.Dispose();
|
||||
DispatchStarted.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MxGateway.Worker.Sta;
|
||||
|
||||
namespace MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared no-operation <see cref="IStaComApartmentInitializer"/> for tests that
|
||||
/// construct an <see cref="StaRuntime"/> without a real COM apartment. Replaces
|
||||
/// the per-file copies that were previously defined independently in
|
||||
/// StaCommandDispatcherTests, MxAccessStaSessionTests, and MxAccessCommandExecutorTests.
|
||||
/// </summary>
|
||||
internal sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Uninitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared no-operation <see cref="IMxAccessEventSink"/> for tests that construct
|
||||
/// an <see cref="MxAccessStaSession"/> but do not exercise the event sink.
|
||||
/// Replaces the per-file <c>NoopEventSink</c>/<c>NullEventSink</c> copies that
|
||||
/// were previously defined independently in MxAccessCommandExecutorTests and
|
||||
/// AlarmCommandExecutorTests.
|
||||
/// </summary>
|
||||
internal sealed class NoopEventSink : IMxAccessEventSink
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Attach(object mxAccessComObject, string sessionId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Detach()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for building raw length-prefixed worker frames in tests.
|
||||
/// Replaces the per-file <c>CreateFrame</c>/<c>WriteUInt32LittleEndian</c> copies
|
||||
/// that were previously defined independently in WorkerFrameProtocolTests and
|
||||
/// WorkerPipeSessionTests.
|
||||
/// </summary>
|
||||
internal static class WorkerFrameTestHelpers
|
||||
{
|
||||
/// <summary>Builds a length-prefixed frame from a protobuf message.</summary>
|
||||
/// <param name="message">Message to serialize into the frame payload.</param>
|
||||
public static byte[] CreateFrame(IMessage message)
|
||||
{
|
||||
return CreateFrame(message.ToByteArray());
|
||||
}
|
||||
|
||||
/// <summary>Builds a length-prefixed frame from a raw payload.</summary>
|
||||
/// <param name="payload">Payload bytes to wrap in a frame.</param>
|
||||
public static byte[] CreateFrame(byte[] payload)
|
||||
{
|
||||
byte[] frame = new byte[sizeof(uint) + payload.Length];
|
||||
WriteUInt32LittleEndian(frame, (uint)payload.Length);
|
||||
payload.CopyTo(frame, sizeof(uint));
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>Writes a little-endian unsigned 32-bit integer to the buffer head.</summary>
|
||||
/// <param name="buffer">Buffer to write into; must have at least four bytes.</param>
|
||||
/// <param name="value">Value to encode.</param>
|
||||
public static void WriteUInt32LittleEndian(
|
||||
byte[] buffer,
|
||||
uint value)
|
||||
{
|
||||
buffer[0] = (byte)value;
|
||||
buffer[1] = (byte)(value >> 8);
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
buffer[3] = (byte)(value >> 24);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user