test(playwright): alarm-override trigger-config scenarios — HiLo merge, non-HiLo replace, validation, cancel, clear (T41)

This commit is contained in:
Joseph Doherty
2026-06-18 19:35:14 -04:00
parent 2e4ca5a35f
commit b7f7fe935c
5 changed files with 312 additions and 9 deletions
@@ -405,7 +405,7 @@
@if (_editingError != null)
{
<div class="alert alert-danger small mt-2 mb-0">@_editingError</div>
<div class="alert alert-danger small mt-2 mb-0" data-test="alarm-override-error">@_editingError</div>
}
</div>
<div class="modal-footer justify-content-between">
@@ -413,12 +413,13 @@
@if (HasOverride(_editingAlarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
data-test="alarm-clear-from-modal"
@onclick="() => ClearFromModal()"
disabled="@_saving">Clear Override</button>
}
</div>
<div>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-outline-secondary btn-sm" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success btn-sm" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
</div>
</div>
@@ -220,6 +220,7 @@
if (t == "Boolean")
{
<select class="form-select form-select-sm"
data-test="alarm-matchvalue-input"
@bind="_matchValueText"
@bind:after="OnMatchValueChanged">
<option value="True">True</option>
@@ -229,6 +230,7 @@
else if (IsNumericType(t ?? ""))
{
<input type="number" step="any" class="form-control form-control-sm"
data-test="alarm-matchvalue-input"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
@@ -236,6 +238,7 @@
else
{
<input type="text" class="form-control form-control-sm"
data-test="alarm-matchvalue-input"
placeholder="value"
@bind="_matchValueText"
@bind:event="oninput"
@@ -382,28 +385,28 @@
_hiHiDeadbandText, v => _hiHiDeadbandText = v, OnHiHiDeadbandChanged,
_hiHiPriorityText, v => _hiHiPriorityText = v, OnHiHiPriorityChanged,
_hiHiMessageText, v => _hiHiMessageText = v, OnHiHiMessageChanged,
"text-danger")
"text-danger", "alarm-hilo-hihi")
@HiLoSetpointRow("HIGH (warning)",
_hiText, v => _hiText = v, OnHiChanged,
_hiDeadbandText, v => _hiDeadbandText = v, OnHiDeadbandChanged,
_hiPriorityText, v => _hiPriorityText = v, OnHiPriorityChanged,
_hiMessageText, v => _hiMessageText = v, OnHiMessageChanged,
"text-warning-emphasis")
"text-warning-emphasis", "alarm-hilo-hi")
@HiLoSetpointRow("LOW (warning)",
_loText, v => _loText = v, OnLoChanged,
_loDeadbandText, v => _loDeadbandText = v, OnLoDeadbandChanged,
_loPriorityText, v => _loPriorityText = v, OnLoPriorityChanged,
_loMessageText, v => _loMessageText = v, OnLoMessageChanged,
"text-warning-emphasis")
"text-warning-emphasis", "alarm-hilo-lo")
@HiLoSetpointRow("LOW-LOW (critical)",
_loLoText, v => _loLoText = v, OnLoLoChanged,
_loLoDeadbandText, v => _loLoDeadbandText = v, OnLoLoDeadbandChanged,
_loLoPriorityText, v => _loLoPriorityText = v, OnLoLoPriorityChanged,
_loLoMessageText, v => _loLoMessageText = v, OnLoLoMessageChanged,
"text-danger")
"text-danger", "alarm-hilo-lolo")
};
/// <summary>
@@ -418,7 +421,7 @@
string? deadband, Action<string?> deadbandSetter, Func<Task> onDeadbandChanged,
string? priority, Action<string?> prioritySetter, Func<Task> onPriorityChanged,
string? message, Action<string?> messageSetter, Func<Task> onMessageChanged,
string severityClass) => __builder =>
string severityClass, string setpointTestId) => __builder =>
{
<div class="row g-2 align-items-end mb-1">
<div class="col-md-5">
@@ -428,6 +431,7 @@
<div class="input-group input-group-sm">
<span class="input-group-text">setpoint</span>
<input type="number" step="any" class="form-control"
data-test="@setpointTestId"
placeholder="—"
value="@value"
@oninput="@(e => { valueSetter(e.Value?.ToString()); _ = onValueChanged(); })" />
@@ -104,10 +104,18 @@ public static partial class CliRunner
/// Adds an alarm to a template via <c>template alarm add</c> (using the typed setpoint
/// flags) and returns its new <c>id</c>. Throws on failure.
/// </summary>
/// <remarks>
/// The HiLo setpoint flags (<paramref name="hi"/>/<paramref name="hiHi"/>/<paramref name="lo"/>/
/// <paramref name="loLo"/>) and the ValueMatch <paramref name="matchValue"/> are additive,
/// per-trigger-type selectors over the same <c>template alarm add</c> command — only the flags
/// relevant to <paramref name="triggerType"/> are emitted, the rest are skipped when null. This
/// lets one helper provision both the fixture's HiLo alarm and a ValueMatch alarm
/// (<c>--match-value</c>) without a parallel helper.
/// </remarks>
public static async Task<int> AddAlarmAsync(
int templateId, string name, string triggerType = "HiLo", int priority = 500,
string? attribute = null, double? hi = null, double? hiHi = null,
double? lo = null, double? loLo = null)
double? lo = null, double? loLo = null, string? matchValue = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List<string>
@@ -123,6 +131,7 @@ public static partial class CliRunner
if (hiHi.HasValue) { args.Add("--hihi"); args.Add(hiHi.Value.ToString(inv)); }
if (lo.HasValue) { args.Add("--lo"); args.Add(lo.Value.ToString(inv)); }
if (loLo.HasValue) { args.Add("--lolo"); args.Add(loLo.Value.ToString(inv)); }
if (matchValue is not null) { args.Add("--match-value"); args.Add(matchValue); }
using var doc = await RunJsonAsync([.. args]);
return RequireId(doc, "template alarm add");
@@ -36,9 +36,24 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime
/// <summary>The single bindable/overridable attribute name on the fixture template.</summary>
public string AttributeName => "Value";
/// <summary>The single non-locked alarm on the fixture template (for the override test).</summary>
/// <summary>The single non-locked HiLo alarm on the fixture template (for the merge override test).</summary>
public string AlarmName => "HiHi";
/// <summary>
/// A second, non-HiLo (ValueMatch) alarm on the fixture template. HiLo overrides MERGE into
/// inherited setpoints, but ValueMatch / RangeViolation / RateOfChange overrides WHOLE-REPLACE
/// the inherited trigger config — so this alarm exercises the whole-replace path that the HiLo
/// alarm cannot. Bound to the same <see cref="AttributeName"/> (<c>Value</c>) so the
/// AlarmTriggerEditor's attribute picker already has it selected.
/// </summary>
public string NonHiLoAlarmName => "Match";
/// <summary>Trigger type of <see cref="NonHiLoAlarmName"/>.</summary>
public string NonHiLoAlarmTriggerType => "ValueMatch";
/// <summary>The inherited (template) ValueMatch value baked into <see cref="NonHiLoAlarmName"/>.</summary>
public string NonHiLoInheritedMatchValue => "100";
/// <summary>The fixture data-connection name (for locating it in the bindings UI dropdown).</summary>
public string ConnectionName { get; private set; } = string.Empty;
@@ -63,6 +78,10 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime
await CliRunner.AddAttributeAsync(TemplateId, AttributeName, "Double", dataSourceReference: AttributeName);
await CliRunner.AddAlarmAsync(TemplateId, AlarmName, "HiLo", priority: 500,
attribute: AttributeName, hi: 80, hiHi: 95);
// Second, non-HiLo alarm so the whole-replace override path is covered (HiLo merges,
// ValueMatch/RangeViolation/RateOfChange whole-replace). Bound to the same attribute.
await CliRunner.AddAlarmAsync(TemplateId, NonHiLoAlarmName, NonHiLoAlarmTriggerType,
priority: 400, attribute: AttributeName, matchValue: NonHiLoInheritedMatchValue);
ConnectionName = CliRunner.UniqueName("conn");
ConnectionId = await CliRunner.CreateDataConnectionAsync(SiteAId, ConnectionName);
AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("cfgarea"));
@@ -244,4 +244,274 @@ public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixt
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
/// <summary>
/// HiLo MERGE: edits the fixture's HiLo alarm (<c>_cfg.AlarmName</c> = "HiHi", inherited
/// <c>hi=80, hiHi=95</c>), changes ONLY the <c>hi</c> setpoint to 70 (leaving <c>hiHi</c> untouched),
/// Saves → toast + badge, then verifies via CLI read-back that the persisted
/// <c>triggerConfigurationOverride</c> stores ONLY the changed key (<c>hi=70</c>) and does NOT
/// carry <c>hiHi</c>.
///
/// <para>
/// Why "no hiHi in the stored override" proves the MERGE: HiLo overrides are stored as the minimal
/// per-key diff against the inherited config (<c>FlatteningService.DiffHiLoConfig</c>), and merged
/// back over the inherited setpoints at flatten time (<c>MergeHiLoConfig</c>) — so the inherited
/// <c>hiHi=95</c> survives precisely because the override does NOT replace the whole config. A
/// whole-replace (the wrong behavior) would have stored every editor field; storing only <c>hi=70</c>
/// is the merge signature. The editor pre-fills the merged effective config, and the priority field
/// is intentionally left blank so the only delta is the setpoint.
/// </para>
/// </summary>
[SkippableFact]
public async Task AlarmOverride_HiLoSetpoint_Merges()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Open the HiLo edit modal and change ONLY the Hi setpoint (80 → 70). The setpoint input
// commits via @oninput, so FillAsync's input event commits before the Save click.
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var hiInput = page.Locator("[data-test='alarm-hilo-hi']");
await Assertions.Expect(hiInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await hiInput.FillAsync("70");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
// Read-back: the stored override is the minimal diff — only the changed `hi` key, NOT `hiHi`.
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var overrides = doc.RootElement.GetProperty("alarmOverrides");
var ovr = overrides.EnumerateArray().Single(o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
var triggerJson = ovr.GetProperty("triggerConfigurationOverride").GetString();
Assert.False(string.IsNullOrWhiteSpace(triggerJson),
"Expected a non-empty triggerConfigurationOverride after the HiLo setpoint edit.");
using var triggerDoc = JsonDocument.Parse(triggerJson!);
var trigger = triggerDoc.RootElement;
Assert.True(trigger.TryGetProperty("hi", out var hi)
&& hi.GetDouble() == 70, "Expected the override to carry the changed hi=70.");
// The merge signature: hiHi is NOT in the override (it survives from the inherited config
// at flatten time, rather than being re-stored by a whole-replace).
Assert.False(trigger.TryGetProperty("hiHi", out _),
"Expected the HiLo override to store only the changed key (no hiHi) — proving the merge.");
}
finally
{
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
/// <summary>
/// Non-HiLo WHOLE-REPLACE: edits the fixture's ValueMatch alarm (<c>_cfg.NonHiLoAlarmName</c> =
/// "Match", inherited <c>matchValue="100"</c>), changes the match value to a sentinel, Saves →
/// toast + badge, then verifies via CLI read-back that the persisted
/// <c>triggerConfigurationOverride</c> is the WHOLE edited config (the new <c>matchValue</c>), not a
/// per-key diff. ValueMatch / RangeViolation / RateOfChange overrides whole-replace the inherited
/// trigger config (unlike HiLo, which merges).
/// </summary>
[SkippableFact]
public async Task AlarmOverride_NonHiLo_WholeReplaces()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.NonHiLoAlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Open the ValueMatch edit modal and change the match value (whole-replace path).
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var matchInput = page.Locator("[data-test='alarm-matchvalue-input']");
await Assertions.Expect(matchInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await matchInput.FillAsync("250");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
// Read-back: the whole edited config is stored — matchValue is the new sentinel (250),
// not the inherited 100, and the override carries the full ValueMatch shape.
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var overrides = doc.RootElement.GetProperty("alarmOverrides");
var ovr = overrides.EnumerateArray().Single(o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.NonHiLoAlarmName);
var triggerJson = ovr.GetProperty("triggerConfigurationOverride").GetString();
Assert.False(string.IsNullOrWhiteSpace(triggerJson),
"Expected a non-empty triggerConfigurationOverride after the ValueMatch edit.");
using var triggerDoc = JsonDocument.Parse(triggerJson!);
var trigger = triggerDoc.RootElement;
Assert.True(trigger.TryGetProperty("matchValue", out var mv)
&& mv.GetString() == "250",
"Expected the whole-replaced override to carry the new matchValue=250.");
}
finally
{
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.NonHiLoAlarmName);
}
}
/// <summary>
/// Validation: opens the HiLo edit modal and enters a non-integer priority (<c>1.5</c> — a valid
/// number-input value the browser accepts, but one that fails the page's
/// <c>int.TryParse</c> guard). Clicking Save surfaces the inline error
/// (<c>data-test='alarm-override-error'</c>, message "Priority must be an integer.") and keeps the
/// modal open — the Save button stays visible, no toast, no badge, and CLI read-back shows no
/// override was written.
///
/// <para>
/// A decimal (not a textual non-number) is the deterministic invalid-value: <c>&lt;input
/// type="number"&gt;</c> rejects non-numeric text via <c>FillAsync</c>, but accepts <c>1.5</c>,
/// which the page's integer-only priority parse then rejects client-side — the one validation
/// branch in <c>SaveOverrideFromModal</c> that fires before any server round-trip.
/// </para>
/// </summary>
[SkippableFact]
public async Task AlarmOverride_InvalidValue_ShowsError_StaysOpen()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var priorityInput = page.Locator("[data-test='alarm-priority-input']");
await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await priorityInput.FillAsync("1.5");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
// The inline error appears and the modal stays open (Save button still visible).
var error = page.Locator("[data-test='alarm-override-error']");
await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 15_000 });
await Assertions.Expect(error).ToContainTextAsync("integer");
await Assertions.Expect(page.Locator("[data-test='alarm-save-override']")).ToBeVisibleAsync();
// No success toast and no override badge on the row — the Save was rejected.
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(0);
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
// Read-back: no override was persisted.
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
}
finally
{
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
/// <summary>
/// Cancel: opens the HiLo edit modal, changes a setpoint, then clicks Cancel
/// (<c>data-test='alarm-cancel-override'</c>). The modal closes (the editor's setpoint input is
/// gone), no toast/badge appears, and CLI read-back confirms NO override was written for the alarm
/// — the in-flight edit was discarded.
/// </summary>
[SkippableFact]
public async Task AlarmOverride_Cancel_DiscardsChanges()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var hiInput = page.Locator("[data-test='alarm-hilo-hi']");
await Assertions.Expect(hiInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await hiInput.FillAsync("60");
// Cancel discards the edit and closes the modal.
await page.Locator("[data-test='alarm-cancel-override']").ClickAsync();
await Assertions.Expect(hiInput).ToHaveCountAsync(0, new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
// Read-back: nothing was persisted.
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
}
finally
{
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
/// <summary>
/// Clear-from-modal: first creates a priority override on the HiLo alarm (Edit → priority → Save →
/// badge), then reopens Edit and clicks "Clear Override" (<c>data-test='alarm-clear-from-modal'</c>).
/// The badge disappears and CLI read-back confirms the override is gone — exercising the modal's
/// own clear path (distinct from the row-level Clear button covered by
/// <see cref="AlarmOverride_SetPriority_ThenClear_RoundTrips"/>).
/// </summary>
[SkippableFact]
public async Task AlarmOverride_ClearFromModal_RemovesOverride()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
// First set an override so there is something to clear.
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var priorityInput = page.Locator("[data-test='alarm-priority-input']");
await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await priorityInput.FillAsync("600");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
// Reopen the modal — the "Clear Override" button renders only when an override exists.
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var clearFromModal = page.Locator("[data-test='alarm-clear-from-modal']");
await Assertions.Expect(clearFromModal).ToBeVisibleAsync(new() { Timeout = 15_000 });
await clearFromModal.ClickAsync();
// Badge gone + read-back shows no override.
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
}
finally
{
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
}