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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user