using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
///
/// Verifies that honours
/// MxGateway:Dashboard:ShowTagValues (TST-16): the alarm value fields of
/// both value-bearing payload arms are stripped from the copy broadcast to
/// browser clients when the flag is off, present when it is on, and the source
/// — shared with the gRPC StreamAlarms
/// subscribers and the alarms page — is never mutated.
///
public sealed class AlarmsHubPublisherTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// Both value-bearing arms lose their values when the flag is off; metadata survives.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ExecuteAsync_WhenShowTagValuesFalse_RedactsBothArmsButKeepsMetadata()
{
AlarmFeedMessage transition = BuildTransition();
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
transition,
activeAlarm);
Assert.Equal(2, hubContext.Sent.Count);
AlarmFeedMessage sentTransition = hubContext.Sent[0];
Assert.Null(sentTransition.Transition.CurrentValue);
Assert.Null(sentTransition.Transition.LimitValue);
Assert.Equal("Tank01.Level.HiHi", sentTransition.Transition.AlarmFullReference);
Assert.Equal("Tank01", sentTransition.Transition.SourceObjectReference);
Assert.Equal(AlarmTransitionKind.Raise, sentTransition.Transition.TransitionKind);
Assert.Equal(800, sentTransition.Transition.Severity);
Assert.Equal("Process", sentTransition.Transition.Category);
AlarmFeedMessage sentActive = hubContext.Sent[1];
Assert.Null(sentActive.ActiveAlarm.CurrentValue);
Assert.Null(sentActive.ActiveAlarm.LimitValue);
Assert.Equal("Tank02.Level.Lo", sentActive.ActiveAlarm.AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, sentActive.ActiveAlarm.CurrentState);
Assert.Equal(500, sentActive.ActiveAlarm.Severity);
}
///
/// Redaction applies to a clone: the source message fans out to gRPC
/// StreamAlarms subscribers and the alarms page, so it must keep its
/// values.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task ExecuteAsync_WhenShowTagValuesFalse_DoesNotMutateSourceMessage()
{
AlarmFeedMessage transition = BuildTransition();
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
transition,
activeAlarm);
Assert.NotNull(transition.Transition.CurrentValue);
Assert.Equal(88.0, transition.Transition.CurrentValue.DoubleValue);
Assert.NotNull(transition.Transition.LimitValue);
Assert.NotNull(activeAlarm.ActiveAlarm.CurrentValue);
Assert.Equal(12.5, activeAlarm.ActiveAlarm.CurrentValue.DoubleValue);
Assert.NotNull(activeAlarm.ActiveAlarm.LimitValue);
Assert.NotSame(transition, hubContext.Sent[0]);
Assert.NotSame(activeAlarm, hubContext.Sent[1]);
}
/// Values pass through unredacted — and uncloned — when the flag is on.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ExecuteAsync_WhenShowTagValuesTrue_KeepsValues()
{
AlarmFeedMessage transition = BuildTransition();
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: true,
transition,
activeAlarm);
Assert.Same(transition, hubContext.Sent[0]);
Assert.Same(activeAlarm, hubContext.Sent[1]);
Assert.Equal(88.0, hubContext.Sent[0].Transition.CurrentValue.DoubleValue);
Assert.Equal(90.0, hubContext.Sent[0].Transition.LimitValue.DoubleValue);
Assert.Equal(12.5, hubContext.Sent[1].ActiveAlarm.CurrentValue.DoubleValue);
Assert.Equal(10.0, hubContext.Sent[1].ActiveAlarm.LimitValue.DoubleValue);
}
///
/// A payload arm that carries no value is forwarded as-is — the same
/// instance, no clone. This is also the contract for arms added later: the
/// switch names only the value-bearing arms, so a new arm passes through.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task ExecuteAsync_WithValuelessPayloads_ForwardsThemUntouched()
{
AlarmFeedMessage snapshotComplete = new() { SnapshotComplete = true };
AlarmFeedMessage providerStatus = new()
{
ProviderStatus = new AlarmProviderStatus
{
Mode = AlarmProviderMode.Subtag,
Degraded = true,
Reason = "alarmmgr unavailable",
},
};
AlarmFeedMessage snapshotStatus = new()
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = true },
};
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
snapshotComplete,
providerStatus,
snapshotStatus);
Assert.Same(snapshotComplete, hubContext.Sent[0]);
Assert.Same(providerStatus, hubContext.Sent[1]);
Assert.Same(snapshotStatus, hubContext.Sent[2]);
Assert.True(hubContext.Sent[2].SnapshotStatus.Truncated);
}
///
/// Starts the publisher over a scripted feed, waits until every scripted
/// message has been broadcast, and stops it.
///
/// The Dashboard:ShowTagValues value under test.
/// The messages the fake alarm feed yields, in order.
/// The hub context holding everything the publisher sent.
private static async Task RunPublisherAsync(
bool showTagValues,
params AlarmFeedMessage[] messages)
{
ScriptedAlarmService alarmService = new(messages);
CapturingHubContext hubContext = new();
GatewayOptions gatewayOptions = new()
{
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
};
AlarmsHubPublisher publisher = new(
alarmService,
hubContext,
Options.Create(gatewayOptions),
NullLogger.Instance);
using CancellationTokenSource cts = new();
await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout);
await WaitUntilAsync(() => hubContext.Sent.Count >= messages.Length);
await cts.CancelAsync();
await publisher.StopAsync(CancellationToken.None);
return hubContext;
}
private static async Task WaitUntilAsync(Func predicate)
{
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
while (!predicate())
{
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token);
}
}
/// Builds a value-bearing transition feed message.
/// The message.
private static AlarmFeedMessage BuildTransition()
{
return new AlarmFeedMessage
{
Transition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 800,
Category = "Process",
Description = "Level high-high",
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 },
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 },
},
};
}
/// Builds a value-bearing active_alarm feed message.
/// The message.
private static AlarmFeedMessage BuildActiveAlarm()
{
return new AlarmFeedMessage
{
ActiveAlarm = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank02.Level.Lo",
SourceObjectReference = "Tank02",
AlarmTypeName = "AnalogLimitAlarm.Lo",
CurrentState = AlarmConditionState.Active,
Severity = 500,
Category = "Process",
Description = "Level low",
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 12.5 },
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 10.0 },
},
};
}
///
/// Yields a scripted message list once and then stays open until cancelled,
/// so the publisher's reconnect loop never re-subscribes mid-test.
///
/// The messages to yield, in order.
private sealed class ScriptedAlarmService(IReadOnlyList messages) : IGatewayAlarmService
{
///
public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
///
public string? LastError => null;
///
public int? WorkerProcessId => null;
///
public IReadOnlyList CurrentAlarms => [];
///
public bool SnapshotTruncated => false;
///
public async IAsyncEnumerable StreamAsync(
string? alarmFilterPrefix,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (AlarmFeedMessage message in messages)
{
yield return message;
}
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
///
public Task AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
}
private sealed class CapturingHubContext : IHubContext
{
private readonly CapturingHubClients _clients = new();
/// Gets the hub clients.
public IHubClients Clients => _clients;
/// Gets the group manager.
public IGroupManager Groups { get; } = new NoopGroupManager();
/// Gets every message the publisher broadcast, in order.
public IReadOnlyList Sent => _clients.GroupProxy.Sent;
}
private sealed class CapturingHubClients : IHubClients
{
/// Gets the capturing client proxy shared by this fake.
public CapturingClientProxy GroupProxy { get; } = new();
public IClientProxy All => GroupProxy;
public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => GroupProxy;
public IClientProxy Client(string connectionId) => GroupProxy;
public IClientProxy Clients(IReadOnlyList connectionIds) => GroupProxy;
public IClientProxy Group(string groupName) => GroupProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => GroupProxy;
public IClientProxy Groups(IReadOnlyList groupNames) => GroupProxy;
public IClientProxy User(string userId) => GroupProxy;
public IClientProxy Users(IReadOnlyList userIds) => GroupProxy;
}
private sealed class CapturingClientProxy : IClientProxy
{
private readonly List _sent = [];
/// Gets every alarm message sent through this proxy, in order.
public IReadOnlyList Sent
{
get
{
lock (_sent)
{
return [.. _sent];
}
}
}
/// Records the broadcast message and completes synchronously.
/// The SignalR method name.
/// The method arguments.
/// Token to observe for cancellation.
/// A completed task.
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
if (args.Length > 0 && args[0] is AlarmFeedMessage message)
{
lock (_sent)
{
_sent.Add(message);
}
}
return Task.CompletedTask;
}
}
private sealed class NoopGroupManager : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}