@@ -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+
1119vi . mock ( '../hooks' , ( ) => ( {
1220 useAnalytics : ( ) => ( {
1321 trackPageview : vi . fn ( ) ,
14- trackEvent : vi . fn ( ) ,
22+ trackEvent : mockTrackEvent ,
1523 } ) ,
1624} ) ) ;
1725
1826vi . 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+
2336vi . 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+
3467const 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.
77112class 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 {
87130describe ( '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 ( / F a i l e d t o l o a d m a p / ) ) . 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