c3c603f169
The final integration review's non-blocker reservations, all documentation or comment truth except one test arm. The alarm feed opens provider_status -> snapshot_status -> cached active_alarm -> snapshot_complete, which is what GatewayAlarmMonitor has done since the snapshot_status frame landed. Two places still described the old order: docs/Grpc.md said provider_status arrived *after* the initial snapshot, contradicting its own snapshot_status section two paragraphs down, and AlarmFeedMessage's leading proto comment named neither status frame at all. Both now state the sequence the monitor emits, so a client author reading either one gets the frame order right. The proto comment change flows through the generated trees (Contracts, Go, Java) and the client descriptor set; the Rust vendored copy stays byte-identical to canonical. Python's generator does not carry proto comments into its output, so it has no delta. AlarmsHubPublisherTests' valueless-payload case covered snapshot_complete and provider_status but not snapshot_status, leaving the newest arm unpinned against the redaction switch that must ignore it. Added. WnWrapAlarmConsumer's ack comment led with the 2026-05-01 reading that -55 tracks the 8-arg overload, then refuted itself six lines later with the 2026-08-18 probe. It now leads with the observation labelled as narrower than it reads -- mirroring the correction already in docs/AlarmClientDiscovery.md -- so the block argues one thing: the 6-arg call site stays for parity, and rc semantics are per the probe. A paragraph orphaned by an earlier splice is rewrapped. Comment interior only; the file compiles on Windows. TST-16 gets a dated closure note rather than a rewrite: the flag it called dead was implemented 2026-08-18. GatewayDashboardDesign's /browse paragraph gains the failed-read carve-out GatewayConfiguration already documented, so the two agree that a failed read keeps its - placeholder.
352 lines
14 KiB
C#
352 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Verifies that <see cref="AlarmsHubPublisher"/> honours
|
|
/// <c>MxGateway:Dashboard:ShowTagValues</c> (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
|
|
/// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c>
|
|
/// subscribers and the alarms page — is never mutated.
|
|
/// </summary>
|
|
public sealed class AlarmsHubPublisherTests
|
|
{
|
|
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>Both value-bearing arms lose their values when the flag is off; metadata survives.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Redaction applies to a clone: the source message fans out to gRPC
|
|
/// <c>StreamAlarms</c> subscribers and the alarms page, so it must keep its
|
|
/// values.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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]);
|
|
}
|
|
|
|
/// <summary>Values pass through unredacted — and uncloned — when the flag is on.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts the publisher over a scripted feed, waits until every scripted
|
|
/// message has been broadcast, and stops it.
|
|
/// </summary>
|
|
/// <param name="showTagValues">The <c>Dashboard:ShowTagValues</c> value under test.</param>
|
|
/// <param name="messages">The messages the fake alarm feed yields, in order.</param>
|
|
/// <returns>The hub context holding everything the publisher sent.</returns>
|
|
private static async Task<CapturingHubContext> 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<AlarmsHubPublisher>.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<bool> predicate)
|
|
{
|
|
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
|
|
while (!predicate())
|
|
{
|
|
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token);
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds a value-bearing <c>transition</c> feed message.</summary>
|
|
/// <returns>The message.</returns>
|
|
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 },
|
|
},
|
|
};
|
|
}
|
|
|
|
/// <summary>Builds a value-bearing <c>active_alarm</c> feed message.</summary>
|
|
/// <returns>The message.</returns>
|
|
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 },
|
|
},
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Yields a scripted message list once and then stays open until cancelled,
|
|
/// so the publisher's reconnect loop never re-subscribes mid-test.
|
|
/// </summary>
|
|
/// <param name="messages">The messages to yield, in order.</param>
|
|
private sealed class ScriptedAlarmService(IReadOnlyList<AlarmFeedMessage> messages) : IGatewayAlarmService
|
|
{
|
|
/// <inheritdoc />
|
|
public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
|
|
|
|
/// <inheritdoc />
|
|
public string? LastError => null;
|
|
|
|
/// <inheritdoc />
|
|
public int? WorkerProcessId => null;
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];
|
|
|
|
/// <inheritdoc />
|
|
public bool SnapshotTruncated => false;
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<AlarmFeedMessage> 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)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
|
AcknowledgeAlarmRequest request,
|
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
|
}
|
|
|
|
private sealed class CapturingHubContext : IHubContext<AlarmsHub>
|
|
{
|
|
private readonly CapturingHubClients _clients = new();
|
|
|
|
/// <summary>Gets the hub clients.</summary>
|
|
public IHubClients Clients => _clients;
|
|
|
|
/// <summary>Gets the group manager.</summary>
|
|
public IGroupManager Groups { get; } = new NoopGroupManager();
|
|
|
|
/// <summary>Gets every message the publisher broadcast, in order.</summary>
|
|
public IReadOnlyList<AlarmFeedMessage> Sent => _clients.GroupProxy.Sent;
|
|
}
|
|
|
|
private sealed class CapturingHubClients : IHubClients
|
|
{
|
|
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
|
|
public CapturingClientProxy GroupProxy { get; } = new();
|
|
|
|
public IClientProxy All => GroupProxy;
|
|
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
|
|
|
public IClientProxy Client(string connectionId) => GroupProxy;
|
|
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
|
|
|
|
public IClientProxy Group(string groupName) => GroupProxy;
|
|
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
|
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
|
|
|
|
public IClientProxy User(string userId) => GroupProxy;
|
|
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
|
|
}
|
|
|
|
private sealed class CapturingClientProxy : IClientProxy
|
|
{
|
|
private readonly List<AlarmFeedMessage> _sent = [];
|
|
|
|
/// <summary>Gets every alarm message sent through this proxy, in order.</summary>
|
|
public IReadOnlyList<AlarmFeedMessage> Sent
|
|
{
|
|
get
|
|
{
|
|
lock (_sent)
|
|
{
|
|
return [.. _sent];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Records the broadcast message and completes synchronously.</summary>
|
|
/// <param name="method">The SignalR method name.</param>
|
|
/// <param name="args">The method arguments.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
/// <returns>A completed task.</returns>
|
|
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;
|
|
}
|
|
}
|