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:
Joseph Doherty
2026-05-18 22:59:07 -04:00
parent 9582de077b
commit 371bcb3f91
19 changed files with 507 additions and 512 deletions
+17 -17
View File
@@ -7,7 +7,7 @@
| Review date | 2026-05-18 |
| Commit reviewed | `6c64030` |
| Status | Reviewed |
| Open findings | 8 |
| Open findings | 0 |
## Checklist coverage
@@ -138,13 +138,13 @@
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs:175-182` |
| Status | Open |
| Status | Resolved |
**Description:** `Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging` lives in `VariantConverterTests` but asserts on `WorkerLogRedactor.RedactValue`, which has nothing to do with `VariantConverter`. It is also a near-duplicate of coverage in `WorkerLogRedactorTests`. Placing redaction coverage inside the variant-converter class is misleading.
**Recommendation:** Move this test into `Bootstrap/WorkerLogRedactorTests.cs` (which already exists and tests `RedactFields`).
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — The misplaced redaction test was removed from `VariantConverterTests.cs` and re-added to `Bootstrap/WorkerLogRedactorTests.cs` as `RedactValue_WithCredentialBearingFieldNames_ReturnsRedactedValue` — alongside the existing `RedactFields` coverage, where redaction tests belong. Confirmed root cause: the old test asserted only on `WorkerLogRedactor.RedactValue` and never touched `VariantConverter`. The now-orphaned `using MxGateway.Worker.Bootstrap;` was removed from `VariantConverterTests.cs` (`TreatWarningsAsErrors`). The new home is `RedactValue` per-field coverage; `WorkerLogRedactorTests.RedactFields_...` already covers the dictionary path, so the two are complementary rather than duplicates.
### Worker.Tests-009
@@ -153,13 +153,13 @@
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs`, `AlarmDispatcherTests.cs`, `AlarmCommandExecutorTests.cs`, `AlarmRecordTransitionMapperTests.cs`, `WnWrapAlarmConsumerXmlTests.cs` |
| Status | Open |
| Status | Resolved |
**Description:** The alarm-related test files use `snake_case` method names while the rest of the project uses the `Method_State_Result` PascalCase convention. `docs/style-guides/CSharpStyleGuide.md` and the surrounding code establish PascalCase as the project convention; the alarm files diverge.
**Recommendation:** Rename alarm-test methods to the `Method_Scenario_Expectation` PascalCase form for one consistent convention.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — Renamed every `[Fact]`/`[Theory]` method in the five alarm test files from `snake_case` to the project's `Method_Scenario_Expectation` PascalCase form (46 test methods total: 10 in `AlarmCommandHandlerTests`, 8 in `AlarmDispatcherTests`, 12 in `AlarmCommandExecutorTests`, 8 in `AlarmRecordTransitionMapperTests`, 9 in `WnWrapAlarmConsumerXmlTests` minus the existing PascalCase probe methods). Only test methods were renamed — `snake_case` is not present; the method names that *look* like helpers (`Subscribe`, `PollOnce`, `Dispose` on the fake doubles) are interface implementations of `IAlarmCommandHandler`/`IAlarmTransitionConsumer`/`IDisposable` and were correctly left unchanged. The suite stays green; xUnit discovers tests by attribute, not name, so the renames are behaviour-neutral.
### Worker.Tests-010
@@ -168,13 +168,13 @@
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs:230-258` |
| Status | Open |
| Status | Resolved |
**Description:** `StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest` asserts `Assert.Contains("alarm", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase)`. The XML doc claims it verifies the diagnostic says "alarm consumer not configured", but the assertion only checks the substring "alarm" — which would also match an unrelated message like "invalid alarm GUID". The assertion is weaker than the documented intent.
**Recommendation:** Assert the full diagnostic phrase so the test fails if the diagnostic regresses to a misleading message.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — The weak `Assert.Contains("alarm", ...)` was replaced with an exact `Assert.Equal` against the diagnostic the executor actually emits. Re-triage: the test's XML doc claimed the phrase was "alarm consumer not configured", but `MxAccessCommandExecutor.ExecuteSubscribeAlarms` (verified in `src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs:310-315`) produces "SubscribeAlarms requires an alarm command handler; the worker was constructed without one." — the doc was wrong, so both the assertion and the XML doc were corrected to the real phrase. The test now fails if the diagnostic regresses to any other message.
### Worker.Tests-011
@@ -183,13 +183,13 @@
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs:92-112` |
| Status | Open |
| Status | Resolved |
**Description:** `DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply` is named and documented as if it proves cancellation arrived after execution began. The test does `Started.Wait(...)` then `cancellation.Cancel()`, which proves execution started, but because the executor is already running on the STA the cancellation is inherently a no-op — the test cannot distinguish "cancel was observed and ignored" from "cancel was never checked". The name overstates what is proven.
**Recommendation:** Either tighten the test (assert the dispatcher's cancel path was reached and declined) or rename/comment it to "cancellation cannot abort an in-flight STA command", matching `gateway.md`'s stated behavior.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — Took the rename/re-document option. The test is renamed `DispatchAsync_WhenCanceledWhileExecuting_DoesNotAbortInFlightCommand` and its XML doc rewritten to state exactly what it proves — an in-flight STA command is *not* aborted by cancellation — and to state explicitly that the test cannot and does not distinguish "cancel observed and ignored" from "cancel never checked". The doc now cites `gateway.md`'s wording ("cannot safely abort an in-flight COM call on the STA"). The test body is unchanged: it already asserts the command runs to completion and returns its normal `Ok` reply, which is the genuine behaviour. No runtime behaviour changed.
### Worker.Tests-012
@@ -198,13 +198,13 @@
| Severity | Low |
| Category | Testing coverage |
| Location | `src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs` |
| Status | Open |
| Status | Resolved |
**Description:** `docs/WorkerFrameProtocol.md` states the reader "rejects zero-length payloads and payloads larger than the configured maximum (default 16 MiB) before allocating the payload buffer." `WorkerFrameProtocolTests` covers malformed-length, wrong protocol version, wrong session, and malformed payload, but has no test for the zero-length-payload rejection or the oversized-frame rejection — both explicit security-relevant input-validation paths.
**Recommendation:** Add tests feeding a frame with `payload_length == 0` and one with `payload_length` above the configured maximum, asserting the corresponding `WorkerFrameProtocolErrorCode`.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — Re-triage of the zero-length half: the finding's "no test for the zero-length-payload rejection" is partly inaccurate. The pre-existing `ReadAsync_WithMalformedLength_ThrowsMalformedLength` fed a four-zero-byte stream — which is exactly a frame declaring `payload_length == 0` — so the zero-length path *was* already covered, just under a misleading name (the length prefix itself is well-formed; only the declared length is zero). That test was renamed `ReadAsync_WithZeroLengthPayload_ThrowsMalformedLength` with an XML doc explaining the four-zero-byte construction, rather than adding a duplicate. The oversized half was a genuine gap: a new `ReadAsync_WithPayloadAboveConfiguredMaximum_ThrowsMessageTooLarge` constructs `WorkerFrameProtocolOptions` with a 64-byte maximum, feeds a length prefix of 65, and asserts `WorkerFrameProtocolErrorCode.MessageTooLarge` — verified against `WorkerFrameReader.ReadAsync`, both checks fire before the payload buffer is rented. The small configured maximum keeps the test from allocating a multi-megabyte buffer.
### Worker.Tests-013
@@ -213,13 +213,13 @@
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs:539-546` |
| Status | Open |
| Status | Resolved |
**Description:** `ThrowIfCompletedAsync` does an unconditional `await Task.Delay(TimeSpan.FromMilliseconds(100))` then checks `task.IsCompleted`. This adds a fixed 100 ms to the test and only catches a `RunAsync` that fails within that arbitrary window; a session that faults after 100 ms slips past undetected.
**Recommendation:** Replace with a deterministic race: `await Task.WhenAny(runTask, <first-expected-frame-read>)` and assert the run task did not win.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — `ThrowIfCompletedAsync` was deleted (it had a single call site, in `RunAsync_SendsHeartbeatPayloadFromRuntimeSnapshot`). That test now races `runTask` against the first-heartbeat `ReadUntilAsync` with `Task.WhenAny`; if `runTask` wins it is awaited to surface the underlying fault and the test fails via `Assert.Fail`. The fixed 100 ms delay is gone — the check is now deterministic: a `RunAsync` faulting at *any* time before the first heartbeat is caught, and a healthy run completes as soon as the heartbeat arrives instead of always paying 100 ms.
### Worker.Tests-014
@@ -228,13 +228,13 @@
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs:194`, `WorkerPipeSessionTests.cs:622`, `Sta/StaCommandDispatcherTests.cs:348`, `MxAccess/MxAccessStaSessionTests.cs:334`, `MxAccess/MxAccessCommandExecutorTests.cs:1124` |
| Status | Open |
| Status | Resolved |
**Description:** `FakeRuntimeSession`, `NoopComApartmentInitializer`, `NoopEventSink`/`NullEventSink`, and the `CreateFrame`/`WriteUInt32LittleEndian` helpers are re-implemented independently in multiple test files. The two `FakeRuntimeSession` implementations have already diverged (one supports `BlockDispatch`/event enqueue, one does not), and `NoopComApartmentInitializer` is defined four times.
**Recommendation:** Extract shared test doubles (`NoopComApartmentInitializer`, frame helpers, a single configurable `FakeRuntimeSession`) into a `TestSupport` folder/namespace consumed by all test classes.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — Added a `src/MxGateway.Worker.Tests/TestSupport/` folder (namespace `MxGateway.Worker.Tests.TestSupport`) with four shared doubles: `NoopComApartmentInitializer`, `NoopEventSink`, `WorkerFrameTestHelpers` (`CreateFrame`/`WriteUInt32LittleEndian`), and a single configurable `FakeRuntimeSession`. The consolidated `FakeRuntimeSession` is the richer of the two divergent copies (it supports `BlockDispatch`, event enqueue, shutdown-timeout, and throw-after-release); the minimal `WorkerPipeClientTests` caller simply leaves the options unset. The per-file copies were deleted from `WorkerPipeClientTests`, `WorkerPipeSessionTests`, `StaCommandDispatcherTests`, `MxAccessStaSessionTests`, `MxAccessCommandExecutorTests`, and `WorkerFrameProtocolTests`, and the orphaned `NullEventSink` in `AlarmCommandExecutorTests` was replaced with the shared `NoopEventSink`. Re-triage: the finding says `NoopComApartmentInitializer` "is defined four times" — it was defined **three** times (`StaCommandDispatcherTests`, `MxAccessStaSessionTests`, `MxAccessCommandExecutorTests`); the fourth alarm-area `IStaComApartmentInitializer` implementation is `StaRuntimeTests.RecordingComApartmentInitializer`, which is a *recording* double (asserts init/uninit ordering), not a no-op, so it was deliberately left in place rather than folded into the shared no-op. Unused `using` directives left behind by the removals were stripped (`TreatWarningsAsErrors`).
### Worker.Tests-015
@@ -243,10 +243,10 @@
| Severity | Low |
| Category | Testing coverage |
| Location | `src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs` |
| Status | Open |
| Status | Resolved |
**Description:** `MxAccessEventQueueTests` covers monotonic sequencing, drain, capacity overflow, and first-fault-wins, but does not cover `Drain` with `maxEvents: 0` (drain-all) — a branch `FakeRuntimeSession.DrainEvents` even special-cases — nor draining an empty queue, nor enqueue after a manual `RecordFault`. These are minor branches but the overflow/fault interaction is the worker's backpressure contract.
**Recommendation:** Add a `Drain(0)` drain-all test and an empty-queue drain test.
**Resolution:** _(open)_
**Resolution:** 2026-05-18 — Added three tests to `MxAccessEventQueueTests`. `Drain_WithZeroMaxEvents_DrainsAllEvents` covers the `maxEvents == 0` drain-all branch in `MxAccessEventQueue.Drain` (verified at `src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs:174`) — three events enqueued, `Drain(0)` returns all three in order and empties the queue. `Drain_WhenQueueIsEmpty_ReturnsEmptyList` covers the `drainCount == 0` early-return branch for both `Drain(0)` and `Drain(5)` on an empty queue. `Enqueue_AfterRecordFault_ThrowsInvalidOperationException` covers the backpressure contract gap the finding flagged — after a manual `RecordFault`, `Enqueue` throws `InvalidOperationException` ("outbound event queue is faulted") and the event is not queued.
@@ -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);
}
}