using System.Text.Json; using Bunit; using Bunit.JSInterop; using Microsoft.AspNetCore.Components; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// /// The AlarmTriggerEditor and ScriptTriggerEditor /// components must surface an Advisory|Strict selector for Expression triggers /// and round-trip the "analysisKind" key into the trigger config JSON /// that the ValidationService backend reads. /// // ── AlarmTriggerConfigCodec — analysisKind parse / serialize ──────────────── public class AlarmTriggerAnalysisKindCodecTests { // Codec: parse — absent key defaults to Advisory (false) /// Verifies that parsing an Expression trigger with no analysis kind defaults to Advisory. [Fact] public void Parse_Expression_NoAnalysisKind_DefaultsToAdvisory() { var json = @"{""expression"":""Attributes[""Temp""] > 50""}"; var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.Expression); Assert.False(model.IsStrictAnalysisKind); } // Codec: parse — "Advisory" explicit → false /// Verifies that parsing an Expression trigger with the Advisory key returns false. [Fact] public void Parse_Expression_AdvisoryKey_ReturnsFalse() { var json = @"{""expression"":""x > 0"",""analysisKind"":""Advisory""}"; var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.Expression); Assert.False(model.IsStrictAnalysisKind); } // Codec: parse — "Strict" → true /// Verifies that parsing an Expression trigger with the Strict key returns true. [Fact] public void Parse_Expression_StrictKey_ReturnsTrue() { var json = @"{""expression"":""x > 0"",""analysisKind"":""Strict""}"; var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.Expression); Assert.True(model.IsStrictAnalysisKind); } // Codec: parse — case-insensitive /// Verifies that the Strict key is parsed case-insensitively. [Fact] public void Parse_Expression_StrictKeyCaseInsensitive() { var json = @"{""expression"":""x > 0"",""analysisKind"":""strict""}"; var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.Expression); Assert.True(model.IsStrictAnalysisKind); } // Codec: serialize — Advisory (false) → "analysisKind" key omitted /// Verifies that serializing an Advisory Expression trigger omits the analysisKind key. [Fact] public void Serialize_Expression_Advisory_OmitsAnalysisKindKey() { var model = new AlarmTriggerModel { Expression = "x > 0", IsStrictAnalysisKind = false }; var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.Expression); using var doc = JsonDocument.Parse(json); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } // Codec: serialize — Strict (true) → "analysisKind":"Strict" /// Verifies that serializing a Strict Expression trigger writes analysisKind as Strict. [Fact] public void Serialize_Expression_Strict_WritesAnalysisKindStrict() { var model = new AlarmTriggerModel { Expression = "x > 0", IsStrictAnalysisKind = true }; var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.Expression); using var doc = JsonDocument.Parse(json); Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); } // Codec: serialize — Strict not emitted for non-Expression trigger types /// Verifies that serializing a non-Expression trigger does not emit analysisKind. [Fact] public void Serialize_NonExpression_DoesNotEmitAnalysisKind() { var model = new AlarmTriggerModel { AttributeName = "Temp", Min = 0, Max = 100, IsStrictAnalysisKind = true // should be ignored for non-Expression }; var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation); using var doc = JsonDocument.Parse(json); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } // Codec: round-trip Strict /// Verifies that a Strict Expression trigger is preserved through a round trip. [Fact] public void RoundTrip_Expression_Strict_Preserved() { var original = new AlarmTriggerModel { Expression = "Temp > 80", IsStrictAnalysisKind = true }; var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.Expression); var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.Expression); Assert.True(round.IsStrictAnalysisKind); Assert.Equal("Temp > 80", round.Expression); } // Codec: round-trip Advisory (omit + re-parse → still false) /// Verifies that an Advisory Expression trigger is preserved through a round trip. [Fact] public void RoundTrip_Expression_Advisory_Preserved() { var original = new AlarmTriggerModel { Expression = "Temp > 80", IsStrictAnalysisKind = false }; var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.Expression); var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.Expression); Assert.False(round.IsStrictAnalysisKind); } } // ── ScriptTriggerConfigCodec — analysisKind parse / serialize ─────────────── public class ScriptTriggerAnalysisKindCodecTests { // Codec: parse — absent key defaults to Advisory (false) /// Verifies that parsing an Expression trigger with no analysis kind defaults to Advisory. [Fact] public void Parse_Expression_NoAnalysisKind_DefaultsToAdvisory() { var json = @"{""expression"":""x > 0"",""mode"":""OnTrue""}"; var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression); Assert.False(model.IsStrictAnalysisKind); } // Codec: parse — "Strict" → true /// Verifies that parsing an Expression trigger with the Strict key returns true. [Fact] public void Parse_Expression_StrictKey_ReturnsTrue() { var json = @"{""expression"":""x > 0"",""mode"":""OnTrue"",""analysisKind"":""Strict""}"; var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression); Assert.True(model.IsStrictAnalysisKind); } // Codec: serialize — Advisory → key omitted /// Verifies that serializing an Advisory Expression trigger omits the analysisKind key. [Fact] public void Serialize_Expression_Advisory_OmitsAnalysisKindKey() { var model = new ScriptTriggerModel { Expression = "x > 0", IsStrictAnalysisKind = false }; var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Expression)!; using var doc = JsonDocument.Parse(json); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } // Codec: serialize — Strict → "analysisKind":"Strict" /// Verifies that serializing a Strict Expression trigger writes analysisKind as Strict. [Fact] public void Serialize_Expression_Strict_WritesAnalysisKindStrict() { var model = new ScriptTriggerModel { Expression = "x > 0", IsStrictAnalysisKind = true }; var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Expression)!; using var doc = JsonDocument.Parse(json); Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); } // Codec: serialize — Strict not emitted for non-Expression triggers /// Verifies that serializing a non-Expression trigger does not emit analysisKind. [Fact] public void Serialize_NonExpression_DoesNotEmitAnalysisKind() { var model = new ScriptTriggerModel { AttributeName = "Temp", Operator = ">", Threshold = 50, IsStrictAnalysisKind = true // must be ignored for Conditional }; var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Conditional)!; using var doc = JsonDocument.Parse(json); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } // Codec: round-trip Strict /// Verifies that a Strict Expression trigger is preserved through a round trip. [Fact] public void RoundTrip_Expression_Strict_Preserved() { var original = new ScriptTriggerModel { Expression = "Temp > 80", Mode = ScriptTriggerMode.WhileTrue, IsStrictAnalysisKind = true }; var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Expression)!; var round = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression); Assert.True(round.IsStrictAnalysisKind); Assert.Equal(ScriptTriggerMode.WhileTrue, round.Mode); } } // ── AlarmTriggerEditor — UI selector (bUnit component tests) ───────────────── public class AlarmTriggerEditorAnalysisKindTests : BunitContext { /// Initializes the test context for the alarm trigger editor. public AlarmTriggerEditorAnalysisKindTests() { // MonacoEditor calls MonacoBlazor.createEditor via JS interop. Use Loose // mode so the editor renders without requiring a full setup for each call. JSInterop.Mode = JSRuntimeMode.Loose; } // Selector is visible for Expression trigger type /// Verifies that the alarm trigger editor shows the analysis kind selector for an Expression trigger. [Fact] public void AlarmTriggerEditor_Expression_ShowsAnalysisKindSelector() { var cut = Render(ps => ps .Add(p => p.TriggerType, AlarmTriggerType.Expression) .Add(p => p.Value, @"{""expression"":""""}")); // The selector must exist and have id="alarm-trigger-kind" var select = cut.Find("#alarm-trigger-kind"); Assert.NotNull(select); } // Selector is NOT visible for non-Expression trigger types /// Verifies that the alarm trigger editor does not show the analysis kind selector for a non-Expression trigger. [Fact] public void AlarmTriggerEditor_NonExpression_DoesNotShowAnalysisKindSelector() { var cut = Render(ps => ps .Add(p => p.TriggerType, AlarmTriggerType.RangeViolation) .Add(p => p.Value, @"{""attributeName"":""Temp"",""min"":0,""max"":100}")); Assert.Throws(() => cut.Find("#alarm-trigger-kind")); } // Selector defaults to Advisory /// Verifies that the alarm trigger editor selector defaults to Advisory for an Expression trigger. [Fact] public void AlarmTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory() { var cut = Render(ps => ps .Add(p => p.TriggerType, AlarmTriggerType.Expression) .Add(p => p.Value, @"{""expression"":""x > 0""}")); var select = cut.Find("#alarm-trigger-kind"); // The bound value must be "Advisory" — not just present in the HTML Assert.Equal("Advisory", select.GetAttribute("value")); } // Selector defaults to Advisory when config has no analysisKind (ScriptTriggerEditor) /// Verifies that the trigger editor selector defaults to Advisory when the config has no analysis kind. [Fact] public void ScriptTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory() { var cut = Render(ps => ps .Add(p => p.TriggerType, "Expression") .Add(p => p.TriggerConfig, @"{""expression"":""x > 0"",""mode"":""OnTrue""}")); var select = cut.Find("#script-trigger-kind"); // The bound value must be "Advisory" — not just present in the HTML Assert.Equal("Advisory", select.GetAttribute("value")); } // Choosing Strict writes analysisKind:"Strict" into emitted config /// Verifies that choosing Strict in the alarm trigger editor emits analysisKind as Strict. [Fact] public void AlarmTriggerEditor_Expression_ChoosingStrict_EmitsAnalysisKindStrict() { string? emitted = null; var cut = Render(ps => ps .Add(p => p.TriggerType, AlarmTriggerType.Expression) .Add(p => p.Value, @"{""expression"":""x > 0""}") .Add(p => p.ValueChanged, EventCallback.Factory.Create(this, v => emitted = v))); cut.Find("#alarm-trigger-kind").Change("Strict"); Assert.NotNull(emitted); using var doc = JsonDocument.Parse(emitted!); Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); } // Loaded Strict config reflects in selector, emits Strict on next change /// Verifies that a loaded Strict config is retained when the alarm trigger is edited. [Fact] public void AlarmTriggerEditor_Expression_LoadedStrictConfig_RetainedOnEdit() { string? emitted = null; var cut = Render(ps => ps .Add(p => p.TriggerType, AlarmTriggerType.Expression) .Add(p => p.Value, @"{""expression"":""x > 0"",""analysisKind"":""Strict""}") .Add(p => p.ValueChanged, EventCallback.Factory.Create(this, v => emitted = v))); // Switch to Advisory — config must no longer carry analysisKind cut.Find("#alarm-trigger-kind").Change("Advisory"); Assert.NotNull(emitted); using var doc = JsonDocument.Parse(emitted!); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } } // ── ScriptTriggerEditor — UI selector (bUnit component tests) ──────────────── public class ScriptTriggerEditorAnalysisKindTests : BunitContext { /// Initializes the test context for the script trigger editor. public ScriptTriggerEditorAnalysisKindTests() { // MonacoEditor calls MonacoBlazor.createEditor via JS interop. Use Loose // mode so the editor renders without requiring a full setup for each call. JSInterop.Mode = JSRuntimeMode.Loose; } // Selector is visible for Expression trigger type /// Verifies that the script trigger editor shows the analysis kind selector for an Expression trigger. [Fact] public void ScriptTriggerEditor_Expression_ShowsAnalysisKindSelector() { var cut = Render(ps => ps .Add(p => p.TriggerType, "Expression") .Add(p => p.TriggerConfig, @"{""expression"":"""",""mode"":""OnTrue""}")); var select = cut.Find("#script-trigger-kind"); Assert.NotNull(select); } // Selector is NOT visible for non-Expression trigger types /// Verifies that the script trigger editor does not show the analysis kind selector for a Conditional trigger. [Fact] public void ScriptTriggerEditor_Conditional_DoesNotShowAnalysisKindSelector() { var cut = Render(ps => ps .Add(p => p.TriggerType, "Conditional") .Add(p => p.TriggerConfig, @"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""OnTrue""}")); Assert.Throws(() => cut.Find("#script-trigger-kind")); } // Choosing Strict writes analysisKind:"Strict" into emitted config /// Verifies that choosing Strict in the script trigger editor emits analysisKind as Strict. [Fact] public void ScriptTriggerEditor_Expression_ChoosingStrict_EmitsAnalysisKindStrict() { ScriptTriggerValue? captured = null; var cut = Render(ps => ps .Add(p => p.TriggerType, "Expression") .Add(p => p.TriggerConfig, @"{""expression"":""x > 0"",""mode"":""OnTrue""}") .Add(p => p.Changed, EventCallback.Factory.Create(this, v => captured = v))); cut.Find("#script-trigger-kind").Change("Strict"); Assert.NotNull(captured); using var doc = JsonDocument.Parse(captured!.Config!); Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); } // Choosing Advisory emits config without analysisKind key /// Verifies that choosing Advisory in the script trigger editor omits the analysisKind key. [Fact] public void ScriptTriggerEditor_Expression_ChoosingAdvisory_OmitsAnalysisKindKey() { ScriptTriggerValue? captured = null; var cut = Render(ps => ps .Add(p => p.TriggerType, "Expression") .Add(p => p.TriggerConfig, @"{""expression"":""x > 0"",""mode"":""OnTrue"",""analysisKind"":""Strict""}") .Add(p => p.Changed, EventCallback.Factory.Create(this, v => captured = v))); cut.Find("#script-trigger-kind").Change("Advisory"); Assert.NotNull(captured); using var doc = JsonDocument.Parse(captured!.Config!); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } // Loaded Strict is retained on unrelated edit (fire mode change) /// Verifies that a loaded Strict setting is retained when the trigger mode changes. [Fact] public void ScriptTriggerEditor_Expression_LoadedStrict_RetainedOnModeChange() { ScriptTriggerValue? captured = null; var cut = Render(ps => ps .Add(p => p.TriggerType, "Expression") .Add(p => p.TriggerConfig, @"{""expression"":""x > 0"",""mode"":""OnTrue"",""analysisKind"":""Strict""}") .Add(p => p.Changed, EventCallback.Factory.Create(this, v => captured = v))); // Change fire mode — Strict kind must survive cut.Find("#script-trigger-mode").Change("WhileTrue"); Assert.NotNull(captured); Assert.Contains("\"analysisKind\":\"Strict\"", captured!.Config); } }