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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -71,22 +71,31 @@ public class ArchitecturalConstraintTests
|
||||
[Fact]
|
||||
public void Commons_ShouldNotContainServiceOrActorImplementations()
|
||||
{
|
||||
// Heuristic: class has > 3 public non-property methods that are not constructors
|
||||
// Heuristic: class has > 3 public methods that are neither constructors,
|
||||
// property accessors, common Object overrides, nor interface-implementation
|
||||
// methods (a dictionary wrapper exposing ContainsKey/TryGetValue/GetEnumerator
|
||||
// via IReadOnlyDictionary isn't a service — that's just the interface).
|
||||
var types = CommonsAssembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && !t.IsInterface);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var interfaceMethodNames = type.GetInterfaces()
|
||||
.SelectMany(i => i.GetMethods())
|
||||
.Select(m => m.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var publicMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => !m.IsSpecialName) // excludes property getters/setters
|
||||
.Where(m => !m.Name.StartsWith("<")) // excludes compiler-generated
|
||||
.Where(m => m.Name != "ToString" && m.Name != "GetHashCode" &&
|
||||
m.Name != "Equals" && m.Name != "Deconstruct" &&
|
||||
m.Name != "PrintMembers" && m.Name != "GetType")
|
||||
.Where(m => !interfaceMethodNames.Contains(m.Name))
|
||||
.ToList();
|
||||
|
||||
Assert.True(publicMethods.Count <= 3,
|
||||
$"Type {type.FullName} has {publicMethods.Count} public methods ({string.Join(", ", publicMethods.Select(m => m.Name))}), which suggests it may contain service/actor logic");
|
||||
$"Type {type.FullName} has {publicMethods.Count} public non-interface methods ({string.Join(", ", publicMethods.Select(m => m.Name))}), which suggests it may contain service/actor logic");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ public class EnumTests
|
||||
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
|
||||
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
|
||||
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
|
||||
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange" })]
|
||||
[InlineData(typeof(AlarmLevel), new[] { "None", "Low", "LowLow", "High", "HighHigh" })]
|
||||
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo" })]
|
||||
[InlineData(typeof(ConnectionHealth), new[] { "Connected", "Disconnected", "Connecting", "Error" })]
|
||||
public void Enum_ShouldHaveExpectedValues(Type enumType, string[] expectedNames)
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class EnumTests
|
||||
[InlineData(typeof(InstanceState))]
|
||||
[InlineData(typeof(DeploymentStatus))]
|
||||
[InlineData(typeof(AlarmState))]
|
||||
[InlineData(typeof(AlarmLevel))]
|
||||
[InlineData(typeof(AlarmTriggerType))]
|
||||
[InlineData(typeof(ConnectionHealth))]
|
||||
public void Enum_ShouldBeSingularNamed(Type enumType)
|
||||
|
||||
@@ -98,6 +98,44 @@ public class SiteStreamGrpcClientTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
@@ -257,4 +258,618 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
// No additional messages (no script execution side effects)
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ── RateOfChange ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a RateOfChange config JSON with the given threshold (units/sec),
|
||||
/// window (seconds), and direction. Used by the rate-of-change tests.
|
||||
/// </summary>
|
||||
private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
|
||||
$"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";
|
||||
|
||||
private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "RocAlarm",
|
||||
TriggerType = "RateOfChange",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = 3
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
|
||||
new("Pump1", "Pressure", "Pressure",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", ts);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// 50 units/sec threshold, 2 sec window
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// First sample establishes the window baseline; needs ≥2 samples to compute a rate.
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// 100 over 1 sec = 100 units/sec > 50 threshold → activate
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
// 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
|
||||
alarm.Tell(PressureSample(10, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
// -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(100, t0));
|
||||
alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
|
||||
{
|
||||
// The evaluator needs at least two samples in the window to compute a rate.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(1000, t0));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
|
||||
{
|
||||
// 1-second window. Sample at t=0 with value 0 should fall out before the
|
||||
// sample at t=3, so the in-window history is just the two recent samples
|
||||
// (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
|
||||
// the threshold, so no alarm even though the long-term delta is huge.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(3)));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
||||
|
||||
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
// Spike: activate
|
||||
alarm.Tell(PressureSample(0, t0));
|
||||
alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Now sample again well past the 1-second window with only a tiny change
|
||||
// → rate falls below threshold → clears.
|
||||
alarm.Tell(PressureSample(101, t0.AddSeconds(3)));
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
|
||||
{
|
||||
// Old configs used "attribute" / "value" before the canonical names landed.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
|
||||
{
|
||||
// matchValue "!=Good" means: alarm when Status is anything other than Good.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Inverted",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Status=Critical (not "Good") → alarm activates
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, activate.State);
|
||||
|
||||
// Status=Good → alarm clears
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
|
||||
{
|
||||
// Older configs used "low" / "high" instead of the current "min" / "max".
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Legacy",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
// Within range → no alarm
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Outside range → activate
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
// ── HiLo ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
|
||||
private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempAlarm",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = config,
|
||||
PriorityLevel = priority
|
||||
};
|
||||
return ActorOf(Props.Create(() => new AlarmActor(
|
||||
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
}
|
||||
|
||||
private static AttributeValueChanged TempSample(double value) =>
|
||||
new("Pump1", "Temperature", "Temperature",
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(50)); // normal band — no emit
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(85)); // crosses Hi but not HiHi
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, first.Level);
|
||||
|
||||
alarm.Tell(TempSample(120)); // crosses HiHi
|
||||
|
||||
var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, second.State);
|
||||
Assert.Equal(AlarmLevel.HighHigh, second.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(50)); // back to normal
|
||||
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-5));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.LowLow, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
// Alarm-level priority is 500; HiHi explicitly bumps to 900.
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, hi.Level);
|
||||
Assert.Equal(600, hi.Priority);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
|
||||
Assert.Equal(900, hiHi.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(432, msg.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
|
||||
{
|
||||
// Only HiHi is configured — values that would have hit a Lo or Hi band
|
||||
// (in a fully-configured alarm) are inside the implicit normal band here.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(-1000));
|
||||
alarm.Tell(TempSample(95));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(110));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
|
||||
{
|
||||
// When the value exactly equals the boundary, the most-severe matching
|
||||
// band wins. value == HiHi → HighHigh (not High).
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(100));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
|
||||
{
|
||||
// Two updates that resolve to the same level should produce exactly one
|
||||
// AlarmStateChanged — the second is a no-op.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(90)); // still in the Hi band
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
|
||||
{
|
||||
// Validation flags this as a warning at design time; runtime behavior
|
||||
// is "evaluates to None forever".
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(99999));
|
||||
alarm.Tell(TempSample(-99999));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
// ── HiLo hysteresis ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
|
||||
{
|
||||
// HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
|
||||
// until the value drops below 95 — at 96 it should still be HighHigh.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, entered.Level);
|
||||
|
||||
// 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
|
||||
alarm.Tell(TempSample(96));
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
|
||||
alarm.Tell(TempSample(94));
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
|
||||
{
|
||||
// Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
alarm.Tell(TempSample(78)); // 78 > 75 → still High
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
|
||||
{
|
||||
// Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(8));
|
||||
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.Low, entered.Level);
|
||||
|
||||
alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
alarm.Tell(TempSample(14)); // 14 > 13 → clears
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
|
||||
{
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant warm — check tank", hi.Message);
|
||||
|
||||
alarm.Tell(TempSample(120));
|
||||
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Coolant critical — shut down", hiHi.Message);
|
||||
|
||||
// Clearing back to normal carries an empty message.
|
||||
alarm.Tell(TempSample(50));
|
||||
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(string.Empty, clear.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
|
||||
{
|
||||
// Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
|
||||
var instanceProbe = CreateTestProbe();
|
||||
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
|
||||
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
||||
|
||||
alarm.Tell(TempSample(85));
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Despite the large deadband, escalation uses the activation threshold (100).
|
||||
alarm.Tell(TempSample(101));
|
||||
var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
{
|
||||
// ParseEvalConfig falls back to a safe default on JSON failure; the actor
|
||||
// should accept messages without throwing and just never trigger.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Bad",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{not valid json",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"Bad", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class FlatteningServiceMergeTests
|
||||
{
|
||||
// ── MergeHiLoConfig ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedKeysWin_InheritedKeysSurvive()
|
||||
{
|
||||
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
|
||||
const string derived = @"{""hi"":90}"; // derived only overrides Hi
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
Assert.NotNull(result);
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal("Temp", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
|
||||
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedCanOverrideAttribute()
|
||||
{
|
||||
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
|
||||
const string derived = @"{""attributeName"":""Pressure"",""hi"":50}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal("Pressure", doc.RootElement.GetProperty("attributeName").GetString());
|
||||
Assert.Equal(50, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedNull_ReturnsInherited()
|
||||
{
|
||||
const string inherited = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, null);
|
||||
|
||||
Assert.Equal(inherited, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_InheritedNull_ReturnsDerived()
|
||||
{
|
||||
const string derived = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(null, derived);
|
||||
|
||||
Assert.Equal(derived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_BothNull_ReturnsNull()
|
||||
{
|
||||
Assert.Null(FlatteningService.MergeHiLoConfig(null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_MalformedInherited_FallsBackToDerived()
|
||||
{
|
||||
// Safe fallback — never throw on malformed input.
|
||||
const string derived = @"{""hi"":80}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig("{not valid", derived);
|
||||
|
||||
Assert.Equal(derived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHiLoConfig_DerivedAddsNewKey_PreservesInheritedRest()
|
||||
{
|
||||
// Derived adds a deadband that the base didn't have.
|
||||
const string inherited = @"{""hi"":80,""hiHi"":100}";
|
||||
const string derived = @"{""hiDeadband"":3}";
|
||||
|
||||
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
|
||||
|
||||
using var doc = JsonDocument.Parse(result!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble());
|
||||
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
|
||||
}
|
||||
|
||||
// ── Instance-level alarm override (end-to-end Flatten) ─────────────────
|
||||
|
||||
private static (Template, Instance) BuildHiLoFixture(string inheritedJson, InstanceAlarmOverride? ovr = null, bool locked = false)
|
||||
{
|
||||
var template = new Template("PumpTpl")
|
||||
{
|
||||
Id = 1,
|
||||
Alarms = new List<TemplateAlarm>
|
||||
{
|
||||
new("Temp")
|
||||
{
|
||||
Id = 10,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.HiLo,
|
||||
TriggerConfiguration = inheritedJson,
|
||||
PriorityLevel = 500,
|
||||
IsLocked = locked
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
|
||||
if (ovr != null) instance.AlarmOverrides.Add(ovr);
|
||||
return (template, instance);
|
||||
}
|
||||
|
||||
private static FlattenedConfiguration Flatten(Template template, Instance instance)
|
||||
{
|
||||
var sut = new FlatteningService();
|
||||
var result = sut.Flatten(
|
||||
instance,
|
||||
new[] { template },
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
if (!result.IsSuccess) Assert.Fail(result.Error);
|
||||
return result.Value!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_HiLo_MergesSetpoints()
|
||||
{
|
||||
// Template has {hi=80, hiHi=100, lo=10, loLo=0}. Instance overrides hi=90 only.
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":90}"
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
|
||||
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
|
||||
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_OverridesPriority()
|
||||
{
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
PriorityLevelOverride = 950
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
Assert.Equal(950, alarm.PriorityLevel);
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_LockedAlarm_OverrideSilentlyIgnored()
|
||||
{
|
||||
// Locked alarm — override should be a no-op at flatten time. (The
|
||||
// InstanceService.SetAlarmOverrideAsync write-time check is what
|
||||
// prevents the override from being persisted in the first place;
|
||||
// this test covers the runtime safety net.)
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":999}"
|
||||
},
|
||||
locked: true);
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble()); // not overridden
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_UnknownName_DoesNothing()
|
||||
{
|
||||
// Override targets an alarm name that doesn't exist on the template —
|
||||
// silently ignored (same behavior as attribute overrides).
|
||||
var (tpl, inst) = BuildHiLoFixture(
|
||||
@"{""attributeName"":""Temp"",""hi"":80}",
|
||||
new InstanceAlarmOverride("DoesNotExist")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""hi"":999}"
|
||||
});
|
||||
|
||||
var flat = Flatten(tpl, inst);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
|
||||
Assert.NotEqual("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceAlarmOverride_BinaryTrigger_ReplacesWholeConfig()
|
||||
{
|
||||
// For non-HiLo trigger types, an instance override replaces the whole
|
||||
// TriggerConfiguration (no per-key merge).
|
||||
var template = new Template("PumpTpl")
|
||||
{
|
||||
Id = 1,
|
||||
Alarms = new List<TemplateAlarm>
|
||||
{
|
||||
new("Temp")
|
||||
{
|
||||
Id = 10,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = @"{""attributeName"":""T"",""min"":0,""max"":100}",
|
||||
PriorityLevel = 500
|
||||
}
|
||||
}
|
||||
};
|
||||
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("Temp")
|
||||
{
|
||||
InstanceId = 100,
|
||||
TriggerConfigurationOverride = @"{""attributeName"":""T"",""min"":-50,""max"":50}"
|
||||
});
|
||||
|
||||
var flat = Flatten(template, instance);
|
||||
var alarm = flat.Alarms.Single();
|
||||
|
||||
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
|
||||
Assert.Equal(-50, doc.RootElement.GetProperty("min").GetDouble());
|
||||
Assert.Equal(50, doc.RootElement.GetProperty("max").GetDouble());
|
||||
}
|
||||
}
|
||||
@@ -251,4 +251,158 @@ public class SemanticValidatorTests
|
||||
Assert.Empty(SemanticValidator.ParseParameterDefinitions(null));
|
||||
Assert.Empty(SemanticValidator.ParseParameterDefinitions(""));
|
||||
}
|
||||
|
||||
// ── HiLo validation ─────────────────────────────────────────────────────
|
||||
|
||||
private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) =>
|
||||
new()
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = attrName, DataType = dataType }],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Hi/Lo Alarm",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = triggerJson
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoOnNonNumericAttribute_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Status", "String",
|
||||
"{\"attributeName\":\"Status\",\"hi\":80}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("HiLo")
|
||||
&& e.Message.Contains("non-numeric"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoOnNumericAttribute_NoOperandTypeError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoNoSetpoints_ReturnsWarning()
|
||||
{
|
||||
// No setpoints means the alarm can never fire — design-time warning.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\"}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Warnings,
|
||||
w => w.Category == ValidationCategory.TriggerOperandType
|
||||
&& w.Message.Contains("no setpoints"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoLoLoGreaterThanLo_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"loLo\":20,\"lo\":10}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("LoLo")
|
||||
&& e.Message.Contains("Lo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoHiGreaterThanHiHi_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":120,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("Hi")
|
||||
&& e.Message.Contains("HiHi"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError()
|
||||
{
|
||||
// Lo (50) >= Hi (40) — bands overlap.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"lo\":50,\"hi\":40}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("overlap"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError()
|
||||
{
|
||||
// Only Hi/HiHi configured — no low-side comparison needed.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoNegativeDeadband_ReturnsError()
|
||||
{
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":-1}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType
|
||||
&& e.Message.Contains("Hi deadband")
|
||||
&& e.Message.Contains("non-negative"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoZeroDeadband_NoError()
|
||||
{
|
||||
// Zero deadband is the default (no hysteresis) and must be accepted.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":0,\"hiHiDeadband\":0}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HiLoValidOrdering_NoErrors()
|
||||
{
|
||||
// LoLo (-10) < Lo (0) < Hi (90) < HiHi (100) — fully valid.
|
||||
var config = HiLoConfig("Temp", "Double",
|
||||
"{\"attributeName\":\"Temp\",\"loLo\":-10,\"lo\":0,\"hi\":90,\"hiHi\":100}");
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Errors,
|
||||
e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
Assert.DoesNotContain(result.Warnings,
|
||||
w => w.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user