diff --git a/code-reviews/Worker.Tests/findings.md b/code-reviews/Worker.Tests/findings.md index 008bdd8..5943072 100644 --- a/code-reviews/Worker.Tests/findings.md +++ b/code-reviews/Worker.Tests/findings.md @@ -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, )` 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. diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs index ea2b68a..6416dc1 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs @@ -32,4 +32,16 @@ public sealed class WorkerLogRedactorTests Assert.Equal("[redacted]", redacted["api_key"]); Assert.Equal("session-1", redacted["session_id"]); } + + /// + /// Verifies redacts individual + /// credential-bearing fields before they reach a log sink. + /// + [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")); + } } diff --git a/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs b/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs index 49cd391..1933f47 100644 --- a/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs +++ b/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs @@ -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); } - /// Verifies that credential-bearing fields are redacted before logging. - [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")); - } - /// Fake unsupported variant type for testing unknown type handling. private sealed class UnsupportedVariant { diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs index 0ab1f55..46d889f 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs +++ b/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs @@ -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); } - /// Verifies that malformed length throws error. + /// + /// Verifies that a frame whose length prefix is zero is rejected before the + /// payload buffer is allocated. docs/WorkerFrameProtocol.md 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. + /// [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); } + /// + /// Verifies that a frame whose length prefix exceeds the configured maximum + /// is rejected before the payload buffer is allocated. docs/WorkerFrameProtocol.md + /// 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. + /// + [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( + async () => await reader.ReadAsync()); + + Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode); + } + /// Verifies that malformed payload throws invalid envelope error. [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); - } } diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs index bedab8e..61f5e6a 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs +++ b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs @@ -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 - { - /// Starts the worker session. - /// Session ID. - /// Worker process ID. - /// Cancellation token. - /// Worker ready response. - public Task 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), - }); - } - - /// Dispatches a command to STA thread. - /// The command. - /// Command reply. - public Task 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", - }, - }); - } - - /// Captures current runtime heartbeat snapshot. - /// Heartbeat snapshot. - public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() - { - return new WorkerRuntimeHeartbeatSnapshot( - DateTimeOffset.UtcNow, - pendingCommandCount: 0, - outboundEventQueueDepth: 0, - lastEventSequence: 0, - currentCommandCorrelationId: string.Empty); - } - - /// Drains queued events. - /// Maximum events to drain. - /// Drained events. - public IReadOnlyList DrainEvents(uint maxEvents) - { - return Array.Empty(); - } - - /// Drains pending fault if any. - /// Fault or null. - public WorkerFault? DrainFault() - { - return null; - } - - /// Cancels a command by correlation ID. - /// Command correlation ID. - /// True if cancelled. - public bool CancelCommand(string correlationId) - { - return false; - } - - /// Requests graceful shutdown. - public void RequestShutdown() - { - } - - /// Shuts down gracefully within timeout. - /// Shutdown timeout. - /// Cancellation token. - /// Shutdown result. - public Task ShutdownGracefullyAsync( - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); - } - - /// Disposes resources. - public void Dispose() - { - } - } } diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs index 45d3487..7cf1960 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs +++ b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs @@ -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 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; - } - } - /// Reads frames until one matching the expected body type is found. /// Frame reader. /// Expected body case. @@ -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 events = new(); - private WorkerRuntimeHeartbeatSnapshot snapshot = new( - DateTimeOffset.UtcNow, - pendingCommandCount: 0, - outboundEventQueueDepth: 0, - lastEventSequence: 0, - currentCommandCorrelationId: string.Empty); - - /// Gets the event signaled when dispatch begins. - public ManualResetEventSlim DispatchStarted { get; } = new(false); - - /// Blocks dispatch execution until explicitly released. - public bool BlockDispatch { get; set; } - - /// Gets or sets whether to throw an exception after dispatch is released. - public bool ThrowAfterDispatchReleased { get; set; } - - /// Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException. - public bool ThrowTimeoutOnShutdown { get; set; } - - /// Gets a value indicating whether Dispose was called. - public bool Disposed { get; private set; } - - /// Starts the worker session with the given session ID and process ID. - /// The session identifier. - /// The worker process ID. - /// Cancellation token. - /// Worker ready response. - public Task 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), - }); - } - - /// Dispatches a command to the STA thread. - /// The command to dispatch. - /// The command reply. - public Task 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", - }, - }; - }); - } - - /// Captures current heartbeat snapshot. - /// Current runtime heartbeat snapshot. - public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() - { - lock (gate) - { - return snapshot; - } - } - - /// Drains queued events up to the specified limit. - /// Maximum events to drain; 0 drains all. - /// The drained events. - public IReadOnlyList DrainEvents(uint maxEvents) - { - lock (gate) - { - int drainCount = maxEvents == 0 - ? events.Count - : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue))); - List drained = new(drainCount); - for (int index = 0; index < drainCount; index++) - { - drained.Add(events.Dequeue()); - } - - return drained; - } - } - - /// Drains a pending fault if any. - /// Pending fault or null. - public WorkerFault? DrainFault() - { - return null; - } - - /// Cancels command by correlation ID. - /// The command correlation ID. - /// True if cancelled; false otherwise. - public bool CancelCommand(string correlationId) - { - return false; - } - - /// Requests graceful shutdown. - public void RequestShutdown() - { - releaseDispatch.Set(); - } - - /// Shuts down gracefully within the specified timeout. - /// Shutdown timeout period. - /// Cancellation token. - /// Shutdown result. - public Task ShutdownGracefullyAsync( - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - releaseDispatch.Set(); - if (ThrowTimeoutOnShutdown) - { - return Task.FromException( - new TimeoutException("Simulated graceful shutdown timeout.")); - } - - return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); - } - - /// Releases a blocked dispatch. - public void ReleaseDispatch() - { - releaseDispatch.Set(); - } - - /// Sets the current heartbeat snapshot. - /// The snapshot to set. - public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value) - { - lock (gate) - { - snapshot = value; - } - } - - /// Enqueues a worker event to be drained. - /// The event to enqueue. - public void EnqueueEvent(WorkerEvent workerEvent) - { - lock (gate) - { - events.Enqueue(workerEvent); - } - } - - /// Disposes resources. - public void Dispose() - { - Disposed = true; - releaseDispatch.Set(); - releaseDispatch.Dispose(); - DispatchStarted.Dispose(); - } - } - private sealed class PipePair : IDisposable { private readonly NamedPipeServerStream gatewayStream; diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index 2ed91c0..b9f512f 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -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; } diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index ebbaf6b..1e41b5e 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -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( diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index f2f511e..d561ed5 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -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(); diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs b/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs index 8e108e4..2cf700c 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs @@ -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)); diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index fa8e4af..43fa4f2 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -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 } } - /// No-operation event sink for testing. - private sealed class NoopEventSink : IMxAccessEventSink - { - /// Attaches to a MXAccess COM object (no-op in test). - /// The MXAccess COM object to attach to. - /// Identifier of the session. - public void Attach( - object mxAccessComObject, - string sessionId) - { - } - - /// Detaches from the MXAccess COM object (no-op in test). - public void Detach() - { - } - } - - /// No-operation STA apartment initializer for testing. - private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer - { - /// Initializes the STA apartment (no-op in test). - public void Initialize() - { - } - - /// Uninitializes the STA apartment (no-op in test). - public void Uninitialize() - { - } - } } diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs index ee2bfb5..ef85577 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs @@ -46,6 +46,53 @@ public sealed class MxAccessEventQueueTests Assert.Equal(1, queue.Count); } + /// Verifies that Drain with maxEvents 0 drains every queued event. + [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 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); + } + + /// Verifies that draining an empty queue returns an empty list. + [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); + } + + /// Verifies that Enqueue is rejected after a fault is recorded manually. + [Fact] + public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException() + { + MxAccessEventQueue queue = new(capacity: 4); + queue.RecordFault(new WorkerFault + { + Category = WorkerFaultCategory.MxaccessEventConversionFailed, + }); + + Assert.Throws( + () => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10))); + Assert.Equal(0, queue.Count); + } + /// Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded. [Fact] public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents() diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index b013b01..887d836 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -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 } /// - /// 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". /// [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); } /// @@ -411,26 +416,6 @@ public sealed class MxAccessStaSessionTests MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123); } - /// - /// Noop STA COM apartment initializer for testing. - /// - private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer - { - /// - /// Initializes the COM apartment (no-op). - /// - public void Initialize() - { - } - - /// - /// Uninitializes the COM apartment (no-op). - /// - public void Uninitialize() - { - } - } - /// /// Fake alarm command handler that records calls and tracks poll thread. /// diff --git a/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 8442feb..096ae02 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -35,21 +35,21 @@ public sealed class WnWrapAlarmConsumerXmlTests ""; [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( "BCC4705395424D65BDAABCDEA6A32A73", @@ -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. /// [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. /// [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)) diff --git a/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs b/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs index c81edda..1675341 100644 --- a/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs +++ b/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs @@ -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 } /// - /// 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 gateway.md: 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. /// [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; } } - - /// - /// No-op COM apartment initializer for testing. - /// - private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer - { - /// - public void Initialize() - { - } - - /// - public void Uninitialize() - { - } - } } diff --git a/src/MxGateway.Worker.Tests/TestSupport/FakeRuntimeSession.cs b/src/MxGateway.Worker.Tests/TestSupport/FakeRuntimeSession.cs new file mode 100644 index 0000000..47a553e --- /dev/null +++ b/src/MxGateway.Worker.Tests/TestSupport/FakeRuntimeSession.cs @@ -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; + +/// +/// Single configurable test double shared by +/// the IPC tests. Replaces the two independent (and previously diverged) +/// FakeRuntimeSession 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. +/// +internal sealed class FakeRuntimeSession : IWorkerRuntimeSession +{ + private readonly ManualResetEventSlim releaseDispatch = new(false); + private readonly object gate = new(); + private readonly Queue events = new(); + private WorkerRuntimeHeartbeatSnapshot snapshot = new( + DateTimeOffset.UtcNow, + pendingCommandCount: 0, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: string.Empty); + + /// Gets the event signaled when dispatch begins. + public ManualResetEventSlim DispatchStarted { get; } = new(false); + + /// Blocks dispatch execution until explicitly released. + public bool BlockDispatch { get; set; } + + /// Gets or sets whether to throw an exception after dispatch is released. + public bool ThrowAfterDispatchReleased { get; set; } + + /// Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException. + public bool ThrowTimeoutOnShutdown { get; set; } + + /// Gets a value indicating whether Dispose was called. + public bool Disposed { get; private set; } + + /// Starts the worker session with the given session ID and process ID. + /// The session identifier. + /// The worker process ID. + /// Cancellation token. + /// Worker ready response. + public Task 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), + }); + } + + /// Dispatches a command to the STA thread. + /// The command to dispatch. + /// The command reply. + public Task 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", + }, + }; + }); + } + + /// Captures current heartbeat snapshot. + /// Current runtime heartbeat snapshot. + public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() + { + lock (gate) + { + return snapshot; + } + } + + /// Drains queued events up to the specified limit. + /// Maximum events to drain; 0 drains all. + /// The drained events. + public IReadOnlyList DrainEvents(uint maxEvents) + { + lock (gate) + { + int drainCount = maxEvents == 0 + ? events.Count + : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue))); + List drained = new(drainCount); + for (int index = 0; index < drainCount; index++) + { + drained.Add(events.Dequeue()); + } + + return drained; + } + } + + /// Drains a pending fault if any. + /// Pending fault or null. + public WorkerFault? DrainFault() + { + return null; + } + + /// Cancels command by correlation ID. + /// The command correlation ID. + /// True if cancelled; false otherwise. + public bool CancelCommand(string correlationId) + { + return false; + } + + /// Requests graceful shutdown. + public void RequestShutdown() + { + releaseDispatch.Set(); + } + + /// Shuts down gracefully within the specified timeout. + /// Shutdown timeout period. + /// Cancellation token. + /// Shutdown result. + public Task ShutdownGracefullyAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + releaseDispatch.Set(); + if (ThrowTimeoutOnShutdown) + { + return Task.FromException( + new TimeoutException("Simulated graceful shutdown timeout.")); + } + + return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); + } + + /// Releases a blocked dispatch. + public void ReleaseDispatch() + { + releaseDispatch.Set(); + } + + /// Sets the current heartbeat snapshot. + /// The snapshot to set. + public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value) + { + lock (gate) + { + snapshot = value; + } + } + + /// Enqueues a worker event to be drained. + /// The event to enqueue. + public void EnqueueEvent(WorkerEvent workerEvent) + { + lock (gate) + { + events.Enqueue(workerEvent); + } + } + + /// Disposes resources. + public void Dispose() + { + Disposed = true; + releaseDispatch.Set(); + releaseDispatch.Dispose(); + DispatchStarted.Dispose(); + } +} diff --git a/src/MxGateway.Worker.Tests/TestSupport/NoopComApartmentInitializer.cs b/src/MxGateway.Worker.Tests/TestSupport/NoopComApartmentInitializer.cs new file mode 100644 index 0000000..caf6aca --- /dev/null +++ b/src/MxGateway.Worker.Tests/TestSupport/NoopComApartmentInitializer.cs @@ -0,0 +1,22 @@ +using MxGateway.Worker.Sta; + +namespace MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared no-operation for tests that +/// construct an without a real COM apartment. Replaces +/// the per-file copies that were previously defined independently in +/// StaCommandDispatcherTests, MxAccessStaSessionTests, and MxAccessCommandExecutorTests. +/// +internal sealed class NoopComApartmentInitializer : IStaComApartmentInitializer +{ + /// + public void Initialize() + { + } + + /// + public void Uninitialize() + { + } +} diff --git a/src/MxGateway.Worker.Tests/TestSupport/NoopEventSink.cs b/src/MxGateway.Worker.Tests/TestSupport/NoopEventSink.cs new file mode 100644 index 0000000..54df74f --- /dev/null +++ b/src/MxGateway.Worker.Tests/TestSupport/NoopEventSink.cs @@ -0,0 +1,23 @@ +using MxGateway.Worker.MxAccess; + +namespace MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared no-operation for tests that construct +/// an but do not exercise the event sink. +/// Replaces the per-file NoopEventSink/NullEventSink copies that +/// were previously defined independently in MxAccessCommandExecutorTests and +/// AlarmCommandExecutorTests. +/// +internal sealed class NoopEventSink : IMxAccessEventSink +{ + /// + public void Attach(object mxAccessComObject, string sessionId) + { + } + + /// + public void Detach() + { + } +} diff --git a/src/MxGateway.Worker.Tests/TestSupport/WorkerFrameTestHelpers.cs b/src/MxGateway.Worker.Tests/TestSupport/WorkerFrameTestHelpers.cs new file mode 100644 index 0000000..47d9ac5 --- /dev/null +++ b/src/MxGateway.Worker.Tests/TestSupport/WorkerFrameTestHelpers.cs @@ -0,0 +1,43 @@ +using Google.Protobuf; + +namespace MxGateway.Worker.Tests.TestSupport; + +/// +/// Shared helpers for building raw length-prefixed worker frames in tests. +/// Replaces the per-file CreateFrame/WriteUInt32LittleEndian copies +/// that were previously defined independently in WorkerFrameProtocolTests and +/// WorkerPipeSessionTests. +/// +internal static class WorkerFrameTestHelpers +{ + /// Builds a length-prefixed frame from a protobuf message. + /// Message to serialize into the frame payload. + public static byte[] CreateFrame(IMessage message) + { + return CreateFrame(message.ToByteArray()); + } + + /// Builds a length-prefixed frame from a raw payload. + /// Payload bytes to wrap in a frame. + 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; + } + + /// Writes a little-endian unsigned 32-bit integer to the buffer head. + /// Buffer to write into; must have at least four bytes. + /// Value to encode. + 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); + } +}