Skip to content

Commit 0c7caf0

Browse files
committed
test: cover theme transition
1 parent b3b558d commit 0c7caf0

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

docs/refactor/jan4.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@
1111
- ~~Add `mime` (better `contentType` on upload).~~
1212
- ~~Dependency hygiene:~~
1313
- ~~Remove unused deps: `@radix-ui/react-slot`, `web-vitals`, `@tanstack/react-router-ssr-query` (keep `lucide-react`, used).~~
14+
- ~~Coverage:~~
15+
- ~~Add tests for `src/lib/theme-transition.ts` to meet global coverage thresholds.~~
1416
- CI:
1517
- Add GitHub Actions workflow: install, lint, test, coverage, build.

src/lib/theme-transition.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { startThemeTransition } from './theme-transition'
3+
4+
describe('startThemeTransition', () => {
5+
it('no-ops when theme does not change', () => {
6+
const setTheme = vi.fn()
7+
startThemeTransition({
8+
currentTheme: 'dark',
9+
nextTheme: 'dark',
10+
setTheme,
11+
})
12+
expect(setTheme).not.toHaveBeenCalled()
13+
})
14+
15+
it('applies theme without document (SSR)', () => {
16+
const original = Object.getOwnPropertyDescriptor(globalThis, 'document')
17+
Object.defineProperty(globalThis, 'document', { value: undefined, configurable: true })
18+
19+
try {
20+
const calls: string[] = []
21+
const setTheme = vi.fn()
22+
startThemeTransition({
23+
currentTheme: 'light',
24+
nextTheme: 'dark',
25+
setTheme,
26+
onBeforeThemeChange: () => calls.push('before'),
27+
onAfterThemeChange: () => calls.push('after'),
28+
})
29+
expect(calls).toEqual(['before', 'after'])
30+
expect(setTheme).toHaveBeenCalledWith('dark')
31+
} finally {
32+
if (original) Object.defineProperty(globalThis, 'document', original)
33+
else delete (globalThis as unknown as { document?: unknown }).document
34+
}
35+
})
36+
37+
it('skips view-transition when prefers reduced motion', () => {
38+
const setTheme = vi.fn()
39+
const root = document.documentElement
40+
41+
window.matchMedia = vi.fn(() => ({ matches: true }) as unknown as MediaQueryList)
42+
;(document as unknown as { startViewTransition?: unknown }).startViewTransition = vi.fn()
43+
44+
startThemeTransition({
45+
currentTheme: 'light',
46+
nextTheme: 'dark',
47+
setTheme,
48+
context: { pointerClientX: 10, pointerClientY: 10 },
49+
})
50+
51+
expect(setTheme).toHaveBeenCalledWith('dark')
52+
expect(root.classList.contains('theme-transition')).toBe(false)
53+
expect(
54+
(document as unknown as { startViewTransition?: unknown }).startViewTransition,
55+
).not.toHaveBeenCalled()
56+
})
57+
58+
it('uses view-transition when available', async () => {
59+
const setTheme = vi.fn()
60+
const root = document.documentElement
61+
62+
window.matchMedia = vi.fn(() => ({ matches: false }) as unknown as MediaQueryList)
63+
64+
;(
65+
document as unknown as {
66+
startViewTransition?: (callback: () => void) => { finished: Promise<void> }
67+
}
68+
).startViewTransition = (callback) => {
69+
callback()
70+
return { finished: Promise.resolve() }
71+
}
72+
73+
startThemeTransition({
74+
currentTheme: 'light',
75+
nextTheme: 'dark',
76+
setTheme,
77+
context: { pointerClientX: 10, pointerClientY: 20 },
78+
})
79+
80+
expect(setTheme).toHaveBeenCalledWith('dark')
81+
expect(root.classList.contains('theme-transition')).toBe(true)
82+
83+
await new Promise((r) => setTimeout(r, 0))
84+
expect(root.classList.contains('theme-transition')).toBe(false)
85+
expect(root.style.getPropertyValue('--theme-switch-x')).toBe('')
86+
expect(root.style.getPropertyValue('--theme-switch-y')).toBe('')
87+
})
88+
89+
it('cleans up when view-transition does not provide finished', () => {
90+
const setTheme = vi.fn()
91+
const root = document.documentElement
92+
93+
window.matchMedia = vi.fn(() => ({ matches: false }) as unknown as MediaQueryList)
94+
;(
95+
document as unknown as { startViewTransition?: (callback: () => void) => unknown }
96+
).startViewTransition = (callback) => {
97+
callback()
98+
return {}
99+
}
100+
101+
startThemeTransition({
102+
currentTheme: 'light',
103+
nextTheme: 'dark',
104+
setTheme,
105+
context: { element: document.body },
106+
})
107+
108+
expect(setTheme).toHaveBeenCalledWith('dark')
109+
expect(root.classList.contains('theme-transition')).toBe(false)
110+
})
111+
112+
it('falls back when view-transition throws', () => {
113+
const setTheme = vi.fn()
114+
const root = document.documentElement
115+
116+
window.matchMedia = vi.fn(() => ({ matches: false }) as unknown as MediaQueryList)
117+
;(document as unknown as { startViewTransition?: () => never }).startViewTransition = () => {
118+
throw new Error('nope')
119+
}
120+
121+
const element = document.createElement('button')
122+
element.getBoundingClientRect = () => ({ left: 10, top: 10, width: 10, height: 10 }) as DOMRect
123+
124+
startThemeTransition({
125+
currentTheme: 'light',
126+
nextTheme: 'dark',
127+
setTheme,
128+
context: { element },
129+
})
130+
131+
expect(setTheme).toHaveBeenCalledWith('dark')
132+
expect(root.classList.contains('theme-transition')).toBe(false)
133+
})
134+
})

0 commit comments

Comments
 (0)