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; /// /// M9-T28b: 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 T28a ValidationService backend reads. /// // ── AlarmTriggerConfigCodec — analysisKind parse / serialize ──────────────── public class AlarmTriggerAnalysisKindCodecTests { // Codec: parse — absent key defaults to Advisory (false) [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 [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 [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 [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 [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" [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 [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 [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) [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) [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 [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 [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" [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 [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 [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 { 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 [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 [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 [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) [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 [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 [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 { 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 [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 [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 [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 [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) [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); } }