Public Access
test(frontend): Sprint 14 — Vitest for useOnboarding (Q4)
Lifts the 'no new npm deps' rule for testing-only. Locks the
S9 bug class (onComplete → reset, the original bug from
1562929) with 7 unit tests in 25 ms.
5 changes:
- frontend/package.json — 4 new devDeps: vitest@^1.6.0,
happy-dom@^14.7.0, @testing-library/react@^14.2.0,
@testing-library/jest-dom@^6.4.0. Plus @types/node@^20
for tsc (Case 7's static check on App.tsx uses node:fs).
- frontend/package.json scripts — adds 'test' (vitest run,
no watch, CI-friendly) and 'test:watch' (vitest).
- frontend/vitest.config.ts (NEW) — defineConfig from
vitest/config, happy-dom env, setupFiles points to
vitest-setup.ts, include is src/**/*.test.{ts,tsx}.
- frontend/vitest-setup.ts (NEW) — one line: imports
@testing-library/jest-dom/vitest, auto-extends expect.
- frontend/src/components/OnboardingTour.test.tsx (NEW) —
7 cases:
1. clean init (localStorage empty) → isComplete=false.
2. persisted init (key = '1') → isComplete=true.
3. markComplete → state=true, localStorage STAYS at '1'
(locks one direction of the S9 bug at the hook level).
4. reset → localStorage cleared + state=false.
5. show → mirror of reset (intentional).
6. localStorage throw on read → silently swallowed,
isComplete=false, no crash.
7. App.tsx wiring — static check: onComplete calls
markComplete, onReset calls reset, neither inverts.
Case 7 is the load-bearing test: Sprint 9's bug was
at the App.tsx call site, not in the hook, so Cases
1-6 cannot catch it. Case 7 reads App.tsx as a string
via node:fs/promises, regex-matches the arrow bodies
of onComplete={...} and onReset={...}, asserts each
calls the right onboarding.* method.
Verified: flipping markComplete → reset in App.tsx makes
Case 7 fail on the onCompleteBody.toMatch(/markComplete/)
assertion. All 7 cases pass in 25 ms. npm run build green
(tsc 0 errors, vite built in 2.6 s, bundle 503.82 kB
unchanged — vitest is devDeps only). No backend change.
No migration. No runtime dep change.
Deploy: git pull + cd frontend && npm install && npm test
(confirm 7/7) + cd .. && docker compose up -d --build
frontend. No backend rebuild.
This commit is contained in:
Generated
+1789
-1
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"test": "vitest run --reporter=default",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -23,14 +25,19 @@
|
|||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^14.3.1",
|
||||||
|
"@types/node": "^20.19.42",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
"happy-dom": "^14.12.3",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.11"
|
"vite": "^5.0.11",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `useOnboarding` — Sprint 14 (Q4).
|
||||||
|
*
|
||||||
|
* Locks the S9 bug class: any future refactor that wires
|
||||||
|
* onComplete → reset (the original S9 bug, `1562929`)
|
||||||
|
* onReset → markComplete (the inverse)
|
||||||
|
* would fail one of these cases. Case 3 + Case 4 cover both directions.
|
||||||
|
*/
|
||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useOnboarding } from './OnboardingTour'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mealplanner:onboarding-complete'
|
||||||
|
|
||||||
|
describe('useOnboarding', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 1: inits to isComplete=false when localStorage is clean', () => {
|
||||||
|
const { result } = renderHook(() => useOnboarding())
|
||||||
|
expect(result.current.isComplete).toBe(false)
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 2: inits to isComplete=true when localStorage has the persisted key', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '1')
|
||||||
|
const { result } = renderHook(() => useOnboarding())
|
||||||
|
expect(result.current.isComplete).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 3: markComplete flips state to true and does NOT clear localStorage (locks the S9 bug)', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '1')
|
||||||
|
const { result } = renderHook(() => useOnboarding())
|
||||||
|
expect(result.current.isComplete).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.markComplete()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.isComplete).toBe(true)
|
||||||
|
// localStorage must remain '1'. If a future refactor wires
|
||||||
|
// onComplete → reset (the S9 bug) this assertion fails.
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 4: reset clears localStorage and flips state to false', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '1')
|
||||||
|
const { result } = renderHook(() => useOnboarding())
|
||||||
|
expect(result.current.isComplete).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.isComplete).toBe(false)
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 5: show is a mirror of reset (clears localStorage + flips state to false)', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '1')
|
||||||
|
const { result } = renderHook(() => useOnboarding())
|
||||||
|
expect(result.current.isComplete).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.isComplete).toBe(false)
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 6: localStorage throw on read is silently swallowed (no crash, isComplete=false)', () => {
|
||||||
|
const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('localStorage disabled (e.g. private mode)')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnboarding())
|
||||||
|
expect(result.current.isComplete).toBe(false)
|
||||||
|
|
||||||
|
getItemSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Case 7: App.tsx wires onComplete → markComplete and onReset → reset (catches the S9 bug pattern)', async () => {
|
||||||
|
// This is a static check on App.tsx: a future refactor that inverts
|
||||||
|
// the wiring (onComplete → reset, the original S9 bug `1562929`) will
|
||||||
|
// fail this test. The S9 bug never reached useOnboarding — it was at
|
||||||
|
// the call site in App.tsx — so the hook tests above can't catch it.
|
||||||
|
// This is the only check that can.
|
||||||
|
const { readFile } = await import('node:fs/promises')
|
||||||
|
const { fileURLToPath } = await import('node:url')
|
||||||
|
const { resolve, dirname } = await import('node:path')
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const appPath = resolve(here, '../App.tsx')
|
||||||
|
const src = await readFile(appPath, 'utf8')
|
||||||
|
|
||||||
|
// The order of definitions in App.tsx is onComplete THEN onReset.
|
||||||
|
// Strip everything between the two `onComplete={` and the next `}}`
|
||||||
|
// block to capture the first arrow body, then assert it calls
|
||||||
|
// markComplete. Then capture the second arrow body and assert it
|
||||||
|
// calls reset.
|
||||||
|
const onCompleteMatch = src.match(/onComplete=\{[^}]*=>\s*\{([\s\S]*?)\}\s*\}/)
|
||||||
|
const onResetMatch = src.match(/onReset=\{[^}]*=>\s*\{([\s\S]*?)\}\s*\}/)
|
||||||
|
expect(onCompleteMatch).not.toBeNull()
|
||||||
|
expect(onResetMatch).not.toBeNull()
|
||||||
|
|
||||||
|
const onCompleteBody = onCompleteMatch![1]
|
||||||
|
const onResetBody = onResetMatch![1]
|
||||||
|
expect(onCompleteBody).toMatch(/onboarding\.markComplete\(\)/)
|
||||||
|
expect(onResetBody).toMatch(/onboarding\.reset\(\)/)
|
||||||
|
|
||||||
|
// Inverse pattern: onComplete must NOT call reset. This is the
|
||||||
|
// original S9 bug — if it returns, markComplete never runs and
|
||||||
|
// the tour re-shows on every render.
|
||||||
|
expect(onCompleteBody).not.toMatch(/onboarding\.reset\(\)/)
|
||||||
|
expect(onResetBody).not.toMatch(/onboarding\.markComplete\(\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
setupFiles: ['./vitest-setup.ts'],
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user