1- import { cleanup , render , waitFor } from "@testing-library/react" ;
1+ import { cleanup , render , act , waitFor } from "@testing-library/react" ;
22import { afterEach , beforeAll , beforeEach , describe , expect , it , vi } from "vitest" ;
33
4- import { ThemeProvider , useTheme , useFont } from "./theme-context" ;
5-
6- const storageKey = "theme-context-test-theme" ;
7- const fontStorageKey = "theme-context-test-font" ;
4+ import { ThemeProvider , useTheme } from "./theme-context" ;
85
96/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
107function installLocalStorageStub ( ) {
@@ -27,8 +24,12 @@ function installLocalStorageStub() {
2724 return map ;
2825}
2926
27+ type MatchMediaListener = ( e : MediaQueryListEvent ) => void ;
28+ let matchMediaListeners : MatchMediaListener [ ] = [ ] ;
29+
3030/** `matchMedia` is not implemented in some test runtimes — stub to a controllable shape. */
3131function installMatchMediaStub ( matches : boolean ) {
32+ matchMediaListeners = [ ] ;
3233 Object . defineProperty ( window , "matchMedia" , {
3334 configurable : true ,
3435 value : vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
@@ -37,8 +38,12 @@ function installMatchMediaStub(matches: boolean) {
3738 onchange : null ,
3839 addListener : vi . fn ( ) ,
3940 removeListener : vi . fn ( ) ,
40- addEventListener : vi . fn ( ) ,
41- removeEventListener : vi . fn ( ) ,
41+ addEventListener : ( _ : string , fn : MatchMediaListener ) => {
42+ matchMediaListeners . push ( fn ) ;
43+ } ,
44+ removeEventListener : ( _ : string , fn : MatchMediaListener ) => {
45+ matchMediaListeners = matchMediaListeners . filter ( ( l ) => l !== fn ) ;
46+ } ,
4247 dispatchEvent : vi . fn ( ) ,
4348 } ) ) ,
4449 } ) ;
@@ -52,6 +57,7 @@ beforeAll(() => {
5257
5358beforeEach ( ( ) => {
5459 storageMap . clear ( ) ;
60+ matchMediaListeners = [ ] ;
5561 document . documentElement . removeAttribute ( "data-theme" ) ;
5662 document . documentElement . removeAttribute ( "data-font" ) ;
5763 document . documentElement . classList . remove ( "light" , "dark" ) ;
@@ -72,62 +78,58 @@ function ThemeProbe() {
7278}
7379
7480function FontProbe ( ) {
75- const { font } = useFont ( ) ;
81+ const { font } = useTheme ( ) ;
7682 return < span data-testid = "font" > { font } </ span > ;
7783}
7884
79- describe ( "ThemeProvider — legacy id migration" , ( ) => {
80- it . each ( [
81- [ "tailor-light" , "cream" ] ,
82- [ "tailor-bloom" , "bloom" ] ,
83- [ "tailor-dark" , "dark" ] ,
84- ] ) ( "maps stored %s → %s on first render" , async ( stored , expected ) => {
85- storageMap . set ( storageKey , stored ) ;
85+ describe ( "ThemeProvider — storage validation" , ( ) => {
86+ it ( "falls back to defaultTheme for an unrecognized stored value" , ( ) => {
87+ storageMap . set ( "appshell-ui-theme" , "totally-not-a-theme" ) ;
8688
8789 const { getByTestId } = render (
88- < ThemeProvider storageKey = { storageKey } fontStorageKey = { fontStorageKey } defaultTheme = "bloom " >
90+ < ThemeProvider defaultTheme = "light " >
8991 < ThemeProbe />
9092 </ ThemeProvider > ,
9193 ) ;
9294
93- expect ( getByTestId ( "theme" ) . textContent ) . toBe ( expected ) ;
94- await waitFor ( ( ) => {
95- expect ( document . documentElement . dataset . theme ) . toBe ( expected ) ;
96- } ) ;
95+ expect ( getByTestId ( "theme" ) . textContent ) . toBe ( "light" ) ;
9796 } ) ;
9897
99- it ( "falls back to defaultTheme for an unrecognized stored value " , ( ) => {
100- storageMap . set ( storageKey , "totally-not-a-theme ") ;
98+ it ( "falls back to defaultFont for an unrecognized stored font " , ( ) => {
99+ storageMap . set ( "appshell-ui-font" , "wingdings ") ;
101100
102101 const { getByTestId } = render (
103- < ThemeProvider storageKey = { storageKey } fontStorageKey = { fontStorageKey } defaultTheme = "light ">
104- < ThemeProbe />
102+ < ThemeProvider defaultFont = "inter ">
103+ < FontProbe />
105104 </ ThemeProvider > ,
106105 ) ;
107106
108- expect ( getByTestId ( "theme " ) . textContent ) . toBe ( "light " ) ;
107+ expect ( getByTestId ( "font " ) . textContent ) . toBe ( "inter " ) ;
109108 } ) ;
110109
111- it ( "falls back to defaultFont for an unrecognized stored font" , ( ) => {
112- storageMap . set ( fontStorageKey , "wingdings " ) ;
110+ it ( "reads a valid stored theme" , async ( ) => {
111+ storageMap . set ( "appshell-ui-theme" , "cream " ) ;
113112
114113 const { getByTestId } = render (
115- < ThemeProvider storageKey = { storageKey } fontStorageKey = { fontStorageKey } defaultFont = "inter" >
116- < FontProbe />
114+ < ThemeProvider >
115+ < ThemeProbe />
117116 </ ThemeProvider > ,
118117 ) ;
119118
120- expect ( getByTestId ( "font" ) . textContent ) . toBe ( "inter" ) ;
119+ expect ( getByTestId ( "theme" ) . textContent ) . toBe ( "cream" ) ;
120+ await waitFor ( ( ) => {
121+ expect ( document . documentElement . dataset . theme ) . toBe ( "cream" ) ;
122+ } ) ;
121123 } ) ;
122124} ) ;
123125
124126describe ( "ThemeProvider — system resolution" , ( ) => {
125127 it ( "resolves system → dark when prefers-color-scheme: dark matches" , async ( ) => {
126128 installMatchMediaStub ( true ) ;
127- storageMap . set ( storageKey , "system" ) ;
129+ storageMap . set ( "appshell-ui-theme" , "system" ) ;
128130
129131 const { getByTestId } = render (
130- < ThemeProvider storageKey = { storageKey } fontStorageKey = { fontStorageKey } >
132+ < ThemeProvider >
131133 < ThemeProbe />
132134 </ ThemeProvider > ,
133135 ) ;
@@ -142,10 +144,10 @@ describe("ThemeProvider — system resolution", () => {
142144
143145 it ( "resolves system → light when prefers-color-scheme: dark does not match" , async ( ) => {
144146 installMatchMediaStub ( false ) ;
145- storageMap . set ( storageKey , "system" ) ;
147+ storageMap . set ( "appshell-ui-theme" , "system" ) ;
146148
147149 const { getByTestId } = render (
148- < ThemeProvider storageKey = { storageKey } fontStorageKey = { fontStorageKey } >
150+ < ThemeProvider >
149151 < ThemeProbe />
150152 </ ThemeProvider > ,
151153 ) ;
@@ -156,19 +158,37 @@ describe("ThemeProvider — system resolution", () => {
156158 expect ( document . documentElement . classList . contains ( "light" ) ) . toBe ( true ) ;
157159 } ) ;
158160 } ) ;
161+
162+ it ( "reacts to OS color-scheme change while theme is system" , async ( ) => {
163+ installMatchMediaStub ( false ) ;
164+ storageMap . set ( "appshell-ui-theme" , "system" ) ;
165+
166+ const { getByTestId } = render (
167+ < ThemeProvider >
168+ < ThemeProbe />
169+ </ ThemeProvider > ,
170+ ) ;
171+
172+ expect ( getByTestId ( "resolved" ) . textContent ) . toBe ( "light" ) ;
173+
174+ // Simulate OS dark mode toggle
175+ act ( ( ) => {
176+ for ( const listener of matchMediaListeners ) {
177+ listener ( { matches : true } as MediaQueryListEvent ) ;
178+ }
179+ } ) ;
180+
181+ expect ( getByTestId ( "resolved" ) . textContent ) . toBe ( "dark" ) ;
182+ await waitFor ( ( ) => {
183+ expect ( document . documentElement . dataset . theme ) . toBe ( "dark" ) ;
184+ } ) ;
185+ } ) ;
159186} ) ;
160187
161- describe ( "useTheme / useFont — provider guard" , ( ) => {
188+ describe ( "useTheme — provider guard" , ( ) => {
162189 it ( "throws when useTheme is called outside ThemeProvider" , ( ) => {
163- // Silence React's expected error log for this assertion.
164190 const spy = vi . spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
165191 expect ( ( ) => render ( < ThemeProbe /> ) ) . toThrow ( / u s e T h e m e m u s t b e u s e d w i t h i n a T h e m e P r o v i d e r / ) ;
166192 spy . mockRestore ( ) ;
167193 } ) ;
168-
169- it ( "throws when useFont is called outside ThemeProvider" , ( ) => {
170- const spy = vi . spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
171- expect ( ( ) => render ( < FontProbe /> ) ) . toThrow ( / u s e F o n t m u s t b e u s e d w i t h i n a T h e m e P r o v i d e r / ) ;
172- spy . mockRestore ( ) ;
173- } ) ;
174194} ) ;
0 commit comments