feat(ui): tri-state MES reportable override on Instance Configure (reportable-only override is a delta)
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
+50
-3
@@ -459,6 +459,13 @@
|
|||||||
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
|
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
|
||||||
title="@alarm.TriggerConfiguration">
|
title="@alarm.TriggerConfiguration">
|
||||||
@(alarm.TriggerConfiguration ?? "—")
|
@(alarm.TriggerConfiguration ?? "—")
|
||||||
|
@if (alarm.IsMesReportable)
|
||||||
|
{
|
||||||
|
@* The inherited MES-reportable flag; an instance
|
||||||
|
override of it shows in the Override cell's summary. *@
|
||||||
|
<span class="badge bg-success ms-1"
|
||||||
|
data-test="alarm-inherited-mes" title="Template alarm is MES reportable">MES</span>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">
|
<td class="small">
|
||||||
@if (HasOverride(alarm.Name))
|
@if (HasOverride(alarm.Name))
|
||||||
@@ -538,6 +545,17 @@
|
|||||||
placeholder="@_editingAlarm.PriorityLevel"
|
placeholder="@_editingAlarm.PriorityLevel"
|
||||||
@bind="_editingPriorityText" @bind:event="oninput" />
|
@bind="_editingPriorityText" @bind:event="oninput" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
@* 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. *@
|
||||||
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">MES reportable</label>
|
||||||
|
<select class="form-select form-select-sm" data-test="alarm-mes-select" @bind="_editingMesReportableText">
|
||||||
|
<option value="">Inherited (@(_editingAlarm.IsMesReportable ? "reportable" : "not reportable"))</option>
|
||||||
|
<option value="true">Reportable</option>
|
||||||
|
<option value="false">Not reportable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_editingError != null)
|
@if (_editingError != null)
|
||||||
@@ -837,6 +855,9 @@
|
|||||||
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
|
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
|
||||||
private string? _editingInheritedValue; // the inherited config snapshot we diff against on save
|
private string? _editingInheritedValue; // the inherited config snapshot we diff against on save
|
||||||
private string? _editingPriorityText;
|
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 string? _editingError;
|
||||||
private IReadOnlyList<AlarmAttributeChoice> _editingAvailableAttributes = Array.Empty<AlarmAttributeChoice>();
|
private IReadOnlyList<AlarmAttributeChoice> _editingAvailableAttributes = Array.Empty<AlarmAttributeChoice>();
|
||||||
|
|
||||||
@@ -1552,10 +1573,23 @@
|
|||||||
}
|
}
|
||||||
if (ovr.PriorityLevelOverride.HasValue)
|
if (ovr.PriorityLevelOverride.HasValue)
|
||||||
parts.Add($"priority={ovr.PriorityLevelOverride.Value}");
|
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);
|
return parts.Count == 0 ? "(empty)" : string.Join(", ", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The save-time delta rule: does the modal carry anything worth persisting?
|
||||||
|
/// When it does not, <see cref="SaveOverrideFromModal"/> DELETES any existing
|
||||||
|
/// override — so every override term must be represented here or it would be
|
||||||
|
/// silently dropped on save. A <c>false</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
|
||||||
|
=> !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the override editor modal pre-populated with the merged
|
/// Opens the override editor modal pre-populated with the merged
|
||||||
/// (inherited + existing override) config so the user sees the effective
|
/// (inherited + existing override) config so the user sees the effective
|
||||||
@@ -1577,6 +1611,12 @@
|
|||||||
: (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration);
|
: (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration);
|
||||||
|
|
||||||
_editingPriorityText = existing?.PriorityLevelOverride?.ToString();
|
_editingPriorityText = existing?.PriorityLevelOverride?.ToString();
|
||||||
|
_editingMesReportableText = existing?.IsMesReportableOverride switch
|
||||||
|
{
|
||||||
|
true => "true",
|
||||||
|
false => "false",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
_editingAvailableAttributes = _flattenedAttributes;
|
_editingAvailableAttributes = _flattenedAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,11 +1661,18 @@
|
|||||||
: _editingOverrideValue;
|
: _editingOverrideValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool? mesReportable = _editingMesReportableText switch
|
||||||
|
{
|
||||||
|
"true" => true,
|
||||||
|
"false" => false,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
var user = await GetCurrentUserAsync();
|
var user = await GetCurrentUserAsync();
|
||||||
var alarmName = _editingAlarm.Name;
|
var alarmName = _editingAlarm.Name;
|
||||||
|
|
||||||
// No diff + no priority → clear any existing override and close.
|
// Nothing overridden at all → clear any existing override and close.
|
||||||
if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue)
|
if (!HasOverrideDelta(overrideJson, priority, mesReportable))
|
||||||
{
|
{
|
||||||
if (_existingAlarmOverrides.ContainsKey(alarmName))
|
if (_existingAlarmOverrides.ContainsKey(alarmName))
|
||||||
{
|
{
|
||||||
@@ -1647,7 +1694,7 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync(
|
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync(
|
||||||
Id, alarmName, overrideJson, priority, currentUser));
|
Id, alarmName, overrideJson, priority, currentUser, mesReportable));
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
_editingError = result.Error;
|
_editingError = result.Error;
|
||||||
|
|||||||
+66
@@ -516,4 +516,70 @@ public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixt
|
|||||||
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
|
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tri-state MES reportable round-trip. The override editor's third control is a select whose
|
||||||
|
/// blank option means "inherit the template alarm's <c>IsMesReportable</c>"; "Reportable" /
|
||||||
|
/// "Not reportable" pin it per-instance.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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 <c>mesReportable</c> — and that switching back to Inherited removes it again.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The fixture instance is SHARED, so this clears its own override in-body and again in a
|
||||||
|
/// <c>finally</c>, exactly as <see cref="AlarmOverride_SetPriority_ThenClear_RoundTrips"/> does.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MES reportable (tri-state) on the Instance Configure alarm-override editor.
|
||||||
|
///
|
||||||
|
/// <para>The instance-level override is a <c>bool?</c>: null means "inherit the
|
||||||
|
/// template alarm's <c>IsMesReportable</c>", true/false pin it. The modal surfaces
|
||||||
|
/// that as a three-option select (Inherited / Reportable / Not reportable).</para>
|
||||||
|
///
|
||||||
|
/// <para><c>InstanceConfigure</c> is a heavyweight page (~7 injected services with
|
||||||
|
/// their own graphs), so — consistent with the rest of this suite's coverage of it
|
||||||
|
/// (see <c>InstanceConfigureListOverrideTests</c>) — 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.</para>
|
||||||
|
/// </summary>
|
||||||
|
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("</select>", selectStart, StringComparison.Ordinal);
|
||||||
|
Assert.True(selectEnd > selectStart, "MES reportable select is not closed.");
|
||||||
|
|
||||||
|
var selectBody = markup[selectStart..selectEnd];
|
||||||
|
var optionCount = selectBody.Split("<option").Length - 1;
|
||||||
|
Assert.Equal(3, optionCount);
|
||||||
|
Assert.Contains("value=\"\"", selectBody);
|
||||||
|
Assert.Contains("value=\"true\"", selectBody);
|
||||||
|
Assert.Contains("value=\"false\"", selectBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// The new term: a reportable-only override is a real delta in BOTH directions
|
||||||
|
// (false is not "no override" — it pins a reportable template alarm off).
|
||||||
|
[InlineData(null, null, true)]
|
||||||
|
[InlineData(null, null, false)]
|
||||||
|
public void HasOverrideDelta_TreatsMesReportableAloneAsADelta(
|
||||||
|
string? overrideJson, int? priority, bool? mesReportable)
|
||||||
|
=> 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user