|
5 | 5 | NetworkAccessibleDataTable, |
6 | 6 | computeCanvasAriaLabel, |
7 | 7 | computeNetworkAriaLabel, |
| 8 | + extractAllRows, |
8 | 9 | } from "./AccessibleDataTable" |
9 | 10 | import type { Datum } from "../charts/shared/datumTypes" |
10 | 11 |
|
@@ -168,154 +169,164 @@ describe("AccessibleDataTable styling hooks", () => { |
168 | 169 | }) |
169 | 170 | }) |
170 | 171 |
|
171 | | -// ── extractAllRows logic (tested via internal behavior) ───────────────── |
172 | | - |
173 | | -// Since extractAllRows is not exported, we test it indirectly by exercising |
174 | | -// the same data shapes that would flow through AccessibleDataTable. |
175 | | -// We test the extraction patterns inline here. |
176 | | - |
177 | | -describe("extractAllRows — data shape resilience", () => { |
178 | | - // We recreate the extraction logic to test it in isolation |
179 | | - function extractRow(node: any): any | null { |
180 | | - switch (node.type) { |
181 | | - case "point": |
182 | | - return { label: "Point", values: { x: node.x, y: node.y } } |
183 | | - case "line": { |
184 | | - const path = node.path |
185 | | - const data = Array.isArray(node.datum) ? node.datum : [] |
186 | | - if (!path) return null |
187 | | - const rows = [] |
188 | | - for (let i = 0; i < path.length && i < data.length; i++) { |
189 | | - rows.push({ label: "Line point", values: { x: path[i][0], y: path[i][1] } }) |
190 | | - } |
191 | | - return rows |
192 | | - } |
193 | | - case "rect": { |
194 | | - const datum = node.datum ?? {} |
195 | | - const category = datum.category ?? node.group ?? "" |
196 | | - const rawValue = datum.value ?? datum.__aggregateValue ?? datum.total |
197 | | - return { label: "Bar", values: { category, value: rawValue ?? "" } } |
198 | | - } |
199 | | - case "wedge": |
200 | | - return { |
201 | | - label: "Wedge", |
202 | | - values: { |
203 | | - category: node.datum?.category ?? node.datum?.label ?? "", |
204 | | - value: node.datum?.value ?? "", |
205 | | - }, |
206 | | - } |
207 | | - case "circle": |
208 | | - return { |
209 | | - label: "Node", |
210 | | - values: { id: node.datum?.id || "", x: node.cx ?? node.x, y: node.cy ?? node.y }, |
211 | | - } |
212 | | - default: |
213 | | - return null |
214 | | - } |
215 | | - } |
216 | | - |
217 | | - it("handles point with undefined x/y", () => { |
218 | | - const row = extractRow({ type: "point" }) |
219 | | - expect(row).toEqual({ label: "Point", values: { x: undefined, y: undefined } }) |
220 | | - }) |
| 172 | +// ── interaction fixes (issue #971) ────────────────────────────────────── |
221 | 173 |
|
222 | | - it("handles point with NaN values", () => { |
223 | | - const row = extractRow({ type: "point", x: NaN, y: NaN }) |
224 | | - expect(row).toEqual({ label: "Point", values: { x: NaN, y: NaN } }) |
225 | | - }) |
226 | | - |
227 | | - it("handles point with Infinity", () => { |
228 | | - const row = extractRow({ type: "point", x: Infinity, y: -Infinity }) |
229 | | - expect(row!.values.x).toBe(Infinity) |
230 | | - expect(row!.values.y).toBe(-Infinity) |
231 | | - }) |
| 174 | +describe("AccessibleDataTable — focus & pagination", () => { |
| 175 | + const lineScene = (n: number) => [ |
| 176 | + { |
| 177 | + type: "line", |
| 178 | + path: Array.from({ length: n }, (_, i) => [i, i]), |
| 179 | + datum: Array.from({ length: n }, (_, i) => ({ month: i + 1, sales: 100 * (i + 1) })), |
| 180 | + }, |
| 181 | + ] |
232 | 182 |
|
233 | | - it("handles point with string coordinates (mistyped data)", () => { |
234 | | - const row = extractRow({ type: "point", x: "hello", y: "world" }) |
235 | | - expect(row!.values.x).toBe("hello") |
236 | | - expect(row!.values.y).toBe("world") |
| 183 | + it("does NOT auto-expand when focus bubbles up from the trigger button", () => { |
| 184 | + render(<AccessibleDataTable tableId="t1" chartType="line chart" scene={lineScene(3)} />) |
| 185 | + // Focusing the inner button (target !== region container) must not expand. |
| 186 | + fireEvent.focus(screen.getByRole("button", { name: /view data summary/i })) |
| 187 | + expect(screen.queryByRole("table")).toBeNull() |
237 | 188 | }) |
238 | 189 |
|
239 | | - it("handles line with null path", () => { |
240 | | - const row = extractRow({ type: "line", path: null, datum: [] }) |
241 | | - expect(row).toBeNull() |
| 190 | + it("auto-expands when the region container itself receives focus (skip-link path)", () => { |
| 191 | + render(<AccessibleDataTable tableId="t2" chartType="line chart" scene={lineScene(3)} />) |
| 192 | + const region = screen.getByRole("region", { name: /data summary for line chart/i }) |
| 193 | + fireEvent.focus(region) // target === currentTarget |
| 194 | + expect(screen.getByRole("table")).toBeInTheDocument() |
242 | 195 | }) |
243 | 196 |
|
244 | | - it("handles line with undefined path", () => { |
245 | | - const row = extractRow({ type: "line", datum: [] }) |
246 | | - expect(row).toBeNull() |
247 | | - }) |
| 197 | + it("pages through rows beyond the initial sample via Show more", () => { |
| 198 | + render(<AccessibleDataTable tableId="t3" chartType="line chart" scene={lineScene(40)} />) |
| 199 | + fireEvent.click(screen.getByRole("button", { name: /view data summary/i })) |
248 | 200 |
|
249 | | - it("handles line with empty path/datum arrays", () => { |
250 | | - const rows = extractRow({ type: "line", path: [], datum: [] }) |
251 | | - expect(rows).toEqual([]) |
252 | | - }) |
| 201 | + // Initial sample is 5 rows (+1 header row). |
| 202 | + expect(screen.getAllByRole("row")).toHaveLength(5 + 1) |
| 203 | + expect(screen.getByText(/first 5 of 40 data points/i)).toBeInTheDocument() |
253 | 204 |
|
254 | | - it("handles line where datum is not an array", () => { |
255 | | - const rows = extractRow({ type: "line", path: [[0, 0], [1, 1]], datum: "not-an-array" }) |
256 | | - expect(rows).toEqual([]) // data becomes [], so loop never runs |
257 | | - }) |
| 205 | + fireEvent.click(screen.getByRole("button", { name: /show .* more rows/i })) |
| 206 | + expect(screen.getAllByRole("row")).toHaveLength(30 + 1) // 5 + 25 page |
258 | 207 |
|
259 | | - it("handles line with mismatched path/datum lengths", () => { |
260 | | - const rows = extractRow({ |
261 | | - type: "line", |
262 | | - path: [[0, 0], [1, 1], [2, 2], [3, 3]], |
263 | | - datum: [{ x: 0 }, { x: 1 }], // only 2 datums for 4 path points |
264 | | - }) |
265 | | - expect(rows).toHaveLength(2) // min(4, 2) |
266 | | - }) |
267 | | - |
268 | | - it("handles rect with no datum at all", () => { |
269 | | - const row = extractRow({ type: "rect" }) |
270 | | - expect(row).toEqual({ label: "Bar", values: { category: "", value: "" } }) |
| 208 | + fireEvent.click(screen.getByRole("button", { name: /show .* more rows/i })) |
| 209 | + expect(screen.getAllByRole("row")).toHaveLength(40 + 1) // capped at total |
| 210 | + expect(screen.getByText(/all 40 data points/i)).toBeInTheDocument() |
| 211 | + // No further "Show more" once everything is shown. |
| 212 | + expect(screen.queryByRole("button", { name: /show .* more/i })).toBeNull() |
271 | 213 | }) |
| 214 | +}) |
272 | 215 |
|
273 | | - it("handles rect where datum is a primitive", () => { |
274 | | - const row = extractRow({ type: "rect", datum: 42 }) |
275 | | - // datum ?? {} → 42, then 42.category → undefined, node.group → undefined |
276 | | - expect(row!.label).toBe("Bar") |
| 216 | +// ── extractAllRows logic ──────────────────────────────────────────────── |
| 217 | + |
| 218 | +describe("extractAllRows — surfaces raw data, not pixels", () => { |
| 219 | + it("emits a scatter point's raw datum fields, not pixel x/y", () => { |
| 220 | + // The scene node carries pixel x/y for rendering; the table must show data. |
| 221 | + const rows = extractAllRows([ |
| 222 | + { type: "point", x: 412, y: 88, datum: { month: 1, sales: 4200 } }, |
| 223 | + ]) |
| 224 | + expect(rows).toEqual([{ label: "Point", values: { month: 1, sales: 4200 } }]) |
| 225 | + }) |
| 226 | + |
| 227 | + it("emits each line vertex from the datum array (data, not path pixels)", () => { |
| 228 | + const rows = extractAllRows([ |
| 229 | + { |
| 230 | + type: "line", |
| 231 | + path: [[0, 0], [50, 50]], // pixel path — must be ignored |
| 232 | + datum: [{ month: 1, sales: 4200 }, { month: 2, sales: 5100 }], |
| 233 | + }, |
| 234 | + ]) |
| 235 | + expect(rows).toEqual([ |
| 236 | + { label: "Line point", values: { month: 1, sales: 4200 } }, |
| 237 | + { label: "Line point", values: { month: 2, sales: 5100 } }, |
| 238 | + ]) |
| 239 | + }) |
| 240 | + |
| 241 | + it("emits area vertices from the datum array", () => { |
| 242 | + const rows = extractAllRows([ |
| 243 | + { type: "area", topPath: [[0, 0]], datum: [{ x: 1, y: 2 }] }, |
| 244 | + ]) |
| 245 | + expect(rows).toEqual([{ label: "Area point", values: { x: 1, y: 2 } }]) |
| 246 | + }) |
| 247 | + |
| 248 | + it("skips redundant point nodes when a series node carries the same data", () => { |
| 249 | + // showPoints=true emits both a line node and per-point nodes; the points |
| 250 | + // are decorative duplicates and must not double-count. |
| 251 | + const rows = extractAllRows([ |
| 252 | + { type: "line", path: [[0, 0]], datum: [{ month: 1, sales: 4200 }] }, |
| 253 | + { type: "point", x: 412, y: 88, datum: { month: 1, sales: 4200 } }, |
| 254 | + ]) |
| 255 | + expect(rows).toEqual([{ label: "Line point", values: { month: 1, sales: 4200 } }]) |
| 256 | + }) |
| 257 | + |
| 258 | + it("emits a candlestick's raw OHLC datum, not undefined node fields", () => { |
| 259 | + // The node only carries openY/closeY pixels — node.open etc. don't exist. |
| 260 | + const rows = extractAllRows([ |
| 261 | + { |
| 262 | + type: "candlestick", |
| 263 | + x: 100, openY: 50, closeY: 20, highY: 10, lowY: 60, |
| 264 | + datum: { date: "2024-01-01", open: 10, high: 15, low: 8, close: 12 }, |
| 265 | + }, |
| 266 | + ]) |
| 267 | + expect(rows[0].values).toEqual({ date: "2024-01-01", open: 10, high: 15, low: 8, close: 12 }) |
| 268 | + }) |
| 269 | + |
| 270 | + it("falls back to the rendered cell value when a heatcell datum omits it", () => { |
| 271 | + const rows = extractAllRows([ |
| 272 | + { type: "heatcell", x: 5, y: 9, value: 42, datum: { row: "A", col: "B" } }, |
| 273 | + ]) |
| 274 | + expect(rows[0].values).toEqual({ row: "A", col: "B", value: 42 }) |
| 275 | + }) |
| 276 | + |
| 277 | + it("skips synthetic underscore-prefixed keys", () => { |
| 278 | + const rows = extractAllRows([ |
| 279 | + { type: "point", x: 1, y: 2, datum: { month: 1, _transitionKey: "k", _decayOpacity: 0.5 } }, |
| 280 | + ]) |
| 281 | + expect(rows[0].values).toEqual({ month: 1 }) |
| 282 | + }) |
| 283 | + |
| 284 | + it("preserves a falsy-but-valid 0 value", () => { |
| 285 | + const rows = extractAllRows([ |
| 286 | + { type: "point", x: 1, y: 2, datum: { month: 0, sales: 4200 } }, |
| 287 | + ]) |
| 288 | + expect(rows[0].values).toEqual({ month: 0, sales: 4200 }) |
277 | 289 | }) |
| 290 | +}) |
278 | 291 |
|
279 | | - it("handles rect with __aggregateValue fallback", () => { |
280 | | - const row = extractRow({ type: "rect", datum: { __aggregateValue: 99 } }) |
281 | | - expect(row!.values.value).toBe(99) |
| 292 | +describe("extractAllRows — data shape resilience (never throws)", () => { |
| 293 | + it("returns [] for a non-array scene", () => { |
| 294 | + expect(extractAllRows(null as any)).toEqual([]) |
| 295 | + expect(extractAllRows("nope" as any)).toEqual([]) |
282 | 296 | }) |
283 | 297 |
|
284 | | - it("handles rect with total fallback", () => { |
285 | | - const row = extractRow({ type: "rect", datum: { total: 50 } }) |
286 | | - expect(row!.values.value).toBe(50) |
| 298 | + it("skips nodes with null datum", () => { |
| 299 | + expect(extractAllRows([{ type: "point", datum: null }])).toEqual([]) |
287 | 300 | }) |
288 | 301 |
|
289 | | - it("handles wedge with no datum", () => { |
290 | | - const row = extractRow({ type: "wedge" }) |
291 | | - expect(row).toEqual({ label: "Wedge", values: { category: "", value: "" } }) |
| 302 | + it("handles a point with no datum (empty values, no throw)", () => { |
| 303 | + const rows = extractAllRows([{ type: "point", x: NaN, y: NaN }]) |
| 304 | + expect(rows).toEqual([{ label: "Point", values: {} }]) |
292 | 305 | }) |
293 | 306 |
|
294 | | - it("handles wedge where datum.category is 0 (falsy but valid)", () => { |
295 | | - const row = extractRow({ type: "wedge", datum: { category: 0, value: 100 } }) |
296 | | - // ?? preserves 0 (unlike ||) |
297 | | - expect(row!.values.category).toBe(0) |
298 | | - expect(row!.values.value).toBe(100) |
| 307 | + it("handles a line whose datum is not an array", () => { |
| 308 | + expect(extractAllRows([{ type: "line", path: [[0, 0]], datum: "not-an-array" }])).toEqual([]) |
299 | 309 | }) |
300 | 310 |
|
301 | | - it("handles circle with cx/cy", () => { |
302 | | - const row = extractRow({ type: "circle", cx: 10, cy: 20, datum: { id: "a" } }) |
303 | | - expect(row!.values).toEqual({ id: "a", x: 10, y: 20 }) |
| 311 | + it("drops non-finite and non-primitive datum fields", () => { |
| 312 | + const rows = extractAllRows([ |
| 313 | + { type: "point", x: 1, y: 2, datum: { a: Infinity, b: NaN, c: { nested: 1 }, d: 5 } }, |
| 314 | + ]) |
| 315 | + expect(rows[0].values).toEqual({ d: 5 }) |
304 | 316 | }) |
305 | 317 |
|
306 | | - it("handles circle with x/y fallback", () => { |
307 | | - const row = extractRow({ type: "circle", x: 5, y: 15, datum: { id: "b" } }) |
308 | | - expect(row!.values).toEqual({ id: "b", x: 5, y: 15 }) |
| 318 | + it("keeps the rect aggregate-value fallbacks", () => { |
| 319 | + expect(extractAllRows([{ type: "rect", datum: { __aggregateValue: 99 } }])[0].values.value).toBe(99) |
| 320 | + expect(extractAllRows([{ type: "rect", datum: { total: 50 } }])[0].values.value).toBe(50) |
309 | 321 | }) |
310 | 322 |
|
311 | | - it("handles circle with no datum", () => { |
312 | | - const row = extractRow({ type: "circle", cx: 0, cy: 0 }) |
313 | | - expect(row!.values).toEqual({ id: "", x: 0, y: 0 }) |
| 323 | + it("preserves a wedge category of 0", () => { |
| 324 | + const rows = extractAllRows([{ type: "wedge", datum: { category: 0, value: 100 } }]) |
| 325 | + expect(rows[0].values).toEqual({ category: 0, value: 100 }) |
314 | 326 | }) |
315 | 327 |
|
316 | | - it("handles unknown type gracefully", () => { |
317 | | - const row = extractRow({ type: "hologram" }) |
318 | | - expect(row).toBeNull() |
| 328 | + it("skips unknown node types", () => { |
| 329 | + expect(extractAllRows([{ type: "hologram", datum: { a: 1 } }])).toEqual([]) |
319 | 330 | }) |
320 | 331 | }) |
321 | 332 |
|
|
0 commit comments