621d6bfd94
Typing in the field is the filter: the forest is rebuilt to the matching instances plus their ancestor areas and force-expanded so every match is on screen; clearing the text restores the full forest, collapsed. Both bulk expansions are deferred to OnAfterRenderAsync, because the TreeView only exists once the panel has rendered. Enter picks the first instance the operator can actually SEE — document order, descending into a branch only when the tree reports it expanded — so with areas collapsed it picks the first root instance, and with a filter (every branch expanded) it degrades to plain document order. Escape closes and restores the field text from the current selection; ArrowDown hands keyboard navigation to the tree via a new additive TreeView.FocusFirstVisibleAsync(), which reuses the existing roving-tabindex _focusNeedsApply path rather than adding a second focus mechanism. No preventDefault: Blazor's flag is all-or-nothing per element, and neither default is harmful (Enter in a text input outside a form does nothing, ArrowDown just moves the caret) while suppressing Tab would trap the control. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
359 lines
11 KiB
C#
359 lines
11 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();
|
|
|
|
[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")));
|
|
Assert.Contains(
|
|
JSInterop.Invocations,
|
|
i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[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']"));
|
|
}
|
|
}
|