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 7d3ab306..6412f759 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 @@ -459,6 +459,13 @@ @(alarm.TriggerConfiguration ?? "—") + @if (alarm.IsMesReportable) + { + @* The inherited MES-reportable flag; an instance + override of it shows in the Override cell's summary. *@ + MES + } @if (HasOverride(alarm.Name)) @@ -538,6 +545,17 @@ placeholder="@_editingAlarm.PriorityLevel" @bind="_editingPriorityText" @bind:event="oninput" /> +
+ @* Tri-state: blank = inherit the template alarm's flag. + "Not reportable" is a real override, not a clear — it + pins a reportable template alarm off for this instance. *@ + + +
@if (_editingError != null) @@ -837,6 +855,9 @@ private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor private string? _editingInheritedValue; // the inherited config snapshot we diff against on save private string? _editingPriorityText; + // Tri-state MES reportable, as the select's string value: "" = inherited, + // "true"/"false" = pinned. Parsed back to bool? on save. + private string _editingMesReportableText = ""; private string? _editingError; private IReadOnlyList _editingAvailableAttributes = Array.Empty(); @@ -1552,10 +1573,23 @@ } if (ovr.PriorityLevelOverride.HasValue) parts.Add($"priority={ovr.PriorityLevelOverride.Value}"); + if (ovr.IsMesReportableOverride is bool m) + parts.Add(m ? "mesReportable" : "notMesReportable"); return parts.Count == 0 ? "(empty)" : string.Join(", ", parts); } + /// + /// The save-time delta rule: does the modal carry anything worth persisting? + /// When it does not, DELETES any existing + /// override — so every override term must be represented here or it would be + /// silently dropped on save. A false MES-reportable choice counts: it + /// pins a reportable template alarm off for this instance, which is a real + /// override and not the same as inheriting. + /// + internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable) + => !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue; + /// /// Opens the override editor modal pre-populated with the merged /// (inherited + existing override) config so the user sees the effective @@ -1577,6 +1611,12 @@ : (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration); _editingPriorityText = existing?.PriorityLevelOverride?.ToString(); + _editingMesReportableText = existing?.IsMesReportableOverride switch + { + true => "true", + false => "false", + _ => "", + }; _editingAvailableAttributes = _flattenedAttributes; } @@ -1621,11 +1661,18 @@ : _editingOverrideValue; } + bool? mesReportable = _editingMesReportableText switch + { + "true" => true, + "false" => false, + _ => null, + }; + var user = await GetCurrentUserAsync(); var alarmName = _editingAlarm.Name; - // No diff + no priority → clear any existing override and close. - if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue) + // Nothing overridden at all → clear any existing override and close. + if (!HasOverrideDelta(overrideJson, priority, mesReportable)) { if (_existingAlarmOverrides.ContainsKey(alarmName)) { @@ -1647,7 +1694,7 @@ else { var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync( - Id, alarmName, overrideJson, priority, currentUser)); + Id, alarmName, overrideJson, priority, currentUser, mesReportable)); if (!result.IsSuccess) { _editingError = result.Error; 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 5b95beb5..ef603199 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs @@ -516,4 +516,70 @@ public sealed class InstanceConfigureTests : IClassFixture + /// Tri-state MES reportable round-trip. The override editor's third control is a select whose + /// blank option means "inherit the template alarm's IsMesReportable"; "Reportable" / + /// "Not reportable" pin it per-instance. + /// + /// + /// This is the regression guard for the save-time delta rule: a Save with no config-diff and no + /// priority DELETES the override, so a reportable-only choice has to count as a delta on its own + /// or it would be silently dropped. The assertion is therefore that choosing ONLY "Reportable" + /// (leaving the trigger config and priority untouched) creates the override — badge + a summary + /// naming mesReportable — and that switching back to Inherited removes it again. + /// + /// + /// + /// The fixture instance is SHARED, so this clears its own override in-body and again in a + /// finally, exactly as does. + /// + /// + [SkippableFact] + public async Task AlarmOverride_MesReportable_RoundTrips() + { + 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 Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0); + + // Edit → choose "Reportable" ONLY (no config edit, no priority) → Save. + await row.Locator("[data-test='alarm-edit-btn']").ClickAsync(); + var mesSelect = page.Locator("[data-test='alarm-mes-select']"); + await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 }); + await mesSelect.SelectOptionAsync("true"); + 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 }); + // Summary part is "mesReportable" (the negative pin renders "notMesReportable"). + await Assertions.Expect(row).ToContainTextAsync("mesReportable", new() { Timeout = 15_000 }); + + // Reopen → back to Inherited (blank) → Save. With nothing else set that is now a + // no-delta save, which clears the override outright. + await row.Locator("[data-test='alarm-edit-btn']").ClickAsync(); + await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 }); + await mesSelect.SelectOptionAsync(""); + await page.Locator("[data-test='alarm-save-override']").ClickAsync(); + + 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); + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs new file mode 100644 index 00000000..5dafbfff --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs @@ -0,0 +1,80 @@ +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// MES reportable (tri-state) on the Instance Configure alarm-override editor. +/// +/// The instance-level override is a bool?: null means "inherit the +/// template alarm's IsMesReportable", true/false pin it. The modal surfaces +/// that as a three-option select (Inherited / Reportable / Not reportable). +/// +/// InstanceConfigure is a heavyweight page (~7 injected services with +/// their own graphs), so — consistent with the rest of this suite's coverage of it +/// (see InstanceConfigureListOverrideTests) — the markup half is a structural +/// assertion over the component source. The half that carries the real risk is the +/// save-time delta rule, which is extracted as a pure helper and pinned directly: +/// the page DELETES the override when it sees no delta, so a reportable-only +/// override would be silently dropped on save if the new term were missing. +/// +public class InstanceConfigureMesReportableTests +{ + private static string DeploymentComponentsRoot + { + get + { + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 6 && dir is not null; i++) + dir = Directory.GetParent(dir)?.FullName; + return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI", + "Components", "Pages", "Deployment"); + } + } + + private static string InstanceConfigureMarkup + => File.ReadAllText(Path.Combine(DeploymentComponentsRoot, "InstanceConfigure.razor")); + + [Fact] + public void OverrideModal_HasTriStateMesReportableSelect() + { + var markup = InstanceConfigureMarkup; + + Assert.Contains("data-test=\"alarm-mes-select\"", markup); + + // Three options: Inherited (empty value) / Reportable (true) / Not reportable (false). + var selectStart = markup.IndexOf("data-test=\"alarm-mes-select\"", StringComparison.Ordinal); + var selectEnd = markup.IndexOf("", selectStart, StringComparison.Ordinal); + Assert.True(selectEnd > selectStart, "MES reportable select is not closed."); + + var selectBody = markup[selectStart..selectEnd]; + var optionCount = selectBody.Split(" Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable)); + + [Theory] + [InlineData(null, null, null)] + [InlineData("", null, null)] + [InlineData(" ", null, null)] + public void HasOverrideDelta_NothingSet_IsNotADelta( + string? overrideJson, int? priority, bool? mesReportable) + => Assert.False(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable)); + + [Theory] + [InlineData("{\"hi\":70}", null, null)] + [InlineData(null, 750, null)] + public void HasOverrideDelta_ExistingTermsStillCount( + string? overrideJson, int? priority, bool? mesReportable) + => Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable)); +}