docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class AuditWireBudgetTests
|
||||
{
|
||||
/// <summary>Verifies that a row over the soft budget travels alone in its batch without being truncated.</summary>
|
||||
[Fact]
|
||||
public void OversizeSoftBudgetRow_TravelsAloneWithoutTruncation()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public class AuditWireBudgetTests
|
||||
Assert.True(bytes < GrpcMessageLimits.MaxMessageBytes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a hard-oversize row does not block the rows before it and fails explicitly when it is first in a batch.</summary>
|
||||
[Fact]
|
||||
public void HardOversizeRow_DoesNotBlockGoodPrefix_AndFailsExplicitlyWhenFirst()
|
||||
{
|
||||
|
||||
+33
-3
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
/// back as null-or-empty rather than as a zero-valued stand-in. The minimal cases are
|
||||
/// the ones that matter: a per-field unit test happily passes while a whole optional
|
||||
/// branch is silently never written, which is exactly the class of bug the round-trip
|
||||
/// guard in PLAN-05 T8 caught five times over.
|
||||
/// guard caught five times over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a round-trip here fails, the mapper is wrong — not the test.
|
||||
@@ -38,6 +38,7 @@ public class CentralControlDtoMapperTests
|
||||
// NotificationSubmit / Ack
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a fully populated notification submission round-trips with every field preserved.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_RoundTrip_FullyPopulated_PreservesEveryField()
|
||||
{
|
||||
@@ -60,6 +61,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(original, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a notification submission with all optional fields null keeps them null.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_RoundTrip_AllOptionalsNull_StayNull()
|
||||
{
|
||||
@@ -86,6 +88,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Null(roundTripped.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-UTC offset timestamp is normalized to the same instant.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_NonUtcOffset_NormalizesToTheSameInstant()
|
||||
{
|
||||
@@ -103,6 +106,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(TimeSpan.Zero, roundTripped.SiteEnqueuedAt.Offset);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that accepted and rejected submission acknowledgements both round-trip.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmitAck_RoundTrip_Accepted_And_Rejected()
|
||||
{
|
||||
@@ -118,6 +122,7 @@ public class CentralControlDtoMapperTests
|
||||
// NotificationStatusQuery / Response
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a notification status query round-trips with every field preserved.</summary>
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_RoundTrip_PreservesEveryField()
|
||||
{
|
||||
@@ -126,6 +131,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fully populated status response round-trips with every field preserved.</summary>
|
||||
[Fact]
|
||||
public void NotificationStatusResponse_RoundTrip_FullyPopulated_PreservesEveryField()
|
||||
{
|
||||
@@ -140,6 +146,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a not-found status response leaves its optional fields null.</summary>
|
||||
[Fact]
|
||||
public void NotificationStatusResponse_RoundTrip_NotFound_LeavesOptionalsNull()
|
||||
{
|
||||
@@ -162,6 +169,7 @@ public class CentralControlDtoMapperTests
|
||||
// Audit ingest envelopes (rows delegate to AuditEventDtoMapper / SiteCallDtoMapper)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that an audit-ingest command preserves the order and content of its rows.</summary>
|
||||
[Fact]
|
||||
public void IngestAuditEventsCommand_RoundTrip_PreservesOrderAndRows()
|
||||
{
|
||||
@@ -178,6 +186,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal("OnTrigger", roundTripped.Events[1].AsRow().SourceScript);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty audit-ingest batch stays empty.</summary>
|
||||
[Fact]
|
||||
public void IngestAuditEventsCommand_RoundTrip_EmptyBatch_StaysEmpty()
|
||||
{
|
||||
@@ -187,6 +196,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Empty(roundTripped.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached-telemetry command preserves both the audit and operational halves of each packet.</summary>
|
||||
[Fact]
|
||||
public void IngestCachedTelemetryCommand_RoundTrip_PreservesBothHalvesOfEachPacket()
|
||||
{
|
||||
@@ -204,6 +214,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(siteCall with { IngestedAtUtc = default }, entry.SiteCall with { IngestedAtUtc = default });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nullable site-call fields in cached telemetry stay null.</summary>
|
||||
[Fact]
|
||||
public void IngestCachedTelemetryCommand_RoundTrip_NullableSiteCallFields_StayNull()
|
||||
{
|
||||
@@ -226,6 +237,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Null(entry.SiteCall.TerminalAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty cached-telemetry batch stays empty.</summary>
|
||||
[Fact]
|
||||
public void IngestCachedTelemetryCommand_RoundTrip_EmptyBatch_StaysEmpty()
|
||||
{
|
||||
@@ -235,6 +247,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Empty(roundTripped.Entries);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ingest acknowledgement preserves its ids and their order.</summary>
|
||||
[Fact]
|
||||
public void IngestAck_RoundTrip_PreservesIdsAndOrder()
|
||||
{
|
||||
@@ -246,6 +259,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(ids, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ingest acknowledgement with nothing accepted stays empty.</summary>
|
||||
[Fact]
|
||||
public void IngestAck_RoundTrip_NothingAccepted_StaysEmpty()
|
||||
{
|
||||
@@ -259,6 +273,7 @@ public class CentralControlDtoMapperTests
|
||||
// Startup reconciliation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a reconcile request preserves its inventory map.</summary>
|
||||
[Fact]
|
||||
public void ReconcileSiteRequest_RoundTrip_PreservesInventoryMap()
|
||||
{
|
||||
@@ -280,6 +295,7 @@ public class CentralControlDtoMapperTests
|
||||
roundTripped.LocalNameToRevisionHash.OrderBy(kv => kv.Key));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reconcile request with an empty inventory stays empty.</summary>
|
||||
[Fact]
|
||||
public void ReconcileSiteRequest_RoundTrip_EmptyInventory_StaysEmpty()
|
||||
{
|
||||
@@ -291,6 +307,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Empty(roundTripped.LocalNameToRevisionHash);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reconcile response preserves its gap, orphans and URL.</summary>
|
||||
[Fact]
|
||||
public void ReconcileSiteResponse_RoundTrip_PreservesGapOrphansAndUrl()
|
||||
{
|
||||
@@ -310,6 +327,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(original.CentralFetchBaseUrl, roundTripped.CentralFetchBaseUrl);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reconcile response with no gap stays empty.</summary>
|
||||
[Fact]
|
||||
public void ReconcileSiteResponse_RoundTrip_NoGap_StaysEmpty()
|
||||
{
|
||||
@@ -324,6 +342,7 @@ public class CentralControlDtoMapperTests
|
||||
// Site health
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a fully populated site health report round-trips with every field preserved.</summary>
|
||||
[Fact]
|
||||
public void SiteHealthReport_RoundTrip_FullyPopulated_PreservesEveryField()
|
||||
{
|
||||
@@ -334,6 +353,7 @@ public class CentralControlDtoMapperTests
|
||||
AssertHealthReportsEqual(original, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a minimal site health report leaves every optional field null or empty.</summary>
|
||||
[Fact]
|
||||
public void SiteHealthReport_RoundTrip_MinimalReport_LeavesEveryOptionalNullOrEmpty()
|
||||
{
|
||||
@@ -372,6 +392,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Null(roundTripped.SecretMirrorStatuses);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty nullable collections in a health report stay empty rather than becoming null.</summary>
|
||||
[Fact]
|
||||
public void SiteHealthReport_RoundTrip_EmptyNullableCollections_StayEmptyNotNull()
|
||||
{
|
||||
@@ -401,6 +422,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Empty(roundTripped.ClusterNodes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that false and zero gauge values in a health report stay false and zero.</summary>
|
||||
[Fact]
|
||||
public void SiteHealthReport_RoundTrip_FalseAndZeroGauges_StayFalseAndZero()
|
||||
{
|
||||
@@ -427,6 +449,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a backlog report with an empty queue keeps a null oldest-pending value.</summary>
|
||||
[Fact]
|
||||
public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending()
|
||||
{
|
||||
@@ -440,6 +463,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(new SiteAuditBacklogSnapshot(0, null, 4096), roundTripped.SiteAuditBacklog);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that accepted and rejected health report acknowledgements both round-trip.</summary>
|
||||
[Fact]
|
||||
public void SiteHealthReportAck_RoundTrip_Accepted_And_Rejected()
|
||||
{
|
||||
@@ -455,6 +479,8 @@ public class CentralControlDtoMapperTests
|
||||
// Heartbeat + ConnectionHealth enum
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a heartbeat message round-trips with every field preserved.</summary>
|
||||
/// <param name="isActive">Whether the heartbeat reports an active node.</param>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
@@ -465,6 +491,8 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every connection health value survives a round trip.</summary>
|
||||
/// <param name="health">The connection health value under test.</param>
|
||||
[Theory]
|
||||
[InlineData(ConnectionHealth.Connected)]
|
||||
[InlineData(ConnectionHealth.Disconnected)]
|
||||
@@ -475,6 +503,7 @@ public class CentralControlDtoMapperTests
|
||||
Assert.Equal(health, CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(health)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unspecified wire value decodes to the error-not-connected health state.</summary>
|
||||
[Fact]
|
||||
public void ConnectionHealth_Unspecified_DecodesToErrorNotConnected()
|
||||
{
|
||||
@@ -485,6 +514,7 @@ public class CentralControlDtoMapperTests
|
||||
CentralControlDtoMapper.FromDto(ConnectionHealthEnum.ConnectionHealthUnspecified));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every connection health enum member has a wire mapping.</summary>
|
||||
[Fact]
|
||||
public void ConnectionHealth_EveryEnumMemberIsMapped()
|
||||
{
|
||||
@@ -559,12 +589,12 @@ public class CentralControlDtoMapperTests
|
||||
LocalDbReplicationConnected = true,
|
||||
LocalDbOplogBacklog = 250L,
|
||||
StartupReconcilerActive = true,
|
||||
// arch-review 2026-08-19 P8-1: these three never crossed the wire at all
|
||||
// These three never crossed the wire at all
|
||||
// (mapper omitted them entirely, always reading back as 0) until this fix.
|
||||
DetachedScriptThreads = 2,
|
||||
ScriptRunShedCount = 13,
|
||||
SiteStreamAlarmDropCount = 41L,
|
||||
// Review finding M1: every node's secret mirror status, standby included.
|
||||
// Every node's secret mirror status, standby included.
|
||||
SecretMirrorStatuses =
|
||||
[
|
||||
new SecretMirrorNodeStatus("node-a", SampleInstant, []),
|
||||
|
||||
+11
-1
@@ -18,7 +18,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) site→central ingest routing on <see cref="CentralControlGrpcService"/>.
|
||||
/// Audit Log site→central ingest routing on <see cref="CentralControlGrpcService"/>.
|
||||
/// <para>
|
||||
/// The service Asks the <c>audit-log-ingest</c> singleton proxy DIRECTLY. It used to relay
|
||||
/// through <see cref="CentralCommunicationActor"/>, which re-Asked the same proxy with the same
|
||||
@@ -40,6 +40,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
NullLogger<CentralControlGrpcService>.Instance,
|
||||
Options.Create(new CommunicationOptions()));
|
||||
|
||||
/// <summary>Verifies that ingesting audit events asks the ingest proxy directly and never goes through the communication actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_AsksTheIngestProxy_AndNeverTheCommunicationActor()
|
||||
{
|
||||
@@ -66,6 +68,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting cached telemetry asks the ingest proxy directly and never goes through the communication actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_AsksTheIngestProxy_AndNeverTheCommunicationActor()
|
||||
{
|
||||
@@ -94,6 +98,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting audit events before the ingest proxy is wired returns an empty acknowledgement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
|
||||
{
|
||||
@@ -111,6 +117,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting cached telemetry before the ingest proxy is wired returns an empty acknowledgement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
|
||||
{
|
||||
@@ -129,6 +137,7 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting the audit ingest actor is independent of marking the service ready.</summary>
|
||||
[Fact]
|
||||
public void SetAuditIngestActor_IsIndependentOfSetReady()
|
||||
{
|
||||
@@ -141,6 +150,7 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
Assert.False(service.IsReady);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the central communication actor no longer relays ingest commands.</summary>
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_NoLongerRelaysIngestCommands()
|
||||
{
|
||||
|
||||
+17
@@ -32,6 +32,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
NullLogger<CentralControlGrpcService>.Instance,
|
||||
Options.Create(options ?? new CommunicationOptions()));
|
||||
|
||||
/// <summary>Verifies that a unary call made before the service is set ready fails as unavailable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BeforeSetReady_AUnaryCall_IsUnavailable()
|
||||
{
|
||||
@@ -45,6 +47,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-empty ingest batch sent before the service is set ready fails as unavailable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable()
|
||||
{
|
||||
@@ -58,6 +62,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a unary call made after the service is set ready reaches the actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AfterSetReady_AUnaryCall_ReachesTheActor()
|
||||
{
|
||||
@@ -70,6 +76,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.True(ack.Accepted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a heartbeat before the service is set ready succeeds and is dropped.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped()
|
||||
{
|
||||
@@ -82,6 +90,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.NotNull(reply);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a heartbeat after the service is set ready is told to the actor and never asked.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks()
|
||||
{
|
||||
@@ -96,6 +106,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.NotNull(reply);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a call to an actor that never replies fails with deadline exceeded rather than unavailable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable()
|
||||
{
|
||||
@@ -114,6 +126,8 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty ingest batch short-circuits even before the service is set ready.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady()
|
||||
{
|
||||
@@ -125,6 +139,7 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit ingest timeout is the single shared constant.</summary>
|
||||
[Fact]
|
||||
public void TheAuditIngestTimeout_IsTheOneSharedConstant()
|
||||
{
|
||||
@@ -166,6 +181,7 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
|
||||
private sealed class StubActor : ReceiveActor
|
||||
{
|
||||
/// <summary>Creates the actor, which acknowledges every notification submit as accepted.</summary>
|
||||
public StubActor()
|
||||
{
|
||||
Receive<NotificationSubmit>(msg =>
|
||||
@@ -176,6 +192,7 @@ public class CentralControlGrpcServiceTests : TestKit
|
||||
/// <summary>Swallows every message and never replies, so an Ask against it times out.</summary>
|
||||
private sealed class NeverRepliesActor : ReceiveActor
|
||||
{
|
||||
/// <summary>Creates the actor, which ignores every message.</summary>
|
||||
public NeverRepliesActor() => ReceiveAny(_ => { });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
[Collection("SiteStreamSubscriberGauge")]
|
||||
public class CleanupVerificationTests : TestKit
|
||||
{
|
||||
/// <summary>Verifies that cancelling a stream cleans up its site stream manager subscription.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Stream_Cancellation_CleansUp_SiteStreamManager_Subscription()
|
||||
{
|
||||
@@ -71,6 +73,8 @@ public class CleanupVerificationTests : TestKit
|
||||
subscriber.Received(1).RemoveSubscriber(subscribedActor!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancelling several streams cleans up all of their subscriptions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Multiple_Streams_Cancelled_AllCleanedUp()
|
||||
{
|
||||
|
||||
+73
-23
@@ -25,6 +25,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
private const string GrpcNodeA = "http://localhost:5100";
|
||||
private const string GrpcNodeB = "http://localhost:5200";
|
||||
|
||||
/// <summary>Initializes the test class with a short-timeout Akka configuration.</summary>
|
||||
public DebugStreamBridgeActorTests() : base(@"akka.loglevel = DEBUG")
|
||||
{
|
||||
// Use a very short reconnect delay for testing
|
||||
@@ -67,10 +68,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
return new TestContext(actor, commProbe, mockClient, events, terminated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an instance-not-found snapshot is forwarded to the event callback, tears down the stream and terminates the actor.</summary>
|
||||
[Fact]
|
||||
public void On_InstanceNotFound_Snapshot_Forwards_To_OnEvent_Tears_Down_Stream_And_Terminates()
|
||||
{
|
||||
// M2.11 (revised for M2.18 stream-first): the gRPC subscription is now opened
|
||||
// Stream-first: the gRPC subscription is now opened
|
||||
// up-front in PreStart, so when the site reports InstanceNotFound=true the
|
||||
// bridge actor must
|
||||
// (a) forward the not-found snapshot to _onEvent so DebugStreamService's TCS
|
||||
@@ -111,6 +113,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PreStart sends a subscribe debug view request through the communication actor.</summary>
|
||||
[Fact]
|
||||
public void PreStart_Sends_SubscribeDebugViewRequest_Via_ClusterClient()
|
||||
{
|
||||
@@ -125,6 +128,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Equal("corr-1", req.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a snapshot is forwarded to the event callback.</summary>
|
||||
[Fact]
|
||||
public void On_Snapshot_Forwards_To_OnEvent_Callback()
|
||||
{
|
||||
@@ -144,10 +148,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
lock (ctx.ReceivedEvents) { Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that receiving a snapshot does not open an additional gRPC stream.</summary>
|
||||
[Fact]
|
||||
public void On_Snapshot_Does_Not_Open_Additional_GrpcStream()
|
||||
{
|
||||
// M2.18 stream-first: the gRPC subscription is opened in PreStart, BEFORE the
|
||||
// Stream-first: the gRPC subscription is opened in PreStart, BEFORE the
|
||||
// snapshot arrives. After the snapshot is delivered the actor switches to
|
||||
// pass-through — it must NOT open a second subscription. Exactly ONE subscribe
|
||||
// call should have been made (the PreStart one).
|
||||
@@ -173,6 +178,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Equal(InstanceName, singleCall.InstanceUniqueName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that events from the gRPC callback are forwarded to the event callback.</summary>
|
||||
[Fact]
|
||||
public void Events_From_GrpcCallback_Forwarded_To_OnEvent()
|
||||
{
|
||||
@@ -198,6 +204,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
lock (ctx.ReceivedEvents) { Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a gRPC error causes a reconnect to the other node.</summary>
|
||||
[Fact]
|
||||
public void On_GrpcError_Reconnects_To_Other_Node()
|
||||
{
|
||||
@@ -221,10 +228,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a gRPC error unsubscribes the old stream before reconnecting.</summary>
|
||||
[Fact]
|
||||
public void On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect()
|
||||
{
|
||||
// Communication-002 regression: a reconnect must unsubscribe the previous
|
||||
// Regression: a reconnect must unsubscribe the previous
|
||||
// stream so the old node does not keep a zombie relay actor / subscription.
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
@@ -249,6 +257,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the actor terminates after the maximum number of retries.</summary>
|
||||
[Fact]
|
||||
public void After_MaxRetries_Terminates()
|
||||
{
|
||||
@@ -282,6 +291,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.True(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping the debug stream cancels the gRPC subscription and sends an unsubscribe.</summary>
|
||||
[Fact]
|
||||
public void StopDebugStream_Cancels_Grpc_And_Sends_Unsubscribe()
|
||||
{
|
||||
@@ -312,6 +322,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
ExpectTerminated(ctx.BridgeActor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a debug stream terminated message stops the actor idempotently.</summary>
|
||||
[Fact]
|
||||
public void DebugStreamTerminated_Stops_Actor_Idempotently()
|
||||
{
|
||||
@@ -325,10 +336,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.True(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a flapping stream that delivers events between failures still terminates after the maximum retries.</summary>
|
||||
[Fact]
|
||||
public void FlappingStream_DeliveringEventsBetweenFailures_StillTerminatesAfterMaxRetries()
|
||||
{
|
||||
// Communication-008 regression: a stream that connects, delivers an event,
|
||||
// Regression: a stream that connects, delivers an event,
|
||||
// then fails — repeatedly — must still trip MaxRetries. The retry count is
|
||||
// NO LONGER reset by a received event (only by the stability window). The
|
||||
// previous behaviour reset _retryCount on every event, so a flapping site
|
||||
@@ -367,10 +379,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.True(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a gRPC error reconnects through a client bound to the other node's endpoint.</summary>
|
||||
[Fact]
|
||||
public void On_GrpcError_Reconnects_To_Other_Node_Endpoint()
|
||||
{
|
||||
// Communication-015 regression: drive the bridge actor through a node flip
|
||||
// Regression: drive the bridge actor through a node flip
|
||||
// with an endpoint-aware factory (one distinct mock client per endpoint).
|
||||
// The first subscribe targets NodeA; after a gRPC error the bridge must
|
||||
// reconnect via a client bound to the *NodeB* endpoint.
|
||||
@@ -407,8 +420,9 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Equal("corr-1", factory.ClientFor(GrpcNodeB).SubscribeCalls[0].CorrelationId);
|
||||
}
|
||||
|
||||
// ── WP1.1: graceful (status OK) stream completion is a reconnect trigger ──
|
||||
// ── graceful (status OK) stream completion is a reconnect trigger ──
|
||||
|
||||
/// <summary>Verifies that a graceful stream completion reopens the stream on the same node.</summary>
|
||||
[Fact]
|
||||
public void On_GracefulStreamCompletion_Reopens_On_The_Same_Node()
|
||||
{
|
||||
@@ -429,6 +443,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated graceful completions do not consume the error retry budget.</summary>
|
||||
[Fact]
|
||||
public void RepeatedGracefulCompletions_DoNotConsume_TheErrorRetryBudget()
|
||||
{
|
||||
@@ -449,6 +464,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.False(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a late completion from a previous stream generation is ignored.</summary>
|
||||
[Fact]
|
||||
public void LateCompletionFromAPreviousStreamGeneration_IsIgnored()
|
||||
{
|
||||
@@ -461,14 +477,14 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
// A completion racing out of the dead gen-1 stream must not tear down the live one.
|
||||
// A completion racing out of the dead first-generation stream must not tear down the live one.
|
||||
firstSub.OnCompleted();
|
||||
Thread.Sleep(300);
|
||||
Assert.DoesNotContain("corr-1", factory.ClientFor(GrpcNodeB).UnsubscribedCorrelationIds);
|
||||
Assert.Single(factory.ClientFor(GrpcNodeB).SubscribeCalls);
|
||||
}
|
||||
|
||||
// ── Task 6 (arch review 02, High): teardown/failover unsubscribe is endpoint-safe ──
|
||||
// ── teardown/failover unsubscribe is endpoint-safe ──
|
||||
// Both paths use TryGet, never GetOrCreate, so cleanup can never open a fresh
|
||||
// channel or (with (site,endpoint) keying) touch another session's channel.
|
||||
|
||||
@@ -485,6 +501,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
return (actor, factory);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that gRPC cleanup unsubscribes through TryGet without opening a channel.</summary>
|
||||
[Fact]
|
||||
public void CleanupGrpc_UnsubscribesViaTryGet_WithoutOpeningAChannel()
|
||||
{
|
||||
@@ -501,6 +518,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
Assert.Equal(createdBefore, factory.CreatedCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a session failover unsubscribes the failed endpoint through TryGet and opens only the reconnect channel.</summary>
|
||||
[Fact]
|
||||
public void SessionFailover_UnsubscribesFailedEndpointViaTryGet_OpensOnlyTheReconnectChannel()
|
||||
{
|
||||
@@ -523,13 +541,14 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M2.18 (#26) — stream-first + replay/dedup
|
||||
// Stream-first + replay/dedup
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that PreStart opens the gRPC stream before the snapshot arrives.</summary>
|
||||
[Fact]
|
||||
public void PreStart_Opens_GrpcStream_Before_Snapshot_Arrives()
|
||||
{
|
||||
// M2.18: the gRPC subscription must be opened in PreStart (stream-first),
|
||||
// Stream-first: the gRPC subscription must be opened in PreStart (stream-first),
|
||||
// BEFORE the snapshot is delivered, so live events start flowing during the
|
||||
// snapshot-build + network-transit window. The old lifecycle opened the
|
||||
// stream only after the snapshot arrived, losing gap-window events.
|
||||
@@ -545,10 +564,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
lock (ctx.ReceivedEvents) { Assert.Empty(ctx.ReceivedEvents); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an event buffered in the gap window before the snapshot is delivered exactly once after the snapshot.</summary>
|
||||
[Fact]
|
||||
public void GapWindow_Event_Buffered_Before_Snapshot_Is_Delivered_Exactly_Once_After_Snapshot()
|
||||
{
|
||||
// M2.18: an event arriving DURING the snapshot window (before the snapshot
|
||||
// An event arriving DURING the snapshot window (before the snapshot
|
||||
// is delivered) is buffered, then flushed exactly once AFTER the snapshot.
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
@@ -581,10 +601,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered event already reflected in the snapshot is dropped.</summary>
|
||||
[Fact]
|
||||
public void Buffered_Event_Already_Reflected_In_Snapshot_Is_Dropped()
|
||||
{
|
||||
// M2.18 dedup: a buffered event whose entity is in the snapshot with an equal
|
||||
// Dedup: a buffered event whose entity is in the snapshot with an equal
|
||||
// or newer snapshot timestamp (buffered.Timestamp <= snapshot.Timestamp) is
|
||||
// already reflected and must be DROPPED.
|
||||
var ctx = CreateBridgeActor();
|
||||
@@ -618,10 +639,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered event strictly newer than the snapshot entity is delivered.</summary>
|
||||
[Fact]
|
||||
public void Buffered_Event_Strictly_Newer_Than_Snapshot_Entity_Is_Delivered()
|
||||
{
|
||||
// M2.18 dedup: a buffered event strictly newer than the snapshot's entry for
|
||||
// Dedup: a buffered event strictly newer than the snapshot's entry for
|
||||
// the same entity (buffered.Timestamp > snapshot.Timestamp) is NOT a dup and
|
||||
// must be DELIVERED after the snapshot.
|
||||
var ctx = CreateBridgeActor();
|
||||
@@ -655,10 +677,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that buffered alarm deduplication uses the alarm identity and timestamp.</summary>
|
||||
[Fact]
|
||||
public void Buffered_Alarm_Dedup_Uses_AlarmIdentity_And_Timestamp()
|
||||
{
|
||||
// M2.18 dedup for alarms: identity = (instance, alarm name, source reference).
|
||||
// Dedup for alarms: identity = (instance, alarm name, source reference).
|
||||
// A buffered alarm older-or-equal to the snapshot's same-identity alarm is
|
||||
// dropped; a strictly-newer one is delivered.
|
||||
var ctx = CreateBridgeActor();
|
||||
@@ -701,10 +724,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that buffered events are flushed in arrival order.</summary>
|
||||
[Fact]
|
||||
public void Buffered_Events_Flushed_In_Arrival_Order()
|
||||
{
|
||||
// M2.18: ordering preserved across multiple buffered events (none are dups —
|
||||
// Ordering preserved across multiple buffered events (none are dups —
|
||||
// their entities are absent from the snapshot).
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
@@ -735,10 +759,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that after the flush the actor passes subsequent events through immediately.</summary>
|
||||
[Fact]
|
||||
public void PassThrough_After_Flush_Delivers_Subsequent_Events_Immediately()
|
||||
{
|
||||
// M2.18: after the snapshot+flush the actor switches to pass-through — later
|
||||
// After the snapshot+flush the actor switches to pass-through — later
|
||||
// events go straight to _onEvent (no buffering, no dup).
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
@@ -767,10 +792,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an instance-not-found snapshot after the stream was opened first tears down the stream and does not enter pass-through.</summary>
|
||||
[Fact]
|
||||
public void InstanceNotFound_After_StreamFirst_Tears_Down_Stream_And_Does_Not_PassThrough()
|
||||
{
|
||||
// M2.18 + M2.11: stream-first means the gRPC subscription is already open
|
||||
// Stream-first means the gRPC subscription is already open
|
||||
// when an InstanceNotFound snapshot arrives. The bridge must tear that stream
|
||||
// down (Unsubscribe the just-opened correlation), deliver the not-found
|
||||
// snapshot, NOT enter pass-through, and stop cleanly.
|
||||
@@ -812,10 +838,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
lock (ctx.ReceivedEvents) { Assert.Single(ctx.ReceivedEvents); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reconnect during the buffering phase keeps buffering until the snapshot arrives.</summary>
|
||||
[Fact]
|
||||
public void Reconnect_During_Buffering_Phase_Keeps_Buffering_Until_Snapshot()
|
||||
{
|
||||
// M2.18: a gRPC error/reconnect BEFORE the snapshot arrives must remain in the
|
||||
// A gRPC error/reconnect BEFORE the snapshot arrives must remain in the
|
||||
// buffering phase — events on the new stream are still buffered, then flushed
|
||||
// when the snapshot finally arrives.
|
||||
var ctx = CreateBridgeActor();
|
||||
@@ -849,10 +876,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reconnect after the snapshot resumes pass-through rather than buffering.</summary>
|
||||
[Fact]
|
||||
public void Reconnect_After_Snapshot_Resumes_PassThrough_Not_Buffering()
|
||||
{
|
||||
// M2.18: a mid-session reconnect (after the snapshot was already delivered)
|
||||
// A mid-session reconnect (after the snapshot was already delivered)
|
||||
// must resume pass-through — the snapshot is a one-time thing and events on
|
||||
// the reconnected stream are delivered immediately, not re-buffered.
|
||||
var ctx = CreateBridgeActor();
|
||||
@@ -885,10 +913,11 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the retry count is recovered only after the stream stays stable for the stability window.</summary>
|
||||
[Fact]
|
||||
public void RetryCount_RecoveredOnlyAfterStreamStaysStableForStabilityWindow()
|
||||
{
|
||||
// Communication-008: after a stream has been connected for the stability
|
||||
// After a stream has been connected for the stability
|
||||
// window, the retry budget is recovered — a later transient fault then gets
|
||||
// a fresh set of retries rather than being counted against the old budget.
|
||||
DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromMilliseconds(300);
|
||||
@@ -928,8 +957,9 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
// ── WP2.3: bounded pre-snapshot buffer, hard snapshot deadline, timeout hygiene ──
|
||||
// ── bounded pre-snapshot buffer, hard snapshot deadline, timeout hygiene ──
|
||||
|
||||
/// <summary>Verifies that the pre-snapshot buffer is capped, drops the oldest events and counts the drops.</summary>
|
||||
[Fact]
|
||||
public void PreSnapshotBuffer_IsCapped_DropsOldest_AndCountsTheDrops()
|
||||
{
|
||||
@@ -986,6 +1016,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing snapshot within the deadline fails the session instead of buffering forever.</summary>
|
||||
[Fact]
|
||||
public void NoSnapshotWithinDeadline_FailsTheSession_InsteadOfBufferingForever()
|
||||
{
|
||||
@@ -1013,6 +1044,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the arrival of the snapshot stands down the deadline.</summary>
|
||||
[Fact]
|
||||
public void SnapshotArrival_StandsDownTheDeadline()
|
||||
{
|
||||
@@ -1048,6 +1080,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream events still reach the consumer through the wrapped callback path.</summary>
|
||||
[Fact]
|
||||
public void StreamEvents_StillReachTheConsumer_ThroughTheWrappedCallbackPath()
|
||||
{
|
||||
@@ -1076,6 +1109,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
|
||||
// ----- Orphan net: measures the CONSUMER, not the mailbox ----- //
|
||||
|
||||
/// <summary>Verifies that a healthy session streaming events with consumer keepalives survives well past the orphan window.</summary>
|
||||
[Fact]
|
||||
public void HealthySession_StreamingEvents_WithConsumerKeepalives_SurvivesWellPastTheOrphanWindow()
|
||||
{
|
||||
@@ -1132,6 +1166,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an orphaned session whose consumer is gone still terminates even while events keep arriving.</summary>
|
||||
[Fact]
|
||||
public void OrphanedSession_ConsumerGone_StillTerminates_EvenWhileEventsKeepArriving()
|
||||
{
|
||||
@@ -1173,6 +1208,7 @@ public class DebugStreamBridgeActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a consumer keepalive renews the orphan window after a near miss.</summary>
|
||||
[Fact]
|
||||
public void ConsumerKeepalive_RenewsTheWindow_AfterANearMiss()
|
||||
{
|
||||
@@ -1227,10 +1263,12 @@ internal class MockSiteStreamGrpcClient : SiteStreamGrpcClient
|
||||
|
||||
private MockSiteStreamGrpcClient(bool _) : base() { }
|
||||
|
||||
/// <summary>Initializes a mock client that records subscribe and unsubscribe calls.</summary>
|
||||
public MockSiteStreamGrpcClient() : base()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task SubscribeAsync(
|
||||
string correlationId,
|
||||
string instanceUniqueName,
|
||||
@@ -1249,6 +1287,7 @@ internal class MockSiteStreamGrpcClient : SiteStreamGrpcClient
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Unsubscribe(string correlationId)
|
||||
{
|
||||
lock (_lock) { _unsubscribedCorrelationIds.Add(correlationId); }
|
||||
@@ -1269,14 +1308,18 @@ internal record MockSubscription(
|
||||
internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
private readonly MockSiteStreamGrpcClient _mockClient;
|
||||
/// <summary>Gets the endpoints requested through <c>GetOrCreate</c>, in call order.</summary>
|
||||
public List<string> RequestedEndpoints { get; } = new();
|
||||
|
||||
/// <summary>Initializes the factory with the mock client it always returns.</summary>
|
||||
/// <param name="mockClient">The mock client returned for every endpoint.</param>
|
||||
public MockSiteStreamGrpcClientFactory(MockSiteStreamGrpcClient mockClient)
|
||||
: base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
|
||||
{
|
||||
_mockClient = mockClient;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
RequestedEndpoints.Add(grpcEndpoint);
|
||||
@@ -1285,7 +1328,8 @@ internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory
|
||||
|
||||
// Mirrors the real factory's TryGet: returns the client only for an endpoint
|
||||
// already opened via GetOrCreate, never creating one. Lets teardown/failover
|
||||
// unsubscribe paths (now TryGet-based, Task 6) resolve the mock client.
|
||||
// unsubscribe paths (now TryGet-based) resolve the mock client.
|
||||
/// <inheritdoc />
|
||||
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
|
||||
=> RequestedEndpoints.Contains(grpcEndpoint) ? _mockClient : null;
|
||||
}
|
||||
@@ -1293,27 +1337,33 @@ internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory
|
||||
/// <summary>
|
||||
/// Endpoint-aware mock factory: hands out a distinct <see cref="MockSiteStreamGrpcClient"/>
|
||||
/// per endpoint, mirroring the real factory's corrected NodeA→NodeB failover behaviour
|
||||
/// so node-flip coverage is meaningful (Communication-015).
|
||||
/// so node-flip coverage is meaningful.
|
||||
/// </summary>
|
||||
internal class EndpointTrackingGrpcClientFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, MockSiteStreamGrpcClient> _byEndpoint = new();
|
||||
|
||||
/// <summary>Initializes an empty endpoint-aware factory.</summary>
|
||||
public EndpointTrackingGrpcClientFactory()
|
||||
: base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Gets the mock client bound to an endpoint, creating it on first use.</summary>
|
||||
/// <param name="endpoint">The gRPC endpoint address.</param>
|
||||
/// <returns>The mock client for the endpoint.</returns>
|
||||
public MockSiteStreamGrpcClient ClientFor(string endpoint) =>
|
||||
_byEndpoint.GetOrAdd(endpoint, _ => new MockSiteStreamGrpcClient());
|
||||
|
||||
/// <summary>Number of distinct endpoint clients created so far (channels opened).</summary>
|
||||
public int CreatedCount => _byEndpoint.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
|
||||
=> ClientFor(grpcEndpoint);
|
||||
|
||||
// TryGet never creates: returns the client only for an already-opened endpoint.
|
||||
/// <inheritdoc />
|
||||
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
|
||||
=> _byEndpoint.TryGetValue(grpcEndpoint, out var client) ? client : null;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Communication-005 — the gRPC keepalive and
|
||||
/// Regression tests: the gRPC keepalive and
|
||||
/// max-stream-lifetime / max-concurrent-stream options defined on
|
||||
/// <see cref="CommunicationOptions"/> must actually be applied to the
|
||||
/// gRPC client and server rather than hard-coded.
|
||||
/// </summary>
|
||||
public class GrpcOptionsWiringTests
|
||||
{
|
||||
/// <summary>Verifies that the site stream gRPC client applies its keepalive settings from the options.</summary>
|
||||
[Fact]
|
||||
public void SiteStreamGrpcClient_AppliesKeepAliveFromOptions()
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public class GrpcOptionsWiringTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(7), client.KeepAlivePingTimeout);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the client factory flows the options to the clients it creates.</summary>
|
||||
[Fact]
|
||||
public void SiteStreamGrpcClientFactory_FlowsOptionsToCreatedClients()
|
||||
{
|
||||
@@ -48,6 +50,7 @@ public class GrpcOptionsWiringTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(11), client.KeepAlivePingTimeout);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the gRPC server binds its maximum concurrent streams and stream lifetime from the options.</summary>
|
||||
[Fact]
|
||||
public void SiteStreamGrpcServer_BindsMaxConcurrentStreamsAndLifetimeFromOptions()
|
||||
{
|
||||
|
||||
+4
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
/// </summary>
|
||||
public class NoClusterClientStreamingRegressionTests
|
||||
{
|
||||
/// <summary>Verifies that <c>CentralCommunicationActor</c> has no <c>HandleDebugStreamEvent</c> member.</summary>
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_DoesNotHave_HandleDebugStreamEvent()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class NoClusterClientStreamingRegressionTests
|
||||
Assert.Null(method);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>SiteCommunicationActor</c> has no <c>HandleDebugStreamEvent</c> member.</summary>
|
||||
[Fact]
|
||||
public void SiteCommunicationActor_DoesNotHave_HandleDebugStreamEvent()
|
||||
{
|
||||
@@ -31,6 +33,7 @@ public class NoClusterClientStreamingRegressionTests
|
||||
Assert.Null(method);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>CentralCommunicationActor</c> has no <c>ForwardDebugStreamEvent</c> member.</summary>
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_DoesNotHave_ForwardDebugStreamEvent()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public class NoClusterClientStreamingRegressionTests
|
||||
Assert.Null(method);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Communication assembly contains no <c>DebugStreamEvent</c> type.</summary>
|
||||
[Fact]
|
||||
public void Communication_Assembly_DoesNotContain_DebugStreamEvent_Type()
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ public class ProtoContractTests
|
||||
SiteStreamEvent.EventOneofCase.Batch
|
||||
];
|
||||
|
||||
/// <summary>Verifies that every oneof variant has a conversion handler.</summary>
|
||||
[Fact]
|
||||
public void AllOneofVariants_HaveConversionHandlers()
|
||||
{
|
||||
@@ -44,6 +45,7 @@ public class ProtoContractTests
|
||||
Assert.Contains(c, accountedFor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch frame is unpacked into per-event cases rather than converted whole.</summary>
|
||||
[Fact]
|
||||
public void BatchFrame_IsUnpackedIntoPerEventCases_NotConvertedWhole()
|
||||
{
|
||||
@@ -71,6 +73,8 @@ public class ProtoContractTests
|
||||
seen);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ConvertToDomainEvent handles every oneof variant.</summary>
|
||||
/// <param name="eventCase">The event case.</param>
|
||||
[Theory]
|
||||
[InlineData(SiteStreamEvent.EventOneofCase.AttributeChanged)]
|
||||
[InlineData(SiteStreamEvent.EventOneofCase.AlarmChanged)]
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
/// <summary>Compiles source descriptors and compares the complete vendored wire contract.</summary>
|
||||
public class ProtoDescriptorContractTests
|
||||
{
|
||||
/// <summary>Verifies that the source descriptors match the vendored messages and services.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task XC4_SourceDescriptorsMatchVendoredMessagesAndServices()
|
||||
{
|
||||
@@ -17,6 +19,10 @@ public class ProtoDescriptorContractTests
|
||||
Assert.Equal(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the guard detects drift in names, types and services.</summary>
|
||||
/// <param name="before">The descriptor text before the change.</param>
|
||||
/// <param name="after">The descriptor text after the change.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("string site_identifier = 1;", "string renamed_site = 1;")]
|
||||
[InlineData("string site_identifier = 1;", "bytes site_identifier = 1;")]
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class ProtoRoundtripTests
|
||||
{
|
||||
/// <summary>Verifies that an attribute value update survives a protobuf round trip.</summary>
|
||||
/// <param name="quality">The test input.</param>
|
||||
[Theory]
|
||||
[InlineData(Quality.Good)]
|
||||
[InlineData(Quality.Uncertain)]
|
||||
@@ -39,6 +41,8 @@ public class ProtoRoundtripTests
|
||||
Assert.Equal(timestamp.Nanos, deserialized.Timestamp.Nanos);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm state update survives a protobuf round trip.</summary>
|
||||
/// <param name="state">The test input.</param>
|
||||
[Theory]
|
||||
[InlineData(AlarmStateEnum.AlarmStateNormal)]
|
||||
[InlineData(AlarmStateEnum.AlarmStateActive)]
|
||||
@@ -67,6 +71,7 @@ public class ProtoRoundtripTests
|
||||
Assert.Equal(original.Timestamp, deserialized.Timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site stream event carrying an attribute change sets the matching oneof case.</summary>
|
||||
[Fact]
|
||||
public void SiteStreamEvent_OneOf_AttributeChanged()
|
||||
{
|
||||
@@ -97,6 +102,7 @@ public class ProtoRoundtripTests
|
||||
Assert.Equal("42.5", deserialized.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site stream event carrying an alarm change sets the matching oneof case.</summary>
|
||||
[Fact]
|
||||
public void SiteStreamEvent_OneOf_AlarmChanged()
|
||||
{
|
||||
@@ -127,6 +133,7 @@ public class ProtoRoundtripTests
|
||||
Assert.Equal(1, deserialized.AlarmChanged.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a DateTimeOffset timestamp survives a full protobuf round trip.</summary>
|
||||
[Fact]
|
||||
public void Timestamp_DateTimeOffset_FullRoundTrip()
|
||||
{
|
||||
|
||||
+65
-13
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SiteAlarmAggregatorActor"/> (plan #10, Task 4) — the transient,
|
||||
/// Tests for <see cref="SiteAlarmAggregatorActor"/> — the transient,
|
||||
/// in-memory, per-site live alarm aggregator. Covers the tricky bits: seed-then-stream
|
||||
/// ordering (a delta arriving before the seed completes is neither lost nor double-applied),
|
||||
/// dedup by AlarmKey, placeholder seeded-but-not-on-stream coherence, NodeA↔NodeB reconnect
|
||||
@@ -31,6 +31,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
private const string GrpcNodeB = "http://localhost:5200";
|
||||
private const string Instance = "Site1.Pump01";
|
||||
|
||||
/// <summary>Initializes the test class with a quiet Akka log level.</summary>
|
||||
public SiteAlarmAggregatorActorTests() : base(@"akka.loglevel = WARNING")
|
||||
{
|
||||
}
|
||||
@@ -50,8 +51,12 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
private readonly ConcurrentQueue<TaskCompletionSource<IReadOnlyList<AlarmStateChanged>>> _pending = new();
|
||||
private int _callCount;
|
||||
|
||||
/// <summary>Gets the number of seed calls made so far.</summary>
|
||||
public int CallCount => Volatile.Read(ref _callCount);
|
||||
|
||||
/// <summary>Starts a seed call that stays pending until the test completes it.</summary>
|
||||
/// <param name="ct">A token that cancels the pending seed call.</param>
|
||||
/// <returns>A task that completes with the seeded alarm rows once the test completes it.</returns>
|
||||
public Task<IReadOnlyList<AlarmStateChanged>> Seed(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
@@ -63,6 +68,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
}
|
||||
|
||||
/// <summary>Completes the oldest not-yet-completed seed call with the given rows.</summary>
|
||||
/// <param name="rows">The alarm rows the pending seed call completes with.</param>
|
||||
public void CompleteNext(params AlarmStateChanged[] rows)
|
||||
{
|
||||
SpinWait.SpinUntil(() => _pending.TryPeek(out _), TimeSpan.FromSeconds(3));
|
||||
@@ -86,8 +92,12 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
private sealed class PublishSink
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
/// <summary>Gets every snapshot published so far, in publish order.</summary>
|
||||
public List<IReadOnlyList<AlarmStateChanged>> Snapshots { get; } = new();
|
||||
|
||||
/// <summary>Records a published snapshot and its stream liveness.</summary>
|
||||
/// <param name="snapshot">The published alarm snapshot.</param>
|
||||
/// <param name="streamLive">Whether the stream was live at publish time.</param>
|
||||
public void Publish(IReadOnlyList<AlarmStateChanged> snapshot, bool streamLive)
|
||||
{
|
||||
lock (_lock) { Snapshots.Add(snapshot); StreamLive = streamLive; }
|
||||
@@ -96,11 +106,13 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
/// <summary>Liveness reported with the most recent publish.</summary>
|
||||
public bool StreamLive { get; private set; }
|
||||
|
||||
/// <summary>Gets the most recently published snapshot, or <c>null</c> if nothing has been published.</summary>
|
||||
public IReadOnlyList<AlarmStateChanged>? Latest
|
||||
{
|
||||
get { lock (_lock) { return Snapshots.Count == 0 ? null : Snapshots[^1]; } }
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of snapshots published so far.</summary>
|
||||
public int Count { get { lock (_lock) { return Snapshots.Count; } } }
|
||||
}
|
||||
|
||||
@@ -114,15 +126,19 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
private readonly List<SiteSub> _subs = new();
|
||||
private readonly List<string> _unsubscribed = new();
|
||||
|
||||
/// <summary>Gets a copy of the subscriptions the aggregator opened.</summary>
|
||||
public List<SiteSub> Subs { get { lock (_lock) { return _subs.ToList(); } } }
|
||||
/// <summary>Gets a copy of the correlation ids that were unsubscribed.</summary>
|
||||
public List<string> Unsubscribed { get { lock (_lock) { return _unsubscribed.ToList(); } } }
|
||||
|
||||
/// <summary>Initializes a mock stream client with no real channel.</summary>
|
||||
public MockSiteAlarmStreamClient() : base() { }
|
||||
|
||||
/// <summary>When false the stream never reports connected — the site accepted the TCP
|
||||
/// call but never answered, so the aggregator's connect-driven re-seed must not run.</summary>
|
||||
public bool AutoConnect { get; set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task SubscribeSiteAsync(
|
||||
string correlationId, Action<AlarmStateChanged> onAlarmEvent, Action<Exception> onError,
|
||||
Action onCompleted, CancellationToken ct, Action? onConnected = null)
|
||||
@@ -139,6 +155,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
return tcs.Task; // never completes until cancelled (simulates a live stream)
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Unsubscribe(string correlationId)
|
||||
{
|
||||
lock (_lock) { _unsubscribed.Add(correlationId); }
|
||||
@@ -149,18 +166,24 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, MockSiteAlarmStreamClient> _byEndpoint = new();
|
||||
|
||||
/// <summary>Initializes the factory with a null logger factory.</summary>
|
||||
public MockSiteAlarmStreamClientFactory()
|
||||
: base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) { }
|
||||
|
||||
/// <summary>Applied to every client this factory hands out (set before the actor starts).</summary>
|
||||
public bool AutoConnect { get; set; } = true;
|
||||
|
||||
/// <summary>Gets the mock client for an endpoint, creating it on first use.</summary>
|
||||
/// <param name="endpoint">The gRPC endpoint the client is keyed by.</param>
|
||||
/// <returns>The mock client for that endpoint.</returns>
|
||||
public MockSiteAlarmStreamClient ClientFor(string endpoint) =>
|
||||
_byEndpoint.GetOrAdd(endpoint, _ => new MockSiteAlarmStreamClient { AutoConnect = AutoConnect });
|
||||
|
||||
/// <inheritdoc />
|
||||
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
|
||||
=> ClientFor(grpcEndpoint);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint)
|
||||
=> _byEndpoint.TryGetValue(grpcEndpoint, out var c) ? c : null;
|
||||
}
|
||||
@@ -203,7 +226,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
rows?.FirstOrDefault(r => r.AlarmName == alarmName && r.SourceReference == sourceRef);
|
||||
|
||||
/// <summary>
|
||||
/// Row-40 house pattern for an ABSENCE assertion: proves <paramref name="checks"/> keep
|
||||
/// House pattern for an ABSENCE assertion: proves <paramref name="checks"/> keep
|
||||
/// holding throughout <paramref name="window"/> by polling, rather than a single blind
|
||||
/// Thread.Sleep(window) followed by one end-of-window sample. A bare sleep-then-assert
|
||||
/// cannot distinguish "the mechanism correctly stayed quiet" from "the mechanism just
|
||||
@@ -226,6 +249,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a delta arriving before the seed completes is buffered and applied exactly once.</summary>
|
||||
[Fact]
|
||||
public void Delta_Arriving_Before_Seed_Completes_Is_Buffered_And_Applied_Exactly_Once()
|
||||
{
|
||||
@@ -250,6 +274,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Single(latest, r => r.AlarmName == "Overheat"); // exactly once
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered delta older than the seeded entry is dropped.</summary>
|
||||
[Fact]
|
||||
public void Buffered_Delta_Older_Than_Seed_Entry_Is_Dropped()
|
||||
{
|
||||
@@ -273,6 +298,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Equal(500, pumpFault!.Priority); // seed value kept; older buffered delta dropped
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered delta newer than the seeded entry is applied.</summary>
|
||||
[Fact]
|
||||
public void Buffered_Delta_Newer_Than_Seed_Entry_Is_Applied()
|
||||
{
|
||||
@@ -287,6 +313,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deduplication by alarm key distinguishes alarms by their source reference.</summary>
|
||||
[Fact]
|
||||
public void Dedup_By_AlarmKey_Distinguishes_SourceReference()
|
||||
{
|
||||
@@ -306,6 +333,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.NotNull(Find(latest, "LevelAlarm", "Tank01.Level.LoLo"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a live update with the same key replaces the existing row in place.</summary>
|
||||
[Fact]
|
||||
public void Same_Key_Live_Update_Replaces_In_Place()
|
||||
{
|
||||
@@ -324,6 +352,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Single(sink.Latest!); // still exactly one row for the key
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a seeded native alarm and its live delta collapse onto one key.</summary>
|
||||
[Fact]
|
||||
public void Seeded_Native_Alarm_And_Its_Live_Delta_Collapse_Onto_One_Key()
|
||||
{
|
||||
@@ -348,6 +377,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Single(sink.Latest!); // exactly one row — no seed/live ghost duplicate
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a seeded placeholder coexists with a live real alarm and is not wiped.</summary>
|
||||
[Fact]
|
||||
public void Placeholder_Seeded_Coexists_With_Live_Real_Alarm_And_Is_Not_Wiped()
|
||||
{
|
||||
@@ -372,6 +402,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.NotNull(Find(latest, "Motor1.MotorAlarms", "Motor1.MotorAlarms.HiHi"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a gRPC error flips to the other node, reconnects and re-seeds.</summary>
|
||||
[Fact]
|
||||
public void GrpcError_Flips_Node_Reconnects_And_ReSeeds()
|
||||
{
|
||||
@@ -393,6 +424,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).Unsubscribed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a periodic reconcile authoritatively replaces stale rows.</summary>
|
||||
[Fact]
|
||||
public void Periodic_Reconcile_Authoritatively_Replaces_Stale_Rows()
|
||||
{
|
||||
@@ -403,7 +435,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
seed.CompleteNext(Alarm("PumpFault", "", 500, t)); // seed has a row
|
||||
AwaitCondition(() => Find(sink.Latest, "PumpFault", "") is not null, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Reconcile tick fires → seed #2 (e.g. instance was disabled) returns EMPTY.
|
||||
// Reconcile tick fires → the second seed (e.g. instance was disabled) returns EMPTY.
|
||||
AwaitCondition(() => seed.CallCount == 2, TimeSpan.FromSeconds(3));
|
||||
seed.CompleteNext(); // empty
|
||||
|
||||
@@ -411,6 +443,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => sink.Latest is { } l && l.Count == 0, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed initial seed publishes nothing but a later reconcile recovers.</summary>
|
||||
[Fact]
|
||||
public void Failed_Initial_Seed_Does_Not_Publish_But_A_Later_Reconcile_Recovers()
|
||||
{
|
||||
@@ -431,6 +464,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => Find(sink.Latest, "PumpFault", "") is not null, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream given up after the maximum retries is reopened on the next reconcile tick.</summary>
|
||||
[Fact]
|
||||
public void Stream_GivenUp_After_MaxRetries_Reopens_On_Reconcile_Tick()
|
||||
{
|
||||
@@ -461,6 +495,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
|
||||
// ── R2 T11: queued re-seed after reconnect + stream-generation stamp (N7.1/N7.2) ──
|
||||
|
||||
/// <summary>Verifies that a re-seed requested during an in-flight fan-out is queued rather than dropped.</summary>
|
||||
[Fact]
|
||||
public void ReseedRequestedDuringInFlightFanout_IsQueued_NotDropped()
|
||||
{
|
||||
@@ -470,7 +505,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitAssert(() => Assert.Equal(1, sink.Count));
|
||||
AwaitCondition(() => factory.ClientFor(GrpcNodeA).Subs.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
// The first tick after a fan-out is deliberately skipped (WP2.3: the initial seed
|
||||
// The first tick after a fan-out is deliberately skipped (the initial seed
|
||||
// already covered this window); the second one actually fans out.
|
||||
actor.Tell(new RunReconcile()); // skipped — consumes the "already fanned out" flag
|
||||
actor.Tell(new RunReconcile()); // reconcile fan-out now in flight (CallCount 2)
|
||||
@@ -486,6 +521,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitAssert(() => Assert.Equal(3, seed.CallCount)); // queued re-seed ran immediately after
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a late error from a previous stream generation is ignored.</summary>
|
||||
[Fact]
|
||||
public void LateErrorFromAPreviousStreamGeneration_IsIgnored()
|
||||
{
|
||||
@@ -501,14 +537,15 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => factory.ClientFor(GrpcNodeB).Subs.Count == 1, TimeSpan.FromSeconds(5)); // gen 2 stream open
|
||||
AwaitAssert(() => Assert.Equal(2, TotalSubs()));
|
||||
|
||||
firstSub.OnError(new Exception("late zombie fault")); // stale gen-1 error races in
|
||||
firstSub.OnError(new Exception("late zombie fault")); // stale first-generation error races in
|
||||
// Pre-fix this burns retry budget and opens a THIRD stream; post-fix it is ignored.
|
||||
AssertHoldsFor(TimeSpan.FromMilliseconds(400),
|
||||
(() => TotalSubs() == 2, "a stale gen-1 error must not open a third stream"));
|
||||
}
|
||||
|
||||
// ── WP1.1: graceful (status OK) stream completion is a reconnect trigger ──
|
||||
// ── Graceful (status OK) stream completion is a reconnect trigger ──
|
||||
|
||||
/// <summary>Verifies that a graceful stream completion reopens on the next reconcile tick on the same node.</summary>
|
||||
[Fact]
|
||||
public void GracefulStreamCompletion_ReopensOnReconcileTick_OnTheSameNode()
|
||||
{
|
||||
@@ -530,6 +567,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).Unsubscribed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a graceful stream completion neither spends nor refunds the error retry budget.</summary>
|
||||
[Fact]
|
||||
public void GracefulStreamCompletion_NeitherSpendsNorRefunds_TheErrorRetryBudget()
|
||||
{
|
||||
@@ -563,6 +601,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
(() => TotalSubs() == before, "budget exhausted — the 4th error must not reconnect"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a late completion from a previous stream generation is ignored.</summary>
|
||||
[Fact]
|
||||
public void LateCompletionFromAPreviousStreamGeneration_IsIgnored()
|
||||
{
|
||||
@@ -575,8 +614,8 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
firstSub.OnError(new Exception("real fault")); // gen 1 dies → gen 2 opens on NodeB
|
||||
AwaitCondition(() => factory.ClientFor(GrpcNodeB).Subs.Count == 1, TimeSpan.FromSeconds(5));
|
||||
|
||||
// A completion racing out of the dead gen-1 stream must not tear down the live
|
||||
// gen-2 stream (which would go deltaless until the next reconcile tick).
|
||||
// A completion racing out of the dead first-generation stream must not tear down the live
|
||||
// second-generation stream (which would go deltaless until the next reconcile tick).
|
||||
firstSub.OnCompleted();
|
||||
AssertHoldsFor(TimeSpan.FromMilliseconds(300),
|
||||
(() => !factory.ClientFor(GrpcNodeB).Unsubscribed.Contains("corr-1"),
|
||||
@@ -587,6 +626,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
|
||||
// ── R2 T10: live-delta publish coalescing (N6) ──
|
||||
|
||||
/// <summary>Verifies that a burst of deltas is coalesced into a few publishes that together contain every row.</summary>
|
||||
[Fact]
|
||||
public void DeltaBurst_IsCoalesced_IntoFewPublishes_ContainingEveryRow()
|
||||
{
|
||||
@@ -608,6 +648,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero coalesce window preserves the legacy publish-per-delta behavior.</summary>
|
||||
[Fact]
|
||||
public void ZeroCoalesce_PreservesLegacyPerDeltaPublish()
|
||||
{
|
||||
@@ -624,6 +665,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitAssert(() => Assert.Equal(3, sink.Count)); // 1 seed + 1 per delta
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the stop message tears down the gRPC stream and stops the actor.</summary>
|
||||
[Fact]
|
||||
public void Stop_Message_TearsDown_Grpc_And_Stops_Actor()
|
||||
{
|
||||
@@ -640,12 +682,13 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
// -- WP2.3: seed once per SUCCESSFUL (re)connect, and stream-truthful liveness --
|
||||
// -- Seed once per SUCCESSFUL (re)connect, and stream-truthful liveness --
|
||||
|
||||
/// <summary>Verifies that reconnect attempts do not each re-seed; only the successful connect does.</summary>
|
||||
[Fact]
|
||||
public void ReconnectAttempts_DoNotEachReSeed_OnlyTheSuccessfulConnectDoes()
|
||||
{
|
||||
// Finding #10's per-attempt re-fan-out: every reconnect ATTEMPT used to kick a
|
||||
// The per-attempt re-fan-out: every reconnect ATTEMPT used to kick a
|
||||
// whole-site snapshot fan-out - against a site that is, by definition of the
|
||||
// reconnect, unreachable. Now the re-seed is owed to the connect that succeeds.
|
||||
var (_, seed, _, factory) = CreateActor(
|
||||
@@ -673,6 +716,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
(() => seed.CallCount == 2, "the successful connect must re-seed exactly once"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reconcile tick is skipped when a fan-out already ran within the window.</summary>
|
||||
[Fact]
|
||||
public void ReconcileTick_IsSkipped_WhenAFanoutAlreadyRanInTheWindow()
|
||||
{
|
||||
@@ -692,6 +736,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => seed.CallCount == 2, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unchanged reconcile snapshot is not republished.</summary>
|
||||
[Fact]
|
||||
public void UnchangedReconcileSnapshot_DoesNotRepublish()
|
||||
{
|
||||
@@ -720,10 +765,11 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitAssert(() => Assert.Equal(2, sink.Count));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream completion drops liveness and the reopen restores it.</summary>
|
||||
[Fact]
|
||||
public void StreamCompletion_DropsLiveness_AndTheReopenRestoresIt()
|
||||
{
|
||||
// WP2.3 carried residual: a completed (or given-up) stream kept reporting live
|
||||
// Carried residual: a completed (or given-up) stream kept reporting live
|
||||
// until the next reconcile publish, so the page grafted a freezing snapshot over
|
||||
// fresh poll data.
|
||||
// Reconcile is held far away so the drop can be observed before the reopen
|
||||
@@ -746,6 +792,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => sink.StreamLive, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream fault drops liveness immediately.</summary>
|
||||
[Fact]
|
||||
public void StreamFault_DropsLiveness_Immediately()
|
||||
{
|
||||
@@ -761,6 +808,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => !sink.StreamLive, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that consecutive reconcile ticks with no reconnect each run a fan-out.</summary>
|
||||
[Fact]
|
||||
public void ConsecutiveReconcileTicks_WithNoReconnect_EachRunAFanout()
|
||||
{
|
||||
@@ -789,6 +837,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Equal(5, seed.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a connect-driven re-seed still stands the next reconcile tick down.</summary>
|
||||
[Fact]
|
||||
public void ConnectDrivenReseed_StillStandsTheNextTickDown()
|
||||
{
|
||||
@@ -827,6 +876,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => seed.CallCount == 4, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cancellation error not caused by the actor drops liveness and reopens on the other node.</summary>
|
||||
[Fact]
|
||||
public void ForeignCancelledStreamError_DropsLiveness_AndReopensOnTheOtherNode()
|
||||
{
|
||||
@@ -854,10 +904,10 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => seed.CallCount == 2, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
// ── P3-4: the stream open attempt is deadline-bounded ───────────────────────
|
||||
// ── The stream open attempt is deadline-bounded ───────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Proof of the P3-4 starvation and of its fix.
|
||||
/// Proof of the starvation and of its fix.
|
||||
/// <para>
|
||||
/// An open attempt is cleared by exactly three peer-produced outcomes: connected, error,
|
||||
/// or graceful completion. A stream that produces NONE of them pins <c>_openInFlight</c>.
|
||||
@@ -914,6 +964,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
AwaitCondition(() => sink.StreamLive, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the open deadline does not fire on a stream that connects, flip nodes or burn the retry budget.</summary>
|
||||
[Fact]
|
||||
public void OpenDeadline_DoesNotFire_OnAStreamThatConnects_NorFlipNodeOrBurnBudget()
|
||||
{
|
||||
@@ -946,6 +997,7 @@ public class SiteAlarmAggregatorActorTests : TestKit
|
||||
Assert.Single(factory.ClientFor(GrpcNodeA).Subs);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed seed is retried with backoff before the next reconcile.</summary>
|
||||
[Fact]
|
||||
public void FailedSeed_IsRetried_WithBackoff_BeforeTheNextReconcile()
|
||||
{
|
||||
|
||||
+9
-1
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
/// <summary>
|
||||
/// Round-trip tests for the additive <c>Secrets</c> / <c>SecretsApplied</c> wire contract
|
||||
/// on <see cref="DeployArtifactsCommand"/> / <see cref="ArtifactDeploymentResponse"/>
|
||||
/// (proto field 9 / field 6). Site secrets design §3.3, task-2 of
|
||||
/// (proto field 9 / field 6). Site secrets design §3.3,
|
||||
/// docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md.
|
||||
/// </summary>
|
||||
public class SiteCommandDtoMapperSecretsTests
|
||||
@@ -16,6 +16,7 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
[1, 2, 3], [4], [5], [6, 7], [8], [9], "kek-A", 7,
|
||||
DateTimeOffset.Parse("2026-09-01T00:00:00Z"), DateTimeOffset.Parse("2026-09-02T00:00:00Z"), "d");
|
||||
|
||||
/// <summary>Verifies that deploy-artifacts secrets round-trip verbatim through the DTO mapper.</summary>
|
||||
[Fact]
|
||||
public void DeployArtifacts_Secrets_RoundTrip_Verbatim()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
Assert.Equal(7, back.Secrets[1].Revision);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null deploy-artifacts secrets stay null through the mapper.</summary>
|
||||
[Fact]
|
||||
public void DeployArtifacts_NullSecrets_StaysNull()
|
||||
{
|
||||
@@ -46,6 +48,7 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
Assert.Null(((DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(dto)).Secrets);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty deploy-artifacts secrets stay empty rather than becoming null.</summary>
|
||||
[Fact]
|
||||
public void DeployArtifacts_EmptySecrets_StaysEmpty_NotNull()
|
||||
{
|
||||
@@ -56,6 +59,7 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
Assert.Empty(back.Secrets!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a command DTO that lacks the secrets field maps to null secrets.</summary>
|
||||
[Fact]
|
||||
public void DeployArtifacts_CommandDtoWithoutField9_MapsToNullSecrets()
|
||||
{
|
||||
@@ -74,6 +78,8 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
Assert.Null(back.Secrets);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the applied-secrets count of an artifact response round-trips through the mapper.</summary>
|
||||
/// <param name="applied">The applied-secrets count, or <see langword="null"/> when the site did not report one.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(3)]
|
||||
@@ -83,6 +89,7 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
Assert.Equal(applied, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsApplied);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the removed-secrets list of an artifact response round-trips through the mapper.</summary>
|
||||
[Fact]
|
||||
public void ArtifactResponse_SecretsRemoved_RoundTrips()
|
||||
{
|
||||
@@ -91,6 +98,7 @@ public class SiteCommandDtoMapperSecretsTests
|
||||
Assert.Equal(["old-1", "old-2"], SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsRemoved!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an artifact response with no removed secrets is null on both sides of the mapper.</summary>
|
||||
[Fact]
|
||||
public void ArtifactResponse_NoSecretsRemoved_IsNullOnBothSides()
|
||||
{
|
||||
|
||||
+19
-1
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review 2026-08-19 P3-2 — a channel replaced by the 60 s DB address refresh (or an admin
|
||||
/// A channel replaced by the 60 s DB address refresh (or an admin
|
||||
/// edit) must not be disposed out from under an in-flight <see cref="SitePairChannelProvider.ExecuteAsync{T}"/>
|
||||
/// call. <c>ExecuteAsync</c> reads the channel under the pair gate but runs the RPC outside it, so an
|
||||
/// inline dispose aborted whatever was running — a multi-minute <c>DeployArtifactsCommand</c>
|
||||
@@ -19,6 +19,8 @@ public class SitePairChannelProviderDisposalTests
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<SitePairChannelProvider>.Instance);
|
||||
|
||||
/// <summary>Verifies that shutdown retains the active channel and disposes it only once the call completes.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Shutdown_RetainsActiveChannelThenDisposesItAtCompletion()
|
||||
{
|
||||
@@ -47,6 +49,8 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Throws<ObjectDisposedException>(() => channel.CreateCallInvoker());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a sweep keeps a retired channel alive until the in-flight call actually completes.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetiredChannel_SweepKeepsItAliveUntilActualCallCompletion()
|
||||
{
|
||||
@@ -77,6 +81,8 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Throws<ObjectDisposedException>(() => held!.CreateCallInvoker());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an operation that may have been applied before the channel became unavailable is not replayed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyThenUnavailable_DoesNotReplayAnAmbiguousOperation()
|
||||
{
|
||||
@@ -94,6 +100,8 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a secondary node that applies the operation but loses the response reports an unknown outcome without another replay.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SecondaryAppliesThenLosesResponse_AlsoReportsUnknownWithoutAnotherReplay()
|
||||
{
|
||||
@@ -113,6 +121,9 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Equal(1, effects);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a safe read or an explicit pre-dispatch rejection can be retried on the other node.</summary>
|
||||
/// <param name="safeRead">Whether the call is a safe read rather than an explicit pre-dispatch rejection.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -132,6 +143,8 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Equal(2, attempts);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a site during an in-flight call parks the old channel and lets the call complete.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UpdateSite_WhileCallInFlight_ParksTheOldChannelAndLetsTheCallComplete()
|
||||
{
|
||||
@@ -172,6 +185,7 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Throws<ObjectDisposedException>(() => inFlightChannel!.CreateCallInvoker());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing a site parks both channels rather than disposing them inline.</summary>
|
||||
[Fact]
|
||||
public void RemoveSite_ParksBothChannelsRatherThanDisposingThemInline()
|
||||
{
|
||||
@@ -187,6 +201,7 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Equal(0, provider.ParkedChannelCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating a site with unchanged endpoints parks no channels.</summary>
|
||||
[Fact]
|
||||
public void UpdateSite_UnchangedEndpoints_ParksNothing()
|
||||
{
|
||||
@@ -197,6 +212,7 @@ public class SitePairChannelProviderDisposalTests
|
||||
Assert.Equal(0, provider.ParkedChannelCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the provider drains parked channels regardless of the remaining grace period.</summary>
|
||||
[Fact]
|
||||
public void Dispose_DrainsParkedChannelsRegardlessOfRemainingGrace()
|
||||
{
|
||||
@@ -212,7 +228,9 @@ public class SitePairChannelProviderDisposalTests
|
||||
|
||||
private sealed class NoKeyProvider : ISitePskProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k");
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ public class SiteStreamEventBatcherTests
|
||||
|
||||
// ── Size cap ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a backlog is split into frames of at most the maximum event count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SizeCap_SplitsABacklogIntoFramesOfAtMostMaxEvents()
|
||||
{
|
||||
@@ -89,6 +91,8 @@ public class SiteStreamEventBatcherTests
|
||||
Assert.Equal(Enumerable.Range(0, 250), run.Frames.SelectMany(Seqs));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a size cap of one emits plain per-event frames, the unnegotiated shape.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SizeCapOfOne_EmitsPlainPerEventFrames_TheUnnegotiatedShape()
|
||||
{
|
||||
@@ -112,6 +116,8 @@ public class SiteStreamEventBatcherTests
|
||||
|
||||
// ── Time cap ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that an underfull batch is closed when the batching window elapses.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TimeCap_ClosesAnUnderfullBatchWhenTheWindowElapses()
|
||||
{
|
||||
@@ -152,6 +158,8 @@ public class SiteStreamEventBatcherTests
|
||||
$"batch closed after {elapsed.TotalMilliseconds:0.0} ms, before the {window.TotalMilliseconds:0} ms window");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a lone event on a quiet stream is never delayed by the batching window.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LoneEventOnAQuietStream_IsNeverDelayedByTheWindow()
|
||||
{
|
||||
@@ -187,6 +195,8 @@ public class SiteStreamEventBatcherTests
|
||||
|
||||
// ── Flush on stream close ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that completing the writer flushes the in-flight batch before the pump returns.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriterCompletion_FlushesTheInFlightBatchBeforeReturning()
|
||||
{
|
||||
@@ -213,6 +223,8 @@ public class SiteStreamEventBatcherTests
|
||||
Assert.Equal([0, 1], Seqs(run.Frames[0]));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that completing the writer with nothing buffered returns without emitting a frame.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriterCompletionWithNothingBuffered_ReturnsWithoutEmitting()
|
||||
{
|
||||
@@ -225,6 +237,8 @@ public class SiteStreamEventBatcherTests
|
||||
Assert.Empty(run.Frames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancellation ends the pump with an operation-canceled exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Cancellation_EndsThePumpWithOperationCanceled()
|
||||
{
|
||||
@@ -243,6 +257,8 @@ public class SiteStreamEventBatcherTests
|
||||
|
||||
// ── Ordering ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that event ordering is preserved across many batches while producers race.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Ordering_IsPreservedAcrossManyBatchesUnderAProducerRace()
|
||||
{
|
||||
@@ -273,6 +289,8 @@ public class SiteStreamEventBatcherTests
|
||||
|
||||
// ── Frame shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a batch frame carries the correlation id once and blanks it on the inner events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BatchFrame_CarriesTheCorrelationIdOnceAndBlanksItOnInnerEvents()
|
||||
{
|
||||
@@ -292,6 +310,8 @@ public class SiteStreamEventBatcherTests
|
||||
Assert.All(frame.Batch.Events, e => Assert.Equal(string.Empty, e.CorrelationId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that per-event timestamps survive batching.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PerEventTimestampsSurviveBatching()
|
||||
{
|
||||
|
||||
+22
-2
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Communication-007 — the factory's synchronous
|
||||
/// Regression tests: the factory's synchronous
|
||||
/// <see cref="SiteStreamGrpcClientFactory.Dispose"/> must not block on the
|
||||
/// async disposal path (sync-over-async). It must dispose each client through
|
||||
/// the client's synchronous <see cref="SiteStreamGrpcClient.Dispose"/>.
|
||||
@@ -17,11 +17,15 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
/// </summary>
|
||||
private sealed class TrackingClient : SiteStreamGrpcClient
|
||||
{
|
||||
/// <summary>Gets a value indicating whether the synchronous dispose path was called.</summary>
|
||||
public bool SyncDisposeCalled { get; private set; }
|
||||
/// <summary>Gets a value indicating whether the asynchronous dispose path was called.</summary>
|
||||
public bool AsyncDisposeCalled { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Dispose() => SyncDisposeCalled = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
AsyncDisposeCalled = true;
|
||||
@@ -37,10 +41,13 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
{
|
||||
private readonly ConcurrentBag<TrackingClient> _created = new();
|
||||
|
||||
/// <summary>Initializes the factory with a null logger factory.</summary>
|
||||
public TrackingFactory() : base(NullLoggerFactory.Instance) { }
|
||||
|
||||
/// <summary>Gets the clients this factory has created.</summary>
|
||||
public IReadOnlyCollection<TrackingClient> Created => _created.ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
var client = new TrackingClient();
|
||||
@@ -49,6 +56,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Dispose disposes clients synchronously and not via the async path.</summary>
|
||||
[Fact]
|
||||
public void Dispose_DisposesClientsSynchronously_NotViaAsyncPath()
|
||||
{
|
||||
@@ -69,12 +77,14 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
/// <summary>
|
||||
/// Test client whose disposal always throws — stands in for a client whose
|
||||
/// <c>ReleaseResources</c> hit an <see cref="ObjectDisposedException"/> on a subscription
|
||||
/// token source (arch-review 2026-08-19 P3-1).
|
||||
/// token source.
|
||||
/// </summary>
|
||||
private sealed class ThrowingClient : SiteStreamGrpcClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Dispose() => throw new ObjectDisposedException(nameof(CancellationTokenSource));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync() =>
|
||||
throw new ObjectDisposedException(nameof(CancellationTokenSource));
|
||||
}
|
||||
@@ -85,10 +95,13 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
private readonly ConcurrentBag<TrackingClient> _created = new();
|
||||
private int _next;
|
||||
|
||||
/// <summary>Initializes the factory with a null logger factory.</summary>
|
||||
public OneThrowingClientFactory() : base(NullLoggerFactory.Instance) { }
|
||||
|
||||
/// <summary>Gets the clients this factory has created.</summary>
|
||||
public IReadOnlyCollection<TrackingClient> Created => _created.ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
if (Interlocked.Increment(ref _next) == 1)
|
||||
@@ -102,6 +115,8 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DisposeAsync still disposes every other client when one client throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_OneClientThrowing_StillDisposesEveryOtherClient()
|
||||
{
|
||||
@@ -119,6 +134,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
Assert.True(c.AsyncDisposeCalled, "a throwing client must not strand its peers undisposed"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Dispose still disposes every other client when one client throws.</summary>
|
||||
[Fact]
|
||||
public void Dispose_OneClientThrowing_StillDisposesEveryOtherClient()
|
||||
{
|
||||
@@ -134,6 +150,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
Assert.True(c.SyncDisposeCalled, "a throwing client must not strand its peers undisposed"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Dispose does not deadlock under a single-threaded synchronization context.</summary>
|
||||
[Fact]
|
||||
public void Dispose_DoesNotDeadlock_UnderSingleThreadedSynchronizationContext()
|
||||
{
|
||||
@@ -173,6 +190,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
private readonly BlockingCollection<(SendOrPostCallback cb, object? state)> _queue = new();
|
||||
private readonly Thread _thread;
|
||||
|
||||
/// <summary>Initializes the synchronization context.</summary>
|
||||
public SingleThreadSyncContext()
|
||||
{
|
||||
_thread = new Thread(Run) { IsBackground = true };
|
||||
@@ -186,8 +204,10 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
cb(state);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
|
||||
|
||||
/// <summary>Releases the resources held by this instance.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_queue.CompleteAdding();
|
||||
|
||||
+24
-2
@@ -8,6 +8,7 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance;
|
||||
|
||||
/// <summary>Verifies that requesting the same site twice returns the same client.</summary>
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsSameClientForSameSite()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.Same(client1, client2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different sites get different clients.</summary>
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsDifferentClientsForDifferentSites()
|
||||
{
|
||||
@@ -30,6 +32,8 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.NotSame(client1, client2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing a site disposes its client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveSite_DisposesClient()
|
||||
{
|
||||
@@ -43,6 +47,8 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.NotSame(client1, client2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing a site that does not exist does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveSite_NonExistent_DoesNotThrow()
|
||||
{
|
||||
@@ -50,6 +56,8 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
await factory.RemoveSiteAsync("does-not-exist"); // Should not throw
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the factory disposes all clients.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_DisposesAllClients()
|
||||
{
|
||||
@@ -64,10 +72,11 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
// This tests that Dispose doesn't throw
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a changed endpoint yields a client bound to the new endpoint.</summary>
|
||||
[Fact]
|
||||
public void GetOrCreate_EndpointChanged_ReturnsClientBoundToNewEndpoint()
|
||||
{
|
||||
// Communication-012 regression: when the same site is requested with a
|
||||
// Regression: when the same site is requested with a
|
||||
// *different* endpoint (the NodeA→NodeB failover flip), the factory must
|
||||
// hand back a client bound to the new endpoint, not the stale cached one.
|
||||
using var factory = new TrackingEndpointFactory();
|
||||
@@ -80,13 +89,14 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.Equal("http://localhost:5200", nodeB.Endpoint);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a different endpoint for the same site keeps both clients alive.</summary>
|
||||
[Fact]
|
||||
public void GetOrCreate_DifferentEndpointSameSite_KeepsBothClientsAlive()
|
||||
{
|
||||
// Arch review 02 (High): the cache is keyed by (site, endpoint). Both node
|
||||
// channels for a site coexist, so a per-session NodeA→NodeB failover flip
|
||||
// must NOT dispose the other endpoint's channel — another debug session may
|
||||
// be using it. (Supersedes Communication-013's dispose-on-endpoint-change.)
|
||||
// be using it. (This supersedes the earlier dispose-on-endpoint-change behaviour.)
|
||||
using var factory = new TrackingEndpointFactory();
|
||||
|
||||
var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
@@ -97,6 +107,7 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.Same(a, factory.GetOrCreate("site-a", "http://localhost:5100")); // still cached
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TryGet returns the cached client or null without creating one.</summary>
|
||||
[Fact]
|
||||
public void TryGet_ReturnsCachedClientOrNull_WithoutCreating()
|
||||
{
|
||||
@@ -109,6 +120,8 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.Equal(1, factory.CreatedCount); // TryGet never creates
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing a site disposes every endpoint client for that site and no other.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RemoveSiteAsync_DisposesAllEndpointsForTheSite_OnlyThatSite()
|
||||
{
|
||||
@@ -124,6 +137,7 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
Assert.False(other.Disposed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that requesting the same endpoint again neither disposes nor recreates the client.</summary>
|
||||
[Fact]
|
||||
public void GetOrCreate_SameEndpoint_DoesNotDisposeOrRecreate()
|
||||
{
|
||||
@@ -140,9 +154,14 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
/// <summary>Test client that records its endpoint and disposal (no real channel).</summary>
|
||||
private sealed class TrackingEndpointClient : SiteStreamGrpcClient
|
||||
{
|
||||
/// <summary>Initializes the client for the given endpoint without opening a real channel.</summary>
|
||||
/// <param name="endpoint">The gRPC endpoint the client is bound to.</param>
|
||||
public TrackingEndpointClient(string endpoint) : base(endpoint) { }
|
||||
/// <summary>Gets a value indicating whether the client has been disposed.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
/// <inheritdoc />
|
||||
public override void Dispose() => Disposed = true;
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
Disposed = true;
|
||||
@@ -153,8 +172,11 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
/// <summary>Factory that hands out endpoint-tracking clients.</summary>
|
||||
private sealed class TrackingEndpointFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
/// <summary>Initializes the factory with a null logger factory.</summary>
|
||||
public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { }
|
||||
/// <summary>Gets the number of clients this factory has created.</summary>
|
||||
public int CreatedCount { get; private set; }
|
||||
/// <inheritdoc />
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
CreatedCount++;
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class SiteStreamGrpcClientTests
|
||||
{
|
||||
/// <summary>Verifies that the connected-header timeout is fixed for the lifetime of a client.</summary>
|
||||
[Fact]
|
||||
public void ConnectedHeaderTimeout_IsImmutablePerClient()
|
||||
{
|
||||
@@ -17,6 +18,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Null(property.SetMethod);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an attribute-changed stream event converts to the matching domain event.</summary>
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly()
|
||||
{
|
||||
@@ -46,6 +48,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(ts, attr.Timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm-changed stream event converts to the matching domain event.</summary>
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AlarmChanged_MapsCorrectly()
|
||||
{
|
||||
@@ -104,6 +107,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal("100", alarm.LimitValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown stream event converts to null.</summary>
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_UnknownEvent_ReturnsNull()
|
||||
{
|
||||
@@ -118,6 +122,9 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each gRPC quality value maps to the expected quality string.</summary>
|
||||
/// <param name="input">The gRPC quality value.</param>
|
||||
/// <param name="expected">The expected quality string.</param>
|
||||
[Theory]
|
||||
[InlineData(Quality.Good, "Good")]
|
||||
[InlineData(Quality.Uncertain, "Uncertain")]
|
||||
@@ -129,6 +136,9 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each gRPC alarm state maps to the expected domain alarm state.</summary>
|
||||
/// <param name="input">The gRPC alarm state.</param>
|
||||
/// <param name="expected">The expected domain alarm state.</param>
|
||||
[Theory]
|
||||
[InlineData(AlarmStateEnum.AlarmStateNormal, AlarmState.Normal)]
|
||||
[InlineData(AlarmStateEnum.AlarmStateActive, AlarmState.Active)]
|
||||
@@ -139,6 +149,9 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each gRPC alarm level maps to the expected domain alarm level.</summary>
|
||||
/// <param name="input">The gRPC alarm level.</param>
|
||||
/// <param name="expected">The expected domain alarm level.</param>
|
||||
[Theory]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelNone, AlarmLevel.None)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelLow, AlarmLevel.Low)]
|
||||
@@ -151,6 +164,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an alarm-changed event preserves the alarm level.</summary>
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AlarmChanged_PreservesLevel()
|
||||
{
|
||||
@@ -177,6 +191,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(900, domain.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing cancels the subscription.</summary>
|
||||
[Fact]
|
||||
public void Unsubscribe_CancelsSubscription()
|
||||
{
|
||||
@@ -195,6 +210,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.True(cts.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing from a nonexistent subscription does not throw.</summary>
|
||||
[Fact]
|
||||
public void Unsubscribe_NonExistent_DoesNotThrow()
|
||||
{
|
||||
@@ -202,6 +218,8 @@ public class SiteStreamGrpcClientTests
|
||||
client.Unsubscribe("does-not-exist"); // Should not throw
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the client cancels all subscriptions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_CancelsAllSubscriptions()
|
||||
{
|
||||
@@ -218,8 +236,9 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.True(cts2.IsCancellationRequested);
|
||||
}
|
||||
|
||||
// --- Site-wide (SubscribeSite) alarm-only stream (plan #10 T3) ---
|
||||
// --- Site-wide (SubscribeSite) alarm-only stream ---
|
||||
|
||||
/// <summary>Verifies that an alarm-changed event converts to the mapped alarm event.</summary>
|
||||
[Fact]
|
||||
public void ConvertToAlarmEvent_AlarmChanged_ReturnsMappedAlarm()
|
||||
{
|
||||
@@ -251,6 +270,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal("T01.Hi", alarm.SourceReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an attribute-changed event converts to no alarm event.</summary>
|
||||
[Fact]
|
||||
public void ConvertToAlarmEvent_AttributeChanged_ReturnsNull()
|
||||
{
|
||||
@@ -273,6 +293,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Null(SiteStreamGrpcClient.ConvertToAlarmEvent(evt));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown event converts to no alarm event.</summary>
|
||||
[Fact]
|
||||
public void ConvertToAlarmEvent_UnknownEvent_ReturnsNull()
|
||||
{
|
||||
@@ -280,6 +301,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Null(SiteStreamGrpcClient.ConvertToAlarmEvent(evt));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribing a site on a test-only client throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSiteAsync_OnTestOnlyClient_Throws()
|
||||
{
|
||||
@@ -290,8 +313,10 @@ public class SiteStreamGrpcClientTests
|
||||
client.SubscribeSiteAsync("corr", _ => { }, _ => { }, () => { }, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- WP1.1: graceful (status OK) stream completion is reported, not swallowed ---
|
||||
// --- Graceful (status OK) stream completion is reported, not swallowed ---
|
||||
|
||||
/// <summary>Verifies that a stream the server ends with an OK status invokes the completed callback and not the error callback.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_ServerEndsStreamWithOk_InvokesOnCompleted_NotOnError()
|
||||
{
|
||||
@@ -316,6 +341,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(1, completed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulting stream invokes the error callback and not the completed callback.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_StreamFaults_InvokesOnError_NotOnCompleted()
|
||||
{
|
||||
@@ -337,6 +364,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(0, completed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancelling the client's own token invokes neither the error nor the completed callback.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_OwnCancellation_InvokesNeitherCallback()
|
||||
{
|
||||
@@ -362,6 +391,8 @@ public class SiteStreamGrpcClientTests
|
||||
|
||||
// --- Foreign vs. own Cancelled (review F2) ---
|
||||
|
||||
/// <summary>Verifies that a cancellation not initiated by the client invokes the error callback.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_ForeignCancelled_InvokesOnError()
|
||||
{
|
||||
@@ -390,6 +421,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.False(cts.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cancelled RPC exception caused by the client's own cancellation invokes neither callback.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_OwnCancelledRpcException_InvokesNeitherCallback()
|
||||
{
|
||||
@@ -415,6 +448,8 @@ public class SiteStreamGrpcClientTests
|
||||
|
||||
// --- onConnected is only ever raised on real proof of a live peer (review F3) ---
|
||||
|
||||
/// <summary>Verifies that the connected callback is invoked exactly once when the response headers arrive.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_HeadersArrive_InvokesOnConnectedOnce()
|
||||
{
|
||||
@@ -439,6 +474,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(1, connected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a header timeout on a dead site never reports a connected state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_HeaderTimeoutOnADeadSite_NeverReportsConnected()
|
||||
{
|
||||
@@ -464,6 +501,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(0, connected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a header timeout followed by an event reports connected exactly once, triggered by the event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_HeaderTimeoutThenEvent_ReportsConnectedOnceFromTheEvent()
|
||||
{
|
||||
@@ -514,6 +553,7 @@ public class SiteStreamGrpcClientTests
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Verifies that a plain frame is delivered unchanged.</summary>
|
||||
[Fact]
|
||||
public void ForEachEvent_PlainFrame_IsDeliveredAsIs()
|
||||
{
|
||||
@@ -528,6 +568,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Same(frame, Assert.Single(seen));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch frame is unpacked in order with each event's own timestamp preserved.</summary>
|
||||
[Fact]
|
||||
public void ForEachEvent_BatchFrame_UnpacksInOrderPreservingPerEventTimestamps()
|
||||
{
|
||||
@@ -558,6 +599,7 @@ public class SiteStreamGrpcClientTests
|
||||
seen.Select(e => e.AttributeChanged.Timestamp.ToDateTimeOffset()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nested batches and unknown inner cases are ignored.</summary>
|
||||
[Fact]
|
||||
public void ForEachEvent_IgnoresNestedAndUnknownInnerCases()
|
||||
{
|
||||
@@ -585,6 +627,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal(["0", "1"], seen.Select(e => e.AttributeChanged.Value));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream mixing batched and plain frames delivers every event in order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConsumeStream_MixedBatchedAndPlainFrames_DeliverEveryEventInOrder()
|
||||
{
|
||||
@@ -638,19 +682,27 @@ public class SiteStreamGrpcClientTests
|
||||
private readonly Queue<SiteStreamEvent> _events;
|
||||
private readonly Exception? _fault;
|
||||
|
||||
/// <summary>Initializes the reader to yield the given events and then end the stream.</summary>
|
||||
/// <param name="events">The events to yield.</param>
|
||||
public StubStreamReader(params SiteStreamEvent[] events)
|
||||
{
|
||||
_events = new Queue<SiteStreamEvent>(events);
|
||||
}
|
||||
|
||||
/// <summary>Initializes the reader to throw the given fault when read.</summary>
|
||||
/// <param name="fault">The exception to throw.</param>
|
||||
public StubStreamReader(Exception fault)
|
||||
: this()
|
||||
{
|
||||
_fault = fault;
|
||||
}
|
||||
|
||||
/// <summary>Gets the event most recently read.</summary>
|
||||
public SiteStreamEvent Current { get; private set; } = null!;
|
||||
|
||||
/// <summary>Advances to the next queued event, or faults or completes as configured.</summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A task whose result is <see langword="true"/> if an event was read; otherwise <see langword="false"/>.</returns>
|
||||
public Task<bool> MoveNext(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -665,14 +717,15 @@ public class SiteStreamGrpcClientTests
|
||||
}
|
||||
}
|
||||
|
||||
// --- Communication-003 regression tests ---
|
||||
// --- Subscription registration regression tests ---
|
||||
|
||||
/// <summary>Verifies that reusing a correlation id cancels the prior token source without disposing it.</summary>
|
||||
[Fact]
|
||||
public void RegisterSubscription_ReusedCorrelationId_CancelsPriorCts_WithoutDisposingIt()
|
||||
{
|
||||
// Two SubscribeAsync calls briefly sharing a correlation ID (reconnect race).
|
||||
// Inserting the second must CANCEL the first — and must NOT dispose it (arch-review
|
||||
// 2026-08-19 P3-1, re-pinning the earlier cancel-AND-dispose contract): the prior
|
||||
// Inserting the second must CANCEL the first — and must NOT dispose it (re-pinning the earlier
|
||||
// cancel-AND-dispose contract): the prior
|
||||
// handler's ConsumeStreamAsync is still running and still reads prior.Token, so
|
||||
// disposing from the replacing path throws ObjectDisposedException inside that loop.
|
||||
// The owning SubscribeAsync/SubscribeSiteAsync disposes it as its loop unwinds.
|
||||
@@ -692,6 +745,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.False(second.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that replacing an already-disposed prior token source does not throw into the new subscription.</summary>
|
||||
[Fact]
|
||||
public void RegisterSubscription_ReplacingAnAlreadyDisposedPriorCts_DoesNotThrowIntoTheNewSubscription()
|
||||
{
|
||||
@@ -714,6 +768,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.True(replacement.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing after the client is disposed does nothing.</summary>
|
||||
[Fact]
|
||||
public void Unsubscribe_AfterClientDisposal_IsANoOp()
|
||||
{
|
||||
@@ -731,6 +786,7 @@ public class SiteStreamGrpcClientTests
|
||||
client.Unsubscribe("never-registered");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the client still cancels the remaining subscriptions when one token source is already disposed.</summary>
|
||||
[Fact]
|
||||
public void Dispose_WithAnAlreadyDisposedSubscriptionCts_StillCancelsTheRest()
|
||||
{
|
||||
@@ -753,6 +809,7 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.True(live.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing a subscription removes only its own token source and not a replacement registered under the same id.</summary>
|
||||
[Fact]
|
||||
public void RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
private readonly ISiteStreamSubscriber _subscriber;
|
||||
private readonly ILogger<SiteStreamGrpcServer> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>SiteStreamGrpcServerTests</c> class.</summary>
|
||||
public SiteStreamGrpcServerTests()
|
||||
{
|
||||
_subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
@@ -47,6 +48,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream request is rejected while the server is not ready.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RejectsWhenNotReady()
|
||||
{
|
||||
@@ -62,6 +65,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream request is rejected once the maximum number of streams is reached.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RejectsWhenMaxStreamsReached()
|
||||
{
|
||||
@@ -93,6 +98,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
await stream1Task;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a second stream with the same correlation id cancels the first.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CancelsDuplicateCorrelationId()
|
||||
{
|
||||
@@ -128,6 +135,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
await stream2Task;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancelling a stream cleans up its server-side state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CleansUpOnCancellation()
|
||||
{
|
||||
@@ -149,12 +158,14 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
// --- Host-017 / REQ-HOST-7: site-shutdown ordering ---
|
||||
// --- Site-shutdown ordering ---
|
||||
|
||||
/// <summary>Verifies that cancelling all streams cancels the active streams and refuses new ones.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Host017_CancelAllStreams_CancelsActiveStreamsAndRefusesNewOnes()
|
||||
{
|
||||
// REQ-HOST-7 step (1)+(2): on CoordinatedShutdown the gRPC server must
|
||||
// Shutdown steps (1)+(2): on CoordinatedShutdown the gRPC server must
|
||||
// stop accepting new streams AND cancel every active stream so the
|
||||
// client observes a clean Cancelled (not a silent stream that only
|
||||
// times out via keepalive). Program.cs registers
|
||||
@@ -192,6 +203,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Contains("shutting", ex.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancelling all streams a second time has no further effect.</summary>
|
||||
[Fact]
|
||||
public void Host017_CancelAllStreams_IsIdempotent()
|
||||
{
|
||||
@@ -206,6 +218,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stream subscribes to the stream manager and is removed from it afterwards.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribesAndRemovesFromStreamManager()
|
||||
{
|
||||
@@ -231,6 +245,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
_subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that published events are written to the response stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WritesEventsToResponseStream()
|
||||
{
|
||||
@@ -275,11 +291,13 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
await streamTask;
|
||||
}
|
||||
|
||||
// --- SubscribeSite (site-wide, alarm-only aggregated stream, plan #10 T2) ---
|
||||
// --- SubscribeSite (site-wide, alarm-only aggregated stream) ---
|
||||
|
||||
private static SiteStreamRequest MakeSiteRequest(string correlationId = "site-corr-1")
|
||||
=> new() { CorrelationId = correlationId };
|
||||
|
||||
/// <summary>Verifies that a site subscription subscribes to site alarms and is removed on cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSite_SubscribesSiteAlarmsAndRemovesOnCancel()
|
||||
{
|
||||
@@ -307,6 +325,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site subscription rejects an unsafe correlation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSite_RejectsUnsafeCorrelationId()
|
||||
{
|
||||
@@ -323,6 +343,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site subscription relays an alarm state change as an alarm state update.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSite_RelaysAlarmStateChangedAsAlarmStateUpdate()
|
||||
{
|
||||
@@ -371,6 +393,9 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
await streamTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a correlation id that is not safe as an actor name is rejected.</summary>
|
||||
/// <param name="badCorrelationId">A correlation id that is not safe to use as an actor name.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("corr/with/slash")]
|
||||
[InlineData("corr with space")]
|
||||
@@ -378,7 +403,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
[InlineData("$weird")]
|
||||
public async Task RejectsCorrelationIdThatIsNotActorNameSafe(string badCorrelationId)
|
||||
{
|
||||
// Communication-014 regression: a public gRPC SubscribeInstance must not feed
|
||||
// Regression: a public gRPC SubscribeInstance must not feed
|
||||
// an untrusted correlation_id straight into an Akka actor name. An unsafe id
|
||||
// must be rejected cleanly with InvalidArgument rather than escaping as an
|
||||
// unhandled InvalidActorNameException.
|
||||
@@ -395,6 +420,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a correlation id that is safe as an actor name is accepted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcceptsActorNameSafeCorrelationId()
|
||||
{
|
||||
@@ -415,10 +442,12 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
await streamTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when subscribing throws, the relay actor is stopped and the active stream entry is removed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Comm021_SubscribeThrows_StopsRelayActorAndRemovesActiveStreamEntry()
|
||||
{
|
||||
// Communication-021 regression: SubscribeInstance creates a StreamRelayActor
|
||||
// Regression: SubscribeInstance creates a StreamRelayActor
|
||||
// and registers an _activeStreams entry BEFORE calling _streamSubscriber.Subscribe.
|
||||
// If Subscribe throws (e.g. stale instance, site runtime shutting down) and the
|
||||
// pre-fix code lets the throw escape without the wrapping try, the relay actor
|
||||
@@ -449,6 +478,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
subscriber.DidNotReceive().RemoveSubscriber(Arg.Any<IActorRef>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the active subscribers gauge rises to one on connect and returns to zero on cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SiteStreamSubscribersActiveGauge_GoesToOneOnConnect_AndBackToZeroOnCancel()
|
||||
{
|
||||
@@ -512,6 +543,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
AwaitAssert(() => Assert.Equal(baseline, ReadGauge()), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking the server ready allows streams to be created.</summary>
|
||||
[Fact]
|
||||
public void SetReady_AllowsStreamCreation()
|
||||
{
|
||||
@@ -540,8 +572,9 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
|
||||
}
|
||||
|
||||
// ── WP2.3: the site-wide alarm feed gets its OWN, larger send channel ──
|
||||
// ── The site-wide alarm feed gets its OWN, larger send channel ──
|
||||
|
||||
/// <summary>Verifies that the site alarm stream gets its own channel, larger than the debug view channel.</summary>
|
||||
[Fact]
|
||||
public void SiteAlarmStream_HasItsOwnLargerChannel_ThanTheDebugView()
|
||||
{
|
||||
@@ -556,6 +589,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.True(server.SiteAlarmChannelCapacity > server.InstanceChannelCapacity);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that channel capacities are bound from options and never drop below one.</summary>
|
||||
[Fact]
|
||||
public void ChannelCapacities_AreBoundFromOptions_AndFloorAtOne()
|
||||
{
|
||||
@@ -581,6 +615,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(1, degenerate.SiteAlarmChannelCapacity);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the dropped stream event count starts at zero.</summary>
|
||||
[Fact]
|
||||
public void DroppedStreamEventCount_StartsAtZero()
|
||||
{
|
||||
@@ -590,6 +625,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(0, server.DroppedStreamEventCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate replacement cancels the replaced stream without disposing its cancellation token source.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DuplicateReplacement_CancelsTheReplacedStream_WithoutDisposingItsCts()
|
||||
{
|
||||
@@ -653,6 +690,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
|
||||
// ── R2: gRPC event batching, and its negotiation ────────────────────────────
|
||||
|
||||
/// <summary>Verifies that batch options are bound from configuration and degenerate values are clamped.</summary>
|
||||
[Fact]
|
||||
public void BatchOptions_AreBoundFromOptions_AndClampDegenerateValues()
|
||||
{
|
||||
@@ -674,6 +712,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
Assert.Equal(TimeSpan.Zero, degenerate.StreamBatchWindow);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a subscription that did not negotiate batching never receives a batch frame.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnnegotiatedSubscription_NeverEmitsABatchFrame()
|
||||
{
|
||||
@@ -704,6 +744,8 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batching subscription coalesces a backlog into fewer frames than events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NegotiatedSubscription_CoalescesABacklogIntoFewerFramesThanEvents()
|
||||
{
|
||||
@@ -733,13 +775,15 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
$"batching produced {frames.Count} frames for {burst} events — no coalescing happened");
|
||||
Assert.Contains(frames, f => f.EventCase == SiteStreamEvent.EventOneofCase.Batch);
|
||||
|
||||
// Order is preserved end to end: the values arrive 0..burst-1 exactly once each.
|
||||
// Order is preserved end to end: the values arrive 0 to burst minus 1 exactly once each.
|
||||
var values = frames.SelectMany(FlattenAttributeValues).ToArray();
|
||||
Assert.Equal(Enumerable.Range(0, burst).Select(i => i.ToString()), values);
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the batch size histogram is recorded only for streams that negotiated batching.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BatchSizeHistogram_IsRecordedOnlyForNegotiatedStreams()
|
||||
{
|
||||
@@ -795,7 +839,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
// ── P3-5: the concurrency cap must never refuse a REPLACEMENT ───────────────
|
||||
// ── The concurrency cap must never refuse a REPLACEMENT ───────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ServerCallContext"/> whose <see cref="ServerCallContext.CancellationToken"/>
|
||||
@@ -811,24 +855,38 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
private readonly Metadata _trailers = new();
|
||||
private readonly Dictionary<object, object> _userState = new();
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>GatedServerCallContext</c> class.</summary>
|
||||
/// <param name="token">The cancellation token the context exposes.</param>
|
||||
/// <param name="onCancellationTokenRead">Callback invoked when the cancellation token is read.</param>
|
||||
public GatedServerCallContext(CancellationToken token, Action onCancellationTokenRead)
|
||||
{
|
||||
_token = token;
|
||||
_onCancellationTokenRead = onCancellationTokenRead;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MethodCore => "/sitestream.SiteStreamService/SubscribeInstance";
|
||||
/// <inheritdoc />
|
||||
protected override string HostCore => "localhost";
|
||||
/// <inheritdoc />
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:0";
|
||||
/// <inheritdoc />
|
||||
protected override DateTime DeadlineCore => DateTime.MaxValue;
|
||||
/// <inheritdoc />
|
||||
protected override Metadata RequestHeadersCore => Metadata.Empty;
|
||||
/// <inheritdoc />
|
||||
protected override Metadata ResponseTrailersCore => _trailers;
|
||||
/// <inheritdoc />
|
||||
protected override GrpcStatus StatusCore { get; set; }
|
||||
/// <inheritdoc />
|
||||
protected override WriteOptions? WriteOptionsCore { get; set; }
|
||||
/// <inheritdoc />
|
||||
protected override AuthContext AuthContextCore =>
|
||||
new(null, new Dictionary<string, List<AuthProperty>>());
|
||||
/// <inheritdoc />
|
||||
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override CancellationToken CancellationTokenCore
|
||||
{
|
||||
get
|
||||
@@ -838,17 +896,21 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate reconnect at the cap is not refused and leaves exactly one live stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AtCapOvershoot_DuplicateReconnect_IsNotRefused_AndLeavesExactlyOneLiveStream()
|
||||
{
|
||||
// P3-5. The duplicate-replacement path cancels the incumbent; the cap check then had
|
||||
// The duplicate-replacement path cancels the incumbent; the cap check then had
|
||||
// to decide whether the REPLACEMENT may take its place. Checking `Count >= max` after
|
||||
// the removal is right at exactly the cap but wrong above it — and above it is
|
||||
// reachable, because the cap check is documented check-then-act: two subscribes that
|
||||
@@ -913,10 +975,12 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a genuinely new subscriber at the cap is still refused and the incumbent survives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AtCap_AGenuinelyNewSubscriber_IsStillRefused_AndTheIncumbentSurvives()
|
||||
{
|
||||
// The other half of P3-5: excluding replacements from the cap must NOT weaken the cap
|
||||
// The other half of that rule: excluding replacements from the cap must NOT weaken the cap
|
||||
// for a new correlation id, and a refusal must leave the existing streams untouched
|
||||
// (TryRemove found nothing, so nothing was cancelled before the throw).
|
||||
var server = CreateServer(maxStreams: 1);
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class StreamRelayActorTests : TestKit
|
||||
{
|
||||
/// <summary>Verifies that an attribute value change is relayed as a proto event.</summary>
|
||||
[Fact]
|
||||
public void RelaysAttributeValueChanged_ToProtoEvent()
|
||||
{
|
||||
@@ -49,6 +50,7 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), attr.Timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm state change is relayed as a proto event.</summary>
|
||||
[Fact]
|
||||
public void RelaysAlarmStateChanged_ToProtoEvent()
|
||||
{
|
||||
@@ -113,10 +115,11 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.Equal("90", alarm.LimitValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the native binding linkage of an alarm state change survives a full round trip.</summary>
|
||||
[Fact]
|
||||
public void RelaysAlarmStateChanged_NativeBindingLinkage_SurvivesFullRoundTrip()
|
||||
{
|
||||
// DV-1: the native source-binding canonical name must pack into AlarmStateUpdate
|
||||
// The native source-binding canonical name must pack into AlarmStateUpdate
|
||||
// (StreamRelayActor) and unpack back out (SiteStreamGrpcClient.ConvertToDomainEvent)
|
||||
// — the full cross-process round trip for a real (non-placeholder) native alarm.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
@@ -159,6 +162,7 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.False(roundTripped.IsConfiguredPlaceholder);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the acknowledgement time of an alarm state change survives a full round trip.</summary>
|
||||
[Fact]
|
||||
public void RelaysAlarmStateChanged_AckTime_SurvivesFullRoundTrip()
|
||||
{
|
||||
@@ -222,6 +226,7 @@ public class StreamRelayActorTests : TestKit
|
||||
return protoEvent!;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm state change for a configured placeholder is dropped.</summary>
|
||||
[Fact]
|
||||
public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder()
|
||||
{
|
||||
@@ -255,6 +260,7 @@ public class StreamRelayActorTests : TestKit
|
||||
"Placeholder AlarmStateChanged must not be relayed to the gRPC stream");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the correlation id is set on all relayed events.</summary>
|
||||
[Fact]
|
||||
public void SetsCorrelationId_OnAllEvents()
|
||||
{
|
||||
@@ -282,6 +288,7 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.All(events, e => Assert.Equal(correlationId, e.CorrelationId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an event is dropped when the channel is full.</summary>
|
||||
[Fact]
|
||||
public void DropsEvent_WhenChannelFull()
|
||||
{
|
||||
@@ -311,6 +318,9 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.False(channel.Reader.TryRead(out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a quality string is mapped to the matching proto enum value.</summary>
|
||||
/// <param name="qualityString">The quality string on the domain event.</param>
|
||||
/// <param name="expectedProto">The expected proto quality value.</param>
|
||||
[Theory]
|
||||
[InlineData("Good", Quality.Good)]
|
||||
[InlineData("Uncertain", Quality.Uncertain)]
|
||||
@@ -332,6 +342,7 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.Equal(expectedProto, evt.AttributeChanged.Quality);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null value is mapped to an empty string.</summary>
|
||||
[Fact]
|
||||
public void NullValue_MapsToEmptyString()
|
||||
{
|
||||
@@ -355,6 +366,7 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.Equal("", evt.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list value is encoded as a JSON array.</summary>
|
||||
[Fact]
|
||||
public void ListValue_EncodesAsJsonArray()
|
||||
{
|
||||
@@ -372,6 +384,7 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.Equal("[\"a\",\"b\"]", evt.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar string value passes through unchanged.</summary>
|
||||
[Fact]
|
||||
public void ScalarStringValue_PassesThroughUnchanged()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user