diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs index 8fa6eca3..11957481 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs @@ -17,13 +17,33 @@ internal static class AlarmTriggerConfigJson /// flags, or returns null when none are supplied (so the alarm is created without a /// trigger config). Unknown/blank trigger types yield null. /// + /// The trigger type string (case-insensitive). + /// Attribute name (non-Expression trigger types). + /// ValueMatch: value to compare against. + /// ValueMatch: invert the comparison. + /// RangeViolation: minimum allowed value. + /// RangeViolation: maximum allowed value. + /// RateOfChange: rate threshold per second. + /// RateOfChange: sliding window in seconds. + /// RateOfChange: direction (rising|falling|either). + /// HiLo: low-low setpoint. + /// HiLo: low setpoint. + /// HiLo: high setpoint. + /// HiLo: high-high setpoint. + /// Expression: boolean trigger expression. + /// + /// M9-T28b: optional analysis kind for Expression triggers ("strict" → emits + /// "analysisKind":"Strict"; null/"advisory"/anything else → Advisory default, + /// key omitted). Ignored for non-Expression trigger types. + /// internal static string? Build( string triggerType, string? attribute, string? matchValue, bool notEquals, double? min, double? max, double? thresholdPerSecond, double? windowSeconds, string? direction, double? loLo, double? lo, double? hi, double? hiHi, - string? expression) + string? expression, + string? analysisKind = null) { var type = triggerType?.Trim(); var anyTyped = attribute is not null || matchValue is not null || notEquals @@ -66,6 +86,11 @@ internal static class AlarmTriggerConfigJson break; case "expression": w.WriteString("expression", expression ?? ""); + // M9-T28b: emit "analysisKind":"Strict" only when the caller passes + // --trigger-kind strict (case-insensitive); Advisory (the default) is + // expressed by omitting the key, matching ValidationService.IsStrictAnalysis. + if (string.Equals(analysisKind?.Trim(), "Strict", StringComparison.OrdinalIgnoreCase)) + w.WriteString("analysisKind", "Strict"); break; } w.WriteEndObject(); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index d1491916..f67ce854 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -339,6 +339,13 @@ public static class TemplateCommands var hiOption = new Option("--hi") { Description = "HiLo: high setpoint" }; var hiHiOption = new Option("--hihi") { Description = "HiLo: high-high setpoint" }; var expressionOption = new Option("--expression") { Description = "Expression: boolean trigger expression" }; + // M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory). + // Writes "analysisKind":"Strict" into the trigger config when set to strict. + var triggerKindOption = new Option("--trigger-kind") + { + Description = "Expression trigger analysis kind: advisory (default) or strict. " + + "Strict escalates a blank expression from a warning to a deploy-blocking error." + }; var addCmd = new Command("add") { Description = "Add an alarm to a template" }; addCmd.Add(templateIdOption); @@ -361,6 +368,7 @@ public static class TemplateCommands addCmd.Add(hiOption); addCmd.Add(hiHiOption); addCmd.Add(expressionOption); + addCmd.Add(triggerKindOption); addCmd.SetAction(async (ParseResult result) => { var triggerType = result.GetValue(triggerTypeOption)!; @@ -372,7 +380,8 @@ public static class TemplateCommands result.GetValue(minOption), result.GetValue(maxOption), result.GetValue(thresholdOption), result.GetValue(windowOption), result.GetValue(directionOption), result.GetValue(loLoOption), result.GetValue(loOption), result.GetValue(hiOption), result.GetValue(hiHiOption), - result.GetValue(expressionOption)); + result.GetValue(expressionOption), + result.GetValue(triggerKindOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, @@ -395,6 +404,11 @@ public static class TemplateCommands var updateTriggerConfigOption = new Option("--trigger-config") { Description = "Trigger configuration JSON" }; var updateLockedOption = new Option("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; + // M9-T28b: --trigger-kind for update (same semantics as add) + var updateTriggerKindOption = new Option("--trigger-kind") + { + Description = "Expression trigger analysis kind: advisory (default) or strict." + }; var updateCmd = new Command("update") { Description = "Update a template alarm" }; updateCmd.Add(updateIdOption); @@ -404,6 +418,7 @@ public static class TemplateCommands updateCmd.Add(updateDescOption); updateCmd.Add(updateTriggerConfigOption); updateCmd.Add(updateLockedOption); + updateCmd.Add(updateTriggerKindOption); updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( @@ -514,6 +529,13 @@ public static class TemplateCommands var paramsOption = new Option("--parameters") { Description = "Parameter definitions JSON" }; var returnOption = new Option("--return-def") { Description = "Return definition JSON" }; + // M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory). + var scriptTriggerKindOption = new Option("--trigger-kind") + { + Description = "Expression trigger analysis kind: advisory (default) or strict. " + + "Strict escalates a blank expression from a warning to a deploy-blocking error. " + + "Used with --trigger-config or appended to a built config when --trigger-type is Expression." + }; var addCmd = new Command("add") { Description = "Add a script to a template" }; addCmd.Add(templateIdOption); @@ -524,6 +546,7 @@ public static class TemplateCommands addCmd.Add(lockedOption); addCmd.Add(paramsOption); addCmd.Add(returnOption); + addCmd.Add(scriptTriggerKindOption); addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( @@ -550,6 +573,11 @@ public static class TemplateCommands var updateParamsOption = new Option("--parameters") { Description = "Parameter definitions JSON" }; var updateReturnOption = new Option("--return-def") { Description = "Return definition JSON" }; + // M9-T28b: --trigger-kind for update (same semantics as add) + var updateScriptTriggerKindOption = new Option("--trigger-kind") + { + Description = "Expression trigger analysis kind: advisory (default) or strict." + }; var updateCmd = new Command("update") { Description = "Update a template script" }; updateCmd.Add(updateIdOption); @@ -560,6 +588,7 @@ public static class TemplateCommands updateCmd.Add(updateLockedOption); updateCmd.Add(updateParamsOption); updateCmd.Add(updateReturnOption); + updateCmd.Add(updateScriptTriggerKindOption); updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs index e07c77a9..8d27cf06 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs @@ -100,6 +100,15 @@ internal static class AlarmTriggerConfigCodec case AlarmTriggerType.Expression: model.Expression = TryReadString(root, "expression"); + // M9-T28b: read optional analysisKind discriminator ("Strict" → strict; + // absent/"Advisory"/anything else → Advisory default, preserving + // today's behavior exactly — matches ValidationService.IsStrictAnalysis). + if (root.TryGetProperty("analysisKind", out var ak) + && ak.ValueKind == JsonValueKind.String) + { + model.IsStrictAnalysisKind = string.Equals( + ak.GetString(), "Strict", StringComparison.OrdinalIgnoreCase); + } break; } } @@ -172,6 +181,11 @@ internal static class AlarmTriggerConfigCodec case AlarmTriggerType.Expression: w.WriteString("expression", model.Expression ?? ""); + // M9-T28b: emit "analysisKind":"Strict" only when explicitly set; + // Advisory is the default so the key is omitted to keep the payload + // minimal and backward-compatible with older ValidationService versions. + if (model.IsStrictAnalysisKind) + w.WriteString("analysisKind", "Strict"); break; } @@ -342,4 +356,14 @@ internal sealed class AlarmTriggerModel /// The boolean C# expression to evaluate for Expression triggers. /// public string? Expression { get; set; } + + // 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; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor index 43874141..373d3680 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor @@ -534,6 +534,8 @@ _thresholdText = FormatNullable(_model.ThresholdPerSecond); _windowText = FormatNullable(_model.WindowSeconds); _directionText = _model.Direction; + // M9-T28b: sync the analysis-kind selector from the loaded model. + _analysisKindValue = _model.IsStrictAnalysisKind ? "Strict" : "Advisory"; _loLoText = FormatNullable(_model.LoLo); _loText = FormatNullable(_model.Lo); _hiText = FormatNullable(_model.Hi); @@ -583,6 +585,21 @@
A boolean C# expression — e.g. Attributes["Temperature"] > 80.
+ @* M9-T28b: analysis-kind selector — Advisory (default) keeps the blank-expression + finding as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@ +
+ + +
}; 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); + } +}