Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs
T
Joseph Doherty a33a2f4456 fix(central-ui): InstancePicker — keyboard activation selects/toggles through TreeView selection; aria-label/InputId; aria-controls only while open
ArrowDown moved focus into the tree, but the picker passed no Selectable /
SelectedKeyChanged, so Enter/Space on a treeitem only moved the roving tabindex —
the pick lived on the label span's @onclick, i.e. mouse only. The tree now runs in
Selectable mode and BOTH gestures arrive at one handler (OnTreeKeyActivatedAsync)
through TreeView's SelectedKeyChanged: an `i:{id}` key selects that instance, an
`a:{id}` key toggles the branch. The label's own @onclick is gone — the click
already bubbles into TreeView's content span, so a second handler there would fire
the callback twice. SelectedKey stays unbound (a combobox shows its pick in the
field, not as a row highlight, and binding it would arm TreeView's
selected-key-not-in-tree reset on every render while the pick is filtered out);
SelectedCssClass is emptied to match.

a11y: the field gains an accessible name via a new AriaLabel parameter (default
"Instance") and an optional InputId so a page can point a <label for> at it;
aria-controls is rendered only while the panel exists, instead of always pointing
at an id that is absent when closed.

Also drops the unused @using of Commons.Entities.Instances from the .razor.

Tests: keyboard activation on an instance row and on an area row, a
one-selection-per-gesture guard across mouse and keyboard, and the labelling
assertions. CentralUI suite 1331 passed / 0 failed; build 0 warnings.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 10:00:17 -04:00

560 lines
19 KiB
C#

using Bunit;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for the <see cref="InstancePicker"/> combobox: the closed field, the
/// panel that opens with every area collapsed, area expansion, instance selection
/// (callback + close + field text), the disabled guard, the outside-click backdrop,
/// parent-driven selection changes, the <c>ItemLabel</c> hook, and the search filter
/// with its keyboard surface (Enter / Escape / ArrowDown).
/// </summary>
public class InstancePickerTests : BunitContext
{
public InstancePickerTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static List<Area> Areas() =>
[
new("Line1") { Id = 1, SiteId = 1 },
new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 },
];
private static List<Instance> Instances() =>
[
new("P1") { Id = 7, SiteId = 1, AreaId = 2 },
new("Zulu") { Id = 8, SiteId = 1 },
];
private IRenderedComponent<InstancePicker> RenderPicker(
Action<int>? onChanged = null,
bool disabled = false,
Func<Instance, string>? itemLabel = null,
int selectedInstanceId = 0) =>
Render<InstancePicker>(ps =>
{
ps.Add(p => p.Areas, Areas());
ps.Add(p => p.Instances, Instances());
ps.Add(p => p.DataTestPrefix, "t");
ps.Add(p => p.Disabled, disabled);
ps.Add(p => p.SelectedInstanceId, selectedInstanceId);
if (itemLabel is not null) ps.Add(p => p.ItemLabel, itemLabel);
if (onChanged is not null) ps.Add(p => p.SelectedInstanceIdChanged, onChanged);
});
private static void Open(IRenderedComponent<InstancePicker> cut) =>
cut.Find("[data-test='t-toggle']").Click();
private static void ClickRow(IRenderedComponent<InstancePicker> cut, string text) =>
cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click();
private static string InputValue(IRenderedComponent<InstancePicker> cut) =>
cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty;
private static void Type(IRenderedComponent<InstancePicker> cut, string text) =>
cut.Find("[data-test='t-input']").Input(text);
private static void Press(IRenderedComponent<InstancePicker> cut, string key) =>
cut.Find("[data-test='t-input']").KeyDown(new KeyboardEventArgs { Key = key });
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
/// <summary>The nth treeitem in document order - i.e. the nth row the operator can see.</summary>
private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent<InstancePicker> cut, int index) =>
cut.FindAll("[role='treeitem']")[index];
[Fact]
public void Closed_ShowsNothingButTheField()
{
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
var input = cut.Find("[data-test='t-input']");
Assert.Equal("combobox", input.GetAttribute("role"));
Assert.Equal("false", input.GetAttribute("aria-expanded"));
}
[Fact]
public void Toggle_OpensPanel_WithAreasCollapsed()
{
var cut = RenderPicker();
Open(cut);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("true", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
Assert.Equal(new[] { "Line1", "Zulu" }, rows);
// P1 sits under a collapsed area, so its row is not rendered at all.
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
// Area rows must not carry a data-instance-id at all (Blazor drops a null attribute).
var areaRow = cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == "Line1");
Assert.False(areaRow.HasAttribute("data-instance-id"));
}
[Fact]
public void ClickingAnAreaLabel_ExpandsIt()
{
var cut = RenderPicker();
Open(cut);
ClickRow(cut, "Line1");
Assert.Contains(cut.FindAll("[data-test='t-row']"), r => r.TextContent.Trim() == "Cell A");
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
ClickRow(cut, "Cell A");
Assert.Single(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal(7, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("P1", InputValue(cut));
}
[Fact]
public void Disabled_NeverOpens()
{
var cut = RenderPicker(disabled: true);
Assert.True(cut.Find("[data-test='t-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='t-toggle']").HasAttribute("disabled"));
Open(cut);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void Backdrop_ClickClosesThePanel_WithoutChangingSelection()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
cut.Find("[data-test='t-backdrop']").Click();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Null(picked);
Assert.Equal(string.Empty, InputValue(cut));
}
[Fact]
public void ParentDrivenSelection_UpdatesTheFieldText()
{
var cut = RenderPicker();
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 8));
Assert.Equal("Zulu", InputValue(cut));
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 0));
Assert.Equal(string.Empty, InputValue(cut));
}
[Fact]
public void SiteChange_NewInstancesList_ClearsAStaleSelectionText()
{
var cut = RenderPicker(selectedInstanceId: 8);
Assert.Equal("Zulu", InputValue(cut));
Open(cut);
// A different site: neither the areas nor the previously selected instance survive.
cut.Render(ps =>
{
ps.Add(p => p.Areas, new List<Area>());
ps.Add(p => p.Instances, new List<Instance> { new("Other") { Id = 99, SiteId = 2 } });
});
Assert.Equal(string.Empty, InputValue(cut));
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void ItemLabel_IsUsedForRowsAndField()
{
var cut = RenderPicker(itemLabel: i => i.UniqueName + " (x)");
Open(cut);
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
Assert.Equal(new[] { "Line1", "Zulu (x)" }, rows);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal("P1 (x)", InputValue(cut));
}
[Fact]
public void Typing_Filters_AndExpandsToTheMatches()
{
var cut = RenderPicker();
Open(cut);
Type(cut, "p1");
// The match's ancestor areas are force-expanded, so its row renders without a click.
Assert.Single(cut.FindAll("[data-instance-id='7']"));
var rows = Rows(cut);
Assert.Contains("Line1", rows);
Assert.Contains("Cell A", rows);
Assert.DoesNotContain("Zulu", rows);
}
[Fact]
public void Clearing_RestoresTheFullTree_Collapsed()
{
var cut = RenderPicker();
Open(cut);
Type(cut, "p1");
Assert.Single(cut.FindAll("[data-instance-id='7']"));
Type(cut, string.Empty);
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void Typing_OpensTheClosedPanel()
{
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Type(cut, "z");
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal(new[] { "Zulu" }, Rows(cut));
}
[Fact]
public void Enter_WithNoMatch_DoesNothing()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Type(cut, "zzz");
Press(cut, "Enter");
Assert.Null(picked);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void Enter_PicksTheFirstVisibleMatch()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
// "u" matches Zulu only — P1 drops out along with its now-empty ancestor areas.
Type(cut, "u");
Press(cut, "Enter");
Assert.Equal(8, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Press(cut, "Enter");
// Areas render before root instances, but every area is collapsed — so the first
// instance the operator can actually see is the root-level Zulu, not P1.
Assert.Equal(8, picked);
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void Escape_ClosesAndRestoresTheSelectedText()
{
var picked = 0;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, picked));
Assert.Equal("P1", InputValue(cut));
Open(cut);
Type(cut, "zzz");
Assert.Equal("zzz", InputValue(cut));
Press(cut, "Escape");
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("P1", InputValue(cut));
}
[Fact]
public void ArrowDown_MovesFocusIntoTheTree()
{
var cut = RenderPicker();
Open(cut);
// The first row owns the tab stop by default, so move it off with the tree's own
// arrow navigation first — otherwise the assertion below could pass by accident.
ClickRow(cut, "Line1");
cut.FindAll("[role='treeitem']")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" });
Assert.Equal("-1", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
Press(cut, "ArrowDown");
var items = cut.FindAll("[role='treeitem']");
Assert.Equal("0", items[0].GetAttribute("tabindex"));
Assert.All(items.Skip(1), i => Assert.Equal("-1", i.GetAttribute("tabindex")));
// The tab stop above is the behavioural assertion; this is the belt-and-braces check
// that real browser focus was requested too. ElementReference.FocusAsync surfaces as a
// JS invocation under Loose mode — drop this line if a bUnit upgrade stops recording it.
Assert.Contains(
JSInterop.Invocations,
i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow()
{
// The tree does not exist yet when the keydown is handled, so this pins the deferred
// path: the panel opens in this render pass and the focus lands in the next one.
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Press(cut, "ArrowDown");
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
}
[Fact]
public void Escape_WhenClosed_IsANoOp()
{
var cut = RenderPicker(selectedInstanceId: 8);
Assert.Equal("Zulu", InputValue(cut));
Press(cut, "Escape");
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void FilterIsNotCarriedIntoSelection()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Type(cut, "p1");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal(7, picked);
Assert.Equal("P1", InputValue(cut));
Open(cut);
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void Toggle_WhileOpen_ClosesThePanel()
{
var cut = RenderPicker();
Open(cut);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Open(cut);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("false", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
}
[Fact]
public void TheFieldSitsAboveTheBackdrop_SoItStaysClickableWhileOpen()
{
// The backdrop is a fixed full-viewport layer; the stylesheet lifts the field above
// it through this class. Without it a click on the input or the chevron would land on
// the backdrop instead and close the panel. Only the class can be pinned here — the
// z-index itself is scoped CSS, verified visually.
var cut = RenderPicker();
Open(cut);
var field = cut.Find("[data-test='t-picker'] .input-group");
Assert.Contains("ip-field", field.ClassList);
Assert.Single(cut.FindAll("[data-test='t-backdrop']"));
}
[Fact]
public void TwoPickers_GetDistinctPanelIds()
{
// The panel id (and the field's aria-controls pointing at it) is minted per component
// instance, so two pickers on one page cannot collide on a DOM id.
var first = RenderPicker();
var second = RenderPicker();
Open(first);
Open(second);
var firstPanel = first.Find("[data-test='t-panel']").Id;
var secondPanel = second.Find("[data-test='t-panel']").Id;
Assert.False(string.IsNullOrEmpty(firstPanel));
Assert.NotEqual(firstPanel, secondPanel);
Assert.Equal(firstPanel, first.Find("[data-test='t-input']").GetAttribute("aria-controls"));
Assert.Equal(secondPanel, second.Find("[data-test='t-input']").GetAttribute("aria-controls"));
}
[Fact]
public void SameInstancesReference_LeavesTheTypedTextAndOpenPanelAlone()
{
// Complement of SiteChange_NewInstancesList_ClearsAStaleSelectionText: an unrelated
// parameter change is NOT a site change, so a half-typed filter must survive it.
var cut = RenderPicker();
Open(cut);
Type(cut, "zu");
cut.Render(ps => ps.Add(p => p.Placeholder, "Pick an instance"));
Assert.Equal("zu", InputValue(cut));
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal(new[] { "Zulu" }, Rows(cut));
}
// -- Keyboard activation on the tree itself --------------------------------
[Fact]
public void KeyboardActivation_OnATreeRow_SelectsTheInstance()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Press(cut, "ArrowDown");
// Areas render before root instances, so Zulu is the SECOND treeitem - the first
// one is the Line1 area (see KeyboardActivation_OnAnAreaRow_TogglesIt).
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.Equal(8, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("Zulu", InputValue(cut));
}
[Fact]
public void KeyboardActivation_OnAnAreaRow_TogglesIt()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
Press(cut, "ArrowDown");
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
// An area activates as an expand, not as a pick: its children appear and the
// panel stays open with no callback raised.
Assert.Contains("Cell A", Rows(cut));
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Null(picked);
// The same key on the same row closes it again - activation is a toggle.
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.DoesNotContain("Cell A", Rows(cut));
}
[Fact]
public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection()
{
// The label click and Enter both run through TreeView's single selection path,
// so neither input mode may double-fire the callback.
var picks = new List<int>();
var cut = RenderPicker(onChanged: picks.Add);
Open(cut);
ClickRow(cut, "Zulu");
Assert.Equal(new[] { 8 }, picks);
Open(cut);
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.Equal(new[] { 8, 8 }, picks);
}
// -- Accessible name / labelling -------------------------------------------
[Fact]
public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter()
{
var cut = RenderPicker();
Assert.Equal("Instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
cut.Render(ps => ps.Add(p => p.AriaLabel, "Target instance"));
Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
}
[Fact]
public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter()
{
// A page that wants its own <label for> supplies the id; two pickers left to the
// generated one must still not collide.
var first = RenderPicker();
var second = RenderPicker();
var generated = first.Find("[data-test='t-input']").Id;
Assert.False(string.IsNullOrEmpty(generated));
Assert.NotEqual(generated, second.Find("[data-test='t-input']").Id);
first.Render(ps => ps.Add(p => p.InputId, "flow-instance"));
Assert.Equal("flow-instance", first.Find("[data-test='t-input']").Id);
}
[Fact]
public void AriaControls_IsRenderedOnlyWhileThePanelIsOpen()
{
var cut = RenderPicker();
// Closed: the panel element does not exist, so a dangling aria-controls would
// point screen readers at nothing.
Assert.False(cut.Find("[data-test='t-input']").HasAttribute("aria-controls"));
Open(cut);
Assert.Equal(
cut.Find("[data-test='t-panel']").Id,
cut.Find("[data-test='t-input']").GetAttribute("aria-controls"));
}
}