From b7f7fe935ca0a9e1895f13aaed99f39bd85138d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 19:35:14 -0400 Subject: [PATCH] =?UTF-8?q?test(playwright):=20alarm-override=20trigger-co?= =?UTF-8?q?nfig=20scenarios=20=E2=80=94=20HiLo=20merge,=20non-HiLo=20repla?= =?UTF-8?q?ce,=20validation,=20cancel,=20clear=20(T41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/Deployment/InstanceConfigure.razor | 5 +- .../Shared/AlarmTriggerEditor.razor | 14 +- .../Cluster/CliRunner.Helpers.cs | 11 +- .../Deployment/InstanceConfigureFixture.cs | 21 +- .../Deployment/InstanceConfigureTests.cs | 270 ++++++++++++++++++ 5 files changed, 312 insertions(+), 9 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index f5a6d5bd..81c9da9d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -405,7 +405,7 @@ @if (_editingError != null) { -
@_editingError
+
@_editingError
}
- +
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor index 373d3680..64187daf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor @@ -220,6 +220,7 @@ if (t == "Boolean") { @@ -236,6 +238,7 @@ else { _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") }; /// @@ -418,7 +421,7 @@ string? deadband, Action deadbandSetter, Func onDeadbandChanged, string? priority, Action prioritySetter, Func onPriorityChanged, string? message, Action messageSetter, Func onMessageChanged, - string severityClass) => __builder => + string severityClass, string setpointTestId) => __builder => {
@@ -428,6 +431,7 @@
setpoint diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs index bc9c2013..b8462816 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs @@ -104,10 +104,18 @@ public static partial class CliRunner /// Adds an alarm to a template via template alarm add (using the typed setpoint /// flags) and returns its new id. Throws on failure. ///
+ /// + /// The HiLo setpoint flags (/// + /// ) and the ValueMatch are additive, + /// per-trigger-type selectors over the same template alarm add command — only the flags + /// relevant to are emitted, the rest are skipped when null. This + /// lets one helper provision both the fixture's HiLo alarm and a ValueMatch alarm + /// (--match-value) without a parallel helper. + /// public static async Task 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 @@ -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"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs index 7945b7d9..9db85182 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs @@ -36,9 +36,24 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime /// The single bindable/overridable attribute name on the fixture template. public string AttributeName => "Value"; - /// The single non-locked alarm on the fixture template (for the override test). + /// The single non-locked HiLo alarm on the fixture template (for the merge override test). public string AlarmName => "HiHi"; + /// + /// 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 (Value) so the + /// AlarmTriggerEditor's attribute picker already has it selected. + /// + public string NonHiLoAlarmName => "Match"; + + /// Trigger type of . + public string NonHiLoAlarmTriggerType => "ValueMatch"; + + /// The inherited (template) ValueMatch value baked into . + public string NonHiLoInheritedMatchValue => "100"; + /// The fixture data-connection name (for locating it in the bindings UI dropdown). 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")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs index 92ee8ad7..89c42153 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs @@ -244,4 +244,274 @@ public sealed class InstanceConfigureTests : IClassFixture + /// HiLo MERGE: edits the fixture's HiLo alarm (_cfg.AlarmName = "HiHi", inherited + /// hi=80, hiHi=95), changes ONLY the hi setpoint to 70 (leaving hiHi untouched), + /// Saves → toast + badge, then verifies via CLI read-back that the persisted + /// triggerConfigurationOverride stores ONLY the changed key (hi=70) and does NOT + /// carry hiHi. + /// + /// + /// Why "no hiHi in the stored override" proves the MERGE: HiLo overrides are stored as the minimal + /// per-key diff against the inherited config (FlatteningService.DiffHiLoConfig), and merged + /// back over the inherited setpoints at flatten time (MergeHiLoConfig) — so the inherited + /// hiHi=95 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 hi=70 + /// 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. + /// + /// + [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); + } + } + + /// + /// Non-HiLo WHOLE-REPLACE: edits the fixture's ValueMatch alarm (_cfg.NonHiLoAlarmName = + /// "Match", inherited matchValue="100"), changes the match value to a sentinel, Saves → + /// toast + badge, then verifies via CLI read-back that the persisted + /// triggerConfigurationOverride is the WHOLE edited config (the new matchValue), not a + /// per-key diff. ValueMatch / RangeViolation / RateOfChange overrides whole-replace the inherited + /// trigger config (unlike HiLo, which merges). + /// + [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); + } + } + + /// + /// Validation: opens the HiLo edit modal and enters a non-integer priority (1.5 — a valid + /// number-input value the browser accepts, but one that fails the page's + /// int.TryParse guard). Clicking Save surfaces the inline error + /// (data-test='alarm-override-error', 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. + /// + /// + /// A decimal (not a textual non-number) is the deterministic invalid-value: <input + /// type="number"> rejects non-numeric text via FillAsync, but accepts 1.5, + /// which the page's integer-only priority parse then rejects client-side — the one validation + /// branch in SaveOverrideFromModal that fires before any server round-trip. + /// + /// + [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); + } + } + + /// + /// Cancel: opens the HiLo edit modal, changes a setpoint, then clicks Cancel + /// (data-test='alarm-cancel-override'). 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. + /// + [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); + } + } + + /// + /// Clear-from-modal: first creates a priority override on the HiLo alarm (Edit → priority → Save → + /// badge), then reopens Edit and clicks "Clear Override" (data-test='alarm-clear-from-modal'). + /// 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 + /// ). + /// + [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); + } + } }