From dcc6f623e2c1cae16b62370996f43d1742f1ec4c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 10:44:57 -0400 Subject: [PATCH] feat(m9/T28b): trigger analysis-kind selector (UI) + --trigger-kind (CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the T28a backend "analysisKind" discriminator in both authoring surfaces: an Advisory|Strict + + + + }; private async Task OnExpressionChanged(string value) @@ -591,6 +608,15 @@ await Emit(); } + // M9-T28b: backing field + handler for the Advisory|Strict selector. + private string _analysisKindValue = "Advisory"; + + private async Task OnAnalysisKindChanged() + { + _model.IsStrictAnalysisKind = string.Equals(_analysisKindValue, "Strict", StringComparison.OrdinalIgnoreCase); + await Emit(); + } + // ── Hint text ────────────────────────────────────────────────────────── private string BuildHint() diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs index 550c1cf1..146e69bb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs @@ -42,6 +42,16 @@ internal sealed class ScriptTriggerModel /// Fire mode (Conditional + Expression). Defaults to . public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue; + + // M9-T28b: per-trigger analysis kind (Expression only). When true the + // codec serializes "analysisKind":"Strict"; when false (Advisory, the + // default) the key is omitted. Matches ValidationService.IsStrictAnalysis. + /// + /// When , the trigger config carries + /// "analysisKind":"Strict" so ValidationService escalates the + /// blank-expression advisory to a deploy-blocking error. + /// + public bool IsStrictAnalysisKind { get; set; } } /// @@ -148,6 +158,15 @@ internal static class ScriptTriggerConfigCodec case ScriptTriggerKind.Expression: model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null; model.Mode = ReadMode(root); + // M9-T28b: read optional analysisKind discriminator (matches + // ValidationService.IsStrictAnalysis — "Strict" case-insensitive → strict; + // absent/"Advisory"/anything else → Advisory default). + if (root.TryGetProperty("analysisKind", out var ak) + && ak.ValueKind == JsonValueKind.String) + { + model.IsStrictAnalysisKind = string.Equals( + ak.GetString(), "Strict", StringComparison.OrdinalIgnoreCase); + } break; } } @@ -196,6 +215,10 @@ internal static class ScriptTriggerConfigCodec case ScriptTriggerKind.Expression: w.WriteString("expression", model.Expression ?? ""); w.WriteString("mode", model.Mode.ToString()); + // M9-T28b: emit "analysisKind":"Strict" only when explicitly set; + // Advisory is the default so the key is omitted to stay backward-compatible. + if (model.IsStrictAnalysisKind) + w.WriteString("analysisKind", "Strict"); break; // Call → empty object. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerEditor.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerEditor.razor index 20a4a8f7..650f3b58 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerEditor.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ScriptTriggerEditor.razor @@ -136,6 +136,8 @@ _operator = _model.Operator; _thresholdText = _model.Threshold?.ToString("R", CultureInfo.InvariantCulture); (_intervalText, _intervalUnit) = SplitInterval(_model.IntervalMs); + // M9-T28b: sync the analysis-kind selector from the loaded model. + _analysisKindValue = _model.IsStrictAnalysisKind ? "Strict" : "Advisory"; } /// Chooses the largest whole unit (min/sec/ms) that represents the period exactly. @@ -272,6 +274,21 @@
A boolean C# expression — e.g. Attributes["Temperature"] > 80.
+ @* M9-T28b: analysis-kind selector — Advisory keeps the blank-expression finding + as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@ +
+ + +
}; private async Task OnExpressionChanged(string value) @@ -280,6 +297,15 @@ await Emit(); } + // M9-T28b: backing field + handler for the Advisory|Strict selector. + private string _analysisKindValue = "Advisory"; + + private async Task OnAnalysisKindChanged() + { + _model.IsStrictAnalysisKind = string.Equals(_analysisKindValue, "Strict", StringComparison.OrdinalIgnoreCase); + await Emit(); + } + // ── Fire mode (Conditional + Expression) ─────────────────────────────── private RenderFragment RenderMode() => __builder => diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs new file mode 100644 index 00000000..2b831e78 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs @@ -0,0 +1,157 @@ +using System.CommandLine; +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.CLI; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; + +/// +/// M9-T28b: template alarm add/update and template script add/update +/// must expose a --trigger-kind option (advisory|strict) that writes +/// "analysisKind":"Strict" into the trigger-config JSON for expression +/// triggers, matching the T28a backend contract exactly. +/// +public class TemplateTriggerKindTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static Command AlarmGroup() + => TemplateCommands.Build(Url, Format, Username, Password) + .Subcommands.Single(c => c.Name == "alarm"); + + private static Command ScriptGroup() + => TemplateCommands.Build(Url, Format, Username, Password) + .Subcommands.Single(c => c.Name == "script"); + + // ── Option surface: alarm add + update have --trigger-kind ─────────────── + + [Fact] + public void AlarmAdd_HasTriggerKindOption() + { + var add = AlarmGroup().Subcommands.Single(c => c.Name == "add"); + Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name)); + } + + [Fact] + public void AlarmUpdate_HasTriggerKindOption() + { + var update = AlarmGroup().Subcommands.Single(c => c.Name == "update"); + Assert.Contains("--trigger-kind", update.Options.Select(o => o.Name)); + } + + // ── Option surface: script add + update have --trigger-kind ───────────── + + [Fact] + public void ScriptAdd_HasTriggerKindOption() + { + var add = ScriptGroup().Subcommands.Single(c => c.Name == "add"); + Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name)); + } + + [Fact] + public void ScriptUpdate_HasTriggerKindOption() + { + var update = ScriptGroup().Subcommands.Single(c => c.Name == "update"); + Assert.Contains("--trigger-kind", update.Options.Select(o => o.Name)); + } + + // ── AlarmTriggerConfigJson.Build — analysisKind injection ──────────────── + + [Fact] + public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict() + { + // --trigger-kind strict with --expression set → config carries analysisKind:"Strict" + var json = AlarmTriggerConfigJson.Build( + triggerType: "Expression", + attribute: null, + matchValue: null, notEquals: false, + min: null, max: null, + thresholdPerSecond: null, windowSeconds: null, direction: null, + loLo: null, lo: null, hi: null, hiHi: null, + expression: "Temp > 80", + analysisKind: "strict"); + + Assert.NotNull(json); + using var doc = JsonDocument.Parse(json!); + Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); + } + + [Fact] + public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey() + { + // --trigger-kind advisory (default) → key omitted + var json = AlarmTriggerConfigJson.Build( + triggerType: "Expression", + attribute: null, + matchValue: null, notEquals: false, + min: null, max: null, + thresholdPerSecond: null, windowSeconds: null, direction: null, + loLo: null, lo: null, hi: null, hiHi: null, + expression: "Temp > 80", + analysisKind: "advisory"); + + Assert.NotNull(json); + using var doc = JsonDocument.Parse(json!); + Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); + } + + [Fact] + public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey() + { + // Omitted --trigger-kind → null → Advisory default → key omitted + var json = AlarmTriggerConfigJson.Build( + triggerType: "Expression", + attribute: null, + matchValue: null, notEquals: false, + min: null, max: null, + thresholdPerSecond: null, windowSeconds: null, direction: null, + loLo: null, lo: null, hi: null, hiHi: null, + expression: "Temp > 80", + analysisKind: null); + + Assert.NotNull(json); + using var doc = JsonDocument.Parse(json!); + Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); + } + + [Fact] + public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind() + { + // analysisKind is meaningless for non-expression triggers — must be suppressed + var json = AlarmTriggerConfigJson.Build( + triggerType: "RangeViolation", + attribute: "Temp", + matchValue: null, notEquals: false, + min: 0, max: 100, + thresholdPerSecond: null, windowSeconds: null, direction: null, + loLo: null, lo: null, hi: null, hiHi: null, + expression: null, + analysisKind: "strict"); + + Assert.NotNull(json); + using var doc = JsonDocument.Parse(json!); + Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); + } + + [Fact] + public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive() + { + // "STRICT" (uppercase) must also produce the canonical "Strict" value + var json = AlarmTriggerConfigJson.Build( + triggerType: "Expression", + attribute: null, + matchValue: null, notEquals: false, + min: null, max: null, + thresholdPerSecond: null, windowSeconds: null, direction: null, + loLo: null, lo: null, hi: null, hiHi: null, + expression: "x > 0", + analysisKind: "STRICT"); + + Assert.NotNull(json); + using var doc = JsonDocument.Parse(json!); + Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs new file mode 100644 index 00000000..b04e40c6 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs @@ -0,0 +1,372 @@ +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 selected option must be Advisory (the default) + Assert.Contains("Advisory", select.InnerHtml); + } + + // 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); + } +}