docs(src): add missing XML docs and strip tracking-ID comments
Sweep of 203 source files resolving CommentChecker findings: add <summary>/<param>/<returns>/<inheritdoc> where missing, and remove resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN, Task N) from code comments. Comment/doc-only — no logic changes. Server+Tests build clean under TreatWarningsAsErrors.
This commit is contained in:
@@ -43,6 +43,7 @@ public sealed class ArrayAddressNormalizerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies null, empty, and whitespace addresses are returned unchanged.</summary>
|
||||
/// <param name="address">The blank address under test.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -96,10 +97,14 @@ public sealed class ArrayAddressNormalizerTests
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op stub refresh; the fixed <see cref="Current"/> entry never changes.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op stub wait; the fixed <see cref="Current"/> entry is already loaded.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A bare array <c>AddItem</c> address is normalized to its writable array form on the wire,
|
||||
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -71,6 +72,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
}
|
||||
|
||||
/// <summary>A bare scalar <c>AddItem</c> address is forwarded unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem_ScalarAddress_ForwardedUnchanged()
|
||||
{
|
||||
@@ -99,6 +101,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A sparse-array <see cref="WriteCommand"/> value is expanded to a full, default-filled
|
||||
/// <see cref="MxArray"/> before reaching the worker; no sparse value is ever forwarded.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -145,6 +148,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A bare array <c>AddItem2</c> address is normalized to its writable array form on the wire,
|
||||
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem2_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -199,6 +203,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; no sparse value is ever
|
||||
/// forwarded inside a bulk write.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -253,6 +258,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
|
||||
/// address in the <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -329,6 +335,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -376,6 +383,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -424,6 +432,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -471,6 +480,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -524,6 +534,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -578,6 +589,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
@@ -630,6 +642,7 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
|
||||
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
@@ -745,10 +758,14 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op for this stub; the fixed <see cref="Current"/> entry is always ready.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>No-op for this stub; the fixed <see cref="Current"/> entry is always considered loaded.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -760,16 +777,16 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
/// <summary>Gets or sets the reply returned by the next invocation.</summary>
|
||||
public WorkerCommandReply NextReply { get; set; } = new();
|
||||
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-array-write-wiring";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets the worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -801,7 +818,8 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the test worker client. No-op — there is no unmanaged state to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+32
-4
@@ -14,7 +14,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Task 6 regression tests for the internal dashboard mirror. The dashboard is a
|
||||
/// Regression tests for the internal dashboard mirror. The dashboard is a
|
||||
/// first-class subscriber on the session's <see cref="SessionEventDistributor"/>, so it
|
||||
/// receives session events whether or not a gRPC client is streaming — fixing the
|
||||
/// "dark feed" where the dashboard only saw events while a gRPC client was actively
|
||||
@@ -28,9 +28,8 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// The KEY bug-fix test: the dashboard broadcaster receives session events even when
|
||||
/// NO gRPC <c>StreamEvents</c> subscriber is attached. The session is driven to Ready
|
||||
/// with a fake worker emitting events; only the internal dashboard subscriber exists.
|
||||
/// Before Task 6 the mirror lived inside the per-RPC gRPC loop, so with no gRPC
|
||||
/// subscriber the dashboard saw nothing.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_ReceivesEvents_WithNoGrpcSubscriber()
|
||||
{
|
||||
@@ -59,6 +58,7 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// gRPC path is no longer the dashboard's source — both read independent leases fed by
|
||||
/// the single distributor pump.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_AndGrpcSubscriber_BothReceiveEvents()
|
||||
{
|
||||
@@ -108,11 +108,12 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 4 hazard guard: starting the pump at Ready with a fast-completing worker stream
|
||||
/// Hazard guard: starting the pump at Ready with a fast-completing worker stream
|
||||
/// and zero subscribers used to drain into nothing and leave a later subscriber hanging.
|
||||
/// Now the dashboard subscriber is registered BEFORE the pump starts, so even a worker
|
||||
/// stream that completes immediately delivers every event to the dashboard with no hang.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_FastCompletingWorkerStream_DeliversAllEventsWithoutHang()
|
||||
{
|
||||
@@ -134,6 +135,7 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// The dashboard Publish must be never-throw at the seam too: a throwing broadcaster
|
||||
/// must not fault the session or stop the mirror from continuing past the failure.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_WhenBroadcasterThrows_DoesNotFaultSessionAndKeepsMirroring()
|
||||
{
|
||||
@@ -155,6 +157,7 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
/// The internal dashboard subscriber must NOT count against the single-subscriber
|
||||
/// guard: a gRPC subscriber can still attach while the dashboard mirror is running.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DashboardMirror_DoesNotCountAgainstSingleSubscriberGuard()
|
||||
{
|
||||
@@ -239,8 +242,10 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
{
|
||||
private int _publishAttempts;
|
||||
|
||||
/// <summary>Number of times <see cref="Publish"/> has been invoked.</summary>
|
||||
public int PublishAttempts => Volatile.Read(ref _publishAttempts);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(string sessionId, MxEvent mxEvent)
|
||||
{
|
||||
Interlocked.Increment(ref _publishAttempts);
|
||||
@@ -250,49 +255,59 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
|
||||
private sealed class SingleSessionManager(GatewaySession session) : ISessionManager
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
string? ownerKeyId,
|
||||
CancellationToken cancellationToken) => Task.FromResult(session);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(string sessionId, out GatewaySession gatewaySession)
|
||||
{
|
||||
gatewaySession = session;
|
||||
return string.Equals(sessionId, session.SessionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> CloseSessionAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Events yielded by <see cref="ReadEventsAsync"/>, in order.</summary>
|
||||
public List<WorkerEvent> Events { get; } = [];
|
||||
|
||||
/// <summary>When <see langword="true"/>, <see cref="ReadEventsAsync"/> completes after yielding <see cref="Events"/> instead of hanging.</summary>
|
||||
public bool CompleteAfterConfiguredEvents { get; set; }
|
||||
|
||||
// Gate that holds the event stream before it yields anything. Released by default, so
|
||||
@@ -308,26 +323,35 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>Re-arms the release gate so <see cref="ReadEventsAsync"/> blocks before yielding events until <see cref="ReleaseEvents"/> is called.</summary>
|
||||
public void HoldEventsUntilReleased() =>
|
||||
_releaseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Releases the gate set by <see cref="HoldEventsUntilReleased"/>, letting <see cref="ReadEventsAsync"/> proceed.</summary>
|
||||
public void ReleaseEvents() => _releaseGate.TrySetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-dashboard-mirror";
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -348,12 +372,16 @@ public sealed class GatewaySessionDashboardMirrorTests
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>No-op dispose; the fake worker client owns no resources to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,21 +13,21 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
|
||||
/// Server-015 and Server-016 audited the split lock discipline between
|
||||
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
|
||||
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
|
||||
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>,
|
||||
/// covering the split lock discipline between <c>_syncRoot</c> (state transitions)
|
||||
/// and <c>_closeLock</c> (close serialization) and the gated <c>DisposeAsync</c>.
|
||||
/// </summary>
|
||||
public sealed class GatewaySessionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
|
||||
/// A <c>TransitionTo(Ready)</c> issued after
|
||||
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
|
||||
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
|
||||
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
|
||||
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
|
||||
/// window the audit identified.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
|
||||
{
|
||||
@@ -54,12 +54,13 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
|
||||
/// Once <see cref="GatewaySession.CloseAsync"/> finishes,
|
||||
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
|
||||
/// session out of <see cref="SessionState.Closed"/> either — the close path's
|
||||
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
|
||||
/// machine uses, so the existing "Closed is terminal" invariant holds.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
|
||||
{
|
||||
@@ -76,7 +77,7 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-028 regression. A <see cref="GatewaySession.MarkFaulted"/> issued
|
||||
/// A <see cref="GatewaySession.MarkFaulted"/> issued
|
||||
/// while <see cref="GatewaySession.CloseAsync"/> is parked between its
|
||||
/// <c>Closing</c> and <c>Closed</c> writes must not break the close path's
|
||||
/// terminal contract: the in-flight close runs to <c>Closed</c>, the fault
|
||||
@@ -86,6 +87,7 @@ public sealed class GatewaySessionTests
|
||||
/// Faulted" — this test pins the resolved end state so a future tightening
|
||||
/// of <c>MarkFaulted</c> cannot silently regress it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
|
||||
{
|
||||
@@ -113,11 +115,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
|
||||
/// <see cref="GatewaySession.DisposeAsync"/> must wait
|
||||
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
|
||||
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
|
||||
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
|
||||
{
|
||||
@@ -149,6 +152,7 @@ public sealed class GatewaySessionTests
|
||||
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
|
||||
/// rather than propagating it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
|
||||
{
|
||||
@@ -164,7 +168,7 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue-1 regression. Concurrent <c>Dispose()</c> calls on the same
|
||||
/// Concurrent <c>Dispose()</c> calls on the same
|
||||
/// <see cref="IEventSubscriberLease"/> — as can happen when a gRPC stream
|
||||
/// completion and a client cancellation both fire at the same time — must
|
||||
/// decrement <c>_activeEventSubscriberCount</c> exactly once, never to −1.
|
||||
@@ -174,6 +178,7 @@ public sealed class GatewaySessionTests
|
||||
/// the count must be exactly 0 and a subsequent single-subscriber attach must
|
||||
/// succeed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EventSubscriberLease_ConcurrentDispose_DecrementsCountExactlyOnce()
|
||||
{
|
||||
@@ -222,10 +227,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8 regression. Single-subscriber mode rejects a SECOND concurrent external
|
||||
/// Single-subscriber mode rejects a SECOND concurrent external
|
||||
/// attach with <see cref="SessionManagerErrorCode.EventSubscriberAlreadyActive"/> —
|
||||
/// the legacy guard is preserved unchanged when multi-subscriber is disabled.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_SingleMode_SecondAttachThrowsAlreadyActive()
|
||||
{
|
||||
@@ -244,10 +250,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8. Multi-subscriber mode allows exactly <c>cap</c> concurrent external
|
||||
/// Multi-subscriber mode allows exactly <c>cap</c> concurrent external
|
||||
/// subscribers; the (cap+1)-th attach throws
|
||||
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_AttachesUpToCapThenThrowsLimitReached()
|
||||
{
|
||||
@@ -280,10 +287,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8. The gateway-owned INTERNAL dashboard subscriber must NOT consume cap
|
||||
/// The gateway-owned INTERNAL dashboard subscriber must NOT consume cap
|
||||
/// budget: with the dashboard mirror running, the full cap of external subscribers is
|
||||
/// still attachable.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_DashboardMirrorDoesNotConsumeCap()
|
||||
{
|
||||
@@ -322,12 +330,13 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8 concurrency. Many concurrent attaches in multi-subscriber mode must never
|
||||
/// Many concurrent attaches in multi-subscriber mode must never
|
||||
/// exceed the cap: the count-check-and-increment is atomic under <c>_syncRoot</c>, so
|
||||
/// exactly <c>cap</c> attaches succeed and the rest throw
|
||||
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>. The observed
|
||||
/// count never goes above the cap.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_ConcurrentAttaches_NeverExceedCap()
|
||||
{
|
||||
@@ -396,9 +405,10 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 8. Disposing a subscriber frees a cap slot so a fresh attach succeeds, and a
|
||||
/// Disposing a subscriber frees a cap slot so a fresh attach succeeds, and a
|
||||
/// double-dispose does not double-free the slot (count integrity preserved).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachEventSubscriber_MultiMode_DisposeFreesSlotAndDoubleDisposeIsIdempotent()
|
||||
{
|
||||
@@ -434,10 +444,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With a positive detach-grace, dropping the last external subscriber must
|
||||
/// With a positive detach-grace, dropping the last external subscriber must
|
||||
/// RETAIN the session (it stays <see cref="SessionState.Ready"/>, not Closed/Faulted)
|
||||
/// and record a detached timestamp so the lease monitor can age it out later.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_LastSubscriberDrops_RetainsSessionAndRecordsDetachedTimestamp()
|
||||
{
|
||||
@@ -461,9 +472,10 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Advancing the clock past the detach-grace window makes the retained,
|
||||
/// Advancing the clock past the detach-grace window makes the retained,
|
||||
/// detached session eligible for close (<see cref="GatewaySession.IsDetachGraceExpired"/>).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_ClockAdvancesPastWindow_SessionBecomesEligibleForClose()
|
||||
{
|
||||
@@ -487,10 +499,11 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Re-attaching a subscriber before the window elapses cancels the grace:
|
||||
/// Re-attaching a subscriber before the window elapses cancels the grace:
|
||||
/// the detached timestamp clears and a subsequent clock advance does NOT make the
|
||||
/// session eligible for close.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_ReattachBeforeExpiry_CancelsGrace()
|
||||
{
|
||||
@@ -520,11 +533,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With detach-grace disabled (<c>0</c>), dropping the last subscriber must
|
||||
/// With detach-grace disabled (<c>0</c>), dropping the last subscriber must
|
||||
/// match today's behavior: the session stays <see cref="SessionState.Ready"/> with no
|
||||
/// detached timestamp and is never eligible for a detach-grace close — it lingers only
|
||||
/// until its normal lease expires.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_Disabled_MatchesTodaysBehavior()
|
||||
{
|
||||
@@ -546,13 +560,14 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-055 regression. A FAILED first attach (the distributor never registered a
|
||||
/// A FAILED first attach (the distributor never registered a
|
||||
/// subscriber) must NOT enter the detach-grace window: the catch path's
|
||||
/// <c>DetachEventSubscriber</c> rolls the reserved slot back to 0 but must not stamp
|
||||
/// <c>DetachedAtUtc</c>, because the "last subscriber dropped" semantics only apply once
|
||||
/// a subscriber was successfully registered. A freshly-Ready session whose first attach
|
||||
/// failed must therefore stay out of grace and never become sweep-eligible on that basis.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_FailedFirstAttach_DoesNotEnterGrace()
|
||||
{
|
||||
@@ -583,11 +598,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. The gateway-owned internal dashboard subscriber must NOT keep a session out
|
||||
/// The gateway-owned internal dashboard subscriber must NOT keep a session out
|
||||
/// of detach-grace: with only the dashboard mirror attached (and no external gRPC
|
||||
/// subscriber), dropping the last external subscriber still enters grace and the
|
||||
/// window still expires.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachGrace_DashboardMirrorAlone_DoesNotPreventGraceEntry()
|
||||
{
|
||||
@@ -615,11 +631,12 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
|
||||
/// Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
|
||||
/// subscriber has reattached before flipping to Closing. When the grace window has elapsed but
|
||||
/// a subscriber is attached by the time TryBeginCloseIfExpired runs, it returns false and the
|
||||
/// session remains Ready.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose()
|
||||
{
|
||||
@@ -749,16 +766,16 @@ public sealed class GatewaySessionTests
|
||||
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-test";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets or sets the worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the count of shutdown invocations.</summary>
|
||||
@@ -768,6 +785,7 @@ public sealed class GatewaySessionTests
|
||||
public int DisposeCount { get; private set; }
|
||||
|
||||
/// <summary>Waits for shutdown to start.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForShutdownStartAsync()
|
||||
{
|
||||
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
@@ -811,7 +829,8 @@ public sealed class GatewaySessionTests
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Releases the fake worker resources and records the invocation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
@@ -821,16 +840,16 @@ public sealed class GatewaySessionTests
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; } = "session-test";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets the worker client state.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the count of dispose invocations.</summary>
|
||||
@@ -861,7 +880,8 @@ public sealed class GatewaySessionTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Releases the fake worker resources and records the invocation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class SessionEventDistributorTests
|
||||
{
|
||||
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Two subscribers registered on the same distributor both receive every fanned event, in order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TwoSubscribers_BothReceiveFannedEventsInOrder()
|
||||
{
|
||||
@@ -40,6 +42,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(2ul, b2.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>Disposing one subscriber's lease stops delivery to it while the other subscriber keeps receiving events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposingOneLease_StopsItsDelivery_OtherKeepsReceiving()
|
||||
{
|
||||
@@ -65,6 +69,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(2ul, b2.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>A subscriber registered after the pump has started only receives events emitted after its registration.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscriberRegisteredAfterStart_ReceivesEventsEmittedAfterRegistration()
|
||||
{
|
||||
@@ -84,6 +90,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(2ul, b.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>Disposing the distributor completes every subscriber channel and stops the pump.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposingDistributor_CompletesAllSubscriberChannels_AndStopsPump()
|
||||
{
|
||||
@@ -101,6 +109,8 @@ public sealed class SessionEventDistributorTests
|
||||
await AssertCompletedAsync(leaseB.Reader);
|
||||
}
|
||||
|
||||
/// <summary>Calling <c>Register</c> after the distributor has been disposed throws <see cref="ObjectDisposedException"/>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Register_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
@@ -113,6 +123,11 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Throws<ObjectDisposedException>(() => distributor.Register());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the nested-lock disposal behavior in <c>RegisterWithReplay</c>: calling it after the
|
||||
/// distributor has been disposed throws <see cref="ObjectDisposedException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
@@ -134,6 +149,8 @@ public sealed class SessionEventDistributorTests
|
||||
out _));
|
||||
}
|
||||
|
||||
/// <summary>When retained events exceed the replay buffer's capacity, the oldest entries are evicted first and the replay reports a gap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_OverCapacity_EvictsOldestFirst_AndReportsGap()
|
||||
{
|
||||
@@ -168,6 +185,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(new ulong[] { 3, 4, 5 }, replay.Select(e => e.WorkerSequence));
|
||||
}
|
||||
|
||||
/// <summary>Requesting replay from a sequence still inside the retained window returns only the newer events, with no gap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_WithinRetainedWindow_ReturnsNewerEvents_NoGap()
|
||||
{
|
||||
@@ -194,6 +213,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(new ulong[] { 3, 4, 5 }, replay.Select(e => e.WorkerSequence));
|
||||
}
|
||||
|
||||
/// <summary>Retained replay entries older than the retention window are evicted once that window elapses.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_AgedEntries_AreEvictedAfterRetentionElapses()
|
||||
{
|
||||
@@ -228,6 +249,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.True(gap);
|
||||
}
|
||||
|
||||
/// <summary>Requesting replay from a sequence newer than everything retained returns an empty list with no gap.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_AfterSequenceNewerThanAllRetained_ReturnsEmpty_NoGap()
|
||||
{
|
||||
@@ -254,6 +277,11 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With replay buffering disabled (capacity 0), a caller behind the highest-seen sequence
|
||||
/// is told there is a gap and gets no replayed events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_Capacity0_AfterSequenceBelowHighestSeen_ReportsGap_NoEvents()
|
||||
{
|
||||
@@ -281,6 +309,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>With replay buffering disabled (capacity 0), a caller already caught up sees no gap and no events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_Capacity0_AfterSequenceAtOrAboveHighestSeen_NoGap_NoEvents()
|
||||
{
|
||||
@@ -307,6 +337,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>When no events have ever been seen, any requested sequence reports no gap and no events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_NoEventsSeen_AnyAfterSequence_NoGap_NoEvents()
|
||||
{
|
||||
@@ -325,6 +357,11 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requesting replay from <see cref="ulong.MaxValue"/> with retained events present does not
|
||||
/// falsely report a gap from the wrap-around and yields no new events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayBuffer_AfterSequenceMaxValue_WithRetainedEvents_NoGap_NoNewEvents()
|
||||
{
|
||||
@@ -349,12 +386,15 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Empty(replay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscriber backpressure isolation: one subscriber stops reading and overflows its
|
||||
/// own tiny channel; it is disconnected with an <c>EventQueueOverflow</c> fault while a
|
||||
/// second, healthy subscriber keeps receiving and the pump keeps pumping.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SlowSubscriberOverflow_DisconnectsOnlyThatSubscriber_PumpAndOtherKeepRunning()
|
||||
{
|
||||
// Per-subscriber backpressure isolation (Task 5): one subscriber stops reading and
|
||||
// overflows its own tiny channel; it is disconnected with an EventQueueOverflow fault
|
||||
// while a second, healthy subscriber keeps receiving and the pump keeps pumping.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int overflowCalls = 0;
|
||||
// Separate fields for the bool value and the "set" flag so both can use
|
||||
@@ -380,7 +420,7 @@ public sealed class SessionEventDistributorTests
|
||||
singleSubscriberMode: false);
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Slow subscriber: registered but never read, so its capacity-2 channel fills.
|
||||
// Slow subscriber: registered but never read, so its channel (capacity 2) fills.
|
||||
using IEventSubscriberLease slow = distributor.Register();
|
||||
// Healthy subscriber: drains promptly throughout.
|
||||
using IEventSubscriberLease healthy = distributor.Register();
|
||||
@@ -399,7 +439,7 @@ public sealed class SessionEventDistributorTests
|
||||
async () => await DrainUntilFaultAsync(slow.Reader));
|
||||
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, fault.ErrorCode);
|
||||
|
||||
// Multi-subscriber mode, so isOnlySubscriber is always false (Task 8 mode-gating).
|
||||
// Multi-subscriber mode, so isOnlySubscriber is always false (mode-gating).
|
||||
// Use Interlocked.Read / Volatile.Read so the test-thread reads are ordered after the
|
||||
// pump-thread writes, avoiding a data race by the C# memory model.
|
||||
Assert.Equal(1, Volatile.Read(ref overflowCalls));
|
||||
@@ -413,16 +453,19 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(11ul, afterOverflow.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributor-level pin for "FailFast with multiple subscribers degrades to
|
||||
/// disconnect-only (no session fault)": in multi-subscriber mode isOnlySubscriber is
|
||||
/// always false (mode-gating), so a FailFast-wired handler must NOT fault the session.
|
||||
/// This test drives the distributor directly (without <c>GatewaySession</c>) in
|
||||
/// multi-subscriber mode with two subscribers and a FailFast-style overflow handler
|
||||
/// seam, overflows the slow one, and asserts (a) isOnlySubscriber==false, (b) the other
|
||||
/// subscriber keeps receiving, and (c) the pump keeps running.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SlowSubscriberOverflow_WithMultipleSubscribers_HandlerSeesIsOnlySubscriberFalse_OtherKeepsReceiving()
|
||||
{
|
||||
// Distributor-level pin for "FailFast with multiple subscribers degrades to
|
||||
// disconnect-only (no session fault)": in multi-subscriber mode isOnlySubscriber is
|
||||
// always false (Task 8 mode-gating), so a FailFast-wired handler must NOT fault the
|
||||
// session. This test drives the distributor directly (without GatewaySession) in
|
||||
// multi-subscriber mode with two subscribers and a FailFast-style overflow handler
|
||||
// seam, overflows the slow one, and asserts (a) isOnlySubscriber==false, (b) the other
|
||||
// subscriber keeps receiving, and (c) the pump keeps running.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
bool handlerFiredWithFalse = false;
|
||||
bool sessionFaultWouldBeCalled = false; // tracks if a FailFast path would fault
|
||||
@@ -450,7 +493,7 @@ public sealed class SessionEventDistributorTests
|
||||
singleSubscriberMode: false);
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Slow subscriber: never reads, so capacity-2 channel overflows quickly.
|
||||
// Slow subscriber: never reads, so its channel (capacity 2) overflows quickly.
|
||||
using IEventSubscriberLease slow = distributor.Register();
|
||||
// Healthy subscriber: drains every event promptly.
|
||||
using IEventSubscriberLease healthy = distributor.Register();
|
||||
@@ -478,14 +521,17 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(11ul, afterOverflow.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>CountExternalSubscribers()</c> excludes the internal dashboard
|
||||
/// subscriber, so a FailFast policy would NOT fault the session even when the internal
|
||||
/// subscriber is the ONLY registered subscriber. The overflow handler receives
|
||||
/// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
|
||||
/// and is therefore excluded from the external-subscriber count.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InternalSubscriberOverflow_HandlerSeesIsOnlySubscriberFalse_ProvingCountExcludesInternal()
|
||||
{
|
||||
// Issue 3: verifies that CountExternalSubscribers() excludes the internal dashboard
|
||||
// subscriber, so a FailFast policy would NOT fault the session even when the internal
|
||||
// subscriber is the ONLY registered subscriber. The overflow handler receives
|
||||
// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
|
||||
// and is therefore excluded from the external-subscriber count.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int observedIsOnlySubscriberSet = 0;
|
||||
bool observedIsOnlySubscriberValue = false;
|
||||
@@ -509,7 +555,7 @@ public sealed class SessionEventDistributorTests
|
||||
// Register ONLY an internal subscriber — no external subscriber is attached.
|
||||
using IEventSubscriberLease internalLease = distributor.Register(isInternal: true);
|
||||
|
||||
// Push enough events to overflow the capacity-2 internal subscriber channel.
|
||||
// Push enough events to overflow the internal subscriber channel (capacity 2).
|
||||
for (ulong sequence = 1; sequence <= 10; sequence++)
|
||||
{
|
||||
source.Writer.TryWrite(Event(sequence));
|
||||
@@ -540,13 +586,16 @@ public sealed class SessionEventDistributorTests
|
||||
"isInternal must be true for a subscriber registered with isInternal: true.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode-gating: in single-subscriber mode a lone external subscriber that overflows
|
||||
/// reports isOnlySubscriber==true, so the legacy FailFast session-fault path is
|
||||
/// preserved. The decision is gated on the fixed session mode, NOT a live count, so it
|
||||
/// is race-free.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SingleSubscriberMode_LoneExternalOverflow_HandlerSeesIsOnlySubscriberTrue()
|
||||
{
|
||||
// Task 8 mode-gating: in single-subscriber mode a lone external subscriber that
|
||||
// overflows reports isOnlySubscriber==true, so the legacy FailFast session-fault path
|
||||
// is preserved. The decision is gated on the fixed session mode, NOT a live count, so
|
||||
// it is race-free.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int observedSet = 0;
|
||||
bool observedValue = false;
|
||||
@@ -593,6 +642,11 @@ public sealed class SessionEventDistributorTests
|
||||
"isOnlySubscriber must be true for a lone external subscriber in single-subscriber mode.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registering with replay from a sequence still inside the retained window returns the
|
||||
/// newer retained events with no gap, then continues to deliver live events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_WithinRetainedWindow_ReturnsNewerEvents_NoGap_ThenLive()
|
||||
{
|
||||
@@ -631,6 +685,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(6ul, live.WorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>Registering with replay from a sequence below the oldest retained event reports a gap along with the oldest available sequence.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_BelowOldestRetained_ReportsGap_AndOldestAvailable()
|
||||
{
|
||||
@@ -662,6 +718,8 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(5ul, liveResume);
|
||||
}
|
||||
|
||||
/// <summary>When nothing retained is newer than the requested sequence, the live resume watermark equals the requested sequence and no gap is reported.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterWithReplay_NothingRetainedNewer_LiveResumeEqualsAfterSequence_NoGap()
|
||||
{
|
||||
@@ -733,6 +791,7 @@ public sealed class SessionEventDistributorTests
|
||||
/// <c>GatewaySessionDashboardMirrorTests</c>, where a gRPC subscriber attached after a
|
||||
/// fast-completing worker stream had already drained.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Register_AfterSourceCompletes_CompletesLateSubscriberInsteadOfHanging()
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Tests-013: per-method gateway-side coverage for every
|
||||
/// Per-method gateway-side coverage for every
|
||||
/// <c>GatewaySession.*BulkAsync</c> entry point. Each method gets a
|
||||
/// round-trip test that pins the <see cref="MxCommandKind"/> sent to the
|
||||
/// worker, the per-entry payload shape, a failure-mode (per-entry failure
|
||||
@@ -21,6 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
public sealed class SessionManagerBulkTests
|
||||
{
|
||||
/// <summary>Verifies that AddItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -50,6 +51,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AdviseItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -90,6 +93,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AdviseItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -103,6 +107,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -128,6 +133,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -141,6 +147,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnAdviseItemBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -167,6 +174,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnAdviseItemBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -180,6 +188,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeBulkAsync surfaces per-entry failures.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
@@ -208,6 +217,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -221,6 +231,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeBulkAsync forwards the command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -246,6 +257,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -259,6 +271,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteBulkAsync surfaces per-entry failures.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
@@ -292,6 +305,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -308,6 +322,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Write2BulkAsync forwards the command and preserves timestamp payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
|
||||
{
|
||||
@@ -351,6 +366,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Write2BulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2BulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -376,6 +392,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
|
||||
{
|
||||
@@ -427,6 +444,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecuredBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -452,7 +470,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-022: Pin mid-flight cancellation behaviour for at least one bulk
|
||||
/// Pin mid-flight cancellation behaviour for at least one bulk
|
||||
/// path. Unlike the pre-cancel <c>WriteSecuredBulkAsync_PropagatesCancellation</c>
|
||||
/// above, this fake's <see cref="MidFlightBulkWorkerClient.InvokeAsync"/>
|
||||
/// returns a <see cref="TaskCompletionSource"/>-backed task that does NOT
|
||||
@@ -463,6 +481,7 @@ public sealed class SessionManagerBulkTests
|
||||
/// client closing its stream would hit, which the pre-cancel pattern can't
|
||||
/// exercise.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_WhenCancelledMidFlight_ThrowsOperationCanceledForRequestToken()
|
||||
{
|
||||
@@ -499,6 +518,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
|
||||
{
|
||||
@@ -547,6 +567,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecured2BulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -573,6 +594,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadBulkAsync surfaces per-entry failures.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
@@ -619,6 +641,7 @@ public sealed class SessionManagerBulkTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadBulkAsync propagates cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
@@ -779,12 +802,13 @@ public sealed class SessionManagerBulkTests
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason) => State = WorkerClientState.Faulted;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the fake worker client; this fake has no resources to release.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mid-flight cancellation fake for Tests-022.
|
||||
/// Mid-flight cancellation fake worker client.
|
||||
/// <see cref="InvokeAsync"/> signals <see cref="InvokeStarted"/>, registers
|
||||
/// a cancellation continuation on the caller's <see cref="CancellationToken"/>,
|
||||
/// and parks on a <see cref="TaskCompletionSource{TResult}"/> that completes
|
||||
@@ -858,7 +882,8 @@ public sealed class SessionManagerBulkTests
|
||||
_invokeCompletion.TrySetCanceled();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the fake worker client, cancelling any in-flight invoke.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_invokeCompletion.TrySetCanceled();
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
public sealed class SessionManagerTests
|
||||
{
|
||||
/// <summary>Verifies that opening a session with a ready worker registers the session in ready state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a session opened by an authenticated caller records that caller's API key id in OwnerKeyId.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WithOwnerKeyId_RecordsOwnerKeyIdOnSession()
|
||||
{
|
||||
@@ -52,6 +54,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a session opened without an owner key id records null in OwnerKeyId.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WithNullOwnerKeyId_RecordsNullOwnerKeyIdOnSession()
|
||||
{
|
||||
@@ -67,6 +70,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_SetsInitialDefaultLease()
|
||||
{
|
||||
@@ -83,6 +87,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that session generation creates client correlation ID from client name and session ID.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
|
||||
{
|
||||
@@ -99,6 +104,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session without a client session name uses the client correlation prefix.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix()
|
||||
{
|
||||
@@ -114,6 +120,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a ready session forwards the command to the worker.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
|
||||
{
|
||||
@@ -131,6 +138,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a ready session refreshes its lease expiry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
|
||||
{
|
||||
@@ -159,6 +167,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gateway session subscribe bulk forwards one bulk command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -204,6 +213,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gateway session write bulk forwards one bulk command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -269,6 +279,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gateway session read bulk forwards one bulk command and returns results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -319,6 +330,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
|
||||
{
|
||||
@@ -338,12 +350,13 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-030 regression: when the gateway-side <c>SessionState</c> is
|
||||
/// When the gateway-side <c>SessionState</c> is
|
||||
/// <c>Ready</c> but the worker client's own state is not, the diagnostic
|
||||
/// must surface both states so the mismatch is actionable instead of
|
||||
/// producing a self-contradictory "Session ... is not ready. Current
|
||||
/// state is Ready." message.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates()
|
||||
{
|
||||
@@ -372,6 +385,7 @@ public sealed class SessionManagerTests
|
||||
/// <c>Handshaking</c> but flips to <c>Ready</c> within the timeout window must let the
|
||||
/// command through rather than fail fast.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds()
|
||||
{
|
||||
@@ -402,6 +416,7 @@ public sealed class SessionManagerTests
|
||||
/// A terminal worker state (<c>Faulted</c>) must fail fast even with a positive
|
||||
/// worker-ready wait timeout, surfacing both states without burning the timeout.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenWorkerFaulted_FailsFastWithBothStates()
|
||||
{
|
||||
@@ -431,6 +446,7 @@ public sealed class SessionManagerTests
|
||||
/// When the worker stays transiently not-ready for the whole (small) timeout window,
|
||||
/// the invoke fails after roughly the timeout with both states surfaced.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenTimeoutElapsesStillNotReady_FailsWithBothStates()
|
||||
{
|
||||
@@ -460,6 +476,7 @@ public sealed class SessionManagerTests
|
||||
/// Pins the default (timeout == 0) behavior: a transiently <c>Handshaking</c> worker
|
||||
/// fails fast immediately, byte-for-byte like the original fail-fast path.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenTimeoutZero_FailsFastUnchanged()
|
||||
{
|
||||
@@ -486,6 +503,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing a session removes it from the registry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_RemovesClosedSession()
|
||||
{
|
||||
@@ -507,6 +525,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
|
||||
{
|
||||
@@ -528,6 +547,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot()
|
||||
{
|
||||
@@ -567,15 +587,13 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(1, failingWorkerClient.KillCount);
|
||||
Assert.Equal(1, failingWorkerClient.DisposeCount);
|
||||
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
|
||||
// Server-046: a close-that-failed now accounts as SessionClosed (counter += 1) rather
|
||||
// than SessionRemoved (gauge -= 1, counter unchanged). The session is being removed
|
||||
// from the registry on this path, so it must show up in the closed count.
|
||||
Assert.Equal(1, snapshot.SessionsClosed);
|
||||
Assert.False(snapshot.EventsBySession.ContainsKey(firstSession.SessionId));
|
||||
Assert.Equal(1, snapshot.OpenSessions);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the second close is canceled, the session is not removed if owned by the first close.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose()
|
||||
{
|
||||
@@ -626,10 +644,8 @@ public sealed class SessionManagerTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that killing a worker removes the session from the registry without calling shutdown.
|
||||
/// Tests-028: also pins the <c>reason</c> argument propagating through
|
||||
/// <c>SessionManager.KillWorkerAsync</c> → <c>session.KillWorker(reason)</c> →
|
||||
/// <c>IWorkerClient.Kill(reason)</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_KillsWorkerAndRemovesSession()
|
||||
{
|
||||
@@ -651,11 +667,12 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-028: <see cref="SessionManager.KillWorkerAsync"/> guards its <c>reason</c> argument with
|
||||
/// <see cref="SessionManager.KillWorkerAsync"/> guards its <c>reason</c> argument with
|
||||
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/>. A blank or whitespace reason must throw
|
||||
/// <see cref="ArgumentException"/> before any session lookup or worker call runs.
|
||||
/// </summary>
|
||||
/// <param name="blankReason">A blank or whitespace reason string.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -674,10 +691,11 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-028: <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/> also rejects null.
|
||||
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/> also rejects null.
|
||||
/// <see cref="Theory"/> with <see cref="InlineDataAttribute"/> cannot carry <c>null</c> for a
|
||||
/// non-nullable string parameter on .NET 10, so the null case is its own fact.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WithNullReason_ThrowsArgumentNullException()
|
||||
{
|
||||
@@ -693,6 +711,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that killing the worker for an unknown session raises SessionNotFound.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound()
|
||||
{
|
||||
@@ -705,10 +724,10 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-044: when <c>session.KillWorker</c> throws, the catch path must still
|
||||
/// decrement <c>mxgateway.sessions.open</c> (parity with the Server-006 fix in
|
||||
/// <c>OpenSessionAsync</c>). Without the fix the gauge leaks one open session per failed kill.
|
||||
/// When <c>session.KillWorker</c> throws, the catch path must still
|
||||
/// decrement <c>mxgateway.sessions.open</c>. Without the fix the gauge leaks one open session per failed kill.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WhenSessionKillThrows_DecrementsOpenSessionGauge()
|
||||
{
|
||||
@@ -738,10 +757,11 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-045 / Server-048: concurrent kills on the same session must not
|
||||
/// Concurrent kills on the same session must not
|
||||
/// double-increment <c>mxgateway.sessions.closed</c>. The first kill wins, the second
|
||||
/// observes <c>wasClosed == true</c> (or a missing session after removal) and short-circuits.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_ConcurrentCallsOnSameSession_CountClosedExactlyOnce()
|
||||
{
|
||||
@@ -777,12 +797,13 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-046: <c>ShutdownAsync</c>'s graceful-close fallback (which calls
|
||||
/// <c>ShutdownAsync</c>'s graceful-close fallback (which calls
|
||||
/// <c>KillWorker</c> + <c>RemoveSessionAsync</c> when <c>CloseSessionCoreAsync</c> throws)
|
||||
/// must still account a successful close: both the open-session gauge must drop to zero AND
|
||||
/// the <c>mxgateway.sessions.closed</c> counter must increment. Without the fix, the
|
||||
/// graceful-close failure path under-counts the closed counter.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_WhenSessionCloseThrows_StillDecrementsOpenSessionGaugeAndIncrementsClosedCounter()
|
||||
{
|
||||
@@ -812,6 +833,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
||||
{
|
||||
@@ -832,6 +854,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
|
||||
{
|
||||
@@ -855,6 +878,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expired-lease sweep leaves a session with an active event subscriber open.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
|
||||
{
|
||||
@@ -873,10 +897,11 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With detach-grace enabled, a session whose last external subscriber dropped
|
||||
/// With detach-grace enabled, a session whose last external subscriber dropped
|
||||
/// and whose detach-grace window has elapsed is closed by the lease sweep exactly like an
|
||||
/// expired-lease session — even though its normal lease is still far in the future.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_ClosesSessionWhoseDetachGraceWindowExpired()
|
||||
{
|
||||
@@ -909,11 +934,12 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. TOCTOU race: a session whose detach-grace window has expired but that
|
||||
/// TOCTOU race: a session whose detach-grace window has expired but that
|
||||
/// reattaches an external subscriber before the sweeper calls CloseSessionCoreAsync is
|
||||
/// NOT closed — it remains Ready and usable. This validates that TryBeginCloseIfExpired
|
||||
/// re-checks eligibility atomically so a reconnect that wins the race cancels the close.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_DoesNotCloseSessionThatReattachedBeforeSweepCloses()
|
||||
{
|
||||
@@ -950,6 +976,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
|
||||
{
|
||||
@@ -1097,16 +1124,16 @@ public sealed class SessionManagerTests
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Gets the session ID for the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; init; } = "session-1";
|
||||
|
||||
/// <summary>Gets the process ID for the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; init; } = 1234;
|
||||
|
||||
/// <summary>Gets or sets the state of the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last heartbeat timestamp for the fake worker client.</summary>
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the number of times invoke was called on the fake worker client.</summary>
|
||||
@@ -1118,13 +1145,7 @@ public sealed class SessionManagerTests
|
||||
/// <summary>Gets the number of times kill was called on the fake worker client.</summary>
|
||||
public int KillCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last reason argument observed by <see cref="Kill"/>. Tests-028:
|
||||
/// pins the reason-string propagation through
|
||||
/// <c>SessionManager.KillWorkerAsync</c> → <c>session.KillWorker(reason)</c> →
|
||||
/// <c>IWorkerClient.Kill(reason)</c>. Without this, the chain could silently
|
||||
/// drop or substitute the reason argument and existing tests would still pass.
|
||||
/// </summary>
|
||||
/// <summary>Gets the last reason argument observed by <see cref="Kill"/>.</summary>
|
||||
public string? LastKillReason { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times dispose was called on the fake worker client.</summary>
|
||||
@@ -1222,7 +1243,8 @@ public sealed class SessionManagerTests
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Records that the fake worker client was disposed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
@@ -1230,6 +1252,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Waits for shutdown to start on the fake worker client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForShutdownStartAsync()
|
||||
{
|
||||
return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
+5
@@ -21,6 +21,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
/// Awaits every scripted worker task so an unhandled exception fails the owning test
|
||||
/// instead of surfacing later as an unobserved <see cref="TaskScheduler.UnobservedTaskException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IWorkerTaskLauncher launcher in _launchers)
|
||||
@@ -30,6 +31,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed fake worker startup throws a worker client exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
|
||||
{
|
||||
@@ -83,6 +86,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a worker that never sends ready times out and is killed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
|
||||
{
|
||||
@@ -168,6 +172,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
/// teardown faults expected when the worker client kills or disposes the worker.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for the worker task.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ObserveWorkerTaskAsync(TimeSpan timeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class SparseArrayExpanderTests
|
||||
return new MxValue { SparseArrayValue = sparse };
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding an Int32 sparse array fills unset indices with the default and applies the given element.</summary>
|
||||
[Fact]
|
||||
public void Expand_Int32_FillsDefaultsAndSetsElement()
|
||||
{
|
||||
@@ -43,6 +44,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 0, 7, 0, 0 }, value.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Boolean sparse array with no elements fills every index with the default value <c>false</c>.</summary>
|
||||
[Fact]
|
||||
public void Expand_Boolean_EmptyElements_AllDefaultFalse()
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { false, false, false }, value.ArrayValue.BoolValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a sparse array with a zero total length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_ZeroTotalLength_Throws()
|
||||
{
|
||||
@@ -63,6 +66,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an element index at or beyond the total length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_IndexOutOfRange_Throws()
|
||||
{
|
||||
@@ -75,6 +79,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that two sparse elements sharing the same index throw an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_DuplicateIndex_Throws()
|
||||
{
|
||||
@@ -88,6 +93,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unspecified element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_UnsupportedElementType_Throws()
|
||||
{
|
||||
@@ -97,6 +103,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an element value whose kind does not match the declared element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_ElementValueKindMismatch_Throws()
|
||||
{
|
||||
@@ -109,6 +116,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a String sparse array fills unset indices with an empty string default.</summary>
|
||||
[Fact]
|
||||
public void Expand_String_FillsEmptyStringDefault()
|
||||
{
|
||||
@@ -123,6 +131,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { "a", string.Empty }, value.ArrayValue.StringValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Time sparse array fills unset indices with the epoch timestamp default.</summary>
|
||||
[Fact]
|
||||
public void Expand_Time_FillsEpochDefault()
|
||||
{
|
||||
@@ -140,6 +149,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(5, value.ArrayValue.TimestampValues.Values[1].Seconds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Double sparse array fills defaults and applies the given element.</summary>
|
||||
[Fact]
|
||||
public void Expand_Double_HappyPath()
|
||||
{
|
||||
@@ -154,6 +164,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 0d, 0d, 1.5 }, value.ArrayValue.DoubleValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a Float sparse array fills defaults and applies the given element.</summary>
|
||||
[Fact]
|
||||
public void Expand_Float_HappyPath()
|
||||
{
|
||||
@@ -168,6 +179,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 2.5f, 0f }, value.ArrayValue.FloatValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Integer sparse array whose element is a 64-bit value expands to the Int64 array representation.</summary>
|
||||
[Fact]
|
||||
public void Expand_Int64_WhenElementIsInt64()
|
||||
{
|
||||
@@ -182,6 +194,7 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(new[] { 0L, 0L, 9_000_000_000L }, value.ArrayValue.Int64Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a non-sparse-array value is a no-op, leaving the original value unchanged.</summary>
|
||||
[Fact]
|
||||
public void Expand_NonSparseValue_NoOps()
|
||||
{
|
||||
@@ -193,12 +206,14 @@ public sealed class SparseArrayExpanderTests
|
||||
Assert.Equal(42, value.Int32Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that expanding a null value throws <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_NullValue_ThrowsArgumentNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => SparseArrayExpander.Expand(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a total length exceeding the maximum supported array length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
||||
[Fact]
|
||||
public void Expand_TotalLengthExceedsMaxArrayLength_Throws()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user