using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
public class AlarmTriggerConfigCodecTests
{
// ── Parse: ValueMatch ──────────────────────────────────────────────────
/// Verifies that parsing a value-match configuration reads the canonical keys.
[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);
}
/// Verifies that parsing a value-match configuration accepts the legacy attribute and value keys.
[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);
}
/// Verifies that a not-equals prefix on the match value sets the not-equals flag and is stripped from the value.
[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);
}
/// Verifies that a value-match configuration without a match value leaves it null.
[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 ──────────────────────────────────────────────
/// Verifies that parsing a range-violation configuration reads the canonical keys.
[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);
}
/// Verifies that parsing a range-violation configuration accepts the legacy low and high keys.
[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);
}
/// Verifies that canonical range-violation keys take precedence over legacy ones when both are present.
[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);
}
/// Verifies that numeric values stored as strings in a range-violation configuration are parsed.
[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 ────────────────────────────────────────────────
/// Verifies that parsing a rate-of-change configuration reads all of its fields.
[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);
}
/// Verifies that direction aliases in a rate-of-change configuration are normalized.
/// The direction text as stored in the configuration.
/// The expected normalized 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);
}
/// Verifies that a rate-of-change configuration without a direction defaults to either.
[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 ────────────────────────────────────────────────────────
/// Verifies that null, empty or whitespace input parses to the default model.
/// The null, empty or whitespace configuration text.
[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);
}
/// Verifies that malformed JSON parses to the default model without throwing.
[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 ──────────────────────────────────────────────
/// Verifies that serializing a value-match model writes only the canonical keys.
[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 _));
}
/// Verifies that serializing a not-equals value-match model prepends the not-equals marker to the match value.
[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());
}
/// Verifies that a null attribute name is serialized as an empty string.
[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 ──────────────────────────────────────────
/// Verifies that serializing a range-violation model writes the canonical numeric keys.
[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 _));
}
/// Verifies that a null range bound is omitted from the serialized output.
[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 ────────────────────────────────────────────
/// Verifies that serializing a rate-of-change model writes the threshold, window and direction.
[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());
}
/// Verifies that serializing a rate-of-change model always includes the direction.
[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 ─────────────────────────────────────────────────────────
/// Verifies that a not-equals value-match model survives a serialize and parse 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);
}
/// Verifies that a range-violation model survives a serialize and parse round trip.
[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);
}
/// Verifies that a rate-of-change model survives a serialize and parse round trip.
[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 ────────────────────────────────────────────────────────
/// Verifies that parsing a high-low configuration reads all setpoints and priorities.
[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);
}
/// Verifies that a high-low configuration with only some setpoints parses, leaving the missing ones null.
[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 ────────────────────────────────────────────────────
/// Verifies that serializing a high-low model omits null setpoints and priorities.
[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 _));
}
/// Verifies that serializing a high-low model does not leak fields that belong to other trigger types.
[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 _));
}
/// Verifies that parsing a high-low configuration reads the deadbands.
[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);
}
/// Verifies that serializing a high-low model omits null deadbands.
[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 _));
}
/// Verifies that a high-low model preserves all of its fields through a serialize and parse round trip.
[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) ────────────────────────────────────────
/// Verifies that direction normalization handles every alias and falls back to either for unknown input.
/// The direction text to normalize.
/// The expected normalized direction.
[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));
}
}