Skip to content

Commit 6730e5d

Browse files
ryan-williamsclaude
andcommitted
Add e2e test suite (puppeteer + vitest) for edit-mode UX
16 tests covering: selection overlay, click-to-deselect, select-other-node, dbl-click add, single-click no-op, Backspace/Delete deletion, rotation handle drag, undo/redo via window API and via cmd+z/cmd+shift+z keyboard shortcuts, node-drag undo, and a history-length regression guard (rotation drag = 1 past entry, not N-per-mousemove). - `pnpm test` runs headless; `pnpm test:headful` shows the browser. - Tests read state via `window.__geoSankey` (graph, past/future lengths, undo/redo callbacks), and derive node screen positions via `map.project` + canvas `getBoundingClientRect` offset. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 38c6744 commit 6730e5d

4 files changed

Lines changed: 372 additions & 1 deletion

File tree

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
"scripts": {
1616
"build": "vite build && tsc --emitDeclarationOnly",
1717
"clean": "rm -rf node_modules/.vite dist",
18-
"dev": "cd site && vite"
18+
"dev": "cd site && vite",
19+
"test": "vitest run",
20+
"test:watch": "vitest",
21+
"test:headful": "HEADFUL=1 vitest run"
1922
},
2023
"devDependencies": {
2124
"@types/geojson": "^7946.0.16",

test/e2e/edit-mode.spec.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect } from 'vitest'
2+
import type { Browser, Page } from 'puppeteer-core'
3+
import {
4+
launch,
5+
openEditMode,
6+
getNodeCount,
7+
getNode,
8+
getHistoryLengths,
9+
projectNode,
10+
getSelectedBearing,
11+
getRotHandlePos,
12+
getSelectedLabel,
13+
isSelected,
14+
mapClick,
15+
mapDblClick,
16+
undoViaApi,
17+
redoViaApi,
18+
} from './helpers'
19+
20+
let browser: Browser
21+
let page: Page
22+
23+
beforeAll(async () => { browser = await launch() }, 30_000)
24+
afterAll(async () => { await browser?.close() })
25+
26+
beforeEach(async () => {
27+
page = await openEditMode(browser, { nodeId: 'origin' })
28+
}, 30_000)
29+
afterEach(async () => { await page?.close() })
30+
31+
describe('selection', () => {
32+
it('shows overlay label for selected node', async () => {
33+
const label = await getSelectedLabel(page)
34+
expect(label).toBe('Origin')
35+
})
36+
37+
it('shows rotation handle with bearing label', async () => {
38+
const pos = await getRotHandlePos(page)
39+
expect(pos).not.toBeNull()
40+
const bearing = await getSelectedBearing(page)
41+
expect(bearing).toBe('90°')
42+
})
43+
44+
it('deselects on empty-map click', async () => {
45+
expect(await isSelected(page, 'origin')).toBe(true)
46+
// Click an area on the map with no node
47+
await mapClick(page, 600, 400)
48+
expect(await isSelected(page, 'origin')).toBe(false)
49+
// Overlay should be gone
50+
expect(await getSelectedLabel(page)).toBeNull()
51+
})
52+
53+
it('selects a different node on click', async () => {
54+
const destPos = await projectNode(page, 'dest')
55+
expect(destPos).not.toBeNull()
56+
await mapClick(page, destPos!.x, destPos!.y)
57+
expect(await isSelected(page, 'dest')).toBe(true)
58+
})
59+
})
60+
61+
describe('add node', () => {
62+
it('double-click adds a node', async () => {
63+
const before = await getNodeCount(page)
64+
await mapDblClick(page, 500, 500)
65+
const after = await getNodeCount(page)
66+
expect(after).toBe(before + 1)
67+
})
68+
69+
it('single-click on empty map does NOT add a node', async () => {
70+
const before = await getNodeCount(page)
71+
await mapClick(page, 500, 500)
72+
const after = await getNodeCount(page)
73+
expect(after).toBe(before)
74+
})
75+
})
76+
77+
describe('delete node', () => {
78+
it('Backspace deletes the selected node', async () => {
79+
const before = await getNodeCount(page)
80+
await page.keyboard.press('Backspace')
81+
await new Promise(r => setTimeout(r, 300))
82+
const after = await getNodeCount(page)
83+
expect(after).toBe(before - 1)
84+
expect(await isSelected(page, 'origin')).toBe(false)
85+
})
86+
87+
it('Delete key also deletes the selected node', async () => {
88+
const before = await getNodeCount(page)
89+
await page.keyboard.press('Delete')
90+
await new Promise(r => setTimeout(r, 300))
91+
const after = await getNodeCount(page)
92+
expect(after).toBe(before - 1)
93+
})
94+
})
95+
96+
describe('rotation handle', () => {
97+
it('drag updates bearing', async () => {
98+
const before = await getSelectedBearing(page)
99+
expect(before).toBe('90°')
100+
const handle = await getRotHandlePos(page)
101+
expect(handle).not.toBeNull()
102+
// Drag handle to below the node (south) — origin's screen position is roughly (X_handle - 40, Y_handle)
103+
// Drag direction: move from handle toward a south-of-node position
104+
// origin node is at the handle position minus the bearing-radius offset (40px east at 90°)
105+
const nodeCx = handle!.x - 40
106+
const nodeCy = handle!.y
107+
await page.mouse.move(handle!.x, handle!.y)
108+
await page.mouse.down()
109+
await page.mouse.move(nodeCx, nodeCy + 50, { steps: 10 })
110+
await page.mouse.up()
111+
await new Promise(r => setTimeout(r, 300))
112+
const after = await getSelectedBearing(page)
113+
expect(after).not.toBe('90°')
114+
// A drag to the south should yield a bearing in [120°, 210°] range
115+
const deg = parseInt(after!.replace('°', ''))
116+
expect(deg).toBeGreaterThan(120)
117+
expect(deg).toBeLessThan(210)
118+
})
119+
})
120+
121+
describe('undo / redo', () => {
122+
it('undo reverts a double-click add', async () => {
123+
const before = await getNodeCount(page)
124+
await mapDblClick(page, 500, 500)
125+
expect(await getNodeCount(page)).toBe(before + 1)
126+
await undoViaApi(page)
127+
expect(await getNodeCount(page)).toBe(before)
128+
})
129+
130+
it('redo re-adds after undo', async () => {
131+
const before = await getNodeCount(page)
132+
await mapDblClick(page, 500, 500)
133+
expect(await getNodeCount(page)).toBe(before + 1)
134+
await undoViaApi(page)
135+
expect(await getNodeCount(page)).toBe(before)
136+
await redoViaApi(page)
137+
expect(await getNodeCount(page)).toBe(before + 1)
138+
})
139+
140+
it('undo reverts a rotation drag (single history entry, not per-mousemove)', async () => {
141+
expect(await getSelectedBearing(page)).toBe('90°')
142+
const handle = (await getRotHandlePos(page))!
143+
const nodeCx = handle.x - 40
144+
const nodeCy = handle.y
145+
await page.mouse.move(handle.x, handle.y)
146+
await page.mouse.down()
147+
for (let i = 1; i <= 20; i++) {
148+
await page.mouse.move(nodeCx + 10, nodeCy + 3 * i, { steps: 1 })
149+
}
150+
await page.mouse.up()
151+
await new Promise(r => setTimeout(r, 300))
152+
expect(await getSelectedBearing(page)).not.toBe('90°')
153+
await undoViaApi(page)
154+
expect(await getSelectedBearing(page)).toBe('90°')
155+
})
156+
157+
it('undo reverts a node drag to pre-drag position', async () => {
158+
const originBefore = await getNode(page, 'origin')
159+
const originScreen = (await projectNode(page, 'origin'))!
160+
// Drag origin 100px to the right via the map canvas (edit-mode node drag)
161+
await page.mouse.move(originScreen.x, originScreen.y)
162+
await page.mouse.down()
163+
for (let i = 1; i <= 10; i++) {
164+
await page.mouse.move(originScreen.x + 10 * i, originScreen.y, { steps: 1 })
165+
}
166+
await page.mouse.up()
167+
await new Promise(r => setTimeout(r, 300))
168+
const moved = await getNode(page, 'origin')
169+
expect(moved.pos[1]).not.toBeCloseTo(originBefore.pos[1], 4)
170+
await undoViaApi(page)
171+
const restored = await getNode(page, 'origin')
172+
expect(restored.pos[0]).toBeCloseTo(originBefore.pos[0], 4)
173+
expect(restored.pos[1]).toBeCloseTo(originBefore.pos[1], 4)
174+
})
175+
176+
async function pressMod(keys: string) {
177+
// On macOS, Cmd (Meta) is the mod key. Try Meta first; some headless
178+
// environments need Control.
179+
const mods = process.platform === 'darwin' ? ['Meta'] : ['Control']
180+
for (const k of keys.split('+').slice(0, -1)) mods.push(k === 'Shift' ? 'Shift' : k)
181+
const target = keys.split('+').slice(-1)[0]
182+
for (const m of mods) await page.keyboard.down(m)
183+
await page.keyboard.press(target)
184+
for (const m of mods.slice().reverse()) await page.keyboard.up(m)
185+
}
186+
187+
it('Cmd+Z undo works via keyboard shortcut', async () => {
188+
const before = await getNodeCount(page)
189+
await mapDblClick(page, 500, 500)
190+
expect(await getNodeCount(page)).toBe(before + 1)
191+
await pressMod('z')
192+
await new Promise(r => setTimeout(r, 300))
193+
expect(await getNodeCount(page)).toBe(before)
194+
})
195+
196+
it('Cmd+Shift+Z redo works via keyboard shortcut', async () => {
197+
const before = await getNodeCount(page)
198+
await mapDblClick(page, 500, 500)
199+
await undoViaApi(page)
200+
expect(await getNodeCount(page)).toBe(before)
201+
await pressMod('Shift+z')
202+
await new Promise(r => setTimeout(r, 300))
203+
expect(await getNodeCount(page)).toBe(before + 1)
204+
})
205+
206+
it('rotation drag produces exactly ONE history entry (not per-mousemove)', async () => {
207+
const histBefore = await getHistoryLengths(page)
208+
const handle = (await getRotHandlePos(page))!
209+
await page.mouse.move(handle.x, handle.y)
210+
await page.mouse.down()
211+
for (let i = 1; i <= 20; i++) {
212+
await page.mouse.move(handle.x + 2 * i, handle.y + 5 * i, { steps: 1 })
213+
}
214+
await page.mouse.up()
215+
await new Promise(r => setTimeout(r, 300))
216+
const histAfter = await getHistoryLengths(page)
217+
expect(histAfter.past).toBe(histBefore.past + 1)
218+
})
219+
})

test/e2e/helpers.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import puppeteer, { Browser, Page } from 'puppeteer-core'
2+
3+
const CHROME_PATH = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
4+
export const BASE_URL = process.env.GS_BASE_URL ?? 'http://localhost:7251'
5+
6+
export async function launch(): Promise<Browser> {
7+
return puppeteer.launch({
8+
headless: process.env.HEADFUL ? false : true,
9+
executablePath: CHROME_PATH,
10+
args: ['--no-sandbox', '--disable-dev-shm-usage'],
11+
})
12+
}
13+
14+
/** Load the Simple Flow example in edit mode with the Origin node selected. */
15+
export async function openEditMode(browser: Browser, opts: { nodeId?: string } = {}): Promise<Page> {
16+
const page = await browser.newPage()
17+
await page.setViewport({ width: 1200, height: 800 })
18+
await page.evaluateOnNewDocument((sel) => {
19+
sessionStorage.setItem('geo-sankey-edit', '1')
20+
if (sel) sessionStorage.setItem('geo-sankey-sel', JSON.stringify({ type: 'node', id: sel }))
21+
}, opts.nodeId ?? '')
22+
await page.goto(`${BASE_URL}/?ex=ferry&nodes=2&llz=40.7350_-74.0300_13.50`)
23+
await waitForMap(page)
24+
return page
25+
}
26+
27+
export async function waitForMap(page: Page, timeout = 10_000): Promise<void> {
28+
await page.waitForFunction(() => {
29+
const gs = (window as any).__geoSankey
30+
return gs && gs.mapRef?.current?.getMap?.()
31+
}, { timeout })
32+
await new Promise(r => setTimeout(r, 500))
33+
}
34+
35+
export async function getNodeCount(page: Page): Promise<number> {
36+
return page.evaluate(() => (window as any).__geoSankey?.graph?.nodes?.length ?? -1)
37+
}
38+
39+
export async function getNode(page: Page, id: string): Promise<any> {
40+
return page.evaluate((nodeId) => {
41+
const g = (window as any).__geoSankey?.graph
42+
return g?.nodes?.find((n: any) => n.id === nodeId) ?? null
43+
}, id)
44+
}
45+
46+
export async function getHistoryLengths(page: Page): Promise<{ past: number; future: number }> {
47+
return page.evaluate(() => {
48+
const gs = (window as any).__geoSankey
49+
return { past: gs?.pastLen ?? 0, future: gs?.futureLen ?? 0 }
50+
})
51+
}
52+
53+
export async function undoViaApi(page: Page): Promise<void> {
54+
await page.evaluate(() => (window as any).__geoSankey?.undo?.())
55+
await new Promise(r => setTimeout(r, 150))
56+
}
57+
58+
export async function redoViaApi(page: Page): Promise<void> {
59+
await page.evaluate(() => (window as any).__geoSankey?.redo?.())
60+
await new Promise(r => setTimeout(r, 150))
61+
}
62+
63+
/** Project a node's LL to VIEWPORT pixel coords (accounting for canvas offset). */
64+
export async function projectNode(page: Page, id: string): Promise<{ x: number; y: number } | null> {
65+
return page.evaluate((nodeId) => {
66+
const gs = (window as any).__geoSankey
67+
const map = gs?.mapRef?.current?.getMap?.()
68+
const node = gs?.graph?.nodes?.find((n: any) => n.id === nodeId)
69+
if (!map || !node) return null
70+
const pt = map.project([node.pos[1], node.pos[0]]) // [lng, lat] → canvas px
71+
const canvas = map.getCanvas() as HTMLCanvasElement
72+
const rect = canvas.getBoundingClientRect()
73+
return { x: pt.x + rect.left, y: pt.y + rect.top }
74+
}, id)
75+
}
76+
77+
export async function getSelectedBearing(page: Page): Promise<string | null> {
78+
return page.evaluate(() => {
79+
const els = document.querySelectorAll('.map-container div[style]')
80+
for (const el of els) {
81+
const txt = (el as HTMLElement).textContent ?? ''
82+
// The "NN°" label next to the rotation handle
83+
if (/^\d+°$/.test(txt)) return txt
84+
}
85+
return null
86+
})
87+
}
88+
89+
export async function getRotHandlePos(page: Page): Promise<{ x: number; y: number } | null> {
90+
return page.evaluate(() => {
91+
const divs = document.querySelectorAll('.map-container div[style]')
92+
for (const d of divs) {
93+
const style = (d as HTMLElement).style
94+
if (style.cursor === 'grab' && style.borderRadius === '50%') {
95+
const r = (d as HTMLElement).getBoundingClientRect()
96+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
97+
}
98+
}
99+
return null
100+
})
101+
}
102+
103+
export async function getSelectedLabel(page: Page): Promise<string | null> {
104+
return page.evaluate(() => {
105+
// Find the node-label overlay div (non-expanded form shows label text)
106+
const divs = document.querySelectorAll('.map-container div[style]')
107+
for (const d of divs) {
108+
const s = (d as HTMLElement).style
109+
if (s.position === 'absolute' && s.cursor === 'default') {
110+
const span = d.querySelector('span')
111+
return span?.textContent ?? null
112+
}
113+
}
114+
return null
115+
})
116+
}
117+
118+
export async function isSelected(page: Page, nodeId: string): Promise<boolean> {
119+
return page.evaluate((id) => {
120+
const stored = sessionStorage.getItem('geo-sankey-sel')
121+
if (!stored) return false
122+
try {
123+
const s = JSON.parse(stored)
124+
return s?.type === 'node' && s?.id === id
125+
} catch { return false }
126+
}, nodeId)
127+
}
128+
129+
/** Map-canvas click (single-click = select/deselect in edit mode). */
130+
export async function mapClick(page: Page, x: number, y: number): Promise<void> {
131+
await page.mouse.click(x, y)
132+
await new Promise(r => setTimeout(r, 200))
133+
}
134+
135+
export async function mapDblClick(page: Page, x: number, y: number): Promise<void> {
136+
await page.mouse.click(x, y, { clickCount: 2 })
137+
await new Promise(r => setTimeout(r, 200))
138+
}

vitest.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
include: ['test/e2e/**/*.spec.ts'],
6+
testTimeout: 30_000,
7+
hookTimeout: 30_000,
8+
// Run serially so a single browser instance is shared per worker
9+
fileParallelism: false,
10+
},
11+
})

0 commit comments

Comments
 (0)