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; /// /// bUnit tests for the 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 ItemLabel hook, and the search filter /// with its keyboard surface (Enter / Escape / ArrowDown). /// public class InstancePickerTests : BunitContext { /// Initializes the test context with loose JS interop. public InstancePickerTests() { JSInterop.Mode = JSRuntimeMode.Loose; } private static List Areas() => [ new("Line1") { Id = 1, SiteId = 1 }, new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }, ]; private static List Instances() => [ new("P1") { Id = 7, SiteId = 1, AreaId = 2 }, new("Zulu") { Id = 8, SiteId = 1 }, ]; private IRenderedComponent RenderPicker( Action? onChanged = null, bool disabled = false, Func? itemLabel = null, int selectedInstanceId = 0) => Render(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 cut) => cut.Find("[data-test='t-toggle']").Click(); private static void ClickRow(IRenderedComponent cut, string text) => cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click(); private static string InputValue(IRenderedComponent cut) => cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty; private static void Type(IRenderedComponent cut, string text) => cut.Find("[data-test='t-input']").Input(text); private static void Press(IRenderedComponent cut, string key) => cut.Find("[data-test='t-input']").KeyDown(new KeyboardEventArgs { Key = key }); private static string[] Rows(IRenderedComponent cut) => cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray(); /// The nth treeitem in document order - i.e. the nth row the operator can see. private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent cut, int index) => cut.FindAll("[role='treeitem']")[index]; /// Verifies that a closed picker renders only the input field. [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")); } /// Verifies that toggling opens the panel with all areas collapsed. [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")); } /// Verifies that clicking an area label expands that area. [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']")); } /// Verifies that clicking an instance selects it, closes the panel and raises the selection callback. [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)); } /// Verifies that a disabled picker never opens. [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']")); } /// Verifies that clicking the backdrop closes the panel without changing the selection. [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)); } /// Verifies that a selection set by the parent updates the field text. [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)); } /// Verifies that supplying a new instances list after a site change clears the stale selection text. [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()); ps.Add(p => p.Instances, new List { new("Other") { Id = 99, SiteId = 2 } }); }); Assert.Equal(string.Empty, InputValue(cut)); Assert.Empty(cut.FindAll("[data-test='t-panel']")); } /// Verifies that the item label function is used for both the rows and the field text. [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)); } /// Verifies that typing filters the tree and expands it to the matches. [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); } /// Verifies that clearing the filter restores the full tree in a collapsed state. [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']")); } /// Verifies that typing opens a closed panel. [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)); } /// Verifies that pressing Enter with no matching instance does nothing. [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']")); } /// Verifies that pressing Enter picks the first visible match. [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)); } /// Verifies that pressing Enter with no filter picks the first instance in document order. [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)); } /// Verifies that pressing Escape closes the panel and restores the selected text. [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)); } /// Verifies that pressing ArrowDown moves focus into the tree. [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)); } /// Verifies that pressing ArrowDown on a closed picker opens the panel and focuses the first row. [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")); } /// Verifies that pressing Escape on a closed picker has no effect. [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)); } /// Verifies that the filter text is not carried into the selection. [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']")); } /// Verifies that toggling while the panel is open closes it. [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")); } /// Verifies that the field sits above the backdrop so it stays clickable while the panel is open. [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']")); } /// Verifies that two pickers on the same page get distinct panel ids. [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")); } /// Verifies that re-rendering with the same instances reference leaves the typed text and open panel unchanged. [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 -------------------------------- /// Verifies that keyboard activation on a tree row selects the instance. [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)); } /// Verifies that keyboard activation on an area row toggles it. [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)); } /// Verifies that keyboard activation followed by a mouse click raises exactly one selection. [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(); 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 ------------------------------------------- /// Verifies that the input carries an accessible name by default and from the parameter. [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")); } /// Verifies that the input id defaults to a generated id and honours the parameter. [Fact] public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter() { // A page that wants its own