feat(m9/T28b): trigger analysis-kind selector (UI) + --trigger-kind (CLI)

Surfaces the T28a backend "analysisKind" discriminator in both authoring
surfaces: an Advisory|Strict <select> (id="alarm-trigger-kind" /
"script-trigger-kind") added to the Expression fragment of
AlarmTriggerEditor and ScriptTriggerEditor, and a --trigger-kind option
on template alarm/script add+update in the CLI.

Key/value contract: "analysisKind":"Strict" when strict; key omitted for
Advisory — exactly as ValidationService.IsStrictAnalysis reads it.
Selector only shown for Expression triggers; non-Expression triggers do
not emit the key even if IsStrictAnalysisKind is set on the model.

Both projects build 0 warnings; 101 CentralUI Trigger tests + 33 CLI
Template tests pass.
This commit is contained in:
Joseph Doherty
2026-06-18 10:44:57 -04:00
parent f618ac0322
commit dcc6f623e2
8 changed files with 684 additions and 2 deletions
@@ -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.
/// </summary>
/// <param name="triggerType">The trigger type string (case-insensitive).</param>
/// <param name="attribute">Attribute name (non-Expression trigger types).</param>
/// <param name="matchValue">ValueMatch: value to compare against.</param>
/// <param name="notEquals">ValueMatch: invert the comparison.</param>
/// <param name="min">RangeViolation: minimum allowed value.</param>
/// <param name="max">RangeViolation: maximum allowed value.</param>
/// <param name="thresholdPerSecond">RateOfChange: rate threshold per second.</param>
/// <param name="windowSeconds">RateOfChange: sliding window in seconds.</param>
/// <param name="direction">RateOfChange: direction (rising|falling|either).</param>
/// <param name="loLo">HiLo: low-low setpoint.</param>
/// <param name="lo">HiLo: low setpoint.</param>
/// <param name="hi">HiLo: high setpoint.</param>
/// <param name="hiHi">HiLo: high-high setpoint.</param>
/// <param name="expression">Expression: boolean trigger expression.</param>
/// <param name="analysisKind">
/// M9-T28b: optional analysis kind for Expression triggers ("strict" → emits
/// <c>"analysisKind":"Strict"</c>; null/"advisory"/anything else → Advisory default,
/// key omitted). Ignored for non-Expression trigger types.
/// </param>
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();
@@ -339,6 +339,13 @@ public static class TemplateCommands
var hiOption = new Option<double?>("--hi") { Description = "HiLo: high setpoint" };
var hiHiOption = new Option<double?>("--hihi") { Description = "HiLo: high-high setpoint" };
var expressionOption = new Option<string?>("--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<string?>("--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<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false;
// M9-T28b: --trigger-kind for update (same semantics as add)
var updateTriggerKindOption = new Option<string?>("--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<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
// M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory).
var scriptTriggerKindOption = new Option<string?>("--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<string?>("--parameters") { Description = "Parameter definitions JSON" };
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
// M9-T28b: --trigger-kind for update (same semantics as add)
var updateScriptTriggerKindOption = new Option<string?>("--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(
@@ -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.
/// </summary>
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.
/// <summary>
/// When <see langword="true"/>, the trigger config carries
/// <c>"analysisKind":"Strict"</c> so ValidationService escalates the
/// blank-expression advisory to a deploy-blocking error.
/// </summary>
public bool IsStrictAnalysisKind { get; set; }
}
@@ -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 @@
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
@* 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. *@
<div class="mt-2">
<label for="alarm-trigger-kind" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Analysis kind
</label>
<select id="alarm-trigger-kind"
class="form-select form-select-sm"
style="max-width: 280px;"
@bind="_analysisKindValue"
@bind:after="OnAnalysisKindChanged">
<option value="Advisory">Advisory — blank expression is a warning</option>
<option value="Strict">Strict — blank expression blocks deploy</option>
</select>
</div>
};
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()
@@ -42,6 +42,16 @@ internal sealed class ScriptTriggerModel
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
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.
/// <summary>
/// When <see langword="true"/>, the trigger config carries
/// <c>"analysisKind":"Strict"</c> so ValidationService escalates the
/// blank-expression advisory to a deploy-blocking error.
/// </summary>
public bool IsStrictAnalysisKind { get; set; }
}
/// <summary>
@@ -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.
@@ -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";
}
/// <summary>Chooses the largest whole unit (min/sec/ms) that represents the period exactly.</summary>
@@ -272,6 +274,21 @@
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
@* M9-T28b: analysis-kind selector — Advisory keeps the blank-expression finding
as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@
<div class="mt-2">
<label for="script-trigger-kind" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Analysis kind
</label>
<select id="script-trigger-kind"
class="form-select form-select-sm"
style="max-width: 280px;"
@bind="_analysisKindValue"
@bind:after="OnAnalysisKindChanged">
<option value="Advisory">Advisory — blank expression is a warning</option>
<option value="Strict">Strict — blank expression blocks deploy</option>
</select>
</div>
};
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 =>
@@ -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;
/// <summary>
/// M9-T28b: <c>template alarm add/update</c> and <c>template script add/update</c>
/// must expose a <c>--trigger-kind</c> option (advisory|strict) that writes
/// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression
/// triggers, matching the T28a backend contract exactly.
/// </summary>
public class TemplateTriggerKindTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> 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());
}
}
@@ -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;
/// <summary>
/// M9-T28b: the <c>AlarmTriggerEditor</c> and <c>ScriptTriggerEditor</c>
/// components must surface an Advisory|Strict selector for Expression triggers
/// and round-trip the <c>"analysisKind"</c> key into the trigger config JSON
/// that the T28a ValidationService backend reads.
/// </summary>
// ── 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<AlarmTriggerEditor>(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<AlarmTriggerEditor>(ps => ps
.Add(p => p.TriggerType, AlarmTriggerType.RangeViolation)
.Add(p => p.Value, @"{""attributeName"":""Temp"",""min"":0,""max"":100}"));
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find("#alarm-trigger-kind"));
}
// Selector defaults to Advisory
[Fact]
public void AlarmTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory()
{
var cut = Render<AlarmTriggerEditor>(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<AlarmTriggerEditor>(ps => ps
.Add(p => p.TriggerType, AlarmTriggerType.Expression)
.Add(p => p.Value, @"{""expression"":""x > 0""}")
.Add(p => p.ValueChanged,
EventCallback.Factory.Create<string?>(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<AlarmTriggerEditor>(ps => ps
.Add(p => p.TriggerType, AlarmTriggerType.Expression)
.Add(p => p.Value, @"{""expression"":""x > 0"",""analysisKind"":""Strict""}")
.Add(p => p.ValueChanged,
EventCallback.Factory.Create<string?>(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<ScriptTriggerEditor>(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<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, @"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""OnTrue""}"));
Assert.Throws<Bunit.ElementNotFoundException>(() => 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<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Expression")
.Add(p => p.TriggerConfig, @"{""expression"":""x > 0"",""mode"":""OnTrue""}")
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(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<ScriptTriggerEditor>(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<ScriptTriggerValue>(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<ScriptTriggerEditor>(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<ScriptTriggerValue>(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);
}
}