|
1 | 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
2 | 2 | import { forwardRef, useImperativeHandle } from 'react'; |
| 3 | +import { fireEvent } from '@testing-library/react'; |
3 | 4 |
|
4 | 5 | import { act, render, screen, waitFor } from '../test-utils'; |
5 | | -import { MapPage } from './MapPage'; |
| 6 | +import { MapPage, outlierSquashForce, type SimNode } from './MapPage'; |
6 | 7 |
|
7 | 8 |
|
8 | 9 | vi.mock('react-helmet-async', () => ({ |
@@ -53,11 +54,20 @@ type FgInstance = { |
53 | 54 | refresh: ReturnType<typeof vi.fn>; |
54 | 55 | __forcesWired?: boolean; |
55 | 56 | }; |
| 57 | +// Captures the (forceName, forceFn) pairs registered via `fg.d3Force(...)` |
| 58 | +// during onRenderFramePre. Lets tests assert that the custom outlier-squash |
| 59 | +// force was wired up — without it the d3Force mock would just throw away |
| 60 | +// the function and we couldn't tell apart the four built-in forces from |
| 61 | +// the new one. |
| 62 | +const registeredForces: Map<string, unknown> = new Map(); |
56 | 63 | const fgInstance: FgInstance = { |
57 | 64 | centerAt: vi.fn(), |
58 | 65 | zoom: vi.fn().mockReturnValue(1), |
59 | 66 | zoomToFit: vi.fn(), |
60 | | - d3Force: vi.fn().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }), |
| 67 | + d3Force: vi.fn().mockImplementation((name: string, fn?: unknown) => { |
| 68 | + if (fn !== undefined) registeredForces.set(name, fn); |
| 69 | + return { strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }; |
| 70 | + }), |
61 | 71 | d3ReheatSimulation: vi.fn(), |
62 | 72 | refresh: vi.fn(), |
63 | 73 | }; |
@@ -165,7 +175,11 @@ describe('MapPage', () => { |
165 | 175 | fgInstance.centerAt.mockReset(); |
166 | 176 | fgInstance.zoom.mockReset().mockReturnValue(1); |
167 | 177 | fgInstance.zoomToFit.mockReset(); |
168 | | - fgInstance.d3Force.mockReset().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }); |
| 178 | + registeredForces.clear(); |
| 179 | + fgInstance.d3Force.mockReset().mockImplementation((name: string, fn?: unknown) => { |
| 180 | + if (fn !== undefined) registeredForces.set(name, fn); |
| 181 | + return { strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }; |
| 182 | + }); |
169 | 183 | fgInstance.d3ReheatSimulation.mockReset(); |
170 | 184 | fgInstance.refresh.mockReset(); |
171 | 185 | fgInstance.__forcesWired = undefined; |
@@ -332,13 +346,16 @@ describe('MapPage', () => { |
332 | 346 | render(<MapPage />); |
333 | 347 | await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument()); |
334 | 348 |
|
335 | | - // Gate is visible while the engine is still cooling. |
336 | | - expect(screen.getByText(/arranging/i)).toBeInTheDocument(); |
| 349 | + // Gate is visible (role="status" is reachable) while the engine is still cooling. |
| 350 | + expect(screen.getByRole('status')).toHaveTextContent(/map\.simulate\(\)/); |
337 | 351 |
|
338 | | - // Engine stops → settled flips → overlay disappears. |
| 352 | + // Engine stops → settled flips → overlay sets aria-hidden=true and fades. |
| 353 | + // We keep the node in the DOM for the fade transition, so query-by-role |
| 354 | + // (which filters aria-hidden by default) is the right invariant: it |
| 355 | + // becomes unreachable to assistive tech the moment the gate is settled. |
339 | 356 | const onEngineStop = lastFgProps.current!.onEngineStop as () => void; |
340 | 357 | act(() => onEngineStop()); |
341 | | - await waitFor(() => expect(screen.queryByText(/arranging/i)).not.toBeInTheDocument()); |
| 358 | + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); |
342 | 359 | }); |
343 | 360 |
|
344 | 361 | it('frames the bbox via centerAt + zoom on engine stop', async () => { |
@@ -381,4 +398,189 @@ describe('MapPage', () => { |
381 | 398 | expect(mockNavigate).toHaveBeenCalledWith('/scatter-basic'); |
382 | 399 | expect(mockTrackEvent).toHaveBeenCalledWith('map_node_click', { spec: 'scatter-basic' }); |
383 | 400 | }); |
| 401 | + |
| 402 | + describe('outlierSquashForce', () => { |
| 403 | + // Pure unit tests that exercise the force math directly. We bypass the |
| 404 | + // d3-force harness because the force's contract is "modify vx/vy of |
| 405 | + // outlier nodes in place"; the harness adds nothing beyond invoking |
| 406 | + // force(alpha) and force.initialize(nodes). |
| 407 | + type Sim = SimNode & { x: number; y: number; vx: number; vy: number }; |
| 408 | + const makeNode = (x: number, y: number): Sim => ({ x, y, vx: 0, vy: 0 }); |
| 409 | + |
| 410 | + it('is a no-op when there are no nodes', () => { |
| 411 | + const force = outlierSquashForce(0.95, 200, 0.18); |
| 412 | + // initialize with empty array; force(alpha) must not throw. |
| 413 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 414 | + (force as unknown as { initialize: (n: SimNode[]) => void }).initialize([]); |
| 415 | + expect(() => force(1)).not.toThrow(); |
| 416 | + }); |
| 417 | + |
| 418 | + it('is a no-op for graphs of fewer than 2 nodes', () => { |
| 419 | + const force = outlierSquashForce(0.95, 200, 0.18); |
| 420 | + const nodes: Sim[] = [makeNode(1000, 0)]; |
| 421 | + (force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes); |
| 422 | + force(1); |
| 423 | + expect(nodes[0].vx).toBe(0); |
| 424 | + expect(nodes[0].vy).toBe(0); |
| 425 | + }); |
| 426 | + |
| 427 | + it('leaves nodes inside the threshold untouched (inner geometry preserved)', () => { |
| 428 | + // 99 inner nodes co-located at the origin + 1 far outlier. All inner |
| 429 | + // distances to the centroid are exactly equal, so the percentile |
| 430 | + // cutoff R lands exactly at the inner radius and the early |
| 431 | + // `r <= R → continue` short-circuit fires for every inner node. |
| 432 | + // The outlier is the only node above R. |
| 433 | + const force = outlierSquashForce(0.95, 200, 0.18); |
| 434 | + const inner: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0)); |
| 435 | + const outlier = makeNode(5000, 0); |
| 436 | + const nodes: Sim[] = [...inner, outlier]; |
| 437 | + (force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes); |
| 438 | + force(1); |
| 439 | + for (const n of inner) { |
| 440 | + expect(n.vx).toBe(0); |
| 441 | + expect(n.vy).toBe(0); |
| 442 | + } |
| 443 | + // Outlier was pulled inward — vx is opposite-sign to its position. |
| 444 | + expect(outlier.vx).toBeLessThan(0); |
| 445 | + expect(outlier.vy).toBe(0); |
| 446 | + }); |
| 447 | + |
| 448 | + it('still squashes outliers in small graphs (off-by-one regression guard)', () => { |
| 449 | + // Naive `floor(length * p)` would pick index 19 (the max) on n = 20 |
| 450 | + // and never trigger the squash. The (n - 1) * p indexing must keep |
| 451 | + // at least the most-outlying node above R. |
| 452 | + const force = outlierSquashForce(0.95, 200, 0.18); |
| 453 | + const nodes: Sim[] = Array.from({ length: 20 }, (_, i) => makeNode(i, 0)); |
| 454 | + // Push the last node much further so it's the unambiguous outlier. |
| 455 | + nodes[19] = makeNode(10_000, 0); |
| 456 | + (force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes); |
| 457 | + force(1); |
| 458 | + // The outlier must have a non-zero inward correction. |
| 459 | + expect(nodes[19].vx).not.toBe(0); |
| 460 | + expect(nodes[19].vx).toBeLessThan(0); |
| 461 | + }); |
| 462 | + |
| 463 | + it('keeps the velocity correction finite even for distant outliers', () => { |
| 464 | + // The compression map r' = R + (r - R)/(1 + (r - R)/k) has an |
| 465 | + // asymptote at R + k, so even a node at distance 1e6 produces a |
| 466 | + // bounded velocity correction. This is the property that prevents |
| 467 | + // a single rogue node from blowing up the simulation. |
| 468 | + const force = outlierSquashForce(0.95, 200, 0.18); |
| 469 | + const inner: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0)); |
| 470 | + const far = makeNode(1_000_000, 0); |
| 471 | + const nodes: Sim[] = [...inner, far]; |
| 472 | + (force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes); |
| 473 | + force(1); |
| 474 | + expect(far.vx).toBeLessThan(0); |
| 475 | + expect(Number.isFinite(far.vx)).toBe(true); |
| 476 | + // |vx| upper bound: |position - 0| * strength * alpha = 1e6 * 0.18. |
| 477 | + // The actual value is much smaller because (targetR - r) / r is |
| 478 | + // close to -1 once r >> R, so the correction approaches -position. |
| 479 | + expect(Math.abs(far.vx)).toBeLessThan(1_000_000); |
| 480 | + }); |
| 481 | + |
| 482 | + it('scales the velocity correction with alpha', () => { |
| 483 | + const force = outlierSquashForce(0.95, 200, 0.18); |
| 484 | + const inner: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0)); |
| 485 | + const hot = makeNode(5_000, 0); |
| 486 | + const cool = makeNode(5_000, 0); |
| 487 | + // First simulation — alpha = 1 (hot). |
| 488 | + (force as unknown as { initialize: (n: SimNode[]) => void }).initialize([...inner, hot]); |
| 489 | + force(1); |
| 490 | + // Second simulation — alpha = 0.1 (cooling). |
| 491 | + const force2 = outlierSquashForce(0.95, 200, 0.18); |
| 492 | + const inner2: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0)); |
| 493 | + (force2 as unknown as { initialize: (n: SimNode[]) => void }).initialize([...inner2, cool]); |
| 494 | + force2(0.1); |
| 495 | + // Cooler alpha → ~10× smaller velocity correction. |
| 496 | + expect(Math.abs(hot.vx)).toBeGreaterThan(Math.abs(cool.vx) * 5); |
| 497 | + }); |
| 498 | + }); |
| 499 | + |
| 500 | + it('registers an outlier-squash force on the simulation via onRenderFramePre', async () => { |
| 501 | + mockFetchSuccess(); |
| 502 | + render(<MapPage />); |
| 503 | + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); |
| 504 | + |
| 505 | + // Trigger the lazy force-wiring side effect — onRenderFramePre is |
| 506 | + // idempotent, so we can call it directly. |
| 507 | + const onRenderFramePre = lastFgProps.current!.onRenderFramePre as () => void; |
| 508 | + act(() => onRenderFramePre()); |
| 509 | + |
| 510 | + // The custom force key must have been registered alongside the |
| 511 | + // built-in charge / link / collide / center forces. |
| 512 | + expect(registeredForces.has('outlier-squash')).toBe(true); |
| 513 | + expect(typeof registeredForces.get('outlier-squash')).toBe('function'); |
| 514 | + }); |
| 515 | + |
| 516 | + it('advances the progress bar as onEngineTick fires', async () => { |
| 517 | + mockFetchSuccess(); |
| 518 | + render(<MapPage />); |
| 519 | + // Wait for the canvas mount; once it's there, `ready` is true and the |
| 520 | + // overlay's progressbar is in the DOM. (Same pattern as the existing |
| 521 | + // settling-overlay test.) |
| 522 | + await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument()); |
| 523 | + |
| 524 | + // Two elements have role="progressbar" — MUI's CircularProgress (the |
| 525 | + // spinner) and our hand-rolled determinate bar. Disambiguate via the |
| 526 | + // bar's unique aria-label. |
| 527 | + const bar = screen.getByRole('progressbar', { name: 'Layout computation progress' }); |
| 528 | + expect(bar).toHaveAttribute('aria-valuenow', '0'); |
| 529 | + |
| 530 | + // Tick progress is throttled to a flush every 6 ticks; fire enough |
| 531 | + // ticks to cross the throttle boundary at least once. |
| 532 | + const onEngineTick = lastFgProps.current!.onEngineTick as () => void; |
| 533 | + act(() => { |
| 534 | + for (let i = 0; i < 18; i++) onEngineTick(); |
| 535 | + }); |
| 536 | + |
| 537 | + const after = Number(bar.getAttribute('aria-valuenow')); |
| 538 | + expect(after).toBeGreaterThan(0); |
| 539 | + expect(after).toBeLessThanOrEqual(100); |
| 540 | + }); |
| 541 | + |
| 542 | + it('re-arms the settling overlay when graphData re-derives (weight change)', async () => { |
| 543 | + // Regression guard for the gate re-arm path. When the user moves a |
| 544 | + // weights slider, `weights` state changes → graphData useMemo |
| 545 | + // re-derives with a new identity → the prevGraphData ≠ graphData |
| 546 | + // branch in render must reset settled/tickProgress so the user sees |
| 547 | + // the loading indicator return for the new cooling phase. Without |
| 548 | + // this test, that path is exercised only via manual UI interaction. |
| 549 | + mockFetchSuccess(); |
| 550 | + render(<MapPage />); |
| 551 | + await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument()); |
| 552 | + |
| 553 | + // 1. Initial state: gate is visible, progressbar reachable. |
| 554 | + expect( |
| 555 | + screen.getByRole('progressbar', { name: 'Layout computation progress' }), |
| 556 | + ).toHaveAttribute('aria-valuenow', '0'); |
| 557 | + |
| 558 | + // 2. Cool the simulation. settled flips true → gate gets |
| 559 | + // aria-hidden=true → queryByRole filters it out. |
| 560 | + const onEngineStop = lastFgProps.current!.onEngineStop as () => void; |
| 561 | + act(() => onEngineStop()); |
| 562 | + await waitFor(() => |
| 563 | + expect( |
| 564 | + screen.queryByRole('progressbar', { name: 'Layout computation progress' }), |
| 565 | + ).not.toBeInTheDocument(), |
| 566 | + ); |
| 567 | + |
| 568 | + // 3. Open the weights panel and bump a slider. The first slider in |
| 569 | + // the panel is for the `plot_type` category — changing it |
| 570 | + // triggers setWeights, weights changes, graphData re-derives. |
| 571 | + fireEvent.click(screen.getByText(/^weights/)); |
| 572 | + const sliders = await screen.findAllByRole('slider'); |
| 573 | + expect(sliders.length).toBeGreaterThan(0); |
| 574 | + // MUI Slider's hidden <input type="range"> responds to change events. |
| 575 | + act(() => { |
| 576 | + fireEvent.change(sliders[0], { target: { value: '4' } }); |
| 577 | + }); |
| 578 | + |
| 579 | + // 4. Gate re-armed: progressbar reachable again, aria-valuenow back |
| 580 | + // to 0 (tickCountRef was reset alongside settled). |
| 581 | + await waitFor(() => { |
| 582 | + const bar = screen.getByRole('progressbar', { name: 'Layout computation progress' }); |
| 583 | + expect(bar).toHaveAttribute('aria-valuenow', '0'); |
| 584 | + }); |
| 585 | + }); |
384 | 586 | }); |
0 commit comments