using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
///
/// Carries the worker's truncated-fetch verdict across the gateway: worker
/// reply payload → → the public
/// QueryActiveAlarms stream.
///
///
///
/// The truncation guard itself (the worker merging rather than replacing
/// a capped snapshot) is already covered in the worker suite. What was
/// missing is that the guard is silent: a capped fetch suppresses
/// absence-implies-Clear inference and says so only in a rate-limited
/// stderr warning, so a consumer of the alarm surface could not tell a
/// complete active set from a capped one. These tests pin the structural
/// signal that replaces the guesswork.
///
///
/// The load-bearing assertion is the false one
/// ().
/// "Truncated reply sets the flag" would also pass against a field
/// hard-wired to true; only the complete-reply case proves the flag is
/// actually derived from the worker's verdict.
///
///
public sealed class AlarmTruncationSignalTests
{
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
///
/// A capped worker reply sets the monitor's completeness caveat and
/// stamps every cached snapshot, so both the dashboard (which reads the
/// service flag) and the RPC (which reads the records) can surface it.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task Reconcile_WithTruncatedWorkerReply_SurfacesTheFlagOnMonitorAndSnapshots()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = true,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
Assert.True(monitor.SnapshotTruncated);
ActiveAlarmSnapshot cached = Assert.Single(monitor.CurrentAlarms);
Assert.True(cached.FromTruncatedSnapshot);
// Truncation is about the completeness of the SET, not the fidelity of
// the record — the subtag-fallback flag must stay independent of it.
Assert.False(cached.Degraded);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
///
/// The public QueryActiveAlarms stream carries the per-record flag
/// through untouched. That RPC returns a bare
/// stream ActiveAlarmSnapshot with no envelope message, so the
/// per-record boolean is the only place set-level degraded status can
/// ride — if the service ever starts re-projecting records instead of
/// forwarding them, this is what catches the dropped field.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task QueryActiveAlarms_WithTruncatedSnapshot_StreamsTheFlagToTheClient()
{
FakeGatewayAlarmService alarms = new()
{
SnapshotTruncated = true,
CurrentAlarms = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
MxAccessGatewayService service = CreateService(alarms);
RecordingServerStreamWriter sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
sink,
new TestServerCallContext());
ActiveAlarmSnapshot streamed = Assert.Single(sink.Messages);
Assert.True(streamed.FromTruncatedSnapshot);
Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", streamed.AlarmFullReference);
}
///
/// The control. A complete worker reply must leave both the monitor flag
/// and the streamed records unset — otherwise every snapshot would read
/// as possibly-incomplete and the signal would carry no information.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = false,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: false)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
Assert.False(monitor.SnapshotTruncated);
MxAccessGatewayService service = CreateService(new FakeGatewayAlarmService
{
SnapshotTruncated = monitor.SnapshotTruncated,
CurrentAlarms = monitor.CurrentAlarms,
});
RecordingServerStreamWriter sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
sink,
new TestServerCallContext());
Assert.False(Assert.Single(sink.Messages).FromTruncatedSnapshot);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot)
{
return new ActiveAlarmSnapshot
{
AlarmFullReference = reference,
SourceObjectReference = "Tank01.Level",
AlarmTypeName = "HiHi",
Category = "Area",
Severity = 500,
CurrentState = AlarmConditionState.Active,
SourceProvider = AlarmProviderMode.Alarmmgr,
FromTruncatedSnapshot = fromTruncatedSnapshot,
};
}
private static GatewayAlarmMonitor CreateMonitor(StubSessionManager sessions, GatewayMetrics metrics)
{
AlarmsOptions options = new()
{
Enabled = true,
SubscriptionExpression = @"\\NODE\Galaxy!Area",
};
return new GatewayAlarmMonitor(
sessions,
new StubWatchListResolver(),
metrics,
Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
NullLogger.Instance);
}
private static MxAccessGatewayService CreateService(FakeGatewayAlarmService alarms)
{
StubSessionManager sessions = new();
return new MxAccessGatewayService(
sessions,
new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new StubEventStreamService(),
new GatewayMetrics(),
NullLogger.Instance,
alarms);
}
private static async Task WaitUntilAsync(Func condition, TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met in time.");
}
/// that resolves an empty watch-list.
private sealed class StubWatchListResolver : IAlarmWatchListResolver
{
///
public Task> ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default) =>
Task.FromResult>([]);
}
///
/// Minimal that answers the monitor's
/// QueryActiveAlarms with a scripted reply payload — the seam this suite
/// drives the truncation verdict through.
///
private sealed class StubSessionManager : ISessionManager
{
private readonly Channel _events = Channel.CreateUnbounded();
private readonly TaskCompletionSource _reconciled =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// Gets or sets the truncation verdict the scripted reply carries.
public bool SnapshotTruncated { get; init; }
/// Gets or sets the snapshots the scripted reply carries.
public IReadOnlyList Snapshots { get; init; } = [];
/// Completes once the monitor has issued its first QueryActiveAlarms.
/// The maximum time to wait.
/// A task that represents the asynchronous operation.
public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout);
///
public Task OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken)
{
GatewaySession session = new(
Guid.NewGuid().ToString("N"),
"Galaxy",
"pipe-test",
"nonce-test",
clientIdentity,
null,
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
session.MarkReady();
return Task.FromResult(session);
}
///
public Task InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
MxCommandReply reply = new()
{
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms)
{
QueryActiveAlarmsReplyPayload payload = new() { SnapshotTruncated = SnapshotTruncated };
payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone()));
reply.QueryActiveAlarms = payload;
_reconciled.TrySetResult();
}
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
///
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = null;
return false;
}
///
public Task CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
{
_events.Writer.TryComplete();
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
///
public Task KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
///
public Task CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
Task.FromResult(0);
///
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
///
/// stub — QueryActiveAlarms never
/// touches the event path, but the service constructor requires one.
///
private sealed class StubEventStreamService : IEventStreamService
{
///
public async IAsyncEnumerable StreamEventsAsync(
StreamEventsRequest request,
string? callerKeyId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
}
}