40944a0d04
The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.
Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:
DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
Destructive -> btn-danger }
DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.
Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.
ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.
DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.
Fail-before, both halves:
- Approve call site reverted to `danger: true` -> both new Approve pins fail.
- Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.
Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.
No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.
Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.
CentralUI.Tests: 1055/1055 green.
124 lines
5.1 KiB
C#
124 lines
5.1 KiB
C#
using Bunit;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
|
|
|
/// <summary>
|
|
/// Deferred-work register row 75 — pins the confirm button's severity → Bootstrap class
|
|
/// mapping against the REAL <see cref="DialogService"/> behind a real <c>DialogHost</c>,
|
|
/// so these cover what the user actually sees rather than what an argument was.
|
|
///
|
|
/// <para>
|
|
/// Row 28 split the label from the styling but left the styling a two-state boolean, so a
|
|
/// consequential-but-not-destructive action had to choose between looking like a delete
|
|
/// and looking like the safe default. Row 75 added <see cref="DialogSeverity.Caution"/> as
|
|
/// the middle rung. The three-way mapping is the whole of that change, and these are its
|
|
/// pins; <c>SecuredWritesTests</c> separately pins that the Secured Writes page asks for
|
|
/// the right one.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// bUnit cannot run real JS interop, so the host's <c>sbDialog.*</c> focus-trap calls run
|
|
/// in <see cref="JSRuntimeMode.Loose"/> as no-ops (same arrangement as
|
|
/// <c>FailoverConfirmLabelTests</c>).
|
|
/// </para>
|
|
/// </summary>
|
|
public class DialogSeverityStylingTests : BunitContext
|
|
{
|
|
private readonly DialogService _dialog = new();
|
|
|
|
public DialogSeverityStylingTests()
|
|
{
|
|
Services.AddSingleton<IDialogService>(_dialog);
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens a confirm dialog on a live host and returns its confirm button.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <c>ConfirmAsync</c> task is deliberately not awaited — with the real service it
|
|
/// does not complete until someone answers the dialog, so awaiting it would deadlock
|
|
/// the test. The state is published synchronously before the task is returned.
|
|
/// </remarks>
|
|
private AngleSharp.Dom.IElement OpenConfirm(Func<IDialogService, Task<bool>> open)
|
|
{
|
|
var cut = Render<DialogHost>();
|
|
cut.InvokeAsync(() => { _ = open(_dialog); }).GetAwaiter().GetResult();
|
|
cut.Render();
|
|
|
|
var footer = cut.FindAll("div.modal-footer button").ToList();
|
|
return footer.Single(b => b.TextContent.Trim() != "Cancel");
|
|
}
|
|
|
|
[Fact]
|
|
public void Caution_rendersAmber_notRedAndNotPrimary()
|
|
{
|
|
// The row-75 case. Amber is the point: not red (which reads "delete"), and not
|
|
// the primary blue (which reads "safe default" — wrong for a live device write).
|
|
var confirm = OpenConfirm(d => d.ConfirmAsync("Approve thing", "Sure?", DialogSeverity.Caution, "Approve"));
|
|
|
|
Assert.Contains("btn-warning", confirm.ClassList);
|
|
Assert.DoesNotContain("btn-danger", confirm.ClassList);
|
|
Assert.DoesNotContain("btn-primary", confirm.ClassList);
|
|
}
|
|
|
|
[Fact]
|
|
public void Destructive_stillRendersRed()
|
|
{
|
|
// Regression guard on the ~20 genuine delete call sites: row 75 is additive and
|
|
// must not have shifted what danger looks like.
|
|
var confirm = OpenConfirm(d => d.ConfirmAsync("Delete thing", "Sure?", DialogSeverity.Destructive));
|
|
|
|
Assert.Contains("btn-danger", confirm.ClassList);
|
|
}
|
|
|
|
[Fact]
|
|
public void Normal_stillRendersPrimary()
|
|
{
|
|
var confirm = OpenConfirm(d => d.ConfirmAsync("Do thing", "Sure?", DialogSeverity.Normal));
|
|
|
|
Assert.Contains("btn-primary", confirm.ClassList);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true, "btn-danger")]
|
|
[InlineData(false, "btn-primary")]
|
|
public void BooleanForm_mapsOntoTheSameTwoSeverities(bool danger, string expectedClass)
|
|
{
|
|
// The boolean overload is now a thin adapter onto the severity one. Every existing
|
|
// call site goes through it, so this pins that the adapter did not shift any of
|
|
// them: true is still red, false is still primary.
|
|
//
|
|
// One case per fact, deliberately: DialogService permits only one open dialog at a
|
|
// time and throws on the second, so two OpenConfirm calls in one body would fail
|
|
// on the service's own nested-dialog guard rather than on anything about styling.
|
|
var confirm = OpenConfirm(d => d.ConfirmAsync("Do thing", "Sure?", danger: danger));
|
|
|
|
Assert.Contains(expectedClass, confirm.ClassList);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnlabelledCaution_readsConfirm_notDelete()
|
|
{
|
|
// Caution deliberately does NOT inherit the danger "Delete" fallback: a caution
|
|
// action is by definition not a delete, so falling back to that verb would
|
|
// reintroduce register row 28 through the new door.
|
|
var confirm = OpenConfirm(d => d.ConfirmAsync("Approve thing", "Sure?", DialogSeverity.Caution));
|
|
|
|
Assert.Equal("Confirm", confirm.TextContent.Trim());
|
|
}
|
|
|
|
[Fact]
|
|
public void UnlabelledDestructive_stillFallsBackToDelete()
|
|
{
|
|
// The row-28 fallback is retained deliberately (the Playwright suites click the
|
|
// literal "Delete"). Pinned here alongside its new sibling so the pair is read
|
|
// together.
|
|
var confirm = OpenConfirm(d => d.ConfirmAsync("Delete thing", "Sure?", DialogSeverity.Destructive));
|
|
|
|
Assert.Equal("Delete", confirm.TextContent.Trim());
|
|
}
|
|
}
|