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)
This commit is contained in:
Joseph Doherty
2026-05-13 03:23:32 -04:00
parent 783da8e21a
commit 751248feb6
46 changed files with 4693 additions and 204 deletions

View File

@@ -0,0 +1,534 @@
using System.Text.Json;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Shared;
public class AlarmTriggerConfigCodecTests
{
// ── Parse: ValueMatch ──────────────────────────────────────────────────
[Fact]
public void Parse_ValueMatch_ReadsCanonicalKeys()
{
const string json = @"{""attributeName"":""Status"",""matchValue"":""Critical""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Equal("Critical", model.MatchValue);
Assert.False(model.NotEquals);
}
[Fact]
public void Parse_ValueMatch_AcceptsLegacyAttributeAndValueKeys()
{
// Older configs used "attribute" and "value" instead of the canonical names.
const string json = @"{""attribute"":""Status"",""value"":""Critical""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Equal("Critical", model.MatchValue);
}
[Fact]
public void Parse_ValueMatch_NotEqualsPrefix_SetsFlagAndStripsPrefix()
{
const string json = @"{""attributeName"":""Status"",""matchValue"":""!=Good""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.True(model.NotEquals);
Assert.Equal("Good", model.MatchValue);
}
[Fact]
public void Parse_ValueMatch_MissingMatchValue_LeavesNull()
{
const string json = @"{""attributeName"":""Status""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Null(model.MatchValue);
Assert.False(model.NotEquals);
}
// ── Parse: RangeViolation ──────────────────────────────────────────────
[Fact]
public void Parse_RangeViolation_ReadsCanonicalKeys()
{
const string json = @"{""attributeName"":""Temp"",""min"":0,""max"":100}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(0, model.Min);
Assert.Equal(100, model.Max);
}
[Fact]
public void Parse_RangeViolation_AcceptsLegacyLowHighKeys()
{
const string json = @"{""attributeName"":""Temp"",""low"":-10,""high"":50}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(-10, model.Min);
Assert.Equal(50, model.Max);
}
[Fact]
public void Parse_RangeViolation_CanonicalKeysWinOverLegacy()
{
// If both canonical and legacy aliases are present, the canonical key wins.
const string json = @"{""attributeName"":""T"",""min"":0,""low"":-999,""max"":100,""high"":999}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(0, model.Min);
Assert.Equal(100, model.Max);
}
[Fact]
public void Parse_RangeViolation_StringNumericValues_AreParsed()
{
// Some configs serialize min/max as JSON strings. Codec accepts both.
const string json = @"{""attributeName"":""T"",""min"":""1.5"",""max"":""9.75""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(1.5, model.Min);
Assert.Equal(9.75, model.Max);
}
// ── Parse: RateOfChange ────────────────────────────────────────────────
[Fact]
public void Parse_RateOfChange_ReadsAllFields()
{
const string json = @"{""attributeName"":""Pressure"",""thresholdPerSecond"":25,""windowSeconds"":2,""direction"":""rising""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal("Pressure", model.AttributeName);
Assert.Equal(25, model.ThresholdPerSecond);
Assert.Equal(2, model.WindowSeconds);
Assert.Equal("rising", model.Direction);
}
[Theory]
[InlineData("rising", "rising")]
[InlineData("Rising", "rising")]
[InlineData("up", "rising")]
[InlineData("positive", "rising")]
[InlineData("falling", "falling")]
[InlineData("Down", "falling")]
[InlineData("negative", "falling")]
[InlineData("either", "either")]
[InlineData("bogus", "either")]
[InlineData("", "either")]
public void Parse_RateOfChange_NormalizesDirectionAliases(string input, string expected)
{
var json = $@"{{""attributeName"":""x"",""direction"":""{input}""}}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal(expected, model.Direction);
}
[Fact]
public void Parse_RateOfChange_MissingDirection_DefaultsToEither()
{
// Older configs predate the direction field — the codec must default it
// so existing data round-trips without surprises.
const string json = @"{""attributeName"":""x"",""thresholdPerSecond"":10,""windowSeconds"":1}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal("either", model.Direction);
}
// ── Parse: misc ────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_NullOrWhitespace_ReturnsDefaultModel(string? input)
{
var model = AlarmTriggerConfigCodec.Parse(input, AlarmTriggerType.ValueMatch);
Assert.Null(model.AttributeName);
Assert.Null(model.MatchValue);
Assert.False(model.NotEquals);
Assert.Equal("either", model.Direction);
}
[Fact]
public void Parse_MalformedJson_ReturnsDefaultModel_DoesNotThrow()
{
var model = AlarmTriggerConfigCodec.Parse("{not valid", AlarmTriggerType.RangeViolation);
Assert.Null(model.Min);
Assert.Null(model.Max);
}
// ── Serialize: ValueMatch ──────────────────────────────────────────────
[Fact]
public void Serialize_ValueMatch_WritesCanonicalKeysOnly()
{
var model = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Critical",
// Foreign fields from other trigger types must NOT leak into the JSON.
Min = 5,
ThresholdPerSecond = 99,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal("Status", root.GetProperty("attributeName").GetString());
Assert.Equal("Critical", root.GetProperty("matchValue").GetString());
Assert.False(root.TryGetProperty("min", out _));
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
Assert.False(root.TryGetProperty("direction", out _));
}
[Fact]
public void Serialize_ValueMatch_NotEquals_PrependsBangEqualsToMatchValue()
{
var model = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Good",
NotEquals = true
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
Assert.Equal("!=Good", doc.RootElement.GetProperty("matchValue").GetString());
}
[Fact]
public void Serialize_ValueMatch_NullAttributeName_WritesEmptyString()
{
// AlarmActor uses attributeName for subscription filtering, so the key
// must always be present even when the user hasn't picked one yet.
var model = new AlarmTriggerModel { MatchValue = "x" };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
Assert.Equal("", doc.RootElement.GetProperty("attributeName").GetString());
}
// ── Serialize: RangeViolation ──────────────────────────────────────────
[Fact]
public void Serialize_RangeViolation_WritesCanonicalNumericKeys()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Min = 0,
Max = 100,
MatchValue = "ignored"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(0, root.GetProperty("min").GetDouble());
Assert.Equal(100, root.GetProperty("max").GetDouble());
Assert.False(root.TryGetProperty("matchValue", out _));
}
[Fact]
public void Serialize_RangeViolation_NullBound_OmitsKey()
{
var model = new AlarmTriggerModel { AttributeName = "Temp", Min = 0, Max = null };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("min", out _));
Assert.False(doc.RootElement.TryGetProperty("max", out _));
}
// ── Serialize: RateOfChange ────────────────────────────────────────────
[Fact]
public void Serialize_RateOfChange_WritesThresholdWindowAndDirection()
{
var model = new AlarmTriggerModel
{
AttributeName = "Pressure",
ThresholdPerSecond = 25,
WindowSeconds = 2,
Direction = "falling"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(25, root.GetProperty("thresholdPerSecond").GetDouble());
Assert.Equal(2, root.GetProperty("windowSeconds").GetDouble());
Assert.Equal("falling", root.GetProperty("direction").GetString());
}
[Fact]
public void Serialize_RateOfChange_AlwaysIncludesDirection()
{
// Even with a default-constructed model, the runtime needs to know how
// to evaluate — direction defaults to "either" and is always emitted.
var model = new AlarmTriggerModel { AttributeName = "x" };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
using var doc = JsonDocument.Parse(json);
Assert.Equal("either", doc.RootElement.GetProperty("direction").GetString());
}
// ── Round-trip ─────────────────────────────────────────────────────────
[Fact]
public void RoundTrip_ValueMatch_NotEquals_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Good",
NotEquals = true
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.ValueMatch);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.MatchValue, round.MatchValue);
Assert.True(round.NotEquals);
}
[Fact]
public void RoundTrip_RangeViolation_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Temp",
Min = -10.5,
Max = 42.25
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RangeViolation);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(original.Min, round.Min);
Assert.Equal(original.Max, round.Max);
}
[Fact]
public void RoundTrip_RateOfChange_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Pressure",
ThresholdPerSecond = 25,
WindowSeconds = 2,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RateOfChange);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.ThresholdPerSecond, round.ThresholdPerSecond);
Assert.Equal(original.WindowSeconds, round.WindowSeconds);
Assert.Equal(original.Direction, round.Direction);
}
// ── Parse: HiLo ────────────────────────────────────────────────────────
[Fact]
public void Parse_HiLo_ReadsAllSetpointsAndPriorities()
{
const string json = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":90,""hiHi"":100,""loLoPriority"":900,""loPriority"":500,""hiPriority"":500,""hiHiPriority"":900}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal("Temp", model.AttributeName);
Assert.Equal(0, model.LoLo);
Assert.Equal(10, model.Lo);
Assert.Equal(90, model.Hi);
Assert.Equal(100, model.HiHi);
Assert.Equal(900, model.LoLoPriority);
Assert.Equal(500, model.LoPriority);
Assert.Equal(500, model.HiPriority);
Assert.Equal(900, model.HiHiPriority);
}
[Fact]
public void Parse_HiLo_AcceptsPartialSetpoints_MissingOnesAreNull()
{
// Common case: only Hi/HiHi configured for over-temp protection.
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Null(model.LoLo);
Assert.Null(model.Lo);
Assert.Equal(80, model.Hi);
Assert.Equal(100, model.HiHi);
Assert.Null(model.HiPriority);
}
// ── Serialize: HiLo ────────────────────────────────────────────────────
[Fact]
public void Serialize_HiLo_OmitsNullSetpointsAndPriorities()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
HiHi = 100,
HiHiPriority = 900
// Lo, LoLo, and the other priorities left null
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(80, root.GetProperty("hi").GetDouble());
Assert.Equal(100, root.GetProperty("hiHi").GetDouble());
Assert.Equal(900, root.GetProperty("hiHiPriority").GetInt32());
Assert.False(root.TryGetProperty("lo", out _));
Assert.False(root.TryGetProperty("loLo", out _));
Assert.False(root.TryGetProperty("hiPriority", out _));
Assert.False(root.TryGetProperty("loPriority", out _));
}
[Fact]
public void Serialize_HiLo_DoesNotLeakForeignTriggerTypeFields()
{
// matchValue, min/max, threshold/window/direction must NOT show up in
// HiLo output even if the model happens to carry them.
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
MatchValue = "ignored",
Min = 1,
ThresholdPerSecond = 99,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.False(root.TryGetProperty("matchValue", out _));
Assert.False(root.TryGetProperty("min", out _));
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
Assert.False(root.TryGetProperty("direction", out _));
}
[Fact]
public void Parse_HiLo_ReadsDeadbands()
{
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100,""hiDeadband"":2,""hiHiDeadband"":5}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal(2, model.HiDeadband);
Assert.Equal(5, model.HiHiDeadband);
Assert.Null(model.LoDeadband);
Assert.Null(model.LoLoDeadband);
}
[Fact]
public void Serialize_HiLo_OmitsNullDeadbands()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
HiDeadband = 2
// HiHiDeadband / LoDeadband / LoLoDeadband null
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
Assert.Equal(2, doc.RootElement.GetProperty("hiDeadband").GetDouble());
Assert.False(doc.RootElement.TryGetProperty("hiHiDeadband", out _));
Assert.False(doc.RootElement.TryGetProperty("loDeadband", out _));
}
[Fact]
public void RoundTrip_HiLo_PreservesAllFields()
{
var original = new AlarmTriggerModel
{
AttributeName = "Pressure",
LoLo = -5,
Lo = 0,
Hi = 90,
HiHi = 110,
LoLoPriority = 800,
LoPriority = 400,
HiPriority = 400,
HiHiPriority = 800,
LoLoDeadband = 1,
LoDeadband = 2,
HiDeadband = 3,
HiHiDeadband = 4
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.HiLo);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.LoLo, round.LoLo);
Assert.Equal(original.Lo, round.Lo);
Assert.Equal(original.Hi, round.Hi);
Assert.Equal(original.HiHi, round.HiHi);
Assert.Equal(original.LoLoPriority, round.LoLoPriority);
Assert.Equal(original.LoPriority, round.LoPriority);
Assert.Equal(original.HiPriority, round.HiPriority);
Assert.Equal(original.HiHiPriority, round.HiHiPriority);
Assert.Equal(original.LoLoDeadband, round.LoLoDeadband);
Assert.Equal(original.LoDeadband, round.LoDeadband);
Assert.Equal(original.HiDeadband, round.HiDeadband);
Assert.Equal(original.HiHiDeadband, round.HiHiDeadband);
}
// ── NormalizeDirection (direct) ────────────────────────────────────────
[Theory]
[InlineData("rising", "rising")]
[InlineData("RISING", "rising")]
[InlineData("falling", "falling")]
[InlineData("up", "rising")]
[InlineData("down", "falling")]
[InlineData("positive", "rising")]
[InlineData("negative", "falling")]
[InlineData("either", "either")]
[InlineData("", "either")]
[InlineData(null, "either")]
[InlineData("nonsense", "either")]
public void NormalizeDirection_HandlesAllAliasesAndFallsBackToEither(string? input, string expected)
{
Assert.Equal(expected, AlarmTriggerConfigCodec.NormalizeDirection(input));
}
}