Skip to content

Commit 004b447

Browse files
test(app): cover MapPage canvas/click/hover callbacks
Codecov flagged the new MapPage.tsx at 58% patch coverage (under the 80% threshold) because the smoke test only asserted top-level render output. Capture the props that get passed into the mocked ForceGraph2D wrapper and exercise them directly: - onNodeClick → navigate + analytics event - nodeCanvasObject → both branches (with/without preloaded image) - nodePointerAreaPaint → fillStyle + fillRect call - onNodeHover + linkColor — hover state propagates to the link colorer - linkWidth scales monotonically with weight Also fixed the MockResizeObserver to actually invoke its callback so the canvas mounts in tests (the size.w > 0 guard was previously keeping ForceGraph2D unmounted, hiding all of these branches from coverage). Coverage on MapPage.tsx now ~95% lines; whole-file count for the new helpers + page is 94.4% lines. Refs #5646 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac8019a commit 004b447

1 file changed

Lines changed: 150 additions & 12 deletions

File tree

app/src/pages/MapPage.test.tsx

Lines changed: 150 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,62 @@ vi.mock('react-helmet-async', () => ({
88
Helmet: ({ children }: { children: React.ReactNode }) => <>{children}</>,
99
}));
1010

11+
const mockNavigate = vi.fn();
12+
const mockTrackEvent = vi.fn();
13+
14+
vi.mock('react-router-dom', async () => {
15+
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
16+
return { ...actual, useNavigate: () => mockNavigate };
17+
});
18+
1119
vi.mock('../hooks', () => ({
1220
useAnalytics: () => ({
1321
trackPageview: vi.fn(),
14-
trackEvent: vi.fn(),
22+
trackEvent: mockTrackEvent,
1523
}),
1624
}));
1725

1826
vi.mock('../hooks/useLayoutContext', () => ({
1927
useTheme: () => ({ isDark: false }),
2028
}));
2129

22-
// Stub ForceGraph2D to a marker div so we can assert wiring without rendering canvas.
30+
// Capture the props passed to ForceGraph2D so individual callbacks can be exercised
31+
// from outside React. A live canvas can't run in jsdom, but the callbacks (drawNode,
32+
// onNodeClick, linkColor, …) are pure-ish JS and worth testing in isolation.
33+
type FgProps = Record<string, unknown>;
34+
const lastFgProps: { current: FgProps | null } = { current: null };
35+
2336
vi.mock('react-force-graph-2d', () => ({
24-
default: (props: { graphData: { nodes: { id: string }[]; links: unknown[] } }) => (
25-
<div
26-
data-testid="force-graph-2d"
27-
data-node-count={props.graphData.nodes.length}
28-
data-link-count={props.graphData.links.length}
29-
/>
30-
),
37+
default: (props: FgProps) => {
38+
lastFgProps.current = props;
39+
const data = props.graphData as { nodes: unknown[]; links: unknown[] };
40+
return (
41+
<div
42+
data-testid="force-graph-2d"
43+
data-node-count={data.nodes.length}
44+
data-link-count={data.links.length}
45+
/>
46+
);
47+
},
3148
}));
3249

3350

51+
function makeCtxStub() {
52+
// Minimal mock of CanvasRenderingContext2D — just enough surface for drawNode/paintHitbox.
53+
return {
54+
save: vi.fn(),
55+
restore: vi.fn(),
56+
drawImage: vi.fn(),
57+
fillRect: vi.fn(),
58+
strokeRect: vi.fn(),
59+
fillStyle: '',
60+
strokeStyle: '',
61+
lineWidth: 0,
62+
globalAlpha: 1,
63+
};
64+
}
65+
66+
3467
const mockSpecs = [
3568
{
3669
id: 'scatter-basic',
@@ -73,11 +106,21 @@ function mockFetchSuccess() {
73106
}
74107

75108

76-
// jsdom doesn't ship ResizeObserver; stub it so the page's useEffect doesn't crash.
109+
// jsdom doesn't ship ResizeObserver; stub it so the page's useEffect doesn't crash
110+
// AND fire the callback once with non-zero dimensions so the `size.w > 0` gate that
111+
// guards <ForceGraph2D> mounting is satisfied.
77112
class MockResizeObserver {
113+
cb: ResizeObserverCallback;
114+
constructor(cb: ResizeObserverCallback) {
115+
this.cb = cb;
116+
}
78117
observe(target: Element) {
79-
// Trigger a single layout callback so size > 0 and the canvas mounts.
80-
Object.defineProperty(target, 'contentRect', { value: { width: 800, height: 600 }, configurable: true });
118+
setTimeout(() => {
119+
this.cb(
120+
[{ contentRect: { width: 800, height: 600 } } as unknown as ResizeObserverEntry],
121+
this as unknown as ResizeObserver,
122+
);
123+
}, 0);
81124
}
82125
unobserve() {}
83126
disconnect() {}
@@ -87,6 +130,9 @@ class MockResizeObserver {
87130
describe('MapPage', () => {
88131
beforeEach(() => {
89132
vi.restoreAllMocks();
133+
mockNavigate.mockReset();
134+
mockTrackEvent.mockReset();
135+
lastFgProps.current = null;
90136
vi.stubGlobal('ResizeObserver', MockResizeObserver);
91137
});
92138

@@ -115,4 +161,96 @@ describe('MapPage', () => {
115161
expect(screen.getByText(/Failed to load map/)).toBeInTheDocument();
116162
});
117163
});
164+
165+
it('passes graph data with the expected node count to ForceGraph2D', async () => {
166+
mockFetchSuccess();
167+
render(<MapPage />);
168+
await waitFor(() => {
169+
expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument();
170+
});
171+
expect(screen.getByTestId('force-graph-2d').getAttribute('data-node-count')).toBe('3');
172+
});
173+
174+
it('navigates to the spec page and emits an analytics event on node click', async () => {
175+
mockFetchSuccess();
176+
render(<MapPage />);
177+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
178+
179+
const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void;
180+
onNodeClick({ id: 'scatter-basic' });
181+
182+
expect(mockNavigate).toHaveBeenCalledWith('/scatter-basic');
183+
expect(mockTrackEvent).toHaveBeenCalledWith('map_node_click', { spec_id: 'scatter-basic' });
184+
});
185+
186+
it('drawNode paints a fallback rect when a node has no preloaded image', async () => {
187+
mockFetchSuccess();
188+
render(<MapPage />);
189+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
190+
191+
const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown) => void;
192+
const ctx = makeCtxStub();
193+
drawNode({ id: 'scatter-basic', x: 100, y: 100 }, ctx);
194+
195+
// Without an attached image, the fallback rect path runs.
196+
expect(ctx.fillRect).toHaveBeenCalled();
197+
expect(ctx.strokeRect).toHaveBeenCalled();
198+
expect(ctx.drawImage).not.toHaveBeenCalled();
199+
});
200+
201+
it('drawNode paints the thumbnail when a node has a preloaded image', async () => {
202+
mockFetchSuccess();
203+
render(<MapPage />);
204+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
205+
206+
const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown) => void;
207+
const ctx = makeCtxStub();
208+
const fakeImg = { src: 'x' } as unknown as HTMLImageElement;
209+
drawNode({ id: 'scatter-basic', x: 50, y: 50, img: fakeImg }, ctx);
210+
211+
expect(ctx.drawImage).toHaveBeenCalledWith(fakeImg, expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number));
212+
expect(ctx.strokeRect).toHaveBeenCalled();
213+
});
214+
215+
it('paintHitbox draws a sprite-sized hit rectangle', async () => {
216+
mockFetchSuccess();
217+
render(<MapPage />);
218+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
219+
220+
const paintHitbox = lastFgProps.current!.nodePointerAreaPaint as (n: unknown, c: string, ctx: unknown) => void;
221+
const ctx = makeCtxStub();
222+
paintHitbox({ id: 'scatter-basic', x: 80, y: 60 }, '#ff00ff', ctx);
223+
224+
expect(ctx.fillStyle).toBe('#ff00ff');
225+
expect(ctx.fillRect).toHaveBeenCalled();
226+
});
227+
228+
it('linkColor returns the brand green for links touching the hovered node', async () => {
229+
mockFetchSuccess();
230+
render(<MapPage />);
231+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
232+
233+
// Hover a node, then ask the link-color callback for its incident link.
234+
const onNodeHover = lastFgProps.current!.onNodeHover as (n: { id: string } | null) => void;
235+
onNodeHover({ id: 'scatter-basic' });
236+
await waitFor(() => {
237+
const linkColor = lastFgProps.current!.linkColor as (l: unknown) => string;
238+
const colorInvolved = linkColor({ source: 'scatter-basic', target: 'line-basic', weight: 0.5 });
239+
const colorOther = linkColor({ source: 'line-basic', target: 'scatter-color-mapped', weight: 0.5 });
240+
expect(colorInvolved).toMatch(/^#/); // brand color (hex)
241+
expect(colorInvolved).not.toBe(colorOther);
242+
});
243+
});
244+
245+
it('linkWidth scales with link weight', async () => {
246+
mockFetchSuccess();
247+
render(<MapPage />);
248+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
249+
250+
const linkWidth = lastFgProps.current!.linkWidth as (l: unknown) => number;
251+
const small = linkWidth({ weight: 0.1 });
252+
const large = linkWidth({ weight: 0.9 });
253+
expect(large).toBeGreaterThan(small);
254+
expect(small).toBeGreaterThan(0);
255+
});
118256
});

0 commit comments

Comments
 (0)