1- // @vitest -environment happy-dom
1+ // @vitest -environment node
22/**
33 * ThemePicker Web Component Unit Tests
44 * Tests the LitElement-based theme picker functionality
55 */
66import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest'
7+ import { Window } from 'happy-dom'
78import { experimental_AstroContainer as AstroContainer } from 'astro/container'
89import ThemePickerFixture from '@components/ThemePicker/__fixtures__/client.fixture.astro'
10+ import type { Props as ThemePickerFixtureProps } from '@components/ThemePicker/__fixtures__/client.fixture.astro'
11+
12+ vi . mock (
13+ '@components/Sprite/index.astro' ,
14+ async ( ) => await import ( '@components/ThemePicker/__mocks__/SpriteMock.astro' )
15+ )
16+
17+ type ParsedDocument = ReturnType < Window [ 'DOMParser' ] [ 'prototype' ] [ 'parseFromString' ] >
18+
19+ interface RenderResult {
20+ document : ParsedDocument
21+ }
22+
23+ const queryElement = < T extends Element > ( selector : string , targetDoc : ParsedDocument ) : T => {
24+ const element = targetDoc . querySelector ( selector ) as T | null
25+ if ( ! element ) {
26+ throw new Error ( `Expected element matching "${ selector } " to exist` )
27+ }
28+ return element
29+ }
30+
31+ const queryElements = < T extends Element > ( selector : string , targetDoc : ParsedDocument ) : T [ ] =>
32+ Array . from ( targetDoc . querySelectorAll ( selector ) ) as unknown as T [ ]
33+
34+ const renderFixture = async (
35+ container : AstroContainer ,
36+ props ?: ThemePickerFixtureProps
37+ ) : Promise < RenderResult > => {
38+ const renderOptions = props ? { props : props as Record < string , unknown > } : undefined
39+ const html = await container . renderToString ( ThemePickerFixture , renderOptions )
40+ const window = new Window ( )
41+ const parser = new window . DOMParser ( )
42+ const document = parser . parseFromString ( html , 'text/html' )
43+ return { document }
44+ }
945
1046describe ( 'ThemePicker Component' , ( ) => {
1147 let container : AstroContainer
12- let html : string
48+ let documentRef : ParsedDocument
1349
1450 beforeEach ( async ( ) => {
1551 container = await AstroContainer . create ( )
16- html = await container . renderToString ( ThemePickerFixture )
17- document . body . innerHTML = html
18-
19- // Wait for all async operations (including Lit component initialization) to complete
20- await ( window as any ) . happyDOM . whenAsyncComplete ( )
21-
22- // Additional tick to ensure event handlers are bound
23- await new Promise ( resolve => setTimeout ( resolve , 0 ) )
52+ const renderResult = await renderFixture ( container )
53+ documentRef = renderResult . document
2454 } )
2555
2656 afterEach ( ( ) => {
27- document . body . innerHTML = ''
2857 vi . restoreAllMocks ( )
2958 } )
3059
60+ const getThemePickerElement = ( doc = documentRef ) => queryElement < HTMLElement > ( 'theme-picker' , doc )
61+ const getToggleButton = ( doc = documentRef ) => queryElement < HTMLButtonElement > ( '.themepicker-toggle__toggle-btn' , doc )
62+ const getThemeModal = ( doc = documentRef ) => queryElement < HTMLElement > ( '[data-theme-modal]' , doc )
63+ const getThemeCloseButton = ( doc = documentRef ) => queryElement < HTMLButtonElement > ( '[data-theme-close]' , doc )
64+ const getThemeList = ( doc = documentRef ) => queryElement < HTMLElement > ( '#theme-menu' , doc )
65+ const getThemeListItems = ( doc = documentRef ) => queryElements < HTMLLIElement > ( '.themepicker__item' , doc )
66+ const getThemeButtons = ( doc = documentRef ) => queryElements < HTMLButtonElement > ( '[data-theme]' , doc )
67+ const getThemeSelectButtons = ( doc = documentRef ) => queryElements < HTMLButtonElement > ( '.themepicker__selectBtn' , doc )
68+
3169 describe ( 'Component Structure' , ( ) => {
3270 it ( 'should render theme-picker custom element' , ( ) => {
33- const themePicker = document . querySelector ( 'theme-picker' )
34- expect ( themePicker ) . toBeTruthy ( )
71+ const themePicker = getThemePickerElement ( )
72+ expect ( themePicker . tagName . toLowerCase ( ) ) . toBe ( 'theme-picker' )
3573 } )
3674
3775 it ( 'should render toggle button in header' , ( ) => {
38- const toggleBtn = document . querySelector ( '.themepicker-toggle__toggle-btn' )
39- expect ( toggleBtn ) . toBeTruthy ( )
40- expect ( toggleBtn ?. getAttribute ( 'aria-label' ) ) . toBe ( 'toggle theme switcher' )
41- expect ( toggleBtn ?. getAttribute ( 'aria-haspopup' ) ) . toBe ( 'true' )
76+ const toggleBtn = getToggleButton ( )
77+ expect ( toggleBtn . getAttribute ( 'aria-label' ) ) . toBe ( 'toggle theme switcher' )
78+ expect ( toggleBtn . getAttribute ( 'aria-haspopup' ) ) . toBe ( 'true' )
4279 } )
4380
4481 it ( 'should render modal with correct attributes' , ( ) => {
45- const modal = document . querySelector ( '[data-theme-modal]' )
46- expect ( modal ) . toBeTruthy ( )
47- expect ( modal ?. hasAttribute ( 'hidden' ) ) . toBe ( true )
48- expect ( modal ?. classList . contains ( 'themepicker' ) ) . toBe ( true )
82+ const modal = getThemeModal ( )
83+ expect ( modal . hasAttribute ( 'hidden' ) ) . toBe ( true )
84+ expect ( modal . classList . contains ( 'themepicker' ) ) . toBe ( true )
4985 } )
5086
5187 it ( 'should render theme selection buttons' , ( ) => {
52- const themeButtons = document . querySelectorAll ( '[data-theme]' )
88+ const themeButtons = getThemeButtons ( )
5389 expect ( themeButtons . length ) . toBeGreaterThan ( 0 )
5490
5591 // Should have at least light and dark (holiday is seasonal and may be commented out)
56- const themes = Array . from ( themeButtons ) . map ( btn => btn . getAttribute ( 'data-theme' ) )
92+ const themes = themeButtons . map ( btn => btn . getAttribute ( 'data-theme' ) )
5793 expect ( themes ) . toContain ( 'light' )
5894 expect ( themes ) . toContain ( 'dark' )
5995 } )
6096
6197 it ( 'should render close button' , ( ) => {
62- const closeBtn = document . querySelector ( '[data-theme-close]' )
63- expect ( closeBtn ) . toBeTruthy ( )
64- expect ( closeBtn ?. getAttribute ( 'aria-label' ) ) . toBe ( 'Close theme picker dialog' )
98+ const closeBtn = getThemeCloseButton ( )
99+ expect ( closeBtn . getAttribute ( 'aria-label' ) ) . toBe ( 'Close theme picker dialog' )
65100 } )
66101 } )
67102
68103 describe ( 'Accessibility' , ( ) => {
69104 it ( 'should have proper ARIA attributes on toggle button' , ( ) => {
70- const toggleBtn = document . querySelector ( '.themepicker-toggle__toggle-btn' )
71- expect ( toggleBtn ? .getAttribute ( 'aria-expanded' ) ) . toBe ( 'false' )
72- expect ( toggleBtn ? .getAttribute ( 'aria-owns' ) ) . toBe ( 'theme-menu' )
73- expect ( toggleBtn ? .getAttribute ( 'aria-haspopup' ) ) . toBe ( 'true' )
105+ const toggleBtn = getToggleButton ( )
106+ expect ( toggleBtn . getAttribute ( 'aria-expanded' ) ) . toBe ( 'false' )
107+ expect ( toggleBtn . getAttribute ( 'aria-owns' ) ) . toBe ( 'theme-menu' )
108+ expect ( toggleBtn . getAttribute ( 'aria-haspopup' ) ) . toBe ( 'true' )
74109 } )
75110
76111 it ( 'should have accessible labels on theme buttons' , ( ) => {
77- const themeButtons = document . querySelectorAll ( '[data-theme]' )
112+ const themeButtons = getThemeButtons ( )
78113 themeButtons . forEach ( button => {
79114 const label = button . getAttribute ( 'aria-label' )
80115 expect ( label ) . toBeTruthy ( )
@@ -83,37 +118,36 @@ describe('ThemePicker Component', () => {
83118 } )
84119
85120 it ( 'should have role="banner" on header' , ( ) => {
86- const header = document . querySelector ( '#header' )
87- expect ( header ? .getAttribute ( 'role' ) ) . toBe ( 'banner' )
121+ const header = queryElement < HTMLElement > ( '#header' , documentRef )
122+ expect ( header . getAttribute ( 'role' ) ) . toBe ( 'banner' )
88123 } )
89124 } )
90125
91126 describe ( 'Component Props' , ( ) => {
92127 it ( 'should accept custom label prop' , async ( ) => {
93- const customHtml = await container . renderToString ( ThemePickerFixture , {
94- props : { label : 'Custom Theme Label' }
95- } )
96- expect ( customHtml ) . toContain ( 'Custom Theme Label' )
128+ const { document : customDoc } = await renderFixture ( container , { label : 'Custom Theme Label' } )
129+ const heading = queryElement < HTMLHeadingElement > ( '.themepicker h3' , customDoc )
130+ expect ( heading . textContent ?. trim ( ) ) . toBe ( 'Custom Theme Label' )
97131 } )
98132
99133 it ( 'should use default label when not provided' , ( ) => {
100- const heading = document . querySelector ( '.themepicker h3' )
101- expect ( heading ? .textContent ?. trim ( ) ) . toBe ( 'Select theme' )
134+ const heading = queryElement < HTMLHeadingElement > ( '.themepicker h3' , documentRef )
135+ expect ( heading . textContent ?. trim ( ) ) . toBe ( 'Select theme' )
102136 } )
103137 } )
104138
105139 describe ( 'CSS Classes' , ( ) => {
106140 it ( 'should have Tailwind utility classes on modal' , ( ) => {
107- const modal = document . querySelector ( '[data-theme-modal]' )
108- expect ( modal ? .classList . contains ( 'bg-bg-offset' ) ) . toBe ( true )
109- expect ( modal ? .classList . contains ( 'block' ) ) . toBe ( true )
110- expect ( modal ? .classList . contains ( 'overflow-hidden' ) ) . toBe ( true )
111- expect ( modal ? .classList . contains ( 'relative' ) ) . toBe ( true )
112- expect ( modal ? .classList . contains ( 'w-full' ) ) . toBe ( true )
141+ const modal = getThemeModal ( )
142+ expect ( modal . classList . contains ( 'bg-bg-offset' ) ) . toBe ( true )
143+ expect ( modal . classList . contains ( 'block' ) ) . toBe ( true )
144+ expect ( modal . classList . contains ( 'overflow-hidden' ) ) . toBe ( true )
145+ expect ( modal . classList . contains ( 'relative' ) ) . toBe ( true )
146+ expect ( modal . classList . contains ( 'w-full' ) ) . toBe ( true )
113147 } )
114148
115149 it ( 'should have hover classes on theme items' , ( ) => {
116- const items = document . querySelectorAll ( '.themepicker__item' )
150+ const items = getThemeListItems ( )
117151 items . forEach ( item => {
118152 expect ( item . classList . contains ( 'hover:shadow-md' ) ) . toBe ( true )
119153 expect ( item . classList . contains ( 'hover:scale-105' ) ) . toBe ( true )
@@ -123,12 +157,12 @@ describe('ThemePicker Component', () => {
123157
124158 describe ( 'Data Attributes' , ( ) => {
125159 it ( 'should mark modal with data-nosnippet' , ( ) => {
126- const modal = document . querySelector ( '[data-theme-modal]' )
127- expect ( modal ? .hasAttribute ( 'data-nosnippet' ) ) . toBe ( true )
160+ const modal = getThemeModal ( )
161+ expect ( modal . hasAttribute ( 'data-nosnippet' ) ) . toBe ( true )
128162 } )
129163
130164 it ( 'should have data-theme attributes on selection buttons' , ( ) => {
131- const buttons = document . querySelectorAll ( '.themepicker__selectBtn' )
165+ const buttons = getThemeSelectButtons ( )
132166 buttons . forEach ( button => {
133167 expect ( button . hasAttribute ( 'data-theme' ) ) . toBe ( true )
134168 const theme = button . getAttribute ( 'data-theme' )
@@ -139,22 +173,21 @@ describe('ThemePicker Component', () => {
139173
140174 describe ( 'Theme List Structure' , ( ) => {
141175 it ( 'should render theme list with correct ID' , ( ) => {
142- const list = document . querySelector ( '#theme-menu' )
143- expect ( list ) . toBeTruthy ( )
144- expect ( list ?. classList . contains ( 'themepicker__list' ) ) . toBe ( true )
176+ const list = getThemeList ( )
177+ expect ( list . classList . contains ( 'themepicker__list' ) ) . toBe ( true )
145178 } )
146179
147180 it ( 'should render theme items as list elements' , ( ) => {
148- const items = document . querySelectorAll ( '.themepicker__item' )
181+ const items = getThemeListItems ( )
149182 items . forEach ( item => {
150183 expect ( item . tagName ) . toBe ( 'LI' )
151184 } )
152185 } )
153186
154187 it ( 'should have proper button structure in each theme item' , ( ) => {
155- const items = document . querySelectorAll ( '.themepicker__item' )
188+ const items = getThemeListItems ( )
156189 items . forEach ( item => {
157- const button = item . querySelector ( 'button' )
190+ const button = item . querySelector ( 'button' ) as HTMLButtonElement | null
158191 expect ( button ) . toBeTruthy ( )
159192 expect ( button ?. classList . contains ( 'themepicker__selectBtn' ) ) . toBe ( true )
160193 } )
@@ -163,7 +196,7 @@ describe('ThemePicker Component', () => {
163196
164197 describe ( 'Color Palette Display' , ( ) => {
165198 it ( 'should display color hues for each theme' , ( ) => {
166- const items = document . querySelectorAll ( '.themepicker__item' )
199+ const items = getThemeListItems ( )
167200 items . forEach ( item => {
168201 const hues = item . querySelectorAll ( '.themepicker__hue' )
169202 // Each theme should show 5 color hues
@@ -172,42 +205,51 @@ describe('ThemePicker Component', () => {
172205 } )
173206
174207 it ( 'should have correct Tailwind color classes on hues' , ( ) => {
175- const firstItem = document . querySelector ( '.themepicker__item' )
176- const hues = firstItem ?. querySelectorAll ( '.themepicker__hue' )
177-
178- expect ( hues ?. [ 0 ] . classList . contains ( 'bg-primary' ) ) . toBe ( true )
179- expect ( hues ?. [ 1 ] . classList . contains ( 'bg-secondary' ) ) . toBe ( true )
180- expect ( hues ?. [ 2 ] . classList . contains ( 'bg-border' ) ) . toBe ( true )
181- expect ( hues ?. [ 3 ] . classList . contains ( 'bg-text-offset' ) ) . toBe ( true )
182- expect ( hues ?. [ 4 ] . classList . contains ( 'bg-text' ) ) . toBe ( true )
208+ const items = getThemeListItems ( )
209+ expect ( items . length ) . toBeGreaterThan ( 0 )
210+ const firstItem = items [ 0 ]
211+ if ( ! firstItem ) {
212+ throw new Error ( 'Expected at least one theme list item' )
213+ }
214+
215+ const hues = Array . from ( firstItem . querySelectorAll ( '.themepicker__hue' ) ) as HTMLElement [ ]
216+ const expectedClasses = [ 'bg-primary' , 'bg-secondary' , 'bg-border' , 'bg-text-offset' , 'bg-text' ]
217+
218+ expectedClasses . forEach ( ( className , index ) => {
219+ const hue = hues [ index ]
220+ if ( ! hue ) {
221+ throw new Error ( `Missing hue at index ${ index } ` )
222+ }
223+ expect ( hue . classList . contains ( className ) ) . toBe ( true )
224+ } )
183225 } )
184226 } )
185227
186228 describe ( 'Close Button' , ( ) => {
187229 it ( 'should render sprite/icon for close button' , ( ) => {
188- const closeBtn = document . querySelector ( '[data-theme-close]' )
189- const svg = closeBtn ? .querySelector ( 'svg' )
230+ const closeBtn = getThemeCloseButton ( )
231+ const svg = closeBtn . querySelector ( 'svg' )
190232 expect ( svg ) . toBeTruthy ( )
191233 } )
192234
193235 it ( 'should have proper styling classes on close button' , ( ) => {
194- const closeBtn = document . querySelector ( '[data-theme-close]' )
195- expect ( closeBtn ? .classList . contains ( 'bg-secondary' ) ) . toBe ( true )
196- expect ( closeBtn ? .classList . contains ( 'text-text' ) ) . toBe ( true )
197- expect ( closeBtn ? .classList . contains ( 'absolute' ) ) . toBe ( true )
236+ const closeBtn = getThemeCloseButton ( )
237+ expect ( closeBtn . classList . contains ( 'bg-secondary' ) ) . toBe ( true )
238+ expect ( closeBtn . classList . contains ( 'text-text' ) ) . toBe ( true )
239+ expect ( closeBtn . classList . contains ( 'absolute' ) ) . toBe ( true )
198240 } )
199241 } )
200242
201243 describe ( 'Responsive Design' , ( ) => {
202244 it ( 'should have horizontal scroll on theme list' , ( ) => {
203- const list = document . querySelector ( '.themepicker__list' )
204- expect ( list ? .classList . contains ( 'overflow-x-auto' ) ) . toBe ( true )
205- expect ( list ? .classList . contains ( 'overflow-y-hidden' ) ) . toBe ( true )
245+ const list = queryElement < HTMLElement > ( '.themepicker__list' , documentRef )
246+ expect ( list . classList . contains ( 'overflow-x-auto' ) ) . toBe ( true )
247+ expect ( list . classList . contains ( 'overflow-y-hidden' ) ) . toBe ( true )
206248 } )
207249
208250 it ( 'should have whitespace-nowrap for horizontal layout' , ( ) => {
209- const list = document . querySelector ( '.themepicker__list' )
210- expect ( list ? .classList . contains ( 'whitespace-nowrap' ) ) . toBe ( true )
251+ const list = queryElement < HTMLElement > ( '.themepicker__list' , documentRef )
252+ expect ( list . classList . contains ( 'whitespace-nowrap' ) ) . toBe ( true )
211253 } )
212254 } )
213255} )
0 commit comments