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:
@@ -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"] > 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"] > 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user