test(playwright): alarm-override trigger-config scenarios — HiLo merge, non-HiLo replace, validation, cancel, clear (T41)
This commit is contained in:
+270
@@ -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><input
|
||||
/// type="number"></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user