@oneuptime/common 14.0.5 → 14.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -0,0 +1,2110 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ RenderResult,
16
+ screen,
17
+ within,
18
+ } from "@testing-library/react";
19
+ import React, { ReactElement, useState } from "react";
20
+ import getJestMockFunction, { MockFunction } from "../../MockType";
21
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
22
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
23
+ import URL from "../../../Types/API/URL";
24
+ import Dictionary from "../../../Types/Dictionary";
25
+ import { JSONObject } from "../../../Types/JSON";
26
+ import SiteSearchBox, {
27
+ ComponentProps,
28
+ MIN_SITE_SEARCH_CHARS,
29
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/NetworkSite/SiteSearchBox";
30
+
31
+ /*
32
+ * Issue #3981, from the reader's side of the search box.
33
+ *
34
+ * Device Topology's search used to narrow only the level on screen, so
35
+ * finding "Unit 104822" meant opening every region above it first. The fix
36
+ * mounts the Network Map's SiteSearchBox there too: typing still narrows the
37
+ * page locally, and a dropdown lists hierarchy-wide matches from
38
+ * POST /network-site/search, each with the path to it, one pick away.
39
+ *
40
+ * Everything here is what a person at the keyboard (or behind a screen
41
+ * reader) meets: when the box goes to the server and with what, what a row
42
+ * says, how the arrow keys, Enter and Escape behave, which answers are
43
+ * allowed to paint, and the ids and ARIA each page that mounts the box
44
+ * relies on. The server is an API.post mock whose answers the test releases
45
+ * one at a time, so ordering races are staged rather than hoped for, and the
46
+ * 250 ms debounce runs on jest's fake clock.
47
+ */
48
+
49
+ const PROJECT_ID: string = "5f0f7d7e-8a2b-4a5b-9c1d-2e3f4a5b6c7d";
50
+ const SEARCH_DEBOUNCE_MS: number = 250;
51
+ const DEFAULT_PREFIX: string = "network-map-search";
52
+ const TOPOLOGY_PREFIX: string = "topology-hierarchy-search";
53
+ const DEFAULT_PLACEHOLDER: string =
54
+ "Search sites by name — anywhere in your network";
55
+
56
+ interface SearchRequest {
57
+ url: URL;
58
+ data: JSONObject;
59
+ headers: Dictionary<string>;
60
+ }
61
+
62
+ type SearchAnswer = HTTPResponse<JSONObject> | HTTPErrorResponse;
63
+
64
+ interface PendingSearch {
65
+ request: SearchRequest;
66
+ resolve: (answer: SearchAnswer) => void;
67
+ reject: (reason: unknown) => void;
68
+ }
69
+
70
+ /*
71
+ * Every POST parks here until the test answers it, in whatever order the
72
+ * test chooses. That is what lets "the slow answer for 'ka' lands after the
73
+ * answer for 'kansas'" be written down as two lines instead of two timers.
74
+ */
75
+ let pendingSearches: Array<PendingSearch> = [];
76
+
77
+ const postMock: MockFunction = getJestMockFunction();
78
+ const getFriendlyMessageMock: MockFunction = getJestMockFunction();
79
+ const getCommonHeadersMock: MockFunction = getJestMockFunction();
80
+ const onChangeMock: MockFunction = getJestMockFunction();
81
+ const onSelectSiteMock: MockFunction = getJestMockFunction();
82
+
83
+ /*
84
+ * One ordered log of what the box asked its page to do, so "clear the text,
85
+ * THEN drill" is an assertion about order and not just about two calls.
86
+ */
87
+ let pageCalls: Array<string> = [];
88
+
89
+ /*
90
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the
91
+ * consts, so the mocks are dereferenced lazily, at call time.
92
+ */
93
+ jest.mock("../../../UI/Utils/API/API", () => {
94
+ return {
95
+ __esModule: true,
96
+ default: {
97
+ post: (...args: Array<unknown>): unknown => {
98
+ return postMock(...args);
99
+ },
100
+ getFriendlyMessage: (...args: Array<unknown>): unknown => {
101
+ return getFriendlyMessageMock(...args);
102
+ },
103
+ },
104
+ };
105
+ });
106
+
107
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
108
+ return {
109
+ __esModule: true,
110
+ default: {
111
+ getCommonHeaders: (...args: Array<unknown>): unknown => {
112
+ return getCommonHeadersMock(...args);
113
+ },
114
+ },
115
+ };
116
+ });
117
+
118
+ // The Input translates its placeholder; the identity keeps assertions exact.
119
+ jest.mock("react-i18next", () => {
120
+ return {
121
+ useTranslation: () => {
122
+ return {
123
+ t: (value: string): string => {
124
+ return value;
125
+ },
126
+ };
127
+ },
128
+ };
129
+ });
130
+
131
+ /*
132
+ * Three rows that between them exercise every way a row can look: a status
133
+ * with a colour, no status at all (a unit that has never reported), and a
134
+ * status without a colour at the root of the hierarchy (so no path line).
135
+ */
136
+ const KANSAS_CITY: JSONObject = {
137
+ id: "site-kc",
138
+ name: "Kansas City",
139
+ siteType: "Market",
140
+ isUnitLevel: false,
141
+ path: "North America / Central",
142
+ currentMonitorStatus: {
143
+ id: "status-operational",
144
+ name: "Operational",
145
+ color: "#22c55e",
146
+ priority: 1,
147
+ isOperationalState: true,
148
+ },
149
+ };
150
+
151
+ const UNIT_104822: JSONObject = {
152
+ id: "site-unit-104822",
153
+ name: "Unit 104822 — Michigan Ave",
154
+ siteType: "Unit",
155
+ isUnitLevel: true,
156
+ path: "North America / Central / Chicago",
157
+ };
158
+
159
+ const CENTRAL: JSONObject = {
160
+ id: "site-central",
161
+ name: "Central",
162
+ siteType: "Region",
163
+ isUnitLevel: false,
164
+ currentMonitorStatus: {
165
+ id: "status-degraded",
166
+ name: "Degraded",
167
+ priority: 2,
168
+ isOperationalState: false,
169
+ },
170
+ };
171
+
172
+ const THREE_ROWS: Array<JSONObject> = [KANSAS_CITY, UNIT_104822, CENTRAL];
173
+
174
+ interface HarnessProps {
175
+ initialValue?: string | undefined;
176
+ localMatchCount?: number | undefined;
177
+ localTotalCount?: number | undefined;
178
+ childTypeLabel?: string | undefined;
179
+ dataTestId?: ComponentProps["dataTestId"];
180
+ placeholder?: ComponentProps["placeholder"];
181
+ showLocalCount?: ComponentProps["showLocalCount"];
182
+ }
183
+
184
+ /*
185
+ * The box is controlled — the page owns the text, because the page also
186
+ * filters its own cards with it — so it is mounted the way a page mounts it:
187
+ * under a parent that keeps the value in state.
188
+ */
189
+ function Harness(props: HarnessProps): ReactElement {
190
+ const [value, setValue] = useState<string>(props.initialValue ?? "");
191
+ return (
192
+ <SiteSearchBox
193
+ value={value}
194
+ onChange={(next: string): void => {
195
+ pageCalls.push(`change:${next}`);
196
+ onChangeMock(next);
197
+ setValue(next);
198
+ }}
199
+ onSelectSite={(siteId: string): void => {
200
+ pageCalls.push(`select:${siteId}`);
201
+ onSelectSiteMock(siteId);
202
+ }}
203
+ localMatchCount={props.localMatchCount ?? 0}
204
+ localTotalCount={props.localTotalCount ?? 0}
205
+ childTypeLabel={props.childTypeLabel ?? "Market"}
206
+ dataTestId={props.dataTestId}
207
+ placeholder={props.placeholder}
208
+ showLocalCount={props.showLocalCount}
209
+ />
210
+ );
211
+ }
212
+
213
+ function renderBox(props: HarnessProps = {}): RenderResult {
214
+ return render(<Harness {...props} />);
215
+ }
216
+
217
+ function searchInput(): HTMLInputElement {
218
+ return screen.getByRole("combobox") as HTMLInputElement;
219
+ }
220
+
221
+ function typeText(text: string): void {
222
+ fireEvent.change(searchInput(), { target: { value: text } });
223
+ }
224
+
225
+ function advance(milliseconds: number): void {
226
+ act(() => {
227
+ jest.advanceTimersByTime(milliseconds);
228
+ });
229
+ }
230
+
231
+ async function flush(): Promise<void> {
232
+ await act(async () => {});
233
+ }
234
+
235
+ function searchBody(
236
+ rows: Array<JSONObject>,
237
+ isTruncated: boolean = false,
238
+ ): JSONObject {
239
+ return { results: rows, isTruncated: isTruncated };
240
+ }
241
+
242
+ async function answer(index: number, body: JSONObject): Promise<void> {
243
+ const pending: PendingSearch | undefined = pendingSearches[index];
244
+ if (!pending) {
245
+ throw new Error(`No search request #${index} was sent`);
246
+ }
247
+ await act(async () => {
248
+ pending.resolve(new HTTPResponse<JSONObject>(200, body, {}));
249
+ });
250
+ await flush();
251
+ }
252
+
253
+ async function answerWith(index: number, answer: SearchAnswer): Promise<void> {
254
+ const pending: PendingSearch | undefined = pendingSearches[index];
255
+ if (!pending) {
256
+ throw new Error(`No search request #${index} was sent`);
257
+ }
258
+ await act(async () => {
259
+ pending.resolve(answer);
260
+ });
261
+ await flush();
262
+ }
263
+
264
+ async function rejectWith(index: number, reason: unknown): Promise<void> {
265
+ const pending: PendingSearch | undefined = pendingSearches[index];
266
+ if (!pending) {
267
+ throw new Error(`No search request #${index} was sent`);
268
+ }
269
+ await act(async () => {
270
+ pending.reject(reason);
271
+ });
272
+ await flush();
273
+ }
274
+
275
+ /*
276
+ * Type, let the debounce run out, and answer the request that produced —
277
+ * the whole round trip a reader makes before they can pick anything.
278
+ */
279
+ async function searchFor(
280
+ text: string,
281
+ rows: Array<JSONObject>,
282
+ isTruncated: boolean = false,
283
+ ): Promise<void> {
284
+ const before: number = pendingSearches.length;
285
+ typeText(text);
286
+ advance(SEARCH_DEBOUNCE_MS);
287
+ expect(pendingSearches).toHaveLength(before + 1);
288
+ await answer(before, searchBody(rows, isTruncated));
289
+ }
290
+
291
+ function panel(prefix: string = DEFAULT_PREFIX): HTMLElement | null {
292
+ return screen.queryByTestId(`${prefix}-results`);
293
+ }
294
+
295
+ function option(siteId: string, prefix: string = DEFAULT_PREFIX): HTMLElement {
296
+ return screen.getByTestId(`${prefix}-result-${siteId}`);
297
+ }
298
+
299
+ function selectedOptionIds(): Array<string> {
300
+ return screen
301
+ .queryAllByRole("option")
302
+ .filter((element: HTMLElement): boolean => {
303
+ return element.getAttribute("aria-selected") === "true";
304
+ })
305
+ .map((element: HTMLElement): string => {
306
+ return element.id;
307
+ });
308
+ }
309
+
310
+ function press(key: string): boolean {
311
+ return fireEvent.keyDown(searchInput(), { key: key });
312
+ }
313
+
314
+ beforeEach(() => {
315
+ jest.useFakeTimers();
316
+ pendingSearches = [];
317
+ pageCalls = [];
318
+ onChangeMock.mockReset();
319
+ onSelectSiteMock.mockReset();
320
+
321
+ postMock.mockReset();
322
+ postMock.mockImplementation((request: unknown): Promise<SearchAnswer> => {
323
+ return new Promise<SearchAnswer>(
324
+ (
325
+ resolve: (answer: SearchAnswer) => void,
326
+ reject: (reason: unknown) => void,
327
+ ): void => {
328
+ pendingSearches.push({
329
+ request: request as SearchRequest,
330
+ resolve: resolve,
331
+ reject: reject,
332
+ });
333
+ },
334
+ );
335
+ });
336
+
337
+ // Mirrors the real helper closely enough for the box's two failure paths.
338
+ getFriendlyMessageMock.mockReset();
339
+ getFriendlyMessageMock.mockImplementation((error: unknown): string => {
340
+ if (error instanceof HTTPErrorResponse) {
341
+ return error.message || "Server Error. Please try again";
342
+ }
343
+ if (error instanceof Error) {
344
+ return error.message;
345
+ }
346
+ return "Server Error. Please try again";
347
+ });
348
+
349
+ getCommonHeadersMock.mockReset();
350
+ getCommonHeadersMock.mockReturnValue({ tenantid: PROJECT_ID });
351
+ });
352
+
353
+ afterEach(() => {
354
+ cleanup();
355
+ jest.useRealTimers();
356
+ });
357
+
358
+ /*
359
+ * When the box goes to the server, and with what. One character matches most
360
+ * of an estate, and per-keystroke requests are one round trip per letter, so
361
+ * both the threshold and the debounce are part of the contract.
362
+ */
363
+ describe("when the box asks the server", () => {
364
+ test("the threshold the box exports is two characters", () => {
365
+ expect(MIN_SITE_SEARCH_CHARS).toBe(2);
366
+ });
367
+
368
+ test("one character sends nothing and opens no panel", () => {
369
+ renderBox();
370
+ fireEvent.focus(searchInput());
371
+ typeText("k");
372
+ advance(SEARCH_DEBOUNCE_MS * 4);
373
+
374
+ expect(postMock).not.toHaveBeenCalled();
375
+ expect(panel()).not.toBeInTheDocument();
376
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
377
+ // The page still got the text, for its own local narrowing.
378
+ expect(onChangeMock).toHaveBeenLastCalledWith("k");
379
+ });
380
+
381
+ test("padding does not count toward the threshold", () => {
382
+ renderBox();
383
+ typeText(" k ");
384
+ advance(SEARCH_DEBOUNCE_MS * 4);
385
+ typeText(" ");
386
+ advance(SEARCH_DEBOUNCE_MS * 4);
387
+
388
+ expect(postMock).not.toHaveBeenCalled();
389
+ expect(panel()).not.toBeInTheDocument();
390
+ });
391
+
392
+ test("two characters send exactly one request, and only once the debounce runs out", () => {
393
+ renderBox();
394
+ fireEvent.focus(searchInput());
395
+ typeText("ka");
396
+
397
+ // The panel is open (and says it is searching) before anything is sent.
398
+ expect(panel()).toBeInTheDocument();
399
+ advance(SEARCH_DEBOUNCE_MS - 1);
400
+ expect(postMock).not.toHaveBeenCalled();
401
+
402
+ advance(1);
403
+ expect(postMock).toHaveBeenCalledTimes(1);
404
+
405
+ advance(SEARCH_DEBOUNCE_MS * 4);
406
+ expect(postMock).toHaveBeenCalledTimes(1);
407
+ });
408
+
409
+ test("the request goes to /network-site/search with the trimmed, lower-cased text and the common headers", () => {
410
+ renderBox();
411
+ fireEvent.focus(searchInput());
412
+ typeText(" Kansas CITY ");
413
+ advance(SEARCH_DEBOUNCE_MS);
414
+
415
+ expect(postMock).toHaveBeenCalledTimes(1);
416
+ const request: SearchRequest = pendingSearches[0]!.request;
417
+ expect(request.url.toString()).toMatch(/\/network-site\/search$/);
418
+ expect(request.data).toEqual({ searchText: "kansas city" });
419
+ expect(request.headers).toEqual({ tenantid: PROJECT_ID });
420
+ expect(getCommonHeadersMock).toHaveBeenCalled();
421
+ // The page itself still sees exactly what was typed.
422
+ expect(onChangeMock).toHaveBeenLastCalledWith(" Kansas CITY ");
423
+ });
424
+
425
+ test("rapid typing sends only the last text", () => {
426
+ renderBox();
427
+ fireEvent.focus(searchInput());
428
+ typeText("ka");
429
+ advance(100);
430
+ typeText("kan");
431
+ advance(100);
432
+ typeText("kans");
433
+ advance(SEARCH_DEBOUNCE_MS - 1);
434
+ typeText("kansas");
435
+ advance(SEARCH_DEBOUNCE_MS);
436
+
437
+ expect(postMock).toHaveBeenCalledTimes(1);
438
+ expect(pendingSearches[0]!.request.data).toEqual({
439
+ searchText: "kansas",
440
+ });
441
+ });
442
+
443
+ test("an edit that normalizes to the same text does not search again", async () => {
444
+ renderBox();
445
+ fireEvent.focus(searchInput());
446
+ await searchFor("ka", [KANSAS_CITY]);
447
+
448
+ typeText("ka ");
449
+ advance(SEARCH_DEBOUNCE_MS);
450
+ typeText("KA");
451
+ advance(SEARCH_DEBOUNCE_MS);
452
+
453
+ expect(postMock).toHaveBeenCalledTimes(1);
454
+ // And the answer it already has stays on screen, still answering.
455
+ expect(option(KANSAS_CITY["id"] as string)).toBeInTheDocument();
456
+ expect(
457
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
458
+ ).not.toBeInTheDocument();
459
+ });
460
+
461
+ test("text that drops below the threshold before the debounce runs out sends nothing", () => {
462
+ renderBox();
463
+ fireEvent.focus(searchInput());
464
+ typeText("ka");
465
+ advance(SEARCH_DEBOUNCE_MS - 50);
466
+ typeText("k");
467
+ advance(SEARCH_DEBOUNCE_MS * 4);
468
+
469
+ expect(postMock).not.toHaveBeenCalled();
470
+ });
471
+
472
+ test("unmounting while the debounce is running sends nothing", () => {
473
+ const view: RenderResult = renderBox();
474
+ typeText("kansas");
475
+ view.unmount();
476
+ advance(SEARCH_DEBOUNCE_MS * 4);
477
+
478
+ expect(postMock).not.toHaveBeenCalled();
479
+ });
480
+
481
+ test("an answer that lands after the box is gone is dropped quietly", async () => {
482
+ const view: RenderResult = renderBox();
483
+ typeText("kansas");
484
+ advance(SEARCH_DEBOUNCE_MS);
485
+ expect(postMock).toHaveBeenCalledTimes(1);
486
+ view.unmount();
487
+
488
+ await answer(0, searchBody([KANSAS_CITY]));
489
+
490
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
491
+ });
492
+
493
+ test("a failure that lands after the box is gone is not even turned into a message", async () => {
494
+ const view: RenderResult = renderBox();
495
+ typeText("kansas");
496
+ advance(SEARCH_DEBOUNCE_MS);
497
+ view.unmount();
498
+
499
+ await rejectWith(0, new Error("late"));
500
+
501
+ expect(getFriendlyMessageMock).not.toHaveBeenCalled();
502
+ });
503
+
504
+ test("text the page mounts with is searched, and its answer is waiting when the box is focused", async () => {
505
+ renderBox({ initialValue: "kansas" });
506
+ expect(panel()).not.toBeInTheDocument();
507
+
508
+ advance(SEARCH_DEBOUNCE_MS);
509
+ expect(postMock).toHaveBeenCalledTimes(1);
510
+ await answer(0, searchBody([KANSAS_CITY]));
511
+ expect(panel()).not.toBeInTheDocument();
512
+
513
+ fireEvent.focus(searchInput());
514
+ expect(option(KANSAS_CITY["id"] as string)).toBeInTheDocument();
515
+ expect(postMock).toHaveBeenCalledTimes(1);
516
+ });
517
+ });
518
+
519
+ /*
520
+ * What a hit says. The dropdown exists to answer "where is it", so the path
521
+ * is the important line; the status mark keeps "no data" a different SHAPE
522
+ * from a status, not merely a greyer colour (same rule as the site cards).
523
+ */
524
+ describe("what a result row shows", () => {
525
+ test("name, site type and ancestor path", async () => {
526
+ renderBox();
527
+ fireEvent.focus(searchInput());
528
+ await searchFor("kansas", THREE_ROWS);
529
+
530
+ const row: HTMLElement = option("site-kc");
531
+ expect(within(row).getByText("Kansas City")).toBeInTheDocument();
532
+ expect(within(row).getByText("Market")).toBeInTheDocument();
533
+ expect(
534
+ within(row).getByText("North America / Central"),
535
+ ).toBeInTheDocument();
536
+
537
+ const unit: HTMLElement = option("site-unit-104822");
538
+ expect(
539
+ within(unit).getByText("Unit 104822 — Michigan Ave"),
540
+ ).toBeInTheDocument();
541
+ expect(within(unit).getByText("Unit")).toBeInTheDocument();
542
+ expect(
543
+ within(unit).getByText("North America / Central / Chicago"),
544
+ ).toBeInTheDocument();
545
+ });
546
+
547
+ test("rows keep the server's order", async () => {
548
+ renderBox();
549
+ fireEvent.focus(searchInput());
550
+ await searchFor("ce", THREE_ROWS);
551
+
552
+ expect(
553
+ screen.getAllByRole("option").map((element: HTMLElement): string => {
554
+ return element.getAttribute("data-testid") || "";
555
+ }),
556
+ ).toEqual([
557
+ `${DEFAULT_PREFIX}-result-site-kc`,
558
+ `${DEFAULT_PREFIX}-result-site-unit-104822`,
559
+ `${DEFAULT_PREFIX}-result-site-central`,
560
+ ]);
561
+ });
562
+
563
+ test("a reporting site gets a filled dot in its status colour, titled with the status", async () => {
564
+ renderBox();
565
+ fireEvent.focus(searchInput());
566
+ await searchFor("kansas", THREE_ROWS);
567
+
568
+ const dot: HTMLElement = within(option("site-kc")).getByTitle(
569
+ "Operational",
570
+ );
571
+ expect(dot).toHaveStyle({ backgroundColor: "#22c55e" });
572
+ expect(dot).not.toHaveClass("border");
573
+ expect(
574
+ within(option("site-kc")).queryByTitle("Not reporting"),
575
+ ).not.toBeInTheDocument();
576
+ });
577
+
578
+ test("a status without a colour still gets a filled dot, in neutral grey", async () => {
579
+ renderBox();
580
+ fireEvent.focus(searchInput());
581
+ await searchFor("central", THREE_ROWS);
582
+
583
+ const dot: HTMLElement = within(option("site-central")).getByTitle(
584
+ "Degraded",
585
+ );
586
+ expect(dot).toHaveStyle({ backgroundColor: "#9ca3af" });
587
+ expect(dot).not.toHaveClass("border");
588
+ });
589
+
590
+ test("a site that is not reporting gets a hollow ring instead of a dot", async () => {
591
+ renderBox();
592
+ fireEvent.focus(searchInput());
593
+ await searchFor("unit", THREE_ROWS);
594
+
595
+ const ring: HTMLElement = within(option("site-unit-104822")).getByTitle(
596
+ "Not reporting",
597
+ );
598
+ expect(ring).toHaveClass("border");
599
+ expect(ring.style.backgroundColor).toBe("");
600
+ });
601
+
602
+ test("a root site prints no path line at all", async () => {
603
+ renderBox();
604
+ fireEvent.focus(searchInput());
605
+ await searchFor("central", [CENTRAL]);
606
+
607
+ const row: HTMLElement = option("site-central");
608
+ expect(row.textContent).toBe("CentralRegion");
609
+ });
610
+
611
+ test("a partly broken row falls back to readable labels, and a row without an id is dropped", async () => {
612
+ renderBox();
613
+ fireEvent.focus(searchInput());
614
+ await searchFor("xx", [
615
+ { id: "site-bare" },
616
+ { name: "No id at all", siteType: "Store" },
617
+ KANSAS_CITY,
618
+ ]);
619
+
620
+ const bare: HTMLElement = option("site-bare");
621
+ expect(within(bare).getByText("Unnamed site")).toBeInTheDocument();
622
+ expect(within(bare).getByText("Other")).toBeInTheDocument();
623
+ expect(within(bare).getByTitle("Not reporting")).toBeInTheDocument();
624
+ expect(screen.queryByText("No id at all")).not.toBeInTheDocument();
625
+ expect(screen.getAllByRole("option")).toHaveLength(2);
626
+ });
627
+
628
+ test("the panel is headed as a search of the whole network", async () => {
629
+ renderBox();
630
+ fireEvent.focus(searchInput());
631
+ await searchFor("kansas", [KANSAS_CITY]);
632
+
633
+ expect(
634
+ within(panel()!).getByText("Anywhere in your network"),
635
+ ).toBeInTheDocument();
636
+ });
637
+ });
638
+
639
+ /*
640
+ * Picking with the pointer. The page is told to clear the text BEFORE it is
641
+ * told where to go, so the destination level is not left filtered by the
642
+ * question that led there — and the panel swallows mousedown, because that
643
+ * is what keeps the input from blurring and the panel from unmounting under
644
+ * the pointer before the click lands.
645
+ */
646
+ describe("choosing a result with the pointer", () => {
647
+ test("clicking a result clears the text, then drills to that site", async () => {
648
+ renderBox();
649
+ fireEvent.focus(searchInput());
650
+ await searchFor("unit", THREE_ROWS);
651
+ pageCalls = [];
652
+
653
+ fireEvent.click(option("site-unit-104822"));
654
+
655
+ expect(pageCalls).toEqual(["change:", "select:site-unit-104822"]);
656
+ expect(onSelectSiteMock).toHaveBeenCalledTimes(1);
657
+ expect(onSelectSiteMock).toHaveBeenCalledWith("site-unit-104822");
658
+ expect(searchInput().value).toBe("");
659
+ expect(panel()).not.toBeInTheDocument();
660
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
661
+ });
662
+
663
+ test("mousedown anywhere in the panel is default-prevented", async () => {
664
+ renderBox();
665
+ fireEvent.focus(searchInput());
666
+ await searchFor("kansas", THREE_ROWS);
667
+
668
+ // fireEvent returns false exactly when a handler called preventDefault.
669
+ expect(fireEvent.mouseDown(option("site-kc"))).toBe(false);
670
+ expect(
671
+ fireEvent.mouseDown(within(option("site-kc")).getByText("Kansas City")),
672
+ ).toBe(false);
673
+ expect(
674
+ fireEvent.mouseDown(
675
+ within(panel()!).getByText("Anywhere in your network"),
676
+ ),
677
+ ).toBe(false);
678
+ // Mousedown alone commits nothing — only the click does.
679
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
680
+ });
681
+
682
+ test("the whole press — mousedown then click — lands on the site", async () => {
683
+ renderBox();
684
+ fireEvent.focus(searchInput());
685
+ await searchFor("kansas", THREE_ROWS);
686
+
687
+ const row: HTMLElement = option("site-central");
688
+ fireEvent.mouseDown(row);
689
+ fireEvent.mouseUp(row);
690
+ fireEvent.click(row);
691
+
692
+ expect(onSelectSiteMock).toHaveBeenCalledWith("site-central");
693
+ });
694
+
695
+ test("hovering a result highlights it", async () => {
696
+ renderBox();
697
+ fireEvent.focus(searchInput());
698
+ await searchFor("kansas", THREE_ROWS);
699
+
700
+ fireEvent.mouseEnter(option("site-central"));
701
+
702
+ expect(selectedOptionIds()).toEqual([
703
+ `${DEFAULT_PREFIX}-option-site-central`,
704
+ ]);
705
+ expect(searchInput()).toHaveAttribute(
706
+ "aria-activedescendant",
707
+ `${DEFAULT_PREFIX}-option-site-central`,
708
+ );
709
+ });
710
+
711
+ test("typing again after a pick re-opens the panel for the new search", async () => {
712
+ renderBox();
713
+ fireEvent.focus(searchInput());
714
+ await searchFor("kansas", THREE_ROWS);
715
+ fireEvent.click(option("site-kc"));
716
+ expect(panel()).not.toBeInTheDocument();
717
+
718
+ // Focus never left the input, so no focus event will re-open it.
719
+ typeText("ce");
720
+ expect(panel()).toBeInTheDocument();
721
+ expect(
722
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
723
+ ).toBeInTheDocument();
724
+ });
725
+ });
726
+
727
+ /*
728
+ * The keyboard. Focus never leaves the input, so the arrow keys move a
729
+ * highlight that the input names through aria-activedescendant, and Enter
730
+ * commits only an entry the reader actually moved onto.
731
+ *
732
+ * The Escape cases are the regression the issue's fix shipped with: Escape
733
+ * used to hide the panel but keep the highlight, so a following Enter drilled
734
+ * into a site that was no longer on screen.
735
+ */
736
+ describe("the keyboard", () => {
737
+ async function openWithThreeRows(): Promise<void> {
738
+ renderBox();
739
+ fireEvent.focus(searchInput());
740
+ await searchFor("ce", THREE_ROWS);
741
+ }
742
+
743
+ test("nothing is highlighted until an arrow key is pressed", async () => {
744
+ await openWithThreeRows();
745
+
746
+ expect(selectedOptionIds()).toEqual([]);
747
+ expect(searchInput()).not.toHaveAttribute("aria-activedescendant");
748
+ for (const element of screen.getAllByRole("option")) {
749
+ expect(element).toHaveAttribute("aria-selected", "false");
750
+ }
751
+ });
752
+
753
+ test("ArrowDown highlights the first option and names it on the input", async () => {
754
+ await openWithThreeRows();
755
+
756
+ expect(press("ArrowDown")).toBe(false);
757
+
758
+ const first: HTMLElement = option("site-kc");
759
+ expect(first).toHaveAttribute("aria-selected", "true");
760
+ expect(first.id).toBe(`${DEFAULT_PREFIX}-option-site-kc`);
761
+ expect(searchInput()).toHaveAttribute("aria-activedescendant", first.id);
762
+ expect(
763
+ document.getElementById(
764
+ searchInput().getAttribute("aria-activedescendant") || "",
765
+ ),
766
+ ).toBe(first);
767
+ expect(option("site-unit-104822")).toHaveAttribute(
768
+ "aria-selected",
769
+ "false",
770
+ );
771
+ });
772
+
773
+ test("ArrowDown walks the list and wraps from the last option to the first", async () => {
774
+ await openWithThreeRows();
775
+
776
+ press("ArrowDown");
777
+ press("ArrowDown");
778
+ expect(selectedOptionIds()).toEqual([
779
+ `${DEFAULT_PREFIX}-option-site-unit-104822`,
780
+ ]);
781
+ press("ArrowDown");
782
+ expect(selectedOptionIds()).toEqual([
783
+ `${DEFAULT_PREFIX}-option-site-central`,
784
+ ]);
785
+ press("ArrowDown");
786
+ expect(selectedOptionIds()).toEqual([`${DEFAULT_PREFIX}-option-site-kc`]);
787
+ expect(searchInput()).toHaveAttribute(
788
+ "aria-activedescendant",
789
+ `${DEFAULT_PREFIX}-option-site-kc`,
790
+ );
791
+ });
792
+
793
+ test("ArrowUp with nothing highlighted wraps to the last option", async () => {
794
+ await openWithThreeRows();
795
+
796
+ expect(press("ArrowUp")).toBe(false);
797
+
798
+ expect(selectedOptionIds()).toEqual([
799
+ `${DEFAULT_PREFIX}-option-site-central`,
800
+ ]);
801
+ expect(searchInput()).toHaveAttribute(
802
+ "aria-activedescendant",
803
+ `${DEFAULT_PREFIX}-option-site-central`,
804
+ );
805
+ });
806
+
807
+ test("ArrowUp walks back and wraps from the first option to the last", async () => {
808
+ await openWithThreeRows();
809
+
810
+ press("ArrowDown");
811
+ press("ArrowDown");
812
+ press("ArrowUp");
813
+ expect(selectedOptionIds()).toEqual([`${DEFAULT_PREFIX}-option-site-kc`]);
814
+ press("ArrowUp");
815
+ expect(selectedOptionIds()).toEqual([
816
+ `${DEFAULT_PREFIX}-option-site-central`,
817
+ ]);
818
+ });
819
+
820
+ test("Enter commits the highlighted option: clear the text, then drill", async () => {
821
+ await openWithThreeRows();
822
+ press("ArrowDown");
823
+ press("ArrowDown");
824
+ pageCalls = [];
825
+
826
+ expect(press("Enter")).toBe(false);
827
+
828
+ expect(pageCalls).toEqual(["change:", "select:site-unit-104822"]);
829
+ expect(onSelectSiteMock).toHaveBeenCalledTimes(1);
830
+ expect(panel()).not.toBeInTheDocument();
831
+ expect(searchInput().value).toBe("");
832
+ });
833
+
834
+ test("Enter commits the option the pointer is over", async () => {
835
+ await openWithThreeRows();
836
+ fireEvent.mouseEnter(option("site-central"));
837
+
838
+ press("Enter");
839
+
840
+ expect(onSelectSiteMock).toHaveBeenCalledWith("site-central");
841
+ });
842
+
843
+ test("a bare Enter with nothing highlighted does nothing at all", async () => {
844
+ await openWithThreeRows();
845
+ onChangeMock.mockClear();
846
+
847
+ // Not claimed either, so a surrounding form still sees it.
848
+ expect(press("Enter")).toBe(true);
849
+
850
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
851
+ expect(onChangeMock).not.toHaveBeenCalled();
852
+ expect(panel()).toBeInTheDocument();
853
+ });
854
+
855
+ test("Escape closes the panel and drops the highlight", async () => {
856
+ await openWithThreeRows();
857
+ press("ArrowDown");
858
+
859
+ press("Escape");
860
+
861
+ expect(panel()).not.toBeInTheDocument();
862
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
863
+ expect(searchInput()).not.toHaveAttribute("aria-activedescendant");
864
+ expect(searchInput()).not.toHaveAttribute("aria-controls");
865
+ // Escape hides the answers; it does not throw the question away.
866
+ expect(searchInput().value).toBe("ce");
867
+ });
868
+
869
+ test("REGRESSION: Enter after Escape does not drill into the hidden highlight", async () => {
870
+ await openWithThreeRows();
871
+ press("ArrowDown");
872
+ press("ArrowDown");
873
+ onChangeMock.mockClear();
874
+
875
+ press("Escape");
876
+ expect(press("Enter")).toBe(true);
877
+
878
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
879
+ expect(onChangeMock).not.toHaveBeenCalled();
880
+ expect(searchInput().value).toBe("ce");
881
+ });
882
+
883
+ test("ArrowUp after Escape re-opens the panel, starting again from the end", async () => {
884
+ await openWithThreeRows();
885
+ press("ArrowDown");
886
+ press("ArrowDown");
887
+ press("Escape");
888
+
889
+ expect(press("ArrowUp")).toBe(false);
890
+
891
+ expect(panel()).toBeInTheDocument();
892
+ expect(searchInput()).toHaveAttribute("aria-expanded", "true");
893
+ /*
894
+ * From the middle option ArrowUp would have gone to the first; landing
895
+ * on the last proves Escape really did drop the highlight.
896
+ */
897
+ expect(selectedOptionIds()).toEqual([
898
+ `${DEFAULT_PREFIX}-option-site-central`,
899
+ ]);
900
+ expect(postMock).toHaveBeenCalledTimes(1);
901
+ });
902
+
903
+ test("ArrowDown after Escape re-opens the panel, starting again from the top", async () => {
904
+ await openWithThreeRows();
905
+ press("ArrowDown");
906
+ press("ArrowDown");
907
+ press("Escape");
908
+
909
+ press("ArrowDown");
910
+
911
+ expect(panel()).toBeInTheDocument();
912
+ expect(selectedOptionIds()).toEqual([`${DEFAULT_PREFIX}-option-site-kc`]);
913
+ });
914
+
915
+ test("Enter after Escape and a fresh arrow key commits the new highlight", async () => {
916
+ await openWithThreeRows();
917
+ press("ArrowDown");
918
+ press("ArrowDown");
919
+ press("Escape");
920
+ press("ArrowDown");
921
+
922
+ press("Enter");
923
+
924
+ expect(onSelectSiteMock).toHaveBeenCalledTimes(1);
925
+ expect(onSelectSiteMock).toHaveBeenCalledWith("site-kc");
926
+ });
927
+
928
+ test("arrow keys are left alone when there is nothing to move through", async () => {
929
+ renderBox();
930
+ fireEvent.focus(searchInput());
931
+ await searchFor("zz", []);
932
+
933
+ expect(press("ArrowDown")).toBe(true);
934
+ expect(press("ArrowUp")).toBe(true);
935
+ expect(press("Enter")).toBe(true);
936
+ expect(searchInput()).not.toHaveAttribute("aria-activedescendant");
937
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
938
+ });
939
+
940
+ test("arrow keys do nothing below the threshold", () => {
941
+ renderBox();
942
+ fireEvent.focus(searchInput());
943
+ typeText("k");
944
+
945
+ expect(press("ArrowDown")).toBe(true);
946
+ expect(panel()).not.toBeInTheDocument();
947
+ });
948
+
949
+ test("typing a new search drops the highlight", async () => {
950
+ await openWithThreeRows();
951
+ press("ArrowDown");
952
+
953
+ typeText("cen");
954
+
955
+ expect(selectedOptionIds()).toEqual([]);
956
+ expect(searchInput()).not.toHaveAttribute("aria-activedescendant");
957
+ // So an Enter before the new answer arrives commits nothing.
958
+ press("Enter");
959
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
960
+ });
961
+
962
+ test("other keys neither move the highlight nor close the panel", async () => {
963
+ await openWithThreeRows();
964
+ press("ArrowDown");
965
+
966
+ expect(press("Tab")).toBe(true);
967
+ press("a");
968
+
969
+ expect(panel()).toBeInTheDocument();
970
+ expect(selectedOptionIds()).toEqual([`${DEFAULT_PREFIX}-option-site-kc`]);
971
+ });
972
+ });
973
+
974
+ /*
975
+ * Which answers may paint. Every request takes a sequence number and only the
976
+ * latest may write, so a slow answer for "ka" can never land over the answer
977
+ * for "kansas" — the list on screen must always answer the text in the box.
978
+ */
979
+ describe("stale and failed answers", () => {
980
+ test("a slow earlier answer that lands after a later one is ignored", async () => {
981
+ renderBox();
982
+ fireEvent.focus(searchInput());
983
+ typeText("ka");
984
+ advance(SEARCH_DEBOUNCE_MS);
985
+ typeText("kansas");
986
+ advance(SEARCH_DEBOUNCE_MS);
987
+ expect(postMock).toHaveBeenCalledTimes(2);
988
+
989
+ await answer(1, searchBody([KANSAS_CITY]));
990
+ await answer(0, searchBody([UNIT_104822, CENTRAL], true));
991
+
992
+ expect(screen.getAllByRole("option")).toHaveLength(1);
993
+ expect(option("site-kc")).toBeInTheDocument();
994
+ expect(
995
+ screen.queryByTestId(`${DEFAULT_PREFIX}-result-site-central`),
996
+ ).not.toBeInTheDocument();
997
+ expect(
998
+ screen.queryByTestId(`${DEFAULT_PREFIX}-truncated`),
999
+ ).not.toBeInTheDocument();
1000
+ });
1001
+
1002
+ test("an earlier answer that lands while the later request is still out does not paint", async () => {
1003
+ renderBox();
1004
+ fireEvent.focus(searchInput());
1005
+ typeText("ka");
1006
+ advance(SEARCH_DEBOUNCE_MS);
1007
+ typeText("kansas");
1008
+ advance(SEARCH_DEBOUNCE_MS);
1009
+
1010
+ await answer(0, searchBody([UNIT_104822]));
1011
+
1012
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1013
+ expect(
1014
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1015
+ ).toBeInTheDocument();
1016
+ expect(
1017
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1018
+ ).not.toBeInTheDocument();
1019
+
1020
+ await answer(1, searchBody([KANSAS_CITY]));
1021
+ expect(option("site-kc")).toBeInTheDocument();
1022
+ expect(
1023
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1024
+ ).not.toBeInTheDocument();
1025
+ });
1026
+
1027
+ test("a slow earlier failure does not paint an error over a later answer", async () => {
1028
+ renderBox();
1029
+ fireEvent.focus(searchInput());
1030
+ typeText("ka");
1031
+ advance(SEARCH_DEBOUNCE_MS);
1032
+ typeText("kansas");
1033
+ advance(SEARCH_DEBOUNCE_MS);
1034
+
1035
+ await answer(1, searchBody([KANSAS_CITY]));
1036
+ await answerWith(
1037
+ 0,
1038
+ new HTTPErrorResponse(500, { message: "Search is down" }, {}),
1039
+ );
1040
+
1041
+ expect(
1042
+ screen.queryByTestId(`${DEFAULT_PREFIX}-error`),
1043
+ ).not.toBeInTheDocument();
1044
+ expect(option("site-kc")).toBeInTheDocument();
1045
+ });
1046
+
1047
+ test("a failed request shows the friendly error and no results", async () => {
1048
+ renderBox();
1049
+ fireEvent.focus(searchInput());
1050
+ typeText("kansas");
1051
+ advance(SEARCH_DEBOUNCE_MS);
1052
+ const failure: HTTPErrorResponse = new HTTPErrorResponse(
1053
+ 500,
1054
+ { message: "Search is temporarily unavailable." },
1055
+ {},
1056
+ );
1057
+
1058
+ await answerWith(0, failure);
1059
+
1060
+ expect(getFriendlyMessageMock).toHaveBeenCalledWith(failure);
1061
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-error`)).toHaveTextContent(
1062
+ "Search is temporarily unavailable.",
1063
+ );
1064
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1065
+ // An error is not "no matches", and it is not still searching.
1066
+ expect(
1067
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1068
+ ).not.toBeInTheDocument();
1069
+ expect(
1070
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1071
+ ).not.toBeInTheDocument();
1072
+ });
1073
+
1074
+ test("a request that throws shows its message the same way", async () => {
1075
+ renderBox();
1076
+ fireEvent.focus(searchInput());
1077
+ typeText("kansas");
1078
+ advance(SEARCH_DEBOUNCE_MS);
1079
+
1080
+ await rejectWith(0, new Error("Network request failed"));
1081
+
1082
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-error`)).toHaveTextContent(
1083
+ "Network request failed",
1084
+ );
1085
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1086
+ });
1087
+
1088
+ test("a failure replaces the previous answer's rows and truncation note", async () => {
1089
+ renderBox();
1090
+ fireEvent.focus(searchInput());
1091
+ await searchFor("ka", THREE_ROWS, true);
1092
+ expect(screen.getAllByRole("option")).toHaveLength(3);
1093
+
1094
+ typeText("kan");
1095
+ advance(SEARCH_DEBOUNCE_MS);
1096
+ await answerWith(1, new HTTPErrorResponse(503, { message: "Nope" }, {}));
1097
+
1098
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1099
+ expect(
1100
+ screen.queryByTestId(`${DEFAULT_PREFIX}-truncated`),
1101
+ ).not.toBeInTheDocument();
1102
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-error`)).toHaveTextContent(
1103
+ "Nope",
1104
+ );
1105
+ });
1106
+
1107
+ test("the next successful answer clears the error", async () => {
1108
+ renderBox();
1109
+ fireEvent.focus(searchInput());
1110
+ typeText("ka");
1111
+ advance(SEARCH_DEBOUNCE_MS);
1112
+ await rejectWith(0, new Error("Network request failed"));
1113
+
1114
+ typeText("kansas");
1115
+ advance(SEARCH_DEBOUNCE_MS);
1116
+ await answer(1, searchBody([KANSAS_CITY]));
1117
+
1118
+ expect(
1119
+ screen.queryByTestId(`${DEFAULT_PREFIX}-error`),
1120
+ ).not.toBeInTheDocument();
1121
+ expect(option("site-kc")).toBeInTheDocument();
1122
+ });
1123
+
1124
+ test("dropping below the threshold clears an error", async () => {
1125
+ renderBox();
1126
+ fireEvent.focus(searchInput());
1127
+ typeText("ka");
1128
+ advance(SEARCH_DEBOUNCE_MS);
1129
+ await rejectWith(0, new Error("Network request failed"));
1130
+
1131
+ typeText("k");
1132
+ typeText("ka");
1133
+
1134
+ expect(
1135
+ screen.queryByTestId(`${DEFAULT_PREFIX}-error`),
1136
+ ).not.toBeInTheDocument();
1137
+ expect(
1138
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1139
+ ).toBeInTheDocument();
1140
+ });
1141
+ });
1142
+
1143
+ /*
1144
+ * The status lines inside the panel. "No sites match" must only ever describe
1145
+ * the text in the box: shown over the previous query's rows, the instant
1146
+ * somebody types, it would be a lie about a search that has not run yet.
1147
+ */
1148
+ describe("the panel's status lines", () => {
1149
+ test('"Searching…" shows from the first keystroke until the answer arrives', async () => {
1150
+ renderBox();
1151
+ fireEvent.focus(searchInput());
1152
+ typeText("kansas");
1153
+
1154
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-searching`)).toHaveTextContent(
1155
+ "Searching…",
1156
+ );
1157
+ advance(SEARCH_DEBOUNCE_MS);
1158
+ expect(
1159
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1160
+ ).toBeInTheDocument();
1161
+
1162
+ await answer(0, searchBody([KANSAS_CITY]));
1163
+ expect(
1164
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1165
+ ).not.toBeInTheDocument();
1166
+ });
1167
+
1168
+ test('"No sites match that name." appears only once the answer for the current text is empty', async () => {
1169
+ renderBox();
1170
+ fireEvent.focus(searchInput());
1171
+ typeText("zz");
1172
+ expect(
1173
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1174
+ ).not.toBeInTheDocument();
1175
+
1176
+ advance(SEARCH_DEBOUNCE_MS);
1177
+ expect(
1178
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1179
+ ).not.toBeInTheDocument();
1180
+
1181
+ await answer(0, searchBody([]));
1182
+ expect(
1183
+ screen.getByTestId(`${DEFAULT_PREFIX}-no-results`),
1184
+ ).toHaveTextContent("No sites match that name.");
1185
+ expect(
1186
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1187
+ ).not.toBeInTheDocument();
1188
+ });
1189
+
1190
+ test('"No sites match" disappears the moment the text changes, and "Searching…" takes its place', async () => {
1191
+ renderBox();
1192
+ fireEvent.focus(searchInput());
1193
+ await searchFor("zz", []);
1194
+ expect(
1195
+ screen.getByTestId(`${DEFAULT_PREFIX}-no-results`),
1196
+ ).toBeInTheDocument();
1197
+
1198
+ typeText("zzz");
1199
+
1200
+ expect(
1201
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1202
+ ).not.toBeInTheDocument();
1203
+ expect(
1204
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1205
+ ).toBeInTheDocument();
1206
+
1207
+ advance(SEARCH_DEBOUNCE_MS);
1208
+ await answer(1, searchBody([]));
1209
+ expect(
1210
+ screen.getByTestId(`${DEFAULT_PREFIX}-no-results`),
1211
+ ).toBeInTheDocument();
1212
+ });
1213
+
1214
+ /*
1215
+ * The line is keyed to the text the rows answer, not to "is a request
1216
+ * pending": typing back to text that was already answered puts that
1217
+ * answer's line straight back, rather than claiming to search for it.
1218
+ */
1219
+ test("going back to text that was already answered shows its status line at once", async () => {
1220
+ renderBox();
1221
+ fireEvent.focus(searchInput());
1222
+ await searchFor("zz", []);
1223
+
1224
+ typeText("zzz");
1225
+ expect(
1226
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1227
+ ).not.toBeInTheDocument();
1228
+
1229
+ typeText("zz");
1230
+ expect(
1231
+ screen.getByTestId(`${DEFAULT_PREFIX}-no-results`),
1232
+ ).toBeInTheDocument();
1233
+ expect(
1234
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1235
+ ).not.toBeInTheDocument();
1236
+ });
1237
+
1238
+ test("an answer with rows shows neither status line", async () => {
1239
+ renderBox();
1240
+ fireEvent.focus(searchInput());
1241
+ await searchFor("kansas", [KANSAS_CITY]);
1242
+
1243
+ expect(
1244
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1245
+ ).not.toBeInTheDocument();
1246
+ expect(
1247
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1248
+ ).not.toBeInTheDocument();
1249
+ });
1250
+
1251
+ test("a capped answer says there are more matches than shown", async () => {
1252
+ renderBox();
1253
+ fireEvent.focus(searchInput());
1254
+ await searchFor("unit", [KANSAS_CITY, UNIT_104822], true);
1255
+
1256
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-truncated`)).toHaveTextContent(
1257
+ "More sites match than are shown — keep typing to narrow it down.",
1258
+ );
1259
+ });
1260
+
1261
+ test("the truncation note needs a literal true", async () => {
1262
+ renderBox();
1263
+ fireEvent.focus(searchInput());
1264
+ typeText("unit");
1265
+ advance(SEARCH_DEBOUNCE_MS);
1266
+ await answer(0, { results: [UNIT_104822], isTruncated: "true" });
1267
+
1268
+ expect(
1269
+ screen.queryByTestId(`${DEFAULT_PREFIX}-truncated`),
1270
+ ).not.toBeInTheDocument();
1271
+ });
1272
+
1273
+ test("an uncapped answer after a capped one drops the note", async () => {
1274
+ renderBox();
1275
+ fireEvent.focus(searchInput());
1276
+ await searchFor("un", THREE_ROWS, true);
1277
+ expect(
1278
+ screen.getByTestId(`${DEFAULT_PREFIX}-truncated`),
1279
+ ).toBeInTheDocument();
1280
+
1281
+ await searchFor("unit 1048", [UNIT_104822]);
1282
+ expect(
1283
+ screen.queryByTestId(`${DEFAULT_PREFIX}-truncated`),
1284
+ ).not.toBeInTheDocument();
1285
+ });
1286
+ });
1287
+
1288
+ /*
1289
+ * Clearing and shrinking the text. Below the threshold there is nothing to
1290
+ * show, and the sequence number is bumped so a request already in flight for
1291
+ * the longer text cannot arrive afterwards and repopulate a cleared list.
1292
+ */
1293
+ describe("clearing and shrinking the text", () => {
1294
+ test("the clear button appears only when there is text", () => {
1295
+ renderBox();
1296
+ expect(
1297
+ screen.queryByTestId(`${DEFAULT_PREFIX}-clear`),
1298
+ ).not.toBeInTheDocument();
1299
+
1300
+ typeText("k");
1301
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-clear`)).toBeInTheDocument();
1302
+ expect(
1303
+ screen.getByRole("button", { name: "Clear search" }),
1304
+ ).toBeInTheDocument();
1305
+ });
1306
+
1307
+ test("the clear button hands the page an empty string and closes the panel", async () => {
1308
+ renderBox();
1309
+ fireEvent.focus(searchInput());
1310
+ await searchFor("kansas", THREE_ROWS);
1311
+ onChangeMock.mockClear();
1312
+
1313
+ fireEvent.click(screen.getByTestId(`${DEFAULT_PREFIX}-clear`));
1314
+
1315
+ expect(onChangeMock).toHaveBeenCalledTimes(1);
1316
+ expect(onChangeMock).toHaveBeenCalledWith("");
1317
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
1318
+ expect(searchInput().value).toBe("");
1319
+ expect(panel()).not.toBeInTheDocument();
1320
+ expect(
1321
+ screen.queryByTestId(`${DEFAULT_PREFIX}-clear`),
1322
+ ).not.toBeInTheDocument();
1323
+ });
1324
+
1325
+ test("dropping below the threshold clears the rows, and a late answer does not bring them back", async () => {
1326
+ renderBox();
1327
+ fireEvent.focus(searchInput());
1328
+ typeText("ka");
1329
+ advance(SEARCH_DEBOUNCE_MS);
1330
+ expect(postMock).toHaveBeenCalledTimes(1);
1331
+
1332
+ typeText("k");
1333
+ await answer(0, searchBody(THREE_ROWS, true));
1334
+
1335
+ expect(panel()).not.toBeInTheDocument();
1336
+
1337
+ // Back above the threshold: a fresh search, not the late answer.
1338
+ typeText("ka");
1339
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1340
+ expect(
1341
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1342
+ ).toBeInTheDocument();
1343
+ expect(
1344
+ screen.queryByTestId(`${DEFAULT_PREFIX}-truncated`),
1345
+ ).not.toBeInTheDocument();
1346
+
1347
+ advance(SEARCH_DEBOUNCE_MS);
1348
+ expect(postMock).toHaveBeenCalledTimes(2);
1349
+ await answer(1, searchBody([KANSAS_CITY]));
1350
+ expect(screen.getAllByRole("option")).toHaveLength(1);
1351
+ });
1352
+
1353
+ test("rows already on screen are cleared, not kept, when the text drops below the threshold", async () => {
1354
+ renderBox();
1355
+ fireEvent.focus(searchInput());
1356
+ await searchFor("ka", THREE_ROWS);
1357
+
1358
+ typeText("k");
1359
+ typeText("ka");
1360
+
1361
+ // The same text as before, but the old answer was thrown away.
1362
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1363
+ expect(
1364
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1365
+ ).toBeInTheDocument();
1366
+ });
1367
+
1368
+ test("clearing while a request is in flight keeps its answer off the page", async () => {
1369
+ renderBox();
1370
+ fireEvent.focus(searchInput());
1371
+ typeText("kansas");
1372
+ advance(SEARCH_DEBOUNCE_MS);
1373
+
1374
+ fireEvent.click(screen.getByTestId(`${DEFAULT_PREFIX}-clear`));
1375
+ await answer(0, searchBody([KANSAS_CITY]));
1376
+
1377
+ typeText("ka");
1378
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1379
+ });
1380
+ });
1381
+
1382
+ /*
1383
+ * Focus. Closing on blur is exact because the panel swallows mousedown: the
1384
+ * only thing that blurs the input is the reader going somewhere else.
1385
+ */
1386
+ describe("focus", () => {
1387
+ test("blur closes the panel and focus re-opens it without searching again", async () => {
1388
+ renderBox();
1389
+ fireEvent.focus(searchInput());
1390
+ await searchFor("kansas", THREE_ROWS);
1391
+
1392
+ fireEvent.blur(searchInput());
1393
+ expect(panel()).not.toBeInTheDocument();
1394
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
1395
+
1396
+ fireEvent.focus(searchInput());
1397
+ expect(panel()).toBeInTheDocument();
1398
+ expect(screen.getAllByRole("option")).toHaveLength(3);
1399
+ expect(postMock).toHaveBeenCalledTimes(1);
1400
+ });
1401
+
1402
+ test("a closed panel names no active option, and Enter while blurred commits nothing", async () => {
1403
+ renderBox();
1404
+ fireEvent.focus(searchInput());
1405
+ await searchFor("kansas", THREE_ROWS);
1406
+ press("ArrowDown");
1407
+
1408
+ fireEvent.blur(searchInput());
1409
+
1410
+ expect(searchInput()).not.toHaveAttribute("aria-activedescendant");
1411
+ expect(press("Enter")).toBe(true);
1412
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
1413
+ });
1414
+
1415
+ test("focus alone below the threshold opens nothing", () => {
1416
+ renderBox({ initialValue: "k" });
1417
+ fireEvent.focus(searchInput());
1418
+
1419
+ expect(panel()).not.toBeInTheDocument();
1420
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
1421
+ });
1422
+ });
1423
+
1424
+ /*
1425
+ * The local half's feedback: how far this text narrowed the level in view.
1426
+ * The Network Map prints it under the box; Device Topology already prints
1427
+ * the same count beside its box, so it turns this one off rather than say
1428
+ * it twice.
1429
+ */
1430
+ describe("the local count", () => {
1431
+ function localCount(prefix: string = DEFAULT_PREFIX): HTMLElement | null {
1432
+ return screen.queryByTestId(`${prefix}-local-count`);
1433
+ }
1434
+
1435
+ test("shows by default when the text narrowed the level", () => {
1436
+ renderBox({
1437
+ initialValue: "ka",
1438
+ localMatchCount: 2,
1439
+ localTotalCount: 5,
1440
+ childTypeLabel: "Market",
1441
+ });
1442
+
1443
+ expect(localCount()).toHaveTextContent(
1444
+ "Showing 2 of 5 markets at this level",
1445
+ );
1446
+ });
1447
+
1448
+ test("stays up while the dropdown is closed — it describes the page, not the panel", () => {
1449
+ renderBox({
1450
+ initialValue: "k",
1451
+ localMatchCount: 1,
1452
+ localTotalCount: 4,
1453
+ childTypeLabel: "Region",
1454
+ });
1455
+
1456
+ expect(panel()).not.toBeInTheDocument();
1457
+ expect(localCount()).toHaveTextContent(
1458
+ "Showing 1 of 4 regions at this level",
1459
+ );
1460
+ });
1461
+
1462
+ test("is singular when the level holds exactly one site", () => {
1463
+ renderBox({
1464
+ initialValue: "zz",
1465
+ localMatchCount: 0,
1466
+ localTotalCount: 1,
1467
+ childTypeLabel: "Market",
1468
+ });
1469
+
1470
+ expect(localCount()).toHaveTextContent(
1471
+ "Showing 0 of 1 market at this level",
1472
+ );
1473
+ });
1474
+
1475
+ test.each([
1476
+ ["Facility", "facilities"],
1477
+ ["Branch", "branches"],
1478
+ ["Business", "businesses"],
1479
+ ["Units", "units"],
1480
+ ["Store", "stores"],
1481
+ ])(
1482
+ "pluralizes the customer's %s as %s",
1483
+ (label: string, plural: string): void => {
1484
+ renderBox({
1485
+ initialValue: "ka",
1486
+ localMatchCount: 3,
1487
+ localTotalCount: 7,
1488
+ childTypeLabel: label,
1489
+ });
1490
+
1491
+ expect(localCount()).toHaveTextContent(
1492
+ `Showing 3 of 7 ${plural} at this level`,
1493
+ );
1494
+ },
1495
+ );
1496
+
1497
+ test("is absent when nothing was filtered out", () => {
1498
+ renderBox({
1499
+ initialValue: "ka",
1500
+ localMatchCount: 5,
1501
+ localTotalCount: 5,
1502
+ });
1503
+
1504
+ expect(localCount()).not.toBeInTheDocument();
1505
+ });
1506
+
1507
+ test("is absent when the box is empty or only whitespace", () => {
1508
+ renderBox({ localMatchCount: 2, localTotalCount: 5 });
1509
+ expect(localCount()).not.toBeInTheDocument();
1510
+
1511
+ typeText(" ");
1512
+ expect(localCount()).not.toBeInTheDocument();
1513
+ });
1514
+
1515
+ test("appears and disappears as the text changes", () => {
1516
+ renderBox({ localMatchCount: 2, localTotalCount: 5 });
1517
+ typeText("k");
1518
+ expect(localCount()).toBeInTheDocument();
1519
+
1520
+ fireEvent.click(screen.getByTestId(`${DEFAULT_PREFIX}-clear`));
1521
+ expect(localCount()).not.toBeInTheDocument();
1522
+ });
1523
+
1524
+ test("never shows with showLocalCount={false}", () => {
1525
+ renderBox({
1526
+ initialValue: "ka",
1527
+ localMatchCount: 2,
1528
+ localTotalCount: 5,
1529
+ showLocalCount: false,
1530
+ dataTestId: TOPOLOGY_PREFIX,
1531
+ });
1532
+
1533
+ expect(localCount(TOPOLOGY_PREFIX)).not.toBeInTheDocument();
1534
+ expect(screen.queryByText(/Showing 2 of 5/)).not.toBeInTheDocument();
1535
+ });
1536
+
1537
+ test("shows with an explicit showLocalCount={true}", () => {
1538
+ renderBox({
1539
+ initialValue: "ka",
1540
+ localMatchCount: 2,
1541
+ localTotalCount: 5,
1542
+ showLocalCount: true,
1543
+ });
1544
+
1545
+ expect(localCount()).toBeInTheDocument();
1546
+ });
1547
+
1548
+ test("lives in a polite live region that is mounted even when it is empty", () => {
1549
+ const view: RenderResult = renderBox({
1550
+ localMatchCount: 2,
1551
+ localTotalCount: 5,
1552
+ });
1553
+ const region: Element | null = view.container.querySelector(
1554
+ '[aria-live="polite"]',
1555
+ );
1556
+
1557
+ expect(region).not.toBeNull();
1558
+ expect(region).toBeEmptyDOMElement();
1559
+
1560
+ typeText("ka");
1561
+ expect(region).toContainElement(localCount());
1562
+ });
1563
+ });
1564
+
1565
+ /*
1566
+ * Test ids and DOM ids. The Network Map's end-to-end tests and the explorer's
1567
+ * each find the box by a prefix of their own, so the default must stay the
1568
+ * map's and a custom prefix must rename EVERY id — one left behind would make
1569
+ * two boxes on one page collide, or a page's tests find the wrong one.
1570
+ */
1571
+ describe("test ids", () => {
1572
+ test("default to the Network Map's prefix", async () => {
1573
+ renderBox();
1574
+ expect(screen.getByTestId(DEFAULT_PREFIX)).toBe(searchInput());
1575
+
1576
+ fireEvent.focus(searchInput());
1577
+ typeText("kansas");
1578
+ expect(
1579
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1580
+ ).toBeInTheDocument();
1581
+ advance(SEARCH_DEBOUNCE_MS);
1582
+ await answer(0, searchBody([KANSAS_CITY], true));
1583
+
1584
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-clear`)).toBeInTheDocument();
1585
+ expect(screen.getByTestId(`${DEFAULT_PREFIX}-results`)).toBeInTheDocument();
1586
+ expect(
1587
+ screen.getByTestId(`${DEFAULT_PREFIX}-truncated`),
1588
+ ).toBeInTheDocument();
1589
+ expect(option("site-kc")).toHaveAttribute(
1590
+ "id",
1591
+ `${DEFAULT_PREFIX}-option-site-kc`,
1592
+ );
1593
+ expect(screen.getByRole("listbox")).toHaveAttribute(
1594
+ "id",
1595
+ `${DEFAULT_PREFIX}-listbox`,
1596
+ );
1597
+ });
1598
+
1599
+ test("an empty prefix falls back to the default", () => {
1600
+ renderBox({ dataTestId: "" });
1601
+ expect(screen.getByTestId(DEFAULT_PREFIX)).toBe(searchInput());
1602
+ });
1603
+
1604
+ test("a custom prefix renames the input, panel, rows, clear button and DOM ids", async () => {
1605
+ const view: RenderResult = renderBox({ dataTestId: TOPOLOGY_PREFIX });
1606
+ expect(screen.getByTestId(TOPOLOGY_PREFIX)).toBe(searchInput());
1607
+
1608
+ fireEvent.focus(searchInput());
1609
+ await searchFor("kansas", [KANSAS_CITY, UNIT_104822]);
1610
+
1611
+ expect(
1612
+ screen.getByTestId(`${TOPOLOGY_PREFIX}-results`),
1613
+ ).toBeInTheDocument();
1614
+ expect(screen.getByTestId(`${TOPOLOGY_PREFIX}-clear`)).toBeInTheDocument();
1615
+ expect(option("site-kc", TOPOLOGY_PREFIX)).toHaveAttribute(
1616
+ "id",
1617
+ `${TOPOLOGY_PREFIX}-option-site-kc`,
1618
+ );
1619
+ expect(option("site-unit-104822", TOPOLOGY_PREFIX)).toHaveAttribute(
1620
+ "id",
1621
+ `${TOPOLOGY_PREFIX}-option-site-unit-104822`,
1622
+ );
1623
+ expect(screen.getByRole("listbox")).toHaveAttribute(
1624
+ "id",
1625
+ `${TOPOLOGY_PREFIX}-listbox`,
1626
+ );
1627
+ expect(searchInput()).toHaveAttribute(
1628
+ "aria-controls",
1629
+ `${TOPOLOGY_PREFIX}-listbox`,
1630
+ );
1631
+
1632
+ press("ArrowDown");
1633
+ expect(searchInput()).toHaveAttribute(
1634
+ "aria-activedescendant",
1635
+ `${TOPOLOGY_PREFIX}-option-site-kc`,
1636
+ );
1637
+
1638
+ // Nothing is left under the Network Map's names.
1639
+ expect(
1640
+ view.container.querySelectorAll(`[data-testid^="${DEFAULT_PREFIX}"]`),
1641
+ ).toHaveLength(0);
1642
+ expect(
1643
+ view.container.querySelectorAll(`[id^="${DEFAULT_PREFIX}"]`),
1644
+ ).toHaveLength(0);
1645
+ });
1646
+
1647
+ test("a custom prefix renames the status lines too", async () => {
1648
+ const view: RenderResult = renderBox({
1649
+ dataTestId: TOPOLOGY_PREFIX,
1650
+ initialValue: "k",
1651
+ localMatchCount: 1,
1652
+ localTotalCount: 3,
1653
+ });
1654
+ expect(
1655
+ screen.getByTestId(`${TOPOLOGY_PREFIX}-local-count`),
1656
+ ).toBeInTheDocument();
1657
+
1658
+ fireEvent.focus(searchInput());
1659
+ typeText("zz");
1660
+ expect(
1661
+ screen.getByTestId(`${TOPOLOGY_PREFIX}-searching`),
1662
+ ).toBeInTheDocument();
1663
+ advance(SEARCH_DEBOUNCE_MS);
1664
+ await answer(0, searchBody([]));
1665
+ expect(
1666
+ screen.getByTestId(`${TOPOLOGY_PREFIX}-no-results`),
1667
+ ).toBeInTheDocument();
1668
+
1669
+ typeText("zzz");
1670
+ advance(SEARCH_DEBOUNCE_MS);
1671
+ await answer(1, searchBody([KANSAS_CITY], true));
1672
+ expect(
1673
+ screen.getByTestId(`${TOPOLOGY_PREFIX}-truncated`),
1674
+ ).toBeInTheDocument();
1675
+
1676
+ typeText("zzzz");
1677
+ advance(SEARCH_DEBOUNCE_MS);
1678
+ await rejectWith(2, new Error("Network request failed"));
1679
+ expect(screen.getByTestId(`${TOPOLOGY_PREFIX}-error`)).toBeInTheDocument();
1680
+
1681
+ expect(
1682
+ view.container.querySelectorAll(`[data-testid^="${DEFAULT_PREFIX}"]`),
1683
+ ).toHaveLength(0);
1684
+ });
1685
+
1686
+ test("two boxes with different prefixes on one page keep separate listboxes", async () => {
1687
+ render(
1688
+ <>
1689
+ <Harness />
1690
+ <Harness dataTestId={TOPOLOGY_PREFIX} />
1691
+ </>,
1692
+ );
1693
+ const mapBox: HTMLElement = screen.getByTestId(DEFAULT_PREFIX);
1694
+ const topologyBox: HTMLElement = screen.getByTestId(TOPOLOGY_PREFIX);
1695
+
1696
+ fireEvent.focus(topologyBox);
1697
+ fireEvent.change(topologyBox, { target: { value: "kansas" } });
1698
+ advance(SEARCH_DEBOUNCE_MS);
1699
+ await answer(0, searchBody([KANSAS_CITY]));
1700
+
1701
+ expect(topologyBox).toHaveAttribute(
1702
+ "aria-controls",
1703
+ `${TOPOLOGY_PREFIX}-listbox`,
1704
+ );
1705
+ expect(mapBox).toHaveAttribute("aria-expanded", "false");
1706
+ expect(
1707
+ document.getElementById(`${TOPOLOGY_PREFIX}-option-site-kc`),
1708
+ ).toBeInTheDocument();
1709
+ expect(
1710
+ document.getElementById(`${DEFAULT_PREFIX}-option-site-kc`),
1711
+ ).not.toBeInTheDocument();
1712
+ });
1713
+ });
1714
+
1715
+ /*
1716
+ * Combobox ARIA. The dropdown is a real listbox driven from a text box, so a
1717
+ * screen reader has to be told that it exists, whether it is open, which
1718
+ * element it is, and which of its options the arrow keys are on.
1719
+ */
1720
+ describe("combobox semantics", () => {
1721
+ test("the input is a list-autocompleting combobox that pops up a listbox", () => {
1722
+ renderBox();
1723
+ const input: HTMLInputElement = searchInput();
1724
+
1725
+ expect(input).toHaveAttribute("role", "combobox");
1726
+ expect(input).toHaveAttribute("aria-autocomplete", "list");
1727
+ expect(input).toHaveAttribute("aria-haspopup", "listbox");
1728
+ expect(input).toHaveAttribute("autocomplete", "off");
1729
+ expect(input).toHaveAttribute("spellcheck", "false");
1730
+ });
1731
+
1732
+ test("aria-expanded and aria-controls follow the panel", async () => {
1733
+ renderBox();
1734
+ const input: HTMLInputElement = searchInput();
1735
+ expect(input).toHaveAttribute("aria-expanded", "false");
1736
+ expect(input).not.toHaveAttribute("aria-controls");
1737
+ expect(input).not.toHaveAttribute("aria-activedescendant");
1738
+
1739
+ fireEvent.focus(input);
1740
+ typeText("ka");
1741
+ expect(input).toHaveAttribute("aria-expanded", "true");
1742
+ expect(input).toHaveAttribute("aria-controls", `${DEFAULT_PREFIX}-listbox`);
1743
+
1744
+ advance(SEARCH_DEBOUNCE_MS);
1745
+ await answer(0, searchBody(THREE_ROWS));
1746
+
1747
+ const listbox: HTMLElement | null = document.getElementById(
1748
+ input.getAttribute("aria-controls") || "",
1749
+ );
1750
+ expect(listbox).toHaveAttribute("role", "listbox");
1751
+ expect(listbox).toHaveAttribute("aria-label", "Site search results");
1752
+ expect(within(listbox!).getAllByRole("option")).toHaveLength(3);
1753
+
1754
+ typeText("k");
1755
+ expect(input).toHaveAttribute("aria-expanded", "false");
1756
+ expect(input).not.toHaveAttribute("aria-controls");
1757
+ });
1758
+
1759
+ test("the listbox is mounted, empty, while the first answer is pending", () => {
1760
+ renderBox();
1761
+ fireEvent.focus(searchInput());
1762
+ typeText("ka");
1763
+
1764
+ const listbox: HTMLElement = screen.getByRole("listbox");
1765
+ expect(listbox.id).toBe(searchInput().getAttribute("aria-controls"));
1766
+ expect(within(listbox).queryAllByRole("option")).toHaveLength(0);
1767
+ });
1768
+
1769
+ test("the default placeholder doubles as the accessible name", () => {
1770
+ renderBox();
1771
+ const input: HTMLInputElement = searchInput();
1772
+
1773
+ expect(input).toHaveAttribute("placeholder", DEFAULT_PLACEHOLDER);
1774
+ expect(input).toHaveAttribute("aria-label", DEFAULT_PLACEHOLDER);
1775
+ expect(screen.getByRole("combobox", { name: DEFAULT_PLACEHOLDER })).toBe(
1776
+ input,
1777
+ );
1778
+ });
1779
+
1780
+ test("a custom placeholder is used as both placeholder and accessible name", () => {
1781
+ renderBox({ placeholder: "Find a store, market or region" });
1782
+ const input: HTMLInputElement = searchInput();
1783
+
1784
+ expect(input).toHaveAttribute(
1785
+ "placeholder",
1786
+ "Find a store, market or region",
1787
+ );
1788
+ expect(input).toHaveAttribute(
1789
+ "aria-label",
1790
+ "Find a store, market or region",
1791
+ );
1792
+ expect(
1793
+ screen.queryByRole("combobox", { name: DEFAULT_PLACEHOLDER }),
1794
+ ).not.toBeInTheDocument();
1795
+ });
1796
+
1797
+ test("an empty placeholder falls back to the default", () => {
1798
+ renderBox({ placeholder: "" });
1799
+ expect(searchInput()).toHaveAttribute("placeholder", DEFAULT_PLACEHOLDER);
1800
+ expect(searchInput()).toHaveAttribute("aria-label", DEFAULT_PLACEHOLDER);
1801
+ });
1802
+ });
1803
+
1804
+ /*
1805
+ * Regressions for what review of the #3981 change turned up. Each one is a
1806
+ * way the box could commit, show or hide the wrong thing while the reader is
1807
+ * doing something ordinary: tabbing past the results, clicking back into the
1808
+ * box, typing faster than the server answers, or arrowing past the fifth row.
1809
+ */
1810
+ describe("review regressions", () => {
1811
+ /*
1812
+ * The Clear button sits inside the box's wrapper. Keys pressed on it used
1813
+ * to bubble into the combobox handler: an arrow reopened the panel while
1814
+ * focus was on the button (where no blur could ever close it again), and
1815
+ * Enter then drilled to a site instead of clearing the text.
1816
+ */
1817
+ test("arrow keys on the Clear button do not reopen the panel", async () => {
1818
+ renderBox();
1819
+ fireEvent.focus(searchInput());
1820
+ await searchFor("kansas", THREE_ROWS);
1821
+ fireEvent.blur(searchInput());
1822
+ expect(panel()).not.toBeInTheDocument();
1823
+
1824
+ const clear: HTMLElement = screen.getByTestId(`${DEFAULT_PREFIX}-clear`);
1825
+ expect(fireEvent.keyDown(clear, { key: "ArrowDown" })).toBe(true);
1826
+ expect(fireEvent.keyDown(clear, { key: "ArrowUp" })).toBe(true);
1827
+
1828
+ expect(panel()).not.toBeInTheDocument();
1829
+ expect(selectedOptionIds()).toEqual([]);
1830
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
1831
+ });
1832
+
1833
+ test("Enter on the Clear button never drills, even after an arrow key", async () => {
1834
+ renderBox();
1835
+ fireEvent.focus(searchInput());
1836
+ await searchFor("kansas", THREE_ROWS);
1837
+ fireEvent.blur(searchInput());
1838
+
1839
+ const clear: HTMLElement = screen.getByTestId(`${DEFAULT_PREFIX}-clear`);
1840
+ fireEvent.keyDown(clear, { key: "ArrowDown" });
1841
+
1842
+ // Not default-prevented, so the button's own click still happens.
1843
+ expect(fireEvent.keyDown(clear, { key: "Enter" })).toBe(true);
1844
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
1845
+ });
1846
+
1847
+ /*
1848
+ * Escape closes the panel but leaves focus in the box, and clicking a box
1849
+ * that already has focus fires no focus event. The explorer's empty state
1850
+ * tells the reader to click the box to see matches from elsewhere, so a
1851
+ * click has to reopen it by itself.
1852
+ */
1853
+ test("clicking the box after Escape reopens the results without searching again", async () => {
1854
+ renderBox();
1855
+ fireEvent.focus(searchInput());
1856
+ await searchFor("kansas", THREE_ROWS);
1857
+ press("Escape");
1858
+ expect(panel()).not.toBeInTheDocument();
1859
+
1860
+ fireEvent.click(searchInput());
1861
+
1862
+ expect(panel()).toBeInTheDocument();
1863
+ expect(searchInput()).toHaveAttribute("aria-expanded", "true");
1864
+ expect(screen.getAllByRole("option")).toHaveLength(THREE_ROWS.length);
1865
+ expect(postMock).toHaveBeenCalledTimes(1);
1866
+ });
1867
+
1868
+ test("clicking the box below the threshold still opens nothing", () => {
1869
+ renderBox();
1870
+ typeText("k");
1871
+
1872
+ fireEvent.click(searchInput());
1873
+
1874
+ expect(panel()).not.toBeInTheDocument();
1875
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
1876
+ });
1877
+
1878
+ /*
1879
+ * A highlight is an index into the list it was made in. While the next
1880
+ * text's request is out, the previous list stays on screen and the arrows
1881
+ * move through it; when the new list lands, the same index names another
1882
+ * site. Enter must not commit a row the reader never moved onto.
1883
+ */
1884
+ test("a fresh answer drops a highlight made in the previous list", async () => {
1885
+ renderBox();
1886
+ fireEvent.focus(searchInput());
1887
+ await searchFor("ka", THREE_ROWS);
1888
+
1889
+ typeText("kan");
1890
+ press("ArrowDown");
1891
+ press("ArrowDown");
1892
+ expect(selectedOptionIds()).toEqual([
1893
+ `${DEFAULT_PREFIX}-option-site-unit-104822`,
1894
+ ]);
1895
+
1896
+ advance(SEARCH_DEBOUNCE_MS);
1897
+ await answer(1, searchBody([CENTRAL, KANSAS_CITY]));
1898
+
1899
+ expect(selectedOptionIds()).toEqual([]);
1900
+ expect(searchInput()).not.toHaveAttribute("aria-activedescendant");
1901
+ expect(press("Enter")).toBe(true);
1902
+ expect(onSelectSiteMock).not.toHaveBeenCalled();
1903
+ });
1904
+
1905
+ test("after a fresh answer, the next arrow key starts from the top of the new list", async () => {
1906
+ renderBox();
1907
+ fireEvent.focus(searchInput());
1908
+ await searchFor("ka", THREE_ROWS);
1909
+ typeText("kan");
1910
+ press("ArrowDown");
1911
+ advance(SEARCH_DEBOUNCE_MS);
1912
+ await answer(1, searchBody([CENTRAL, KANSAS_CITY]));
1913
+
1914
+ press("ArrowDown");
1915
+ press("Enter");
1916
+
1917
+ expect(onSelectSiteMock).toHaveBeenCalledWith("site-central");
1918
+ });
1919
+
1920
+ /*
1921
+ * Cancel-stale used to bump its sequence only when the debounced request
1922
+ * fired, so for up to 250 ms after a keystroke the PREVIOUS text's
1923
+ * response still counted as current. Landing in that window, it painted
1924
+ * its rows and switched "Searching…" off for the whole next round trip.
1925
+ */
1926
+ test("an answer for the previous text that lands during the debounce does not paint", async () => {
1927
+ renderBox();
1928
+ fireEvent.focus(searchInput());
1929
+ typeText("ka");
1930
+ advance(SEARCH_DEBOUNCE_MS);
1931
+ expect(pendingSearches).toHaveLength(1);
1932
+
1933
+ typeText("kan");
1934
+ await answer(0, searchBody([UNIT_104822]));
1935
+
1936
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
1937
+ expect(
1938
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1939
+ ).toBeInTheDocument();
1940
+
1941
+ advance(SEARCH_DEBOUNCE_MS);
1942
+ expect(pendingSearches).toHaveLength(2);
1943
+ expect(
1944
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1945
+ ).toBeInTheDocument();
1946
+
1947
+ await answer(1, searchBody([KANSAS_CITY]));
1948
+ expect(screen.getAllByRole("option")).toHaveLength(1);
1949
+ expect(option("site-kc")).toBeInTheDocument();
1950
+ expect(
1951
+ screen.queryByTestId(`${DEFAULT_PREFIX}-searching`),
1952
+ ).not.toBeInTheDocument();
1953
+ });
1954
+
1955
+ test("an empty answer for the previous text does not claim the current text has no matches", async () => {
1956
+ renderBox();
1957
+ fireEvent.focus(searchInput());
1958
+ typeText("ka");
1959
+ advance(SEARCH_DEBOUNCE_MS);
1960
+
1961
+ typeText("kan");
1962
+ await answer(0, searchBody([]));
1963
+
1964
+ expect(
1965
+ screen.queryByTestId(`${DEFAULT_PREFIX}-no-results`),
1966
+ ).not.toBeInTheDocument();
1967
+ expect(
1968
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1969
+ ).toBeInTheDocument();
1970
+ });
1971
+
1972
+ test("a failure for the previous text that lands during the debounce shows no error", async () => {
1973
+ renderBox();
1974
+ fireEvent.focus(searchInput());
1975
+ typeText("ka");
1976
+ advance(SEARCH_DEBOUNCE_MS);
1977
+
1978
+ typeText("kan");
1979
+ await rejectWith(0, new Error("Gateway timed out"));
1980
+
1981
+ expect(
1982
+ screen.queryByTestId(`${DEFAULT_PREFIX}-error`),
1983
+ ).not.toBeInTheDocument();
1984
+ expect(
1985
+ screen.getByTestId(`${DEFAULT_PREFIX}-searching`),
1986
+ ).toBeInTheDocument();
1987
+ });
1988
+
1989
+ /*
1990
+ * The panel shows about five rows of up to fifty and focus never leaves
1991
+ * the input, so the browser will not scroll the highlight into view on its
1992
+ * own. jsdom has no scrollIntoView, so the test installs a recorder.
1993
+ */
1994
+ describe("keeping the highlight in view", () => {
1995
+ interface ScrollCall {
1996
+ element: HTMLElement;
1997
+ options: unknown;
1998
+ }
1999
+
2000
+ let scrollCalls: Array<ScrollCall> = [];
2001
+ let hadOwnScrollIntoView: boolean = false;
2002
+ let originalScrollIntoView: unknown = undefined;
2003
+
2004
+ beforeEach(() => {
2005
+ scrollCalls = [];
2006
+ hadOwnScrollIntoView = Object.prototype.hasOwnProperty.call(
2007
+ HTMLElement.prototype,
2008
+ "scrollIntoView",
2009
+ );
2010
+ originalScrollIntoView = (
2011
+ HTMLElement.prototype as unknown as Dictionary<unknown>
2012
+ )["scrollIntoView"];
2013
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
2014
+ configurable: true,
2015
+ writable: true,
2016
+ value: function (this: HTMLElement, options: unknown): void {
2017
+ scrollCalls.push({ element: this, options: options });
2018
+ },
2019
+ });
2020
+ });
2021
+
2022
+ afterEach(() => {
2023
+ if (hadOwnScrollIntoView) {
2024
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
2025
+ configurable: true,
2026
+ writable: true,
2027
+ value: originalScrollIntoView,
2028
+ });
2029
+ } else {
2030
+ delete (HTMLElement.prototype as unknown as Dictionary<unknown>)[
2031
+ "scrollIntoView"
2032
+ ];
2033
+ }
2034
+ });
2035
+
2036
+ test("ArrowUp from nothing scrolls the last row into view", async () => {
2037
+ renderBox();
2038
+ fireEvent.focus(searchInput());
2039
+ await searchFor("ce", THREE_ROWS);
2040
+
2041
+ press("ArrowUp");
2042
+
2043
+ expect(scrollCalls).toHaveLength(1);
2044
+ expect(scrollCalls[0]?.element).toBe(option("site-central"));
2045
+ expect(scrollCalls[0]?.options).toEqual({ block: "nearest" });
2046
+ });
2047
+
2048
+ test("every arrow move scrolls the row it lands on", async () => {
2049
+ renderBox();
2050
+ fireEvent.focus(searchInput());
2051
+ await searchFor("ce", THREE_ROWS);
2052
+
2053
+ press("ArrowDown");
2054
+ press("ArrowDown");
2055
+
2056
+ expect(
2057
+ scrollCalls.map((call: ScrollCall): HTMLElement => {
2058
+ return call.element;
2059
+ }),
2060
+ ).toEqual([option("site-kc"), option("site-unit-104822")]);
2061
+ });
2062
+
2063
+ test("hovering a row highlights it without scrolling the list under the pointer", async () => {
2064
+ renderBox();
2065
+ fireEvent.focus(searchInput());
2066
+ await searchFor("ce", THREE_ROWS);
2067
+
2068
+ fireEvent.mouseEnter(option("site-central"));
2069
+
2070
+ expect(selectedOptionIds()).toEqual([
2071
+ `${DEFAULT_PREFIX}-option-site-central`,
2072
+ ]);
2073
+ expect(scrollCalls).toHaveLength(0);
2074
+ });
2075
+
2076
+ test("a missing scrollIntoView (older engines, jsdom) is not an error", async () => {
2077
+ delete (HTMLElement.prototype as unknown as Dictionary<unknown>)[
2078
+ "scrollIntoView"
2079
+ ];
2080
+ renderBox();
2081
+ fireEvent.focus(searchInput());
2082
+ await searchFor("ce", THREE_ROWS);
2083
+
2084
+ expect(() => {
2085
+ press("ArrowDown");
2086
+ }).not.toThrow();
2087
+ expect(selectedOptionIds()).toEqual([`${DEFAULT_PREFIX}-option-site-kc`]);
2088
+ });
2089
+ });
2090
+
2091
+ /*
2092
+ * The Clear button only exists while there is text, so clearing unmounts
2093
+ * it under the keyboard focus it holds. Focus goes back to the box rather
2094
+ * than falling to the page body.
2095
+ */
2096
+ test("Clear hands keyboard focus back to the box", () => {
2097
+ renderBox();
2098
+ searchInput().focus();
2099
+ typeText("kansas");
2100
+ const clear: HTMLElement = screen.getByTestId(`${DEFAULT_PREFIX}-clear`);
2101
+ clear.focus();
2102
+ expect(document.activeElement).toBe(clear);
2103
+
2104
+ fireEvent.click(clear);
2105
+
2106
+ expect(document.activeElement).toBe(searchInput());
2107
+ expect(searchInput().value).toBe("");
2108
+ expect(panel()).not.toBeInTheDocument();
2109
+ });
2110
+ });