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)); } }