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)
535 lines
20 KiB
C#
535 lines
20 KiB
C#
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));
|
|
}
|
|
}
|