Files
scadalink-design/tests/ScadaLink.Communication.Tests/Grpc/SiteStreamGrpcClientTests.cs
Joseph Doherty 751248feb6 feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.

Plumbing:
  - new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
  - AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
  - AlarmTriggerConfigCodec extracted from the editor for testability
  - sitestream.proto carries level + message over gRPC
  - SemanticValidator enforces numeric attribute, setpoint ordering,
    non-negative deadband
  - on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
    so notification routing can branch by severity
  - per-instance InstanceAlarmOverride entity + EF migration + flattening
    step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
    types whole-replace
  - DebugView shows a Level badge + per-band message tooltip
  - App.razor auto-reloads on permanent Blazor circuit failure
  - docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
    protoc segfault means generated files are checked in for now)
2026-05-13 03:23:32 -04:00

180 lines
6.0 KiB
C#

using Google.Protobuf.WellKnownTypes;
using ScadaLink.Communication.Grpc;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Communication.Tests.Grpc;
public class SiteStreamGrpcClientTests
{
[Fact]
public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly()
{
var ts = DateTimeOffset.UtcNow;
var evt = new SiteStreamEvent
{
CorrelationId = "corr-1",
AttributeChanged = new AttributeValueUpdate
{
InstanceUniqueName = "Site1.Pump01",
AttributePath = "Modules.IO",
AttributeName = "Temperature",
Value = "42.5",
Quality = Quality.Good,
Timestamp = Timestamp.FromDateTimeOffset(ts)
}
};
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
var attr = Assert.IsType<AttributeValueChanged>(result);
Assert.Equal("Site1.Pump01", attr.InstanceUniqueName);
Assert.Equal("Modules.IO", attr.AttributePath);
Assert.Equal("Temperature", attr.AttributeName);
Assert.Equal("42.5", attr.Value);
Assert.Equal("Good", attr.Quality);
Assert.Equal(ts, attr.Timestamp);
}
[Fact]
public void ConvertToDomainEvent_AlarmChanged_MapsCorrectly()
{
var ts = DateTimeOffset.UtcNow;
var evt = new SiteStreamEvent
{
CorrelationId = "corr-2",
AlarmChanged = new AlarmStateUpdate
{
InstanceUniqueName = "Site1.Motor01",
AlarmName = "OverTemp",
State = AlarmStateEnum.AlarmStateActive,
Priority = 3,
Timestamp = Timestamp.FromDateTimeOffset(ts)
}
};
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
var alarm = Assert.IsType<AlarmStateChanged>(result);
Assert.Equal("Site1.Motor01", alarm.InstanceUniqueName);
Assert.Equal("OverTemp", alarm.AlarmName);
Assert.Equal(AlarmState.Active, alarm.State);
Assert.Equal(3, alarm.Priority);
Assert.Equal(ts, alarm.Timestamp);
}
[Fact]
public void ConvertToDomainEvent_UnknownEvent_ReturnsNull()
{
var evt = new SiteStreamEvent
{
CorrelationId = "corr-3"
// No oneof case set
};
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
Assert.Null(result);
}
[Theory]
[InlineData(Quality.Good, "Good")]
[InlineData(Quality.Uncertain, "Uncertain")]
[InlineData(Quality.Bad, "Bad")]
[InlineData(Quality.Unspecified, "Unknown")]
public void MapQuality_AllValues(Quality input, string expected)
{
var result = SiteStreamGrpcClient.MapQuality(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(AlarmStateEnum.AlarmStateNormal, AlarmState.Normal)]
[InlineData(AlarmStateEnum.AlarmStateActive, AlarmState.Active)]
[InlineData(AlarmStateEnum.AlarmStateUnspecified, AlarmState.Normal)]
public void MapAlarmState_AllValues(AlarmStateEnum input, AlarmState expected)
{
var result = SiteStreamGrpcClient.MapAlarmState(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(AlarmLevelEnum.AlarmLevelNone, AlarmLevel.None)]
[InlineData(AlarmLevelEnum.AlarmLevelLow, AlarmLevel.Low)]
[InlineData(AlarmLevelEnum.AlarmLevelLowLow, AlarmLevel.LowLow)]
[InlineData(AlarmLevelEnum.AlarmLevelHigh, AlarmLevel.High)]
[InlineData(AlarmLevelEnum.AlarmLevelHighHigh, AlarmLevel.HighHigh)]
public void MapAlarmLevel_AllValues(AlarmLevelEnum input, AlarmLevel expected)
{
var result = SiteStreamGrpcClient.MapAlarmLevel(input);
Assert.Equal(expected, result);
}
[Fact]
public void ConvertToDomainEvent_AlarmChanged_PreservesLevel()
{
// Round-trip: a HiLo alarm emitted at HighHigh must come through with Level intact.
var evt = new SiteStreamEvent
{
CorrelationId = "test",
AlarmChanged = new AlarmStateUpdate
{
InstanceUniqueName = "Pump1",
AlarmName = "TempAlarm",
State = AlarmStateEnum.AlarmStateActive,
Priority = 900,
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
Level = AlarmLevelEnum.AlarmLevelHighHigh
}
};
var domain = SiteStreamGrpcClient.ConvertToDomainEvent(evt) as AlarmStateChanged;
Assert.NotNull(domain);
Assert.Equal(AlarmState.Active, domain.State);
Assert.Equal(AlarmLevel.HighHigh, domain.Level);
Assert.Equal(900, domain.Priority);
}
[Fact]
public void Unsubscribe_CancelsSubscription()
{
// We can't easily test the full Subscribe flow without a real gRPC server,
// but we can test the Unsubscribe path by registering a CTS directly.
// Use the internal AddSubscription helper for testability.
var client = SiteStreamGrpcClient.CreateForTesting();
var cts = new CancellationTokenSource();
client.AddSubscriptionForTesting("corr-test", cts);
Assert.False(cts.IsCancellationRequested);
client.Unsubscribe("corr-test");
Assert.True(cts.IsCancellationRequested);
}
[Fact]
public void Unsubscribe_NonExistent_DoesNotThrow()
{
var client = SiteStreamGrpcClient.CreateForTesting();
client.Unsubscribe("does-not-exist"); // Should not throw
}
[Fact]
public async Task DisposeAsync_CancelsAllSubscriptions()
{
var client = SiteStreamGrpcClient.CreateForTesting();
var cts1 = new CancellationTokenSource();
var cts2 = new CancellationTokenSource();
client.AddSubscriptionForTesting("corr-1", cts1);
client.AddSubscriptionForTesting("corr-2", cts2);
await client.DisposeAsync();
Assert.True(cts1.IsCancellationRequested);
Assert.True(cts2.IsCancellationRequested);
}
}