ThemeSwitcher.test.tsx250 lines · main
1import {
2 CommandProvider,
3 PageType,
4 useCommandMenuOpen,
5 useCommands,
6 useCurrentPage,
7 useSetCommandMenuOpen,
8} from '..'
9import { act, render, waitFor } from '@testing-library/react'
10import { useEffect } from 'react'
11import { beforeEach, describe, expect, it, vi } from 'vitest'
12
13import type { ICommand, ICommandSection } from '../internal/types'
14import { useThemeSwitcherCommands } from './ThemeSwitcher'
15
16const themeMock = vi.hoisted(() => ({
17 setTheme: vi.fn(),
18 state: {
19 resolvedTheme: 'light' as string | undefined,
20 },
21}))
22
23vi.mock('next-themes', () => ({
24 useTheme: () => ({
25 resolvedTheme: themeMock.state.resolvedTheme,
26 setTheme: themeMock.setTheme,
27 }),
28}))
29
30type CurrentPage = ReturnType<typeof useCurrentPage>
31
32const captured: {
33 commandSections: ICommandSection[]
34 currentPage: CurrentPage
35 open: boolean
36 setOpen?: (open: boolean) => void
37} = {
38 commandSections: [],
39 currentPage: undefined,
40 open: false,
41 setOpen: undefined,
42}
43
44const ThemeSwitcherHarness = () => {
45 useThemeSwitcherCommands()
46
47 const commandSections = useCommands()
48 const currentPage = useCurrentPage()
49 const open = useCommandMenuOpen()
50 const setOpen = useSetCommandMenuOpen()
51
52 useEffect(() => {
53 captured.commandSections = commandSections as ICommandSection[]
54 captured.currentPage = currentPage
55 captured.open = open
56 captured.setOpen = setOpen
57 }, [commandSections, currentPage, open, setOpen])
58
59 return null
60}
61
62const renderHarness = async () => {
63 const renderResult = render(
64 <CommandProvider>
65 <ThemeSwitcherHarness />
66 </CommandProvider>
67 )
68
69 await waitFor(() => {
70 expect(getThemeSection()).toBeDefined()
71 })
72
73 return renderResult
74}
75
76const getThemeSection = () => captured.commandSections.find((section) => section.name === 'Theme')
77
78const getThemeCommand = (id: string) => {
79 const command = getThemeSection()?.commands.find((x) => x.id === id)
80 expect(command).toBeDefined()
81 return command as ICommand
82}
83
84const runAction = (id: string) => {
85 const command = getThemeCommand(id)
86 expect('action' in command).toBe(true)
87 const actionCommand = command as Extract<ICommand, { action: () => void }>
88
89 act(() => {
90 actionCommand.action()
91 })
92}
93
94describe('useThemeSwitcherCommands', () => {
95 beforeEach(() => {
96 themeMock.setTheme.mockReset()
97 themeMock.state.resolvedTheme = 'light'
98
99 captured.commandSections = []
100 captured.currentPage = undefined
101 captured.open = false
102 captured.setOpen = undefined
103 })
104
105 it('registers hidden direct theme commands in the root Theme section', async () => {
106 await renderHarness()
107
108 const themeSection = getThemeSection()
109 expect(themeSection).toBeDefined()
110
111 const ids = themeSection!.commands.map((command) => command.id)
112 expect(ids).toEqual([
113 'toggle-theme',
114 'set-theme-dark',
115 'set-theme-light',
116 'set-theme-system',
117 'switch-theme',
118 ])
119
120 expect(themeSection!.commands.every((command) => command.defaultHidden)).toBe(true)
121 })
122
123 it('adds root alias values so theme commands are discoverable by search terms', async () => {
124 await renderHarness()
125
126 const values = getThemeSection()!
127 .commands.map((command) => command.value ?? '')
128 .join(' ')
129 .toLowerCase()
130
131 expect(values).toContain('dark')
132 expect(values).toContain('light')
133 expect(values).toContain('toggle')
134 expect(values).toContain('theme')
135 })
136
137 it('direct theme commands call setTheme and close the command menu', async () => {
138 await renderHarness()
139
140 act(() => {
141 captured.setOpen?.(true)
142 })
143
144 await waitFor(() => {
145 expect(captured.open).toBe(true)
146 })
147
148 runAction('set-theme-dark')
149
150 await waitFor(() => {
151 expect(themeMock.setTheme).toHaveBeenCalledWith('dark')
152 expect(captured.open).toBe(false)
153 })
154
155 act(() => {
156 captured.setOpen?.(true)
157 })
158 await waitFor(() => {
159 expect(captured.open).toBe(true)
160 })
161
162 runAction('set-theme-light')
163
164 await waitFor(() => {
165 expect(themeMock.setTheme).toHaveBeenCalledWith('light')
166 expect(captured.open).toBe(false)
167 })
168
169 act(() => {
170 captured.setOpen?.(true)
171 })
172 await waitFor(() => {
173 expect(captured.open).toBe(true)
174 })
175
176 runAction('set-theme-system')
177
178 await waitFor(() => {
179 expect(themeMock.setTheme).toHaveBeenCalledWith('system')
180 expect(captured.open).toBe(false)
181 })
182 })
183
184 it('toggle theme switches dark to light', async () => {
185 themeMock.state.resolvedTheme = 'dark'
186 await renderHarness()
187
188 runAction('toggle-theme')
189
190 expect(themeMock.setTheme).toHaveBeenCalledWith('light')
191 })
192
193 it('toggle theme switches non-dark modes to dark', async () => {
194 themeMock.state.resolvedTheme = 'light'
195 await renderHarness()
196
197 runAction('toggle-theme')
198
199 expect(themeMock.setTheme).toHaveBeenCalledWith('dark')
200 })
201
202 it('toggle theme uses the latest resolved theme across rerenders in the same session', async () => {
203 const renderResult = await renderHarness()
204
205 runAction('toggle-theme')
206 expect(themeMock.setTheme).toHaveBeenLastCalledWith('dark')
207
208 themeMock.state.resolvedTheme = 'dark'
209 renderResult.rerender(
210 <CommandProvider>
211 <ThemeSwitcherHarness />
212 </CommandProvider>
213 )
214
215 await waitFor(() => {
216 expect(getThemeSection()).toBeDefined()
217 })
218
219 runAction('toggle-theme')
220 expect(themeMock.setTheme).toHaveBeenLastCalledWith('light')
221 })
222
223 it('keeps the Switch theme submenu page with System/Dark/Light commands only', async () => {
224 await renderHarness()
225
226 runAction('switch-theme')
227
228 await waitFor(() => {
229 expect(captured.currentPage?.name).toBe('Switch theme')
230 })
231
232 expect(captured.currentPage?.type).toBe(PageType.Commands)
233 const sections =
234 captured.currentPage && 'sections' in captured.currentPage
235 ? captured.currentPage.sections
236 : []
237
238 expect(sections).toHaveLength(1)
239 expect(sections[0].name).toBe('Switch theme')
240
241 const pageCommands = sections[0].commands
242 expect(pageCommands.map((command) => command.name)).toEqual(['System', 'Dark', 'Light'])
243 expect(pageCommands.map((command) => command.value)).toEqual([
244 'System theme, Follow system appearance',
245 'Dark theme, Dark mode',
246 'Light theme, Light mode',
247 ])
248 expect(pageCommands.some((command) => command.name === 'Classic dark')).toBe(false)
249 })
250})