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:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -11,6 +11,8 @@ public class ActiveNodeEvaluatorTests : IAsyncLifetime
{
private ActorSystem? _system;
/// <summary>Starts the test actor system and cluster before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
var port = FreePort();
@@ -31,8 +33,11 @@ akka {{
await Task.Delay(100);
}
/// <summary>Terminates the test actor system.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync() { if (_system != null) await _system.Terminate(); }
/// <summary>Verifies that the sole Up member is considered the oldest Up member.</summary>
[Fact]
public void SelfIsOldestUp_SoleUpMember_ReturnsTrue()
{
@@ -41,6 +46,7 @@ akka {{
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(cluster, "site-x"));
}
/// <summary>Verifies that a node that does not hold the requested role is not considered the oldest Up member.</summary>
[Fact]
public void SelfIsOldestUp_RoleNotHeld_ReturnsFalse()
{
@@ -30,6 +30,7 @@ public class SiteCommunicationActorTransportTests : TestKit
return (actor, transport);
}
/// <summary>Verifies that a notification submit is delegated to the transport with the sender as the reply target.</summary>
[Fact]
public void NotificationSubmit_DelegatesToTransport_WithSenderAsReplyTo()
{
@@ -43,6 +44,7 @@ public class SiteCommunicationActorTransportTests : TestKit
Arg.Is<NotificationSubmit>(m => m.NotificationId == "notif-1"), Arg.Is(TestActor)));
}
/// <summary>Verifies that a notification status query is delegated to the transport with the sender as the reply target.</summary>
[Fact]
public void NotificationStatusQuery_DelegatesToTransport_WithSenderAsReplyTo()
{
@@ -54,6 +56,7 @@ public class SiteCommunicationActorTransportTests : TestKit
Arg.Is<NotificationStatusQuery>(m => m.NotificationId == "notif-1"), Arg.Is(TestActor)));
}
/// <summary>Verifies that an audit event ingest is delegated to the transport with the sender as the reply target.</summary>
[Fact]
public void IngestAuditEvents_DelegatesToTransport_WithSenderAsReplyTo()
{
@@ -64,6 +67,7 @@ public class SiteCommunicationActorTransportTests : TestKit
AwaitAssert(() => transport.Received(1).IngestAuditEvents(Arg.Any<IngestAuditEventsCommand>(), Arg.Is(TestActor)));
}
/// <summary>Verifies that a cached telemetry ingest is delegated to the transport with the sender as the reply target.</summary>
[Fact]
public void IngestCachedTelemetry_DelegatesToTransport_WithSenderAsReplyTo()
{
@@ -74,6 +78,7 @@ public class SiteCommunicationActorTransportTests : TestKit
AwaitAssert(() => transport.Received(1).IngestCachedTelemetry(Arg.Any<IngestCachedTelemetryCommand>(), Arg.Is(TestActor)));
}
/// <summary>Verifies that a site reconciliation request is delegated to the transport with the sender as the reply target.</summary>
[Fact]
public void ReconcileSite_DelegatesToTransport_WithSenderAsReplyTo()
{
@@ -86,6 +91,7 @@ public class SiteCommunicationActorTransportTests : TestKit
Arg.Is<ReconcileSiteRequest>(m => m.NodeId == "node-a"), Arg.Is(TestActor)));
}
/// <summary>Verifies that a site health report is delegated to the transport with the sender as the reply target.</summary>
[Fact]
public void ReportSiteHealth_DelegatesToTransport_WithSenderAsReplyTo()
{
@@ -98,6 +104,7 @@ public class SiteCommunicationActorTransportTests : TestKit
Arg.Is<SiteHealthReport>(m => m.SequenceNumber == 7), Arg.Is(TestActor)));
}
/// <summary>Verifies that a transport failure reply is routed back to the waiting sender.</summary>
[Fact]
public void TransportFailureReply_RoutesBackToTheWaitingSender()
{
@@ -122,6 +129,7 @@ public class SiteCommunicationActorTransportTests : TestKit
Assert.IsType<InvalidOperationException>(failure.Cause);
}
/// <summary>Verifies that an exception thrown by the transport during a heartbeat does not fault the actor.</summary>
[Fact]
public void HeartbeatTransportThrow_DoesNotFaultTheActor()
{
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// the canonical <see cref="AuditEvent"/> ↔ <see cref="AuditEventDto"/> (proto).
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the canonical record carries the ScadaBridge domain fields inside
/// The canonical record carries the ScadaBridge domain fields inside
/// <c>DetailsJson</c>; the proto contract is unchanged (24-field wire). Domain fields
/// are read back as typed properties via <c>AsRow()</c>. <c>ForwardState</c> is
/// site-storage-only (never on the wire) and <c>IngestedAtUtc</c> is central-set
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </remarks>
public class AuditEventDtoMapperTests
{
/// <summary>Verifies that a fully populated event survives a DTO round trip with every field preserved.</summary>
[Fact]
public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields()
{
@@ -88,6 +89,7 @@ public class AuditEventDtoMapperTests
Assert.Null(rt.IngestedAtUtc);
}
/// <summary>Verifies that null string fields map to empty strings in the DTO.</summary>
[Fact]
public void ToDto_NullableStringFields_BecomeEmptyString()
{
@@ -115,6 +117,7 @@ public class AuditEventDtoMapperTests
Assert.Equal(string.Empty, dto.Extra);
}
/// <summary>Verifies that empty strings in the DTO map back to null properties.</summary>
[Fact]
public void FromDto_EmptyString_BecomesNullProperty()
{
@@ -159,6 +162,7 @@ public class AuditEventDtoMapperTests
Assert.Null(evt.Extra);
}
/// <summary>Verifies that the occurrence timestamp keeps its UTC kind through mapping.</summary>
[Fact]
public void ToDto_OccurredAtUtc_PreservesUtcKind()
{
@@ -176,6 +180,7 @@ public class AuditEventDtoMapperTests
Assert.Equal(occurredAt, roundTripped.OccurredAtUtc);
}
/// <summary>Verifies that a null integer maps to a null wrapped Int32 value.</summary>
[Fact]
public void ToDto_NullableInt_BecomesNullInt32Value()
{
@@ -192,6 +197,7 @@ public class AuditEventDtoMapperTests
Assert.Null(dto.DurationMs);
}
/// <summary>Verifies that a null wrapped Int32 value maps back to a null property.</summary>
[Fact]
public void FromDto_NullInt32Value_BecomesNullProperty()
{
@@ -214,6 +220,7 @@ public class AuditEventDtoMapperTests
Assert.Null(evt.DurationMs);
}
/// <summary>Verifies that enum values are stored in the DTO as their string names.</summary>
[Fact]
public void ToDto_EnumValues_StoredAsStringNames()
{
@@ -229,6 +236,7 @@ public class AuditEventDtoMapperTests
Assert.Equal("Parked", dto.Status);
}
/// <summary>Verifies that a DTO round trip preserves the source node.</summary>
[Fact]
public void AuditEventDto_round_trip_preserves_SourceNode()
{
@@ -249,6 +257,7 @@ public class AuditEventDtoMapperTests
Assert.Equal("node-a", roundTripped.SourceNode);
}
/// <summary>Verifies that a DTO round trip preserves a null source node.</summary>
[Fact]
public void AuditEventDto_round_trip_preserves_null_SourceNode()
{
@@ -270,7 +279,7 @@ public class AuditEventDtoMapperTests
}
/// <summary>
/// C3 hardening (Task 2.5): a DTO that carries an unknown/renamed enum string
/// Hardening: a DTO that carries an unknown/renamed enum string
/// for Channel, Kind, or Status must NOT throw on <see cref="AuditEventDtoMapper.FromDto"/>;
/// it degrades gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c>
/// (ApiInbound / InboundRequest / Submitted).
@@ -20,10 +20,11 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary>
public class CentralCommunicationActorClientLifecycleTests : TestKit
{
/// <summary>Verifies that periodic refresh prunes deleted sites from health aggregator.</summary>
[Fact]
public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator()
{
// PLAN-01 Task 18: the periodic site refresh feeds the currently-configured
// The periodic site refresh feeds the currently-configured
// site ids to the aggregator so a deleted site is evicted within one
// refresh interval. Repo returns only "site-a".
var siteRepo = Substitute.For<ISiteRepository>();
@@ -21,6 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary>
public class CentralCommunicationActorReconcileTests : TestKit
{
/// <summary>Verifies that the response to a reconcile request is routed back to the original sender.</summary>
[Fact]
public void ReconcileSiteRequest_RoutesResponseToSender()
{
@@ -26,6 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary>
public class CentralCommunicationActorTests : TestKit
{
/// <summary>Initializes a new instance of the <see cref="CentralCommunicationActorTests"/> class with debug-level Akka logging.</summary>
public CentralCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { }
private (IActorRef actor, ISiteRepository mockRepo) CreateActorWithMockRepo(
@@ -44,6 +45,7 @@ public class CentralCommunicationActorTests : TestKit
return (actor, mockRepo);
}
/// <summary>Verifies that a heartbeat bumps the aggregator timestamp for the site.</summary>
[Fact]
public void Heartbeat_BumpsAggregatorTimestamp()
{
@@ -68,10 +70,11 @@ public class CentralCommunicationActorTests : TestKit
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site1", timestamp));
}
/// <summary>Verifies that a replicated heartbeat marks the local aggregator without being rebroadcast.</summary>
[Fact]
public void HeartbeatReplica_MarksLocalAggregator_WithoutRebroadcast()
{
// Task 16 (arch review 02): a heartbeat replicated from the peer central
// A heartbeat replicated from the peer central
// node marks the local aggregator (so post-failover both nodes know the
// site's liveness) but is NOT re-published (it arrives via pub-sub already).
var mockRepo = Substitute.For<ISiteRepository>();
@@ -95,10 +98,11 @@ public class CentralCommunicationActorTests : TestKit
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts));
}
/// <summary>Verifies that a failure loading site addresses is logged rather than silently swallowed.</summary>
[Fact]
public void LoadSiteAddressesFailure_IsLoggedNotSilentlySwallowed()
{
// Regression test for Communication-006. When the repository query throws,
// Regression test: when the repository query throws,
// PipeTo delivers a Status.Failure to the actor. Without a Receive<Status.Failure>
// handler the failure becomes an unhandled message (debug-level only) and the
// periodic refresh fails silently — operators cannot tell "no addresses
@@ -123,6 +127,7 @@ public class CentralCommunicationActorTests : TestKit
private NotificationSubmit CreateSubmit(string id = "notif1") =>
new(id, "ops-list", "Subject", "Body", "site1", "inst1", "script.cs", DateTimeOffset.UtcNow);
/// <summary>Verifies that a notification submit is forwarded to the outbox proxy and the ack is routed back to the site.</summary>
[Fact]
public void NotificationSubmit_ForwardedToOutboxProxy_AckRoutesBackToSite()
{
@@ -142,6 +147,7 @@ public class CentralCommunicationActorTests : TestKit
siteProbe.ExpectMsg<NotificationSubmitAck>(a => a.NotificationId == "notif1" && a.Accepted);
}
/// <summary>Verifies that a notification status query is forwarded to the outbox proxy and the response is routed back to the site.</summary>
[Fact]
public void NotificationStatusQuery_ForwardedToOutboxProxy_ResponseRoutesBackToSite()
{
@@ -160,6 +166,7 @@ public class CentralCommunicationActorTests : TestKit
siteProbe.ExpectMsg<NotificationStatusResponse>(r => r.CorrelationId == "corr1" && r.Found);
}
/// <summary>Verifies that a notification submit with no outbox configured replies with a non-accepted ack.</summary>
[Fact]
public void NotificationSubmit_NoOutboxConfigured_RepliesNonAccepted()
{
@@ -175,6 +182,7 @@ public class CentralCommunicationActorTests : TestKit
Assert.NotNull(ack.Error);
}
/// <summary>Verifies that a notification status query with no outbox configured replies with a not-found response.</summary>
[Fact]
public void NotificationStatusQuery_NoOutboxConfigured_RepliesNotFound()
{
@@ -12,13 +12,14 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// T1B.3 seam tests: <see cref="CentralCommunicationActor"/> routing a <see cref="SiteEnvelope"/>
/// Seam tests: <see cref="CentralCommunicationActor"/> routing a <see cref="SiteEnvelope"/>
/// through an injected <see cref="ISiteCommandTransport"/> (transport-agnostic), proving the
/// envelope reaches the transport, the reply routes back to the waiting Ask, and the DB refresh
/// tick reconciles per-site transport resources.
/// </summary>
public class CentralCommunicationActorTransportTests : TestKit
{
/// <summary>Initializes the test kit with a quieter Akka log level.</summary>
public CentralCommunicationActorTransportTests() : base(@"akka.loglevel = WARNING") { }
private static Site GrpcSite(string id, string? grpcA = "http://a:8083", string? grpcB = "http://b:8083") =>
@@ -44,6 +45,7 @@ public class CentralCommunicationActorTransportTests : TestKit
return (actor, transport, repo);
}
/// <summary>Verifies that a site envelope is routed to the transport with the sender as the reply-to actor.</summary>
[Fact]
public void SiteEnvelope_IsRoutedToTheTransport_WithTheSenderAsReplyTo()
{
@@ -58,6 +60,8 @@ public class CentralCommunicationActorTransportTests : TestKit
probe.Ref));
}
/// <summary>Verifies that a reply from the transport is routed back to the waiting Ask.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AskReply_FromTransport_RoutesBackToTheWaitingAsk()
{
@@ -80,6 +84,7 @@ public class CentralCommunicationActorTransportTests : TestKit
Assert.Equal(DeploymentStatus.Success, result.Status);
}
/// <summary>Verifies that a database refresh reconciles the transport with the loaded gRPC endpoints.</summary>
[Fact]
public void DbRefresh_ReconcilesTheTransport_WithTheLoadedGrpcEndpoints()
{
@@ -95,6 +100,7 @@ public class CentralCommunicationActorTransportTests : TestKit
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that a site added or removed across refreshes flows through to the transport.</summary>
[Fact]
public void SiteAddedAndRemoved_AcrossRefreshes_FlowsThroughToTheTransport()
{
@@ -117,6 +123,7 @@ public class CentralCommunicationActorTransportTests : TestKit
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that a site without gRPC addresses is absent from the gRPC contacts but still known.</summary>
[Fact]
public void SiteWithoutGrpcAddresses_IsAbsentFromGrpcContacts_ButStillKnown()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary>
public class CommunicationOptionsBindingTests
{
/// <summary>Verifies that registering communication binds the ScadaBridge communication configuration section.</summary>
[Fact]
public void AddCommunication_BindsTheScadaBridgeCommunicationSection()
{
@@ -1,10 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// WP-2: Tests for per-pattern timeout configuration.
/// Tests for per-pattern timeout configuration.
/// </summary>
public class CommunicationOptionsTests
{
/// <summary>Verifies that the default timeouts are reasonable.</summary>
[Fact]
public void DefaultTimeouts_AreReasonable()
{
@@ -19,16 +20,18 @@ public class CommunicationOptionsTests
Assert.Equal(TimeSpan.FromSeconds(10), options.HealthReportTimeout);
}
/// <summary>Verifies that the transport heartbeat has explicit defaults.</summary>
[Fact]
public void TransportHeartbeat_HasExplicitDefaults()
{
var options = new CommunicationOptions();
// WP-3: Transport heartbeat is explicitly configured, not framework defaults
// Transport heartbeat is explicitly configured, not framework defaults
Assert.Equal(TimeSpan.FromSeconds(5), options.TransportHeartbeatInterval);
Assert.Equal(TimeSpan.FromSeconds(15), options.TransportFailureThreshold);
}
/// <summary>Verifies that the deployment timeout is the longest of the timeout patterns.</summary>
[Fact]
public void DeploymentTimeout_IsLongestPattern()
{
@@ -39,6 +42,7 @@ public class CommunicationOptionsTests
Assert.True(options.DeploymentTimeout > options.IntegrationTimeout);
}
/// <summary>Verifies that all timeouts are configurable.</summary>
[Fact]
public void AllTimeouts_AreConfigurable()
{
@@ -59,6 +63,7 @@ public class CommunicationOptionsTests
Assert.Equal(TimeSpan.FromSeconds(2), options.TransportHeartbeatInterval);
}
/// <summary>Verifies that the application heartbeat interval defaults to 5 seconds, independent of the transport heartbeat.</summary>
[Fact]
public void ApplicationHeartbeatInterval_DefaultsTo5s_IndependentOfTransport()
{
@@ -14,6 +14,7 @@ public class CommunicationOptionsValidatorTests
private static ValidateOptionsResult Validate(CommunicationOptions options) =>
new CommunicationOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -21,6 +22,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero deployment timeout is rejected.</summary>
[Fact]
public void ZeroDeploymentTimeout_IsRejected()
{
@@ -29,8 +31,9 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("DeploymentTimeout", result.FailureMessage);
}
// ── Audit-ingest timeout ladder (arch-review phase-2 residual #2) ────────────
// ── Audit-ingest timeout ladder ────────────
/// <summary>Verifies that the audit ingest timeout ladder is strictly monotonic end to end.</summary>
[Fact]
public void TheAuditIngestTimeoutLadder_IsStrictlyMonotonic_EndToEnd()
{
@@ -45,6 +48,7 @@ public class CommunicationOptionsValidatorTests
Assert.Equal(TimeSpan.FromSeconds(30), ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout);
}
/// <summary>Verifies that an audit forward timeout equal to the ask timeout is rejected.</summary>
[Fact]
public void AuditForwardTimeout_EqualToTheAskTimeout_IsRejected()
{
@@ -59,6 +63,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("AuditForwardTimeout", result.FailureMessage);
}
/// <summary>Verifies that an audit forward timeout shorter than the ask timeout is rejected.</summary>
[Fact]
public void AuditForwardTimeout_ShorterThanTheAskTimeout_IsRejected()
{
@@ -71,6 +76,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("AuditForwardTimeout", result.FailureMessage);
}
/// <summary>Verifies that the audit forward timeout is still configurable upwards.</summary>
[Fact]
public void AuditForwardTimeout_IsStillConfigurableUpwards()
{
@@ -82,6 +88,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a non-positive gRPC max concurrent streams value is rejected.</summary>
[Fact]
public void NonPositiveGrpcMaxConcurrentStreams_IsRejected()
{
@@ -92,6 +99,7 @@ public class CommunicationOptionsValidatorTests
// ── R2: site→central stream event batching ──────────────────────────────────
/// <summary>Verifies that the default stream batch options are valid.</summary>
[Fact]
public void DefaultStreamBatchOptions_AreValid()
{
@@ -101,6 +109,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(Validate(options).Succeeded);
}
/// <summary>Verifies that a stream batch max events value of one is valid and means batching is disabled.</summary>
[Fact]
public void StreamBatchMaxEventsOfOne_IsValid_AndMeansBatchingDisabled()
{
@@ -108,6 +117,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a non-positive stream batch max events value is rejected.</summary>
[Fact]
public void NonPositiveStreamBatchMaxEvents_IsRejected()
{
@@ -116,6 +126,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("GrpcStreamBatchMaxEvents", result.FailureMessage);
}
/// <summary>Verifies that a zero stream batch window is valid.</summary>
[Fact]
public void ZeroStreamBatchWindow_IsValid()
{
@@ -125,6 +136,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a negative stream batch window is rejected.</summary>
[Fact]
public void NegativeStreamBatchWindow_IsRejected()
{
@@ -136,6 +148,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("GrpcStreamBatchWindow", result.FailureMessage);
}
/// <summary>Verifies that a stream batch window at or above the latency budget is rejected.</summary>
[Fact]
public void StreamBatchWindowAtOrAboveTheLatencyBudget_IsRejected()
{
@@ -157,8 +170,9 @@ public class CommunicationOptionsValidatorTests
}).Succeeded);
}
// ── Aggregated live alarm cache options (plan #10, Task 6) ───────────────────
// ── Aggregated live alarm cache options ───────────────────
/// <summary>Verifies that a zero live alarm cache linger is valid.</summary>
[Fact]
public void ZeroLiveAlarmCacheLinger_IsValid()
{
@@ -167,6 +181,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a negative live alarm cache linger is rejected.</summary>
[Fact]
public void NegativeLiveAlarmCacheLinger_IsRejected()
{
@@ -175,6 +190,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("LiveAlarmCacheLinger", result.FailureMessage);
}
/// <summary>Verifies that a zero live alarm cache reconcile interval is rejected.</summary>
[Fact]
public void ZeroLiveAlarmCacheReconcileInterval_IsRejected()
{
@@ -183,6 +199,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("LiveAlarmCacheReconcileInterval", result.FailureMessage);
}
/// <summary>Verifies that a zero live alarm cache seed concurrency is rejected.</summary>
[Fact]
public void ZeroLiveAlarmCacheSeedConcurrency_IsRejected()
{
@@ -191,6 +208,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("LiveAlarmCacheSeedConcurrency", result.FailureMessage);
}
/// <summary>Verifies that an excessive live alarm cache seed concurrency is rejected.</summary>
[Fact]
public void ExcessiveLiveAlarmCacheSeedConcurrency_IsRejected()
{
@@ -199,6 +217,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("LiveAlarmCacheSeedConcurrency", result.FailureMessage);
}
/// <summary>Verifies that a zero live alarm cache max subscribers per site is rejected.</summary>
[Fact]
public void ZeroLiveAlarmCacheMaxSubscribersPerSite_IsRejected()
{
@@ -209,6 +228,7 @@ public class CommunicationOptionsValidatorTests
// ── R2 T10: live-delta publish-coalescing window (N6) ────────────────────────
/// <summary>Verifies that a zero live alarm cache publish coalesce is valid.</summary>
[Fact]
public void ZeroLiveAlarmCachePublishCoalesce_IsValid()
{
@@ -217,6 +237,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a negative live alarm cache publish coalesce is rejected.</summary>
[Fact]
public void NegativeLiveAlarmCachePublishCoalesce_IsRejected()
{
@@ -231,6 +252,7 @@ public class CommunicationOptionsValidatorTests
// entries; an EMPTY list is valid (central nodes legitimately declare none). The role-aware
// "a Site must list at least one endpoint" rule lives in StartupValidator, tested there.
/// <summary>Verifies that an empty endpoint list is valid.</summary>
[Fact]
public void EmptyEndpoints_IsValid()
{
@@ -242,6 +264,7 @@ public class CommunicationOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a blank endpoint is rejected.</summary>
[Fact]
public void BlankEndpoint_IsRejected()
{
@@ -253,6 +276,7 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("CentralGrpcEndpoints", result.FailureMessage);
}
/// <summary>Verifies that a configured endpoint list is valid.</summary>
[Fact]
public void Endpoints_IsValid()
{
@@ -11,10 +11,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// WP-2: Tests for CommunicationService initialization and state.
/// Tests for CommunicationService initialization and state.
/// </summary>
public class CommunicationServiceTests : TestKit
{
/// <summary>Verifies that unsubscribing a debug view is sent as a fire-and-forget Tell rather than an Ask.</summary>
[Fact]
public void UnsubscribeDebugView_IsTellNotAsk()
{
@@ -24,8 +25,10 @@ public class CommunicationServiceTests : TestKit
Assert.Equal(typeof(void), method!.ReturnType);
}
// ── DeploymentManager-006: query-the-site-before-redeploy ──
// ── Query the site before redeploy ──
/// <summary>Verifies that querying deployment state before initialization throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryDeploymentStateAsync_BeforeInitialization_Throws()
{
@@ -38,6 +41,8 @@ public class CommunicationServiceTests : TestKit
new DeploymentStateQueryRequest("corr-1", "inst1", DateTimeOffset.UtcNow)));
}
/// <summary>Verifies that querying deployment state sends a site envelope and returns the site's response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryDeploymentStateAsync_SendsEnvelopeAndReturnsResponse()
{
@@ -64,6 +69,8 @@ public class CommunicationServiceTests : TestKit
// ── Notification Outbox: central-side outbox actor calls ──
/// <summary>Verifies that querying the notification outbox before the outbox proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryNotificationOutboxAsync_BeforeOutboxSet_Throws()
{
@@ -77,6 +84,8 @@ public class CommunicationServiceTests : TestKit
"corr-1", null, null, null, null, false, null, null, null, 1, 50)));
}
/// <summary>Verifies that retrying a notification before the outbox proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryNotificationAsync_BeforeOutboxSet_Throws()
{
@@ -88,6 +97,8 @@ public class CommunicationServiceTests : TestKit
service.RetryNotificationAsync(new RetryNotificationRequest("corr-1", "n1")));
}
/// <summary>Verifies that discarding a notification before the outbox proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardNotificationAsync_BeforeOutboxSet_Throws()
{
@@ -99,6 +110,8 @@ public class CommunicationServiceTests : TestKit
service.DiscardNotificationAsync(new DiscardNotificationRequest("corr-1", "n1")));
}
/// <summary>Verifies that getting notification KPIs before the outbox proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetNotificationKpisAsync_BeforeOutboxSet_Throws()
{
@@ -110,6 +123,8 @@ public class CommunicationServiceTests : TestKit
service.GetNotificationKpisAsync(new NotificationKpiRequest("corr-1")));
}
/// <summary>Verifies that querying the notification outbox asks the outbox proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryNotificationOutboxAsync_AsksOutboxProxyDirectly()
{
@@ -134,6 +149,8 @@ public class CommunicationServiceTests : TestKit
Assert.Same(reply, await task);
}
/// <summary>Verifies that retrying a notification asks the outbox proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryNotificationAsync_AsksOutboxProxyDirectly()
{
@@ -154,6 +171,8 @@ public class CommunicationServiceTests : TestKit
Assert.Same(reply, await task);
}
/// <summary>Verifies that discarding a notification asks the outbox proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardNotificationAsync_AsksOutboxProxyDirectly()
{
@@ -176,6 +195,8 @@ public class CommunicationServiceTests : TestKit
Assert.False(result.Success);
}
/// <summary>Verifies that getting notification KPIs asks the outbox proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetNotificationKpisAsync_AsksOutboxProxyDirectly()
{
@@ -198,6 +219,8 @@ public class CommunicationServiceTests : TestKit
Assert.Equal(3, result.QueueDepth);
}
/// <summary>Verifies that getting per-site notification KPIs asks the outbox proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPerSiteNotificationKpisAsync_AsksOutboxProxyDirectly()
{
@@ -226,6 +249,8 @@ public class CommunicationServiceTests : TestKit
// ── Site Call Audit: central-side audit actor calls ──
/// <summary>Verifies that querying site calls before the site call audit proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QuerySiteCallsAsync_BeforeSiteCallAuditSet_Throws()
{
@@ -238,6 +263,8 @@ public class CommunicationServiceTests : TestKit
"corr-1", null, null, null, null, false, null, null, null, null, 50)));
}
/// <summary>Verifies that getting site call KPIs before the site call audit proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
{
@@ -249,6 +276,8 @@ public class CommunicationServiceTests : TestKit
service.GetSiteCallKpisAsync(new SiteCallKpiRequest("corr-1")));
}
/// <summary>Verifies that getting site call detail before the site call audit proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteCallDetailAsync_BeforeSiteCallAuditSet_Throws()
{
@@ -260,6 +289,8 @@ public class CommunicationServiceTests : TestKit
service.GetSiteCallDetailAsync(new SiteCallDetailRequest("corr-1", Guid.NewGuid())));
}
/// <summary>Verifies that getting per-site site call KPIs before the site call audit proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPerSiteSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
{
@@ -271,6 +302,8 @@ public class CommunicationServiceTests : TestKit
service.GetPerSiteSiteCallKpisAsync(new PerSiteSiteCallKpiRequest("corr-1")));
}
/// <summary>Verifies that querying site calls asks the site call audit proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QuerySiteCallsAsync_AsksSiteCallAuditProxyDirectly()
{
@@ -296,6 +329,8 @@ public class CommunicationServiceTests : TestKit
Assert.Same(reply, await task);
}
/// <summary>Verifies that getting site call detail asks the site call audit proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteCallDetailAsync_AsksSiteCallAuditProxyDirectly()
{
@@ -318,6 +353,8 @@ public class CommunicationServiceTests : TestKit
Assert.False(result.Success);
}
/// <summary>Verifies that getting site call KPIs asks the site call audit proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
{
@@ -342,6 +379,8 @@ public class CommunicationServiceTests : TestKit
Assert.Equal(1, result.StuckCount);
}
/// <summary>Verifies that getting per-site site call KPIs asks the site call audit proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPerSiteSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
{
@@ -368,6 +407,8 @@ public class CommunicationServiceTests : TestKit
Assert.Equal("plant-a", result.Sites[0].SourceSite);
}
/// <summary>Verifies that retrying a site call before the site call audit proxy is set throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySiteCallAsync_BeforeSiteCallAuditSet_Throws()
{
@@ -379,6 +420,8 @@ public class CommunicationServiceTests : TestKit
service.RetrySiteCallAsync(new RetrySiteCallRequest("corr-1", Guid.NewGuid(), "plant-a")));
}
/// <summary>Verifies that retrying a site call asks the site call audit proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySiteCallAsync_AsksSiteCallAuditProxyDirectly()
{
@@ -403,6 +446,8 @@ public class CommunicationServiceTests : TestKit
Assert.Same(reply, await task);
}
/// <summary>Verifies that discarding a site call asks the site call audit proxy directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardSiteCallAsync_AsksSiteCallAuditProxyDirectly()
{
@@ -433,6 +478,7 @@ public class CommunicationServiceTests : TestKit
/// </summary>
private class EchoStateQueryActor : ReceiveActor
{
/// <summary>Initializes the actor and registers its handler for site envelopes carrying deployment state queries.</summary>
public EchoStateQueryActor()
{
Receive<SiteEnvelope>(env =>
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Regression tests for Communication-004 — coordinator actors must declare an
/// Regression tests that coordinator actors must declare an
/// explicit <c>Resume</c> supervision strategy per the CLAUDE.md decision
/// ("Resume for coordinator actors"). A child fault under the default
/// (Restart) strategy would wipe a child's in-memory state; the long-lived
@@ -23,9 +23,14 @@ public class CoordinatorSupervisionTests : TestKit
/// </summary>
private sealed class CentralCommunicationActorProbe : CentralCommunicationActor
{
/// <summary>Initializes the probe with the dependencies required by the central communication actor.</summary>
/// <param name="sp">The service provider passed to the base actor.</param>
/// <param name="transport">The site command transport passed to the base actor.</param>
public CentralCommunicationActorProbe(IServiceProvider sp, ISiteCommandTransport transport)
: base(sp, transport) { }
/// <summary>Returns the actor's configured supervisor strategy.</summary>
/// <returns>The supervisor strategy declared by the actor.</returns>
public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy();
}
@@ -34,9 +39,15 @@ public class CoordinatorSupervisionTests : TestKit
/// </summary>
private sealed class SiteCommunicationActorProbe : SiteCommunicationActor
{
/// <summary>Initializes the probe with the dependencies required by the site communication actor.</summary>
/// <param name="siteId">The site identifier passed to the base actor.</param>
/// <param name="options">The communication options passed to the base actor.</param>
/// <param name="dm">The deployment manager reference passed to the base actor.</param>
public SiteCommunicationActorProbe(string siteId, CommunicationOptions options, IActorRef dm)
: base(siteId, options, dm) { }
/// <summary>Returns the actor's configured supervisor strategy.</summary>
/// <returns>The supervisor strategy declared by the actor.</returns>
public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy();
}
@@ -50,6 +61,7 @@ public class CoordinatorSupervisionTests : TestKit
return services.BuildServiceProvider();
}
/// <summary>Verifies that the central communication actor declares a Resume supervisor strategy.</summary>
[Fact]
public void CentralCommunicationActor_SupervisorStrategy_IsResume()
{
@@ -66,6 +78,7 @@ public class CoordinatorSupervisionTests : TestKit
Assert.Equal(Directive.Resume, directive);
}
/// <summary>Verifies that the site communication actor declares a Resume supervisor strategy.</summary>
[Fact]
public void SiteCommunicationActor_SupervisorStrategy_IsResume()
{
@@ -23,10 +23,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
[Collection("DebugStreamStatics")]
public class DebugStreamServiceTests : TestKit
{
/// <summary>Verifies that a stream that terminates before the snapshot arrives throws a meaningful exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartStreamAsync_StreamTerminatesBeforeSnapshot_ThrowsMeaningfulException()
{
// Regression test for Communication-001. When the debug stream terminates before
// Regression test: when the debug stream terminates before
// the initial snapshot arrives, StartStreamAsync used to let the raw
// InvalidOperationException from onTerminatedWrapper escape its
// OperationCanceledException-only catch — the caller saw an untranslated exception
@@ -80,6 +82,8 @@ public class DebugStreamServiceTests : TestKit
Assert.NotNull(ex.InnerException);
}
/// <summary>Verifies that an attached session is kept alive by the service keepalive and dies once detached.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachedSession_IsKeptAliveByTheServiceKeepalive_AndDiesOnceDetached()
{
@@ -165,8 +169,12 @@ public class DebugStreamServiceTests : TestKit
}
}
// --- arch-review 2026-08-19 P3-3: single-endpoint sites ---
// --- single-endpoint sites ---
/// <summary>Verifies that a single-endpoint site uses its sole address for both node slots.</summary>
/// <param name="nodeA">The gRPC address of node A, or null.</param>
/// <param name="nodeB">The gRPC address of node B, or null.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("http://localhost:5100", null)]
[InlineData(null, "http://localhost:5200")]
@@ -222,6 +230,8 @@ public class DebugStreamServiceTests : TestKit
await Assert.ThrowsAsync<InvalidOperationException>(() => startTask);
}
/// <summary>Verifies that a site with no gRPC addresses still throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartStreamAsync_SiteWithNoGrpcAddresses_StillThrows()
{
@@ -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()
{
@@ -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, []),
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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();
@@ -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()
{
@@ -40,6 +40,8 @@ public class GrpcSiteTransportDeadlineTests
private static readonly DateTimeOffset T = DateTimeOffset.UtcNow;
/// <summary>Supplies the command and expected deadline pairs for the deadline theory.</summary>
/// <returns>The theory data rows.</returns>
public static IEnumerable<object[]> Cases()
{
// Lifecycle group — note it is NOT one deadline class.
@@ -84,6 +86,10 @@ public class GrpcSiteTransportDeadlineTests
yield return [new TriggerSiteFailover("c", "s"), Opts.QueryTimeout];
}
/// <summary>Verifies that ResolveDeadline returns the same timeout the Ask-based path used for each command.</summary>
/// <param name="command">The command.</param>
/// <param name="expected">The expected.</param>
/// <returns>The result.</returns>
[Theory]
[MemberData(nameof(Cases))]
public void ResolveDeadline_MatchesTodaysAskTimeout(object command, TimeSpan expected)
@@ -91,6 +97,8 @@ public class GrpcSiteTransportDeadlineTests
Assert.Equal(expected, Transport.ResolveDeadline(command));
}
/// <summary>Verifies that a wait-for-attribute request uses its own timeout plus the integration slack as the deadline.</summary>
/// <returns>The result.</returns>
[Fact]
public void WaitForAttribute_UsesItsDynamicTimeoutPlusIntegrationSlack()
{
@@ -101,7 +109,9 @@ public class GrpcSiteTransportDeadlineTests
private sealed class NoKeyProvider : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k");
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
}
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// WP1.6: a send to a site with no configured gRPC channel must fail the caller's Ask immediately
/// A send to a site with no configured gRPC channel must fail the caller's Ask immediately
/// (<see cref="Status.Failure"/> wrapping <see cref="SiteChannelUnavailableException"/>) instead of
/// warning-and-dropping the message and leaving the Ask to expire at its full timeout.
/// </summary>
@@ -31,6 +31,7 @@ public class GrpcSiteTransportFailFastTests : TestKit
Opts,
NullLogger<GrpcSiteTransport>.Instance);
/// <summary>Verifies that a send to an unconfigured site faults the Ask quickly instead of timing out.</summary>
[Fact]
public void Send_ToUnconfiguredSite_FaultsTheAskFastInsteadOfTimingOut()
{
@@ -57,6 +58,8 @@ public class GrpcSiteTransportFailFastTests : TestKit
Assert.Contains("unconfigured-site", cause.Message);
}
/// <summary>Verifies that an Ask against an unconfigured site completes faulted and fast via a Tell of a status failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Ask_AgainstUnconfiguredSite_CompletesFaultedFastViaTellStatusFailure()
{
@@ -81,6 +84,8 @@ public class GrpcSiteTransportFailFastTests : TestKit
/// <summary>Relays an Ask into the transport, exactly as <c>CentralCommunicationActor</c> does.</summary>
private sealed class EchoingAskTarget : ReceiveActor
{
/// <summary>Initializes the actor to relay every site envelope into the given transport.</summary>
/// <param name="transport">The transport that receives each relayed envelope.</param>
public EchoingAskTarget(GrpcSiteTransport transport)
{
Receive<SiteEnvelope>(env => transport.Send(env, Sender));
@@ -89,7 +94,9 @@ public class GrpcSiteTransportFailFastTests : TestKit
private sealed class NoKeyProvider : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k");
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
}
@@ -28,6 +28,7 @@ public class HealthReportAckTests : TestKit
new Dictionary<string, TagResolutionStatus>(),
0, 0, new Dictionary<string, int>(), 0, 0, 0, 0);
/// <summary>Verifies that the site communication actor replies with a failure rather than staying silent when no transport is configured.</summary>
[Fact]
public void SiteCommunicationActor_NoTransport_RepliesFailure_NotSilence()
{
@@ -42,6 +43,7 @@ public class HealthReportAckTests : TestKit
ExpectMsg<Status.Failure>();
}
/// <summary>Verifies that the central communication actor processes a health report and acknowledges it.</summary>
[Fact]
public void CentralCommunicationActor_OnReport_ProcessesAndAcks()
{
@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </remarks>
public class LooseValueCodecTests
{
/// <summary>Provides scalar values of each supported CLR type for the round-trip theory.</summary>
/// <returns>The scalar test values, one per row.</returns>
public static IEnumerable<object[]> TaggedScalars() =>
[
[true],
@@ -36,6 +38,8 @@ public class LooseValueCodecTests
[new byte[] { 1, 2, 3 }]
];
/// <summary>Verifies that a tagged value keeps its runtime CLR type after a round trip through the codec.</summary>
/// <param name="value">The boxed value to round-trip.</param>
[Theory]
[MemberData(nameof(TaggedScalars))]
public void TaggedValues_KeepTheirClrType(object value)
@@ -12,6 +12,9 @@ public class LowReviewTransportTests
private static SiteStreamGrpcServer Server() => new(
Substitute.For<ISiteStreamSubscriber>(), NullLogger<SiteStreamGrpcServer>.Instance);
/// <summary>Verifies that pull requests with an oversized batch size are rejected before any rows are read.</summary>
/// <param name="size">The oversized batch size requested.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(2001)]
[InlineData(int.MaxValue)]
@@ -33,6 +36,8 @@ public class LowReviewTransportTests
Assert.Empty(store.ReceivedCalls());
}
/// <summary>Verifies that the retired site ingest RPCs return Unimplemented even for empty batches.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetiredSiteIngestRpcs_ReturnUnimplementedEvenForEmptyBatches()
{
@@ -3,10 +3,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// WP-1: Tests that message contracts have correlation IDs and proper structure.
/// Tests that message contracts have correlation IDs and proper structure.
/// </summary>
public class MessageContractTests
{
/// <summary>Verifies that the event log query request has a correlation id.</summary>
[Fact]
public void EventLogQueryRequest_HasCorrelationId()
{
@@ -16,6 +17,7 @@ public class MessageContractTests
Assert.Equal("corr-456", msg.CorrelationId);
}
/// <summary>Verifies that the event log query response has a correlation id.</summary>
[Fact]
public void EventLogQueryResponse_HasCorrelationId()
{
@@ -25,6 +27,7 @@ public class MessageContractTests
Assert.Equal("corr-456", msg.CorrelationId);
}
/// <summary>Verifies that the parked message query request has a correlation id.</summary>
[Fact]
public void ParkedMessageQueryRequest_HasCorrelationId()
{
@@ -34,6 +37,7 @@ public class MessageContractTests
Assert.Equal("corr-789", msg.CorrelationId);
}
/// <summary>Verifies that the parked message query response has a correlation id.</summary>
[Fact]
public void ParkedMessageQueryResponse_HasCorrelationId()
{
@@ -43,6 +47,7 @@ public class MessageContractTests
Assert.Equal("corr-789", msg.CorrelationId);
}
/// <summary>Verifies that every message pattern exists as a record type.</summary>
[Fact]
public void AllMessagePatterns_ExistAsRecordTypes()
{
@@ -39,6 +39,7 @@ public class PendingDeploymentPurgeActorTests : TestKit
sp, options, NullLogger<PendingDeploymentPurgeActor>.Instance)));
}
/// <summary>Verifies that a tick purges expired rows using the current time as the threshold.</summary>
[Fact]
public void Tick_PurgesExpiredRows_WithNowThreshold()
{
@@ -72,6 +73,7 @@ public class PendingDeploymentPurgeActorTests : TestKit
Assert.InRange(captured, before, after);
}
/// <summary>Verifies that the actor survives a purge failure and retries on the next interval.</summary>
[Fact]
public void Tick_PurgeThrows_ActorSurvives_AndRetriesOnNextInterval()
{
@@ -99,7 +101,7 @@ public class PendingDeploymentPurgeActorTests : TestKit
}
finally
{
// Row-95/row-82 pattern: a 100ms self-scheduling ticker left running strands
// Pattern: a 100ms self-scheduling ticker left running strands
// later tests in the same assembly (it keeps firing — and keeps calling the
// now-disposed repo/service-provider — for the rest of the process). Stop it
// deterministically before the test ends.
@@ -5,12 +5,13 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos;
/// <summary>
/// Wire-format round-trip tests for the Audit Log (#23) telemetry proto messages
/// Wire-format round-trip tests for the Audit Log telemetry proto messages
/// (<see cref="AuditEventDto"/>, <see cref="AuditEventBatch"/>, <see cref="IngestAck"/>).
/// Locks the additive contract the site → central audit pipeline depends on.
/// </summary>
public class AuditEventProtoTests
{
/// <summary>Verifies that an <c>AuditEventDto</c> round trip preserves all fields.</summary>
[Fact]
public void AuditEventDto_RoundTrip_PreservesAllFields()
{
@@ -66,6 +67,7 @@ public class AuditEventProtoTests
Assert.Equal(original.Extra, deserialized.Extra);
}
/// <summary>Verifies that an unset nullable integer is absent from the wire encoding.</summary>
[Fact]
public void AuditEventDto_NullableInt_AbsentByDefault_NotIncludedInWire()
{
@@ -90,6 +92,7 @@ public class AuditEventProtoTests
Assert.Null(deserialized.DurationMs);
}
/// <summary>Verifies that an empty <c>AuditEventBatch</c> round-trips to an empty event list.</summary>
[Fact]
public void AuditEventBatch_Empty_RoundTrip_Yields_EmptyEvents()
{
@@ -102,6 +105,7 @@ public class AuditEventProtoTests
Assert.Empty(deserialized.Events);
}
/// <summary>Verifies that an <c>IngestAck</c> preserves its accepted event ids.</summary>
[Fact]
public void IngestAck_PreservesAcceptedEventIds()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos;
/// <summary>
/// Wire-format round-trip tests for the Audit Log (#23) M3 cached-telemetry
/// Wire-format round-trip tests for the Audit Log cached-telemetry
/// proto messages (<see cref="SiteCallOperationalDto"/>,
/// <see cref="CachedTelemetryPacket"/>, <see cref="CachedTelemetryBatch"/>).
/// Locks the additive contract the central dual-write transaction depends on.
@@ -23,6 +23,7 @@ public class CachedTelemetryProtoTests
SourceSiteId = "site-1",
};
/// <summary>Verifies that a <c>SiteCallOperationalDto</c> round trip preserves all fields.</summary>
[Fact]
public void SiteCallOperationalDto_RoundTrip_PreservesAllFields()
{
@@ -66,6 +67,7 @@ public class CachedTelemetryProtoTests
Assert.Equal(original.TerminalAtUtc, deserialized.TerminalAtUtc);
}
/// <summary>Verifies that the terminal timestamp is absent when the call is not terminal.</summary>
[Fact]
public void SiteCallOperationalDto_TerminalAt_AbsentWhenNotTerminal()
{
@@ -91,6 +93,7 @@ public class CachedTelemetryProtoTests
Assert.Null(deserialized.TerminalAtUtc);
}
/// <summary>Verifies that the nullable HTTP status is absent by default.</summary>
[Fact]
public void SiteCallOperationalDto_NullableHttpStatus_AbsentByDefault()
{
@@ -116,6 +119,7 @@ public class CachedTelemetryProtoTests
Assert.Null(deserialized.HttpStatus);
}
/// <summary>Verifies that a <c>CachedTelemetryPacket</c> round trip preserves its nested entities.</summary>
[Fact]
public void CachedTelemetryPacket_RoundTrip_PreservesNestedEntities()
{
@@ -161,6 +165,7 @@ public class CachedTelemetryProtoTests
Assert.Equal(operationalDto.LastError, deserialized.Operational.LastError);
}
/// <summary>Verifies that an empty <c>CachedTelemetryBatch</c> round trip yields no packets.</summary>
[Fact]
public void CachedTelemetryBatch_Empty_RoundTrip_Yields_EmptyPackets()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos;
/// <summary>
/// Wire-format round-trip tests for the Audit Log (#23) M6 reconciliation
/// Wire-format round-trip tests for the Audit Log reconciliation
/// pull proto messages (<see cref="PullAuditEventsRequest"/>,
/// <see cref="PullAuditEventsResponse"/>). Locks the additive contract the
/// central→site reconciliation puller depends on.
@@ -23,6 +23,7 @@ public class PullAuditEventsProtoTests
SourceSiteId = "site-1",
};
/// <summary>Verifies that a <c>PullAuditEventsRequest</c> survives a serialize and parse round trip unchanged.</summary>
[Fact]
public void PullAuditEventsRequest_RoundTrip()
{
@@ -42,6 +43,7 @@ public class PullAuditEventsProtoTests
Assert.Equal(250, deserialized.BatchSize);
}
/// <summary>Verifies that a <c>PullAuditEventsResponse</c> with events and the more-available flag survives a round trip.</summary>
[Fact]
public void PullAuditEventsResponse_RoundTrip_WithEvents_And_MoreAvailable()
{
@@ -67,6 +69,7 @@ public class PullAuditEventsProtoTests
}
}
/// <summary>Verifies that an empty <c>PullAuditEventsResponse</c> parses back with no events.</summary>
[Fact]
public void PullAuditEventsResponse_Empty_Yields_EmptyEvents()
{
@@ -62,6 +62,8 @@ public class ReconcileServiceTests
new(SiteIdentifier, "node-a",
local.ToDictionary(x => x.Name, x => x.Rev));
/// <summary>Verifies that reconciliation reports missing and stale instances as gaps with fresh tokens and snapshot deployment ids, and omits current ones.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_GapIsMissingAndStale_CurrentOmitted_WithFreshTokensAndSnapshotDeploymentIds()
{
@@ -99,6 +101,8 @@ public class ReconcileServiceTests
Assert.Empty(response.OrphanNames);
}
/// <summary>Verifies that a local instance name that is not expected is reported as an orphan.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_LocalNameNotInExpected_IsReportedAsOrphan()
{
@@ -116,6 +120,8 @@ public class ReconcileServiceTests
Assert.Equal("inst-Z", orphan);
}
/// <summary>Verifies that when staging a pending deployment returns false and a row already exists, the gap carries the existing token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_StagePendingReturnsFalse_ExistingRowPresent_ReturnsExistingTokenInGap()
{
@@ -162,6 +168,8 @@ public class ReconcileServiceTests
Assert.True(gapC.IsEnabled);
}
/// <summary>Verifies that when staging a pending deployment returns false and no row exists, that gap item is omitted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_StagePendingReturnsFalse_NoExistingRow_OmitsThatGapItem()
{
@@ -192,6 +200,8 @@ public class ReconcileServiceTests
Assert.DoesNotContain(response.Gap, g => g.InstanceUniqueName == "inst-C");
}
/// <summary>Verifies that a gap item whose snapshot is missing is skipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_SnapshotMissing_SkipsGapItem()
{
@@ -207,6 +217,8 @@ public class ReconcileServiceTests
Assert.Empty(response.OrphanNames);
}
/// <summary>Verifies that reconciling an unknown site returns an empty response without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_UnknownSite_ReturnsEmptyResponse_NoThrow()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Tests for <see cref="SiteAlarmLiveCacheService"/> (plan #10, Task 4) — the DI singleton
/// Tests for <see cref="SiteAlarmLiveCacheService"/> — the DI singleton
/// that reference-counts Alarm Summary viewers over one shared per-site aggregator. Covers
/// shared start (one aggregator for many viewers), last-out linger stop, and re-subscribe
/// cancelling a pending stop. Uses a real (in-memory) ActorSystem and a mock gRPC factory
@@ -30,6 +30,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
private readonly List<Action> _connects = new();
private readonly List<Action> _completions = new();
/// <summary>Initializes a hanging client with no pending subscriptions.</summary>
public HangingClient() : base() { }
/// <summary>When false the site never accepts the subscription (no connect signal).</summary>
@@ -44,7 +45,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
private int _streamEnds;
/// <summary>
/// Register row 40: how many site-wide subscriptions have been torn down (their
/// How many site-wide subscriptions have been torn down (their
/// cancellation token fired). This is the aggregator-stop observable — the linger
/// stop cancels the aggregator's token, which ends the hanging subscribe below.
/// Tests that must show an aggregator was NOT stopped assert on this rather than
@@ -52,6 +53,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
/// </summary>
public int StreamEndCount => Volatile.Read(ref _streamEnds);
/// <inheritdoc />
public override Task SubscribeSiteAsync(
string correlationId, Action<Commons.Messages.Streaming.AlarmStateChanged> onAlarmEvent,
Action<Exception> onError, Action onCompleted, CancellationToken ct,
@@ -73,6 +75,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
});
return tcs.Task;
}
/// <inheritdoc />
public override void Unsubscribe(string correlationId) { }
}
@@ -84,14 +87,17 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
/// <summary>The single client this factory hands out, for connect/complete control.</summary>
public HangingClient Client => _client;
/// <summary>Initializes a counting factory backed by a single hanging client.</summary>
public CountingFactory() : base(NullLoggerFactory.Instance) { }
/// <inheritdoc />
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
{
Interlocked.Increment(ref GetOrCreateCount);
return _client;
}
/// <inheritdoc />
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _client;
}
@@ -137,7 +143,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
}
/// <summary>
/// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave
/// House pattern for an ABSENCE assertion (mirrors the
/// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment in this same test
/// project): proves every check in <paramref name="checks"/> keeps holding throughout
/// <paramref name="window"/> by polling, rather than a single blind
@@ -160,10 +166,11 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
}
}
/// <summary>Verifies that the seed fan-out publishes the instances that failed to answer.</summary>
[Fact]
public void Seed_FanOut_Publishes_The_Instances_That_Failed_To_Answer()
{
// Arch-review phase-2 residual #4: the seed/reconcile fan-out already knows which
// The seed/reconcile fan-out already knows which
// Enabled instances failed to answer and used to discard it, forcing the Alarm Summary
// page to run a SECOND identical fan-out purely to rebuild that list. It is now
// published alongside the snapshot.
@@ -191,6 +198,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
Assert.Equal(new[] { "inst-a", "inst-b" }, service.GetNotReportingInstances(SiteId));
}
/// <summary>Verifies that the not-reporting set is empty for an unknown site.</summary>
[Fact]
public void NotReporting_Is_Empty_For_An_Unknown_Site()
{
@@ -199,6 +207,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
Assert.Empty(service.GetNotReportingInstances(SiteId));
}
/// <summary>Verifies that the first subscriber starts one aggregator that is shared by multiple viewers.</summary>
[Fact]
public void First_Subscriber_Starts_One_Aggregator_Shared_By_Multiple_Viewers()
{
@@ -220,6 +229,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => changes1 >= 1 && changes2 >= 1, TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that the aggregator stops after the linger period once the last viewer leaves.</summary>
[Fact]
public void Last_Viewer_Leaving_Stops_Aggregator_After_Linger()
{
@@ -232,7 +242,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
sub1.Dispose();
// Register row 40: one viewer remains, so the aggregator must NOT be stopped. The old
// One viewer remains, so the aggregator must NOT be stopped. The old
// test slept 300 ms and sampled IsLive once — a teardown that happened at any other
// instant (or one that stopped and restarted the stream) was invisible to it. Poll
// CONTINUOUSLY across three linger windows instead, bailing out the moment either
@@ -259,6 +269,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
Assert.Empty(service.GetCurrentAlarms(SiteId));
}
/// <summary>Verifies that resubscribing within the linger period cancels the pending stop.</summary>
[Fact]
public void Resubscribe_Within_Linger_Cancels_The_Pending_Stop()
{
@@ -271,7 +282,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
sub1.Dispose(); // schedules linger stop
var sub2 = service.Subscribe(SiteId, () => { }); // returns within the linger window → cancels it
// Register row 40: the pending stop must be CANCELLED, not merely outrun. The old test
// The pending stop must be CANCELLED, not merely outrun. The old test
// slept 700 ms and sampled once — it could not see a stream that was torn down and
// silently replaced inside that window, because GetOrCreateCount is checked only at the
// end and the mock hands back the same client object either way. Poll continuously past
@@ -295,6 +306,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => factory.Client.StreamEndCount > 0, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that disposing the service more than once is safe.</summary>
[Fact]
public void Dispose_Is_Idempotent()
{
@@ -308,6 +320,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => !service.IsLive(SiteId), TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that a transient aggregator start failure self-heals on retry while the viewer cohort is stable.</summary>
[Fact]
public void Transient_Start_Failure_Self_Heals_On_Retry_For_A_Stable_Viewer_Cohort()
{
@@ -355,6 +368,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => service.IsLive(SiteId), TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that a viewer over the per-site cap is rejected safely and not counted.</summary>
[Fact]
public void Viewer_Over_The_Per_Site_Cap_Is_Rejected_Safely_And_Not_Counted()
{
@@ -387,6 +401,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => !service.IsLive(SiteId), TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that an unknown site registers the viewer but never goes live.</summary>
[Fact]
public void Unknown_Site_Registers_Viewer_But_Never_Goes_Live()
{
@@ -400,6 +415,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
// ── R2 T15: single-endpoint sites can go live (N9) ──
/// <summary>Verifies that a site with only a node A endpoint starts an aggregator against that endpoint.</summary>
[Fact]
public void SiteWithOnlyNodeAEndpoint_StartsAnAggregator_AgainstThatEndpoint()
{
@@ -444,6 +460,8 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
// ── R2 T10: deathwatch un-sticks IsLive on aggregator death (N6) ──
/// <summary>Verifies that the death of the aggregator resets the live flag and clears the snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Aggregator_Death_Resets_IsLive_And_Clears_The_Snapshot()
{
@@ -463,6 +481,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
}, TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that a linger stop does not resurrect the aggregator.</summary>
[Fact]
public void Linger_Stop_Does_Not_Resurrect_The_Aggregator()
{
@@ -478,8 +497,9 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
(() => factory.GetOrCreateCount == startsAfterStop, "no self-heal restart fired"));
}
// ── WP2.3: IsLive tracks the STREAM, not merely "a snapshot was published once" ──
// ── IsLive tracks the STREAM, not merely "a snapshot was published once" ──
/// <summary>Verifies that the live flag stays false until the site accepts the stream.</summary>
[Fact]
public void IsLive_StaysFalse_UntilTheSiteAcceptsTheStream()
{
@@ -501,6 +521,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => service.IsLive(SiteId), TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that the live flag drops when the stream completes.</summary>
[Fact]
public void IsLive_DropsWhenTheStreamCompletes()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Telemetry guards for the aggregated live alarm cache (plan #10, Task 6): the
/// Telemetry guards for the aggregated live alarm cache: the
/// active-aggregator observable gauge reflects Started/Stopped, and the reconnect
/// counter increments on each recorded reconnect. Mirrors the MeterListener read
/// pattern used by <c>SiteStreamGrpcServerTests.SiteStreamSubscribersActiveGauge_*</c>; reads are
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <see cref="ScadaBridgeTelemetry.LiveAlarmAggregatorStarted"/>/
/// <see cref="ScadaBridgeTelemetry.LiveAlarmAggregatorStopped"/> from its own
/// PreStart/PostStop, so every actor <c>Grpc.SiteAlarmAggregatorActorTests</c> spins up
/// moves this exact gauge — serialized into the same collection (row-96 pattern) so the
/// moves this exact gauge — serialized into the same collection (the same pattern the other telemetry tests use) so the
/// two classes never interleave.
/// </remarks>
[Collection("SiteAlarmAggregatorGauge")]
@@ -61,6 +61,7 @@ public class SiteAlarmLiveCacheTelemetryTests
return delta;
}
/// <summary>Verifies that the active aggregator gauge reflects aggregator start and stop.</summary>
[Fact]
public void ActiveAggregatorGauge_ReflectsStartAndStop()
{
@@ -92,6 +93,7 @@ public class SiteAlarmLiveCacheTelemetryTests
Assert.Equal(baseline, ReadGauge(gauge));
}
/// <summary>Verifies that the reconnect counter increments on each recorded reconnect.</summary>
[Fact]
public void ReconnectCounter_IncrementsOnEachRecordedReconnect()
{
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary>
public class SiteCallDtoMapperTests
{
/// <summary>Verifies the case: from Dto, fully Populated, maps Every Field.</summary>
[Fact]
public void FromDto_FullyPopulated_MapsEveryField()
{
@@ -55,6 +56,7 @@ public class SiteCallDtoMapperTests
Assert.Equal(terminalAt, entity.TerminalAtUtc);
}
/// <summary>Verifies the case: from Dto, empty Last Error, becomes Null.</summary>
[Fact]
public void FromDto_EmptyLastError_BecomesNull()
{
@@ -66,6 +68,7 @@ public class SiteCallDtoMapperTests
Assert.Null(entity.LastError);
}
/// <summary>Verifies the case: from Dto, absent Http Status, stays Null.</summary>
[Fact]
public void FromDto_AbsentHttpStatus_StaysNull()
{
@@ -80,6 +83,7 @@ public class SiteCallDtoMapperTests
Assert.Null(entity.HttpStatus);
}
/// <summary>Verifies the case: from Dto, absent Terminal At, stays Null.</summary>
[Fact]
public void FromDto_AbsentTerminalAt_StaysNull()
{
@@ -93,6 +97,7 @@ public class SiteCallDtoMapperTests
Assert.Null(entity.TerminalAtUtc);
}
/// <summary>Verifies the case: from Dto, timestamps, rehydrate As Utc Kind.</summary>
[Fact]
public void FromDto_Timestamps_RehydrateAsUtcKind()
{
@@ -104,6 +109,7 @@ public class SiteCallDtoMapperTests
Assert.Equal(DateTimeKind.Utc, entity.UpdatedAtUtc.Kind);
}
/// <summary>Verifies the case: from Dto, ingested At Utc, stamped As Recent Placeholder.</summary>
[Fact]
public void FromDto_IngestedAtUtc_StampedAsRecentPlaceholder()
{
@@ -118,12 +124,14 @@ public class SiteCallDtoMapperTests
Assert.Equal(DateTimeKind.Utc, entity.IngestedAtUtc.Kind);
}
/// <summary>Verifies the case: from Dto, null, throws.</summary>
[Fact]
public void FromDto_Null_Throws()
{
Assert.Throws<ArgumentNullException>(() => SiteCallDtoMapper.FromDto(null!));
}
/// <summary>Verifies the case: site Call Operational Dto, round, trip, preserves, source Node.</summary>
[Fact]
public void SiteCallOperationalDto_round_trip_preserves_SourceNode()
{
@@ -141,6 +149,7 @@ public class SiteCallDtoMapperTests
Assert.Equal("node-a", entity.SourceNode);
}
/// <summary>Verifies the case: site Call Operational Dto, round, trip, preserves, null, source Node.</summary>
[Fact]
public void SiteCallOperationalDto_round_trip_preserves_null_SourceNode()
{
@@ -24,6 +24,7 @@ public class SiteCommandDispatcherTests : TestKit
// ── The 19 commands that forward to the Deployment Manager singleton proxy ──
/// <summary>The proxy-routed commands (lifecycle, OPC UA, debug snapshot/subscribe, route).</summary>
/// <returns>The proxy-routed command types, one per xunit member-data row.</returns>
public static IEnumerable<object[]> ProxyCommandTypes() => new[]
{
typeof(Commons.Messages.Deployment.RefreshDeploymentCommand),
@@ -47,6 +48,8 @@ public class SiteCommandDispatcherTests : TestKit
typeof(Commons.Messages.InboundApi.RouteToWaitForAttributeRequest),
}.Select(t => new object[] { t });
/// <summary>Verifies that each proxy-routed command is forwarded to the Deployment Manager proxy.</summary>
/// <param name="commandType">The command message type under test.</param>
[Theory]
[MemberData(nameof(ProxyCommandTypes))]
public void ProxyCommands_ForwardToDeploymentManager(Type commandType)
@@ -61,6 +64,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Same(dm.Ref, route.Target);
}
/// <summary>Verifies that unsubscribing a debug view is forwarded to the Deployment Manager as fire-and-forget and answered with a synthetic acknowledgement.</summary>
[Fact]
public void UnsubscribeDebugView_IsFireAndForget_ToDeploymentManager_WithSyntheticAck()
{
@@ -79,6 +83,7 @@ public class SiteCommandDispatcherTests : TestKit
// ── Artifact handler (null-guarded) ──
/// <summary>Verifies that a deploy-artifacts command with a handler is forwarded to the artifact handler and not to the proxy.</summary>
[Fact]
public void DeployArtifacts_WithHandler_ForwardsToArtifactHandler_NotTheProxy()
{
@@ -94,6 +99,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.NotSame(dm.Ref, route.Target);
}
/// <summary>Verifies that a deploy-artifacts command without a handler replies that the handler is not available.</summary>
[Fact]
public void DeployArtifacts_WithoutHandler_RepliesHandlerNotAvailable()
{
@@ -113,6 +119,7 @@ public class SiteCommandDispatcherTests : TestKit
// ── Event-log handler (null-guarded) ──
/// <summary>Verifies that an event log query with a handler is forwarded to the event log handler.</summary>
[Fact]
public void EventLogQuery_WithHandler_ForwardsToEventLogHandler()
{
@@ -127,6 +134,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Same(eventLog.Ref, route.Target);
}
/// <summary>Verifies that an event log query without a handler replies that the handler is not available.</summary>
[Fact]
public void EventLogQuery_WithoutHandler_RepliesHandlerNotAvailable()
{
@@ -145,6 +153,7 @@ public class SiteCommandDispatcherTests : TestKit
// ── Parked handler (null-guarded) — stays NODE-LOCAL on purpose (replicated store) ──
/// <summary>The five parked commands, each of which routes to the per-node parked handler.</summary>
/// <returns>The parked command types, one per xunit member-data row.</returns>
public static IEnumerable<object[]> ParkedCommandTypes() => new[]
{
typeof(ParkedMessageQueryRequest),
@@ -154,6 +163,8 @@ public class SiteCommandDispatcherTests : TestKit
typeof(DiscardParkedOperation),
}.Select(t => new object[] { t });
/// <summary>Verifies that each parked command with a handler is routed to the node-local parked handler and never to the proxy.</summary>
/// <param name="commandType">The command message type under test.</param>
[Theory]
[MemberData(nameof(ParkedCommandTypes))]
public void ParkedCommands_WithHandler_RouteToNodeLocalParkedHandler_NeverTheProxy(Type commandType)
@@ -173,6 +184,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.NotSame(dm.Ref, route.Target);
}
/// <summary>Verifies that a parked message query without a handler replies that the handler is not available.</summary>
[Fact]
public void ParkedMessageQuery_WithoutHandler_RepliesHandlerNotAvailable()
{
@@ -187,6 +199,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Equal(command.PageNumber, reply.PageNumber);
}
/// <summary>Verifies that a parked message retry without a handler replies that the handler is not available.</summary>
[Fact]
public void ParkedMessageRetry_WithoutHandler_RepliesHandlerNotAvailable()
{
@@ -198,6 +211,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Equal(command.CorrelationId, reply.CorrelationId);
}
/// <summary>Verifies that a parked message discard without a handler replies that the handler is not available.</summary>
[Fact]
public void ParkedMessageDiscard_WithoutHandler_RepliesHandlerNotAvailable()
{
@@ -209,6 +223,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Equal(command.CorrelationId, reply.CorrelationId);
}
/// <summary>Verifies that retrying a parked operation without a handler replies with a not-applied acknowledgement.</summary>
[Fact]
public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck()
{
@@ -221,6 +236,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.NotNull(reply.ErrorMessage);
}
/// <summary>Verifies that discarding a parked operation without a handler replies with a not-applied acknowledgement.</summary>
[Fact]
public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck()
{
@@ -234,6 +250,7 @@ public class SiteCommandDispatcherTests : TestKit
// ── Commands that must NOT enter ResolveRoute ──
/// <summary>Verifies that route resolution rejects the failover command, which must go through PrepareFailover instead.</summary>
[Fact]
public void ResolveRoute_RejectsFailover_ItGoesThroughPrepareFailover()
{
@@ -244,6 +261,7 @@ public class SiteCommandDispatcherTests : TestKit
// ── Failover (local path) ──
/// <summary>Verifies that handling a failover resolves the target, leaves the cluster, and then acknowledges with the target.</summary>
[Fact]
public void HandleFailover_ResolvesAndLeaves_ThenAcksWithTheTarget()
{
@@ -269,6 +287,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.True(leaveIssued);
}
/// <summary>Verifies that handling a failover is refused when there is no peer node.</summary>
[Fact]
public void HandleFailover_RefusesWhenThereIsNoPeer()
{
@@ -281,6 +300,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.NotNull(ack.ErrorMessage);
}
/// <summary>Verifies that a failover command addressed to another site is refused without touching the resolver.</summary>
[Fact]
public void HandleFailover_RefusesACommandAddressedToAnotherSite_WithoutTouchingTheResolver()
{
@@ -294,6 +314,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.False(invoked);
}
/// <summary>Verifies that a fault while leaving the cluster is reported in the reply rather than thrown.</summary>
[Fact]
public void HandleFailover_FaultInTheLeave_IsReportedNotThrown()
{
@@ -306,6 +327,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Contains("cluster unavailable", ack.ErrorMessage);
}
/// <summary>Verifies that PrepareFailover builds the acknowledgement from a dry run and does not leave the cluster until the commit-leave step runs.</summary>
[Fact]
public void PrepareFailover_BuildsTheAckFromADryRun_WithoutLeaving_UntilCommitLeaveRuns()
{
@@ -334,6 +356,7 @@ public class SiteCommandDispatcherTests : TestKit
Assert.Equal(new[] { "resolve", "leave" }, events);
}
/// <summary>Verifies that a refused PrepareFailover has no deferred leave to commit.</summary>
[Fact]
public void PrepareFailover_WhenRefused_HasNoDeferredLeave()
{
@@ -47,6 +47,9 @@ public class SiteCommandDtoMapperGoldenTests
.SelectMany(kv => Enumerable.Range(0, kv.Value.Length)
.Select(i => new object[] { kv.Key.FullName!, i }));
/// <summary>Verifies the case: round Trips, unchanged.</summary>
/// <param name="typeName">The typeName test input.</param>
/// <param name="sampleIndex">The sampleIndex test input.</param>
[Theory]
[MemberData(nameof(AllSamples))]
public void RoundTrips_Unchanged(string typeName, int sampleIndex)
@@ -133,6 +136,7 @@ public class SiteCommandDtoMapperGoldenTests
/// declared in the four multi-command request envelopes. A new proto case
/// with no producing command fails here.
/// </summary>
/// <param name="envelopeName">The name of the request envelope under test.</param>
[Theory]
[MemberData(nameof(RequestEnvelopes))]
public void EveryRequestOneofCase_IsProducedByACommand(string envelopeName)
@@ -152,6 +156,7 @@ public class SiteCommandDtoMapperGoldenTests
}
/// <summary>The reply-envelope mirror of <see cref="EveryRequestOneofCase_IsProducedByACommand"/>.</summary>
/// <param name="envelopeName">The name of the reply envelope under test.</param>
[Theory]
[MemberData(nameof(ReplyEnvelopes))]
public void EveryReplyOneofCase_IsProducedByAReply(string envelopeName)
@@ -232,6 +237,8 @@ public class SiteCommandDtoMapperGoldenTests
// Enum exhaustiveness
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies the case: deployment Status, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(DeploymentStatus.Pending)]
[InlineData(DeploymentStatus.InProgress)]
@@ -240,6 +247,8 @@ public class SiteCommandDtoMapperGoldenTests
public void DeploymentStatus_RoundTrips(DeploymentStatus value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: browse Node Class, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(BrowseNodeClass.Object)]
[InlineData(BrowseNodeClass.Variable)]
@@ -248,6 +257,8 @@ public class SiteCommandDtoMapperGoldenTests
public void BrowseNodeClass_RoundTrips(BrowseNodeClass value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: browse Failure Kind, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(BrowseFailureKind.ConnectionNotFound)]
[InlineData(BrowseFailureKind.ConnectionNotConnected)]
@@ -257,6 +268,8 @@ public class SiteCommandDtoMapperGoldenTests
public void BrowseFailureKind_RoundTrips(BrowseFailureKind value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: read Tag Values Failure Kind, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(ReadTagValuesFailureKind.ConnectionNotFound)]
[InlineData(ReadTagValuesFailureKind.ConnectionNotConnected)]
@@ -265,6 +278,8 @@ public class SiteCommandDtoMapperGoldenTests
public void ReadTagValuesFailureKind_RoundTrips(ReadTagValuesFailureKind value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: verify Failure Kind, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(VerifyFailureKind.Unreachable)]
[InlineData(VerifyFailureKind.AuthFailed)]
@@ -274,6 +289,8 @@ public class SiteCommandDtoMapperGoldenTests
public void VerifyFailureKind_RoundTrips(VerifyFailureKind value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: store And Forward Category, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(StoreAndForwardCategory.ExternalSystem)]
[InlineData(StoreAndForwardCategory.Notification)]
@@ -281,12 +298,16 @@ public class SiteCommandDtoMapperGoldenTests
public void StoreAndForwardCategory_RoundTrips(StoreAndForwardCategory value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: alarm State, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(AlarmState.Active)]
[InlineData(AlarmState.Normal)]
public void AlarmState_RoundTrips(AlarmState value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: alarm Level, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(AlarmLevel.None)]
[InlineData(AlarmLevel.Low)]
@@ -296,6 +317,8 @@ public class SiteCommandDtoMapperGoldenTests
public void AlarmLevel_RoundTrips(AlarmLevel value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: alarm Kind, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(AlarmKind.Computed)]
[InlineData(AlarmKind.NativeOpcUa)]
@@ -303,6 +326,8 @@ public class SiteCommandDtoMapperGoldenTests
public void AlarmKind_RoundTrips(AlarmKind value) =>
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
/// <summary>Verifies the case: alarm Shelve State, round Trips.</summary>
/// <param name="value">The value test input.</param>
[Theory]
[InlineData(AlarmShelveState.Unshelved)]
[InlineData(AlarmShelveState.OneShotShelved)]
@@ -17,6 +17,7 @@ public class SiteCommandDtoMapperTraceTests
{
private static readonly DateTimeOffset T = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero);
/// <summary>Verifies that a route-to-call response round-trips its child execution id and trace events.</summary>
[Fact]
public void RouteToCallResponse_RoundTrips_ChildExecutionId_AndTraceEvents()
{
@@ -43,6 +44,7 @@ public class SiteCommandDtoMapperTraceTests
Assert.Equal(run, restored.TraceEvents[0]);
}
/// <summary>Verifies that old-shape DTOs map to untraced defaults.</summary>
[Fact]
public void OldShapeDtos_MapToUntracedDefaults()
{
@@ -70,6 +72,7 @@ public class SiteCommandDtoMapperTraceTests
Assert.Null(reply.TraceEvents);
}
/// <summary>Verifies that the trace flag propagates on both request types.</summary>
[Fact]
public void TraceFlag_PropagatesOnBothRequests()
{
@@ -82,6 +85,7 @@ public class SiteCommandDtoMapperTraceTests
Assert.True(SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(set)).Trace);
}
/// <summary>Verifies that an empty trace events list maps to null rather than an empty list.</summary>
[Fact]
public void EmptyTraceEventsList_MapsToNull_NotEmpty()
{
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// The two-sample rule is the point. A single "typical" sample proves only that
/// the happy path survives; the pairs are what catch a dropped optional field or
/// a null/empty collapse — the class of silent data loss the transport
/// round-trip guard exposed in PLAN-05 T8, which per-field unit tests had all
/// round-trip guard exposed by the transport round-trip guard, which per-field unit tests had all
/// missed.
/// </para>
/// <para>
@@ -222,7 +222,7 @@ internal static class SiteCommandSamples
private static SharedScriptArtifact SharedScript() =>
new("Calc", "return 1;", "{\"p\":\"int\"}", "{\"r\":\"int\"}");
// MaxRetries/RetryDelay are non-default on purpose: they are the deferred-work-row-27
// MaxRetries/RetryDelay are non-default on purpose: they are the deferred-work
// additions (proto fields 7-8), and the generic round-trip assertion is what pins them
// to the wire. A sample left at the defaults would round-trip vacuously.
private static ExternalSystemArtifact ExternalSystem() =>
@@ -13,17 +13,19 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// WP-4: Tests for SiteCommunicationActor message routing to local actors.
/// Tests for SiteCommunicationActor message routing to local actors.
/// </summary>
public class SiteCommunicationActorTests : TestKit
{
private readonly CommunicationOptions _options = new();
/// <summary>Initializes the test kit and the actor under test.</summary>
public SiteCommunicationActorTests()
: base(@"akka.loglevel = DEBUG")
{
}
/// <summary>Verifies that a refresh deployment command is forwarded to the Deployment Manager.</summary>
[Fact]
public void RefreshDeploymentCommand_ForwardedToDeploymentManager()
{
@@ -39,6 +41,7 @@ public class SiteCommunicationActorTests : TestKit
dmProbe.ExpectMsg<RefreshDeploymentCommand>(msg => msg.DeploymentId == "dep1");
}
/// <summary>Verifies that lifecycle commands are forwarded to the Deployment Manager.</summary>
[Fact]
public void LifecycleCommands_ForwardedToDeploymentManager()
{
@@ -56,10 +59,11 @@ public class SiteCommunicationActorTests : TestKit
dmProbe.ExpectMsg<DeleteInstanceCommand>();
}
/// <summary>Verifies that a deployment state query is forwarded to the Deployment Manager.</summary>
[Fact]
public void DeploymentStateQuery_ForwardedToDeploymentManager()
{
// DeploymentManager-006: the site-before-redeploy query travels over the
// The site-before-redeploy query travels over the
// gRPC command/control transport and is routed to the local
// Deployment Manager, which owns the deployed-config store.
var dmProbe = CreateTestProbe();
@@ -78,6 +82,7 @@ public class SiteCommunicationActorTests : TestKit
// owns the wire, it delegates to an injected ICentralTransport, and those seven sends (plus the
// reply routing and the fail-loud fallback) are now covered by SiteCommunicationActorTransportTests.
/// <summary>Verifies that an event log query without a registered handler returns a failure.</summary>
[Fact]
public void EventLogQuery_WithoutHandler_ReturnsFailure()
{
@@ -93,8 +98,9 @@ public class SiteCommunicationActorTests : TestKit
ExpectMsg<EventLogQueryResponse>(msg => !msg.Success);
}
// ── Task 5 (#22): central→site Retry/Discard relay for parked cached calls ──
// ── Central→site Retry/Discard relay for parked cached calls ──
/// <summary>Verifies that a retry of a parked operation is forwarded to the parked message handler when one is registered.</summary>
[Fact]
public void RetryParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
{
@@ -112,6 +118,7 @@ public class SiteCommunicationActorTests : TestKit
msg.CorrelationId == "corr-rp" && msg.TrackedOperationId.Equals(id));
}
/// <summary>Verifies that a discard of a parked operation is forwarded to the parked message handler when one is registered.</summary>
[Fact]
public void DiscardParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
{
@@ -129,6 +136,7 @@ public class SiteCommunicationActorTests : TestKit
msg.CorrelationId == "corr-dp" && msg.TrackedOperationId.Equals(id));
}
/// <summary>Verifies that a retry of a parked operation without a handler replies with a not-applied acknowledgement.</summary>
[Fact]
public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck()
{
@@ -147,6 +155,7 @@ public class SiteCommunicationActorTests : TestKit
Assert.NotNull(ack.ErrorMessage);
}
/// <summary>Verifies that a discard of a parked operation without a handler replies with a not-applied acknowledgement.</summary>
[Fact]
public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck()
{
@@ -161,15 +170,16 @@ public class SiteCommunicationActorTests : TestKit
Assert.NotNull(ack.ErrorMessage);
}
// ── M7 OPC UA cross-cluster routing: Search (T15), WriteTag (T14), Verify (T17) ──
// ── OPC UA cross-cluster routing: Search, WriteTag, Verify ──
//
// Regression guard for the M7 dead-letter defect. These three interactive
// Regression guard for the OPC UA dead-letter defect. These three interactive
// commands have downstream handlers in DataConnectionManagerActor but were NOT
// forwarded through SiteCommunicationActor → Deployment Manager, so they
// dead-lettered and the central Ask timed out in the real cluster. They must
// forward to the Deployment Manager proxy exactly like BrowseNodeCommand, with
// the original Ask sender preserved so the result routes straight back.
/// <summary>Verifies that a search address space command is forwarded to the Deployment Manager with the original sender preserved.</summary>
[Fact]
public void SearchAddressSpaceCommand_ForwardedToDeploymentManager_SenderPreserved()
{
@@ -185,6 +195,7 @@ public class SiteCommunicationActorTests : TestKit
Assert.Equal(senderProbe.Ref, dmProbe.LastSender);
}
/// <summary>Verifies that a write tag request is forwarded to the Deployment Manager with the original sender preserved.</summary>
[Fact]
public void WriteTagRequest_ForwardedToDeploymentManager_SenderPreserved()
{
@@ -201,6 +212,7 @@ public class SiteCommunicationActorTests : TestKit
Assert.Equal(senderProbe.Ref, dmProbe.LastSender);
}
/// <summary>Verifies that a verify endpoint command is forwarded to the Deployment Manager with the original sender preserved.</summary>
[Fact]
public void VerifyEndpointCommand_ForwardedToDeploymentManager_SenderPreserved()
{
@@ -216,14 +228,18 @@ public class SiteCommunicationActorTests : TestKit
Assert.Equal(senderProbe.Ref, dmProbe.LastSender);
}
// ── Communication-018: heartbeat IsActive reflects this node's cluster role ──
// ── Heartbeat: IsActive reflects this node's cluster role ──
/// <summary>
/// Verifies that the heartbeat stamps IsActive from the injected active-node check.
/// </summary>
/// <param name="isActive">The active-node verdict that the injected check returns.</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Heartbeat_StampsIsActive_FromInjectedCheck(bool isActive)
{
// Communication-018: HeartbeatMessage.IsActive must reflect the actual
// HeartbeatMessage.IsActive must reflect the actual
// active/standby role of this node, not a hard-coded `true`. The actor
// now takes a Func<bool> override (defaulting to a real Akka.Cluster
// leader check in production); tests inject a stub so they do not need
@@ -247,12 +263,13 @@ public class SiteCommunicationActorTests : TestKit
TimeSpan.FromSeconds(3));
}
// ---- Central→site failover relay (Task 10) ----------------------------------
// ---- Central→site failover relay ----------------------------------
// The failover action is injected for the same reason isActiveCheck is: performing a
// real Cluster.Leave would require Akka.Cluster in the TestKit ActorSystem. These
// tests pin the ROUTING and GUARD logic; the actual Leave against a real two-node
// site cluster is covered by SiteFailoverRelayTests in the integration suite.
/// <summary>Verifies that triggering a site failover issues the leave and acknowledges with the target node.</summary>
[Fact]
public void TriggerSiteFailover_IssuesTheLeave_AndAcksWithTheTarget()
{
@@ -278,6 +295,7 @@ public class SiteCommunicationActorTests : TestKit
Assert.Equal("site-site1", roleAskedFor);
}
/// <summary>Verifies that triggering a site failover is refused when there is no peer node.</summary>
[Fact]
public void TriggerSiteFailover_RefusesWhenThereIsNoPeer()
{
@@ -294,6 +312,7 @@ public class SiteCommunicationActorTests : TestKit
Assert.NotNull(ack.ErrorMessage);
}
/// <summary>Verifies that a failover command addressed to another site is refused.</summary>
[Fact]
public void TriggerSiteFailover_RefusesACommandAddressedToAnotherSite()
{
@@ -317,6 +336,7 @@ public class SiteCommunicationActorTests : TestKit
Assert.False(invoked);
}
/// <summary>Verifies that a fault while issuing the leave is reported rather than thrown.</summary>
[Fact]
public void TriggerSiteFailover_FaultInTheLeave_IsReportedNotThrown()
{
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
public class SiteStreamIngestAuditEventsTests
{
/// <summary>Verifies that the retired ingest method rejects both empty and non-empty batches.</summary>
/// <param name="nonEmpty">Whether the submitted batch contains events.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
public class SiteStreamIngestCachedTelemetryTests
{
/// <summary>Verifies that the retired method rejects both empty and non-empty batches.</summary>
/// <param name="nonEmpty">Whether the batch under test contains any entries.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Tests for <see cref="SiteStreamGrpcServer.PullAuditEvents"/>: the request →
/// <c>ISiteAuditQueue.ReadPendingSinceAsync</c> → response round-trip, plus the WP2.3
/// <c>ISiteAuditQueue.ReadPendingSinceAsync</c> → response round-trip, plus the
/// at-least-once contract — rows are retired by the NEXT pull's cursor
/// (<c>MarkReconciledUpToAsync</c>), never by the act of serving them. The queue is an
/// NSubstitute stub so the tests never touch SQLite.
@@ -33,7 +33,7 @@ public class SiteStreamPullAuditEventsTests : TestKit
return context;
}
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
// Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
// ForwardState is no longer a record field — it is a site-storage-only concern.
private static AuditEvent NewEvent(DateTime? occurredAt = null) =>
ScadaBridgeAuditEventFactory.Create(
@@ -44,6 +44,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
sourceSiteId: "site-1");
/// <summary>Verifies that large audit rows are paged by wire bytes and cursor makes progress.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LargeAuditRows_ArePagedByWireBytes_AndCursorMakesProgress()
{
@@ -72,6 +74,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Assert.Equal(256, seen.Count);
}
/// <summary>Verifies that pull audit events no queue wired returns empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_NoQueueWired_ReturnsEmptyResponse()
{
@@ -91,6 +95,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Assert.False(response.MoreAvailable);
}
/// <summary>Verifies that pull audit events with 5 pending rows returns all five dtos and does not flip them.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_With5PendingRows_ReturnsAllFiveDtos_AndDoesNotFlipThem()
{
@@ -122,6 +128,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that pull audit events fault between response and next pull reserves the same rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_FaultBetweenResponseAndNextPull_ReservesTheSameRows()
{
@@ -161,6 +169,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
since, null, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that pull audit events advanced cursor retires everything up to it before reading.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_AdvancedCursor_RetiresEverythingUpToIt_BeforeReading()
{
@@ -193,6 +203,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
cursorTime, 100, cursorId, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that pull audit events first ever pull does not flip anything.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_FirstEverPull_DoesNotFlipAnything()
{
@@ -212,6 +224,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Arg.Any<DateTime>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that pull audit events rows older than since utc excluded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_RowsOlderThanSinceUtc_Excluded()
{
@@ -246,6 +260,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Assert.Equal(since, capturedSince);
}
/// <summary>Verifies that pull audit events batch size 3 returns 3 rows more available true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_BatchSize3_Returns3Rows_MoreAvailableTrue()
{
@@ -271,6 +287,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Assert.True(response.MoreAvailable);
}
/// <summary>Verifies that pull audit events retires before it serves so this batch is never self retired.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_RetiresBeforeItServes_SoThisBatchIsNeverSelfRetired()
{
@@ -310,6 +328,8 @@ public class SiteStreamPullAuditEventsTests : TestKit
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that pull audit events mark reconciled up to throws response still returned.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAuditEvents_MarkReconciledUpToThrows_ResponseStillReturned()
{
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Tests for <see cref="SiteStreamGrpcServer.PullSiteCalls"/> (Site Call Audit
/// #22 reconciliation handler). Verifies the request →
/// reconciliation handler). Verifies the request →
/// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/> → response
/// round-trip through the gRPC handler. The store is an NSubstitute stub so the
/// tests never touch SQLite. Mirrors <see cref="SiteStreamPullAuditEventsTests"/>
@@ -48,6 +48,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
UpdatedAtUtc: DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 1, 0), DateTimeKind.Utc),
TerminalAtUtc: null);
/// <summary>Verifies that pulling site calls with no tracking store wired returns an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_NoStoreWired_ReturnsEmptyResponse()
{
@@ -67,6 +69,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.False(response.MoreAvailable);
}
/// <summary>Verifies that five stored rows are returned as five DTOs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_With5Rows_ReturnsAllFiveDtos()
{
@@ -92,6 +96,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.True(expectedIds.SetEquals(response.Operationals.Select(d => d.TrackedOperationId).ToHashSet()));
}
/// <summary>Verifies that the since timestamp is passed to the store verbatim.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_PassesSinceUtcThroughVerbatim()
{
@@ -121,10 +127,12 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.Equal(since, capturedSince);
}
/// <summary>Verifies that a non-empty after id is passed to the store as the cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_AfterIdSet_PassesCursorThroughToStore()
{
// Task 15: a non-empty after_id is forwarded verbatim to the store as the
// A non-empty after_id is forwarded verbatim to the store as the
// composite-keyset cursor.
var store = Substitute.For<IOperationTrackingStore>();
string? capturedAfterId = "SENTINEL";
@@ -151,6 +159,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.Equal(cursor, capturedAfterId);
}
/// <summary>Verifies that an unset after id is passed to the store as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_AfterIdUnset_PassesNullToStore()
{
@@ -180,6 +190,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.Null(capturedAfterId);
}
/// <summary>Verifies that an unset since timestamp is passed to the store as <see cref="DateTime.MinValue"/>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_SinceUtcUnset_PassesDateTimeMinValue()
{
@@ -212,6 +224,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.Equal(DateTime.MinValue, captured);
}
/// <summary>Verifies that a batch size of three with more rows available returns three rows and flags that more are available.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_BatchSize3_Returns3Rows_MoreAvailableTrue()
{
@@ -236,6 +250,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.True(response.MoreAvailable);
}
/// <summary>Verifies that a non-positive batch size throws an invalid-argument error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_NonPositiveBatchSize_ThrowsInvalidArgument()
{
@@ -254,6 +270,8 @@ public class SiteStreamPullSiteCallsTests : TestKit
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that a store read failure returns an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullSiteCalls_ReadThrows_ReturnsEmptyResponse()
{
@@ -34,6 +34,7 @@ public class SiteStreamPullTraceEventsTests : TestKit
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc), executionId: executionId, sourceSiteId: "site-a");
/// <summary>Verifies that the pull-trace-events RPC sits under the preshared-key-gated service prefix.</summary>
[Fact]
public void PullTraceEvents_IsUnderThePskGatedServicePrefix()
{
@@ -42,6 +43,8 @@ public class SiteStreamPullTraceEventsTests : TestKit
Assert.Equal("sitestream.SiteStreamService", SiteStreamService.Descriptor.FullName);
}
/// <summary>Verifies that the RPC returns an empty result when no queue is wired.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoQueueWired_ReturnsEmpty()
{
@@ -52,6 +55,8 @@ public class SiteStreamPullTraceEventsTests : TestKit
Assert.Empty(response.Events);
}
/// <summary>Verifies that the RPC reads events by id, projects them to DTOs and never changes their state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadsByIds_ProjectsDtos_AndNeverFlipsState()
{
@@ -83,6 +88,10 @@ public class SiteStreamPullTraceEventsTests : TestKit
await queue.DidNotReceiveWithAnyArgs().MarkReconciledAsync(default!, default);
}
/// <summary>Verifies that invalid arguments are rejected.</summary>
/// <param name="maxEvents">The requested maximum number of events.</param>
/// <param name="idCount">The number of ids supplied in the request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(0, 1)]
[InlineData(2001, 1)]
@@ -99,6 +108,8 @@ public class SiteStreamPullTraceEventsTests : TestKit
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that an id that is not a GUID is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NonGuidId_IsRejected()
{
@@ -109,6 +120,8 @@ public class SiteStreamPullTraceEventsTests : TestKit
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that a queue fault collapses to an empty result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueueFault_CollapsesToEmpty()
{
@@ -27,6 +27,7 @@ public class StreamRelayTraceStampTests : TestKit
return evt!;
}
/// <summary>Verifies that a traced change is stamped on the wire and parsed back.</summary>
[Fact]
public void TracedChange_IsStampedOnTheWire_AndParsedBack()
{
@@ -43,6 +44,7 @@ public class StreamRelayTraceStampTests : TestKit
Assert.True(back.Trace);
}
/// <summary>Verifies that an unstamped change round-trips to null and false.</summary>
[Fact]
public void UnstampedChange_RoundTripsToNullAndFalse()
{
@@ -57,6 +59,7 @@ public class StreamRelayTraceStampTests : TestKit
Assert.False(back.Trace);
}
/// <summary>Verifies that an older site frame without the trace fields parses to null and false.</summary>
[Fact]
public void OlderSiteFrame_WithoutTheFields_ParsesToNullAndFalse()
{
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Arch-review phase-2 residual #3: <see cref="CentralChannelProvider"/>'s failback probe reuses
/// <see cref="CentralChannelProvider"/>'s failback probe reuses
/// the <c>Heartbeat</c> RPC to ask "does the preferred central endpoint answer again?". It is
/// emitted by a site's TRANSPORT layer, not by any node's heartbeat timer, so central must not
/// count it as liveness — otherwise a site whose real heartbeats had stopped keeps looking alive
@@ -33,6 +33,7 @@ public class SyntheticHeartbeatTests : TestKit
// ── The consumer: central skips liveness bookkeeping for a synthetic heartbeat ───
/// <summary>Verifies that a synthetic heartbeat does not mark the health aggregator.</summary>
[Fact]
public void SyntheticHeartbeat_DoesNotMarkTheHealthAggregator()
{
@@ -46,6 +47,7 @@ public class SyntheticHeartbeatTests : TestKit
aggregator.DidNotReceiveWithAnyArgs().MarkHeartbeat(default!, default);
}
/// <summary>Verifies that a real heartbeat still marks the health aggregator.</summary>
[Fact]
public void RealHeartbeat_StillMarksTheHealthAggregator()
{
@@ -58,6 +60,7 @@ public class SyntheticHeartbeatTests : TestKit
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", T0));
}
/// <summary>Verifies that a replicated synthetic heartbeat is also skipped.</summary>
[Fact]
public void SyntheticHeartbeatReplica_IsAlsoSkipped()
{
@@ -75,6 +78,8 @@ public class SyntheticHeartbeatTests : TestKit
// ── The wire contract: the flag survives the round trip ─────────────────────────
/// <summary>Verifies that the synthetic flag round-trips through the heartbeat DTO.</summary>
/// <param name="synthetic">The synthetic flag value under test.</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
@@ -89,6 +94,7 @@ public class SyntheticHeartbeatTests : TestKit
Assert.Equal(msg with { Synthetic = synthetic }, back);
}
/// <summary>Verifies that a DTO from an older site defaults to not synthetic.</summary>
[Fact]
public void ADtoFromAnOlderSite_DefaultsToNotSynthetic()
{
@@ -106,6 +112,8 @@ public class SyntheticHeartbeatTests : TestKit
// ── The producer: the failback probe marks itself synthetic ─────────────────────
/// <summary>Verifies that the failback probe marks its heartbeat as synthetic.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TheFailbackProbe_MarksItsHeartbeatSynthetic()
{
@@ -155,7 +163,9 @@ public class SyntheticHeartbeatTests : TestKit
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
@@ -169,6 +179,9 @@ public class SyntheticHeartbeatTests : TestKit
private readonly TaskCompletionSource<HeartbeatDto> _captured =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Waits for the captured heartbeat request.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The decoded heartbeat DTO.</returns>
public async Task<HeartbeatDto> WaitForHeartbeatAsync(TimeSpan timeout)
{
var completed = await Task.WhenAny(_captured.Task, Task.Delay(timeout));
@@ -176,6 +189,7 @@ public class SyntheticHeartbeatTests : TestKit
return await _captured.Task;
}
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
@@ -36,6 +36,7 @@ public class WireSerializationPinTests : TestKit
return (T)serialization.Deserialize(bytes, serializer.Identifier, message!.GetType());
}
/// <summary>Verifies that a heartbeat message round-trips on the wire.</summary>
[Fact]
public void HeartbeatMessage_RoundTripsOnTheWire()
{
@@ -44,6 +45,7 @@ public class WireSerializationPinTests : TestKit
Assert.Equal(original, back); // all-scalar record → structural equality holds
}
/// <summary>Verifies that a notification submit message round-trips on the wire.</summary>
[Fact]
public void NotificationSubmit_RoundTripsOnTheWire()
{
@@ -77,6 +79,7 @@ public class WireSerializationPinTests : TestKit
Assert.Equal(original.SourceNode, back.SourceNode);
}
/// <summary>Verifies that an ingest-audit-events command round-trips on the wire.</summary>
[Fact]
public void IngestAuditEventsCommand_RoundTripsOnTheWire()
{
@@ -130,6 +133,7 @@ public class WireSerializationPinTests : TestKit
Assert.Equal(o.ErrorMessage, rt.ErrorMessage);
}
/// <summary>Verifies that a site health report round-trips on the wire.</summary>
[Fact]
public void SiteHealthReport_RoundTripsOnTheWire()
{
@@ -181,6 +185,9 @@ public class WireSerializationPinTests : TestKit
// central↔site communication. If one of these assertions fails, you are making
// a wire-breaking change — coordinate a full-fleet upgrade or add an explicit
// serialization binding before renaming/moving the type.
/// <summary>Verifies that each cross-cluster contract type keeps its pinned full name.</summary>
/// <param name="type">The contract type under test.</param>
/// <param name="expectedFullName">The full type name that must stay stable on the wire.</param>
[Theory]
[InlineData(typeof(HeartbeatMessage), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.HeartbeatMessage")]
[InlineData(typeof(NotificationSubmit), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationSubmit")]