11import { describe , it , expect , vi , beforeEach } from 'vitest' ;
2- import { render , screen , fireEvent } from '@testing-library/react' ;
2+ import { act , render , screen , fireEvent } from '@testing-library/react' ;
33import Navbar from './navbar' ;
44import type { ReactNode } from 'react' ;
55
6- Object . defineProperty ( window , 'matchMedia' , {
7- writable : true ,
8- value : vi . fn ( ) . mockImplementation ( ( query ) => ( {
9- matches : false ,
10- media : query ,
11- onchange : null ,
12- addListener : vi . fn ( ) ,
13- removeListener : vi . fn ( ) ,
14- addEventListener : vi . fn ( ) ,
15- removeEventListener : vi . fn ( ) ,
16- dispatchEvent : vi . fn ( ) ,
17- } ) ) ,
18- } ) ;
6+ type MatchMediaChangeListener = ( event : MediaQueryListEvent ) => void ;
7+
8+ function mockMatchMedia ( initialMatches = false ) {
9+ let matches = initialMatches ;
10+ const listeners = new Set < MatchMediaChangeListener > ( ) ;
11+
12+ Object . defineProperty ( window , 'matchMedia' , {
13+ writable : true ,
14+ value : vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
15+ get matches ( ) {
16+ return matches ;
17+ } ,
18+ media : query ,
19+ onchange : null ,
20+ addListener : vi . fn ( ( listener : MatchMediaChangeListener ) => listeners . add ( listener ) ) ,
21+ removeListener : vi . fn ( ( listener : MatchMediaChangeListener ) => listeners . delete ( listener ) ) ,
22+ addEventListener : vi . fn ( ( event : string , listener : MatchMediaChangeListener ) => {
23+ if ( event === 'change' ) listeners . add ( listener ) ;
24+ } ) ,
25+ removeEventListener : vi . fn ( ( event : string , listener : MatchMediaChangeListener ) => {
26+ if ( event === 'change' ) listeners . delete ( listener ) ;
27+ } ) ,
28+ dispatchEvent : vi . fn ( ) ,
29+ } ) ) ,
30+ } ) ;
31+
32+ return {
33+ setMatches ( nextMatches : boolean ) {
34+ matches = nextMatches ;
35+ listeners . forEach ( ( listener ) => {
36+ listener ( {
37+ matches,
38+ media : '(min-width: 768px)' ,
39+ } as MediaQueryListEvent ) ;
40+ } ) ;
41+ } ,
42+ } ;
43+ }
1944
2045vi . mock ( 'framer-motion' , ( ) => ( {
2146 motion : {
@@ -34,6 +59,9 @@ vi.mock('lucide-react', () => ({
3459describe ( 'Navbar mobile menu' , ( ) => {
3560 beforeEach ( ( ) => {
3661 window . innerWidth = 500 ;
62+ mockMatchMedia ( false ) ;
63+ window . localStorage . clear ( ) ;
64+ document . documentElement . className = '' ;
3765 } ) ;
3866
3967 it ( 'menu is hidden by default' , ( ) => {
@@ -63,23 +91,65 @@ describe('Navbar mobile menu', () => {
6391 expect ( screen . queryByText ( / c l o s e i c o n / i) ) . toBeNull ( ) ;
6492 } ) ;
6593
66- it ( 'closes menu on resize to desktop' , ( ) => {
94+ it ( 'keeps menu open when a plain resize event fires below desktop' , ( ) => {
6795 render ( < Navbar /> ) ;
6896
6997 const button = screen . getByLabelText ( / o p e n m e n u / i) ;
7098
7199 fireEvent . click ( button ) ;
72100
73- window . innerWidth = 1200 ;
74-
75- window . matchMedia = vi . fn ( ) . mockImplementation ( ( ) => ( {
76- matches : true ,
77- addEventListener : vi . fn ( ) ,
78- removeEventListener : vi . fn ( ) ,
79- } ) ) ;
80-
81101 window . dispatchEvent ( new Event ( 'resize' ) ) ;
82102
83103 expect ( button . getAttribute ( 'aria-expanded' ) ) . toBe ( 'true' ) ;
84104 } ) ;
85105} ) ;
106+
107+ describe ( 'Navbar responsive breakpoints' , ( ) => {
108+ beforeEach ( ( ) => {
109+ window . innerWidth = 500 ;
110+ window . localStorage . clear ( ) ;
111+ document . documentElement . className = '' ;
112+ } ) ;
113+
114+ it ( 'renders semantic navigation and mobile menu controls at small widths' , ( ) => {
115+ mockMatchMedia ( false ) ;
116+
117+ render ( < Navbar /> ) ;
118+
119+ expect ( screen . getByRole ( 'navigation' ) ) . toBeTruthy ( ) ;
120+ expect ( screen . getByRole ( 'link' , { name : / g o t o h o m e / i } ) ) . toBeTruthy ( ) ;
121+ expect ( screen . getByRole ( 'button' , { name : / o p e n m e n u / i } ) . getAttribute ( 'aria-expanded' ) ) . toBe (
122+ 'false'
123+ ) ;
124+
125+ fireEvent . click ( screen . getByRole ( 'button' , { name : / o p e n m e n u / i } ) ) ;
126+
127+ expect ( screen . getByRole ( 'button' , { name : / c l o s e m e n u / i } ) . getAttribute ( 'aria-expanded' ) ) . toBe (
128+ 'true'
129+ ) ;
130+ expect ( screen . getAllByRole ( 'link' , { name : / c u s t o m i z a t i o n s t u d i o / i } ) ) . toHaveLength ( 2 ) ;
131+ expect ( screen . getAllByRole ( 'link' , { name : / g i t h u b r e p o / i } ) ) . toHaveLength ( 2 ) ;
132+ } ) ;
133+
134+ it ( 'closes the open hamburger menu when crossing into the desktop breakpoint' , ( ) => {
135+ const matchMedia = mockMatchMedia ( false ) ;
136+
137+ render ( < Navbar /> ) ;
138+
139+ fireEvent . click ( screen . getByRole ( 'button' , { name : / o p e n m e n u / i } ) ) ;
140+
141+ expect ( screen . getByRole ( 'button' , { name : / c l o s e m e n u / i } ) . getAttribute ( 'aria-expanded' ) ) . toBe (
142+ 'true'
143+ ) ;
144+
145+ act ( ( ) => {
146+ matchMedia . setMatches ( true ) ;
147+ } ) ;
148+
149+ expect ( screen . getByRole ( 'button' , { name : / o p e n m e n u / i } ) . getAttribute ( 'aria-expanded' ) ) . toBe (
150+ 'false'
151+ ) ;
152+ expect ( screen . getAllByRole ( 'link' , { name : / c u s t o m i z a t i o n s t u d i o / i } ) ) . toHaveLength ( 1 ) ;
153+ expect ( screen . getAllByRole ( 'link' , { name : / g i t h u b r e p o / i } ) ) . toHaveLength ( 1 ) ;
154+ } ) ;
155+ } ) ;
0 commit comments