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);
+ }
+}