11import React from "react" ;
2- import { render , screen } from "@testing-library/react" ;
2+ import { act , render , screen , waitFor } from "@testing-library/react" ;
33import userEvent from "@testing-library/user-event" ;
44
55import MarketsPage from "../page" ;
66
7+ // ── next/navigation ────────────────────────────────────────────────────────
8+ // The page syncs filters to the URL via router.replace; capture the calls and
9+ // drive the initial query string from `mockSearchParams`.
10+ const mockReplace = jest . fn ( ) ;
11+ const mockPush = jest . fn ( ) ;
12+ let mockSearchParams = new URLSearchParams ( ) ;
13+
14+ jest . mock ( "next/navigation" , ( ) => ( {
15+ useRouter : ( ) => ( { replace : mockReplace , push : mockPush , prefetch : jest . fn ( ) } ) ,
16+ usePathname : ( ) => "/markets" ,
17+ useSearchParams : ( ) => mockSearchParams ,
18+ } ) ) ;
19+
20+ // ── live region ────────────────────────────────────────────────────────────
21+ // The real hook is a module-level store; stubbing it keeps the assertion on the
22+ // announcement payload local to this suite.
23+ const mockAnnounce = jest . fn ( ) ;
24+ jest . mock ( "@/hooks/use-global-live-region" , ( ) => ( {
25+ useGlobalLiveRegion : ( ) => ( { announcements : [ ] , announce : mockAnnounce } ) ,
26+ } ) ) ;
27+
728// next/link needs a router in tests
829jest . mock ( "next/link" , ( ) => {
930 const Link = ( { children, href } : { children : React . ReactNode ; href : string } ) => (
@@ -13,6 +34,15 @@ jest.mock("next/link", () => {
1334 return Link ;
1435} ) ;
1536
37+ beforeEach ( ( ) => {
38+ mockReplace . mockClear ( ) ;
39+ mockPush . mockClear ( ) ;
40+ mockAnnounce . mockClear ( ) ;
41+ mockSearchParams = new URLSearchParams ( ) ;
42+ } ) ;
43+
44+ const searchbox = ( ) => screen . getByRole ( "searchbox" , { name : / s e a r c h m a r k e t s / i } ) ;
45+
1646describe ( "MarketsPage — empty state" , ( ) => {
1747 it ( "renders markets when no filters are active" , ( ) => {
1848 render ( < MarketsPage /> ) ;
@@ -24,7 +54,7 @@ describe("MarketsPage — empty state", () => {
2454 const user = userEvent . setup ( ) ;
2555 render ( < MarketsPage /> ) ;
2656
27- await user . type ( screen . getByRole ( " searchbox" , { name : / s e a r c h m a r k e t s / i } ) , "zzznomatch" ) ;
57+ await user . type ( searchbox ( ) , "zzznomatch" ) ;
2858
2959 expect ( screen . getByRole ( "status" ) ) . toBeInTheDocument ( ) ;
3060 expect ( screen . getByText ( / n o m a r k e t s m a t c h y o u r s e a r c h / i) ) . toBeInTheDocument ( ) ;
@@ -34,23 +64,119 @@ describe("MarketsPage — empty state", () => {
3464 const user = userEvent . setup ( ) ;
3565 render ( < MarketsPage /> ) ;
3666
37- await user . type ( screen . getByRole ( " searchbox" , { name : / s e a r c h m a r k e t s / i } ) , "zzznomatch" ) ;
67+ await user . type ( searchbox ( ) , "zzznomatch" ) ;
3868 expect ( screen . getByRole ( "status" ) ) . toBeInTheDocument ( ) ;
3969
4070 await user . click ( screen . getByRole ( "button" , { name : / c l e a r a l l f i l t e r s / i } ) ) ;
4171
4272 expect ( screen . queryByRole ( "status" ) ) . not . toBeInTheDocument ( ) ;
4373 expect ( screen . getByText ( / w i l l a r g e n t i n a w i n / i) ) . toBeInTheDocument ( ) ;
44- expect ( screen . getByRole ( " searchbox" , { name : / s e a r c h m a r k e t s / i } ) ) . toHaveValue ( "" ) ;
74+ expect ( searchbox ( ) ) . toHaveValue ( "" ) ;
4575 } ) ;
4676
4777 it ( "empty state has role=status and aria-live=polite for accessibility" , async ( ) => {
4878 const user = userEvent . setup ( ) ;
4979 render ( < MarketsPage /> ) ;
5080
51- await user . type ( screen . getByRole ( " searchbox" , { name : / s e a r c h m a r k e t s / i } ) , "zzznomatch" ) ;
81+ await user . type ( searchbox ( ) , "zzznomatch" ) ;
5282
5383 const status = screen . getByRole ( "status" ) ;
5484 expect ( status ) . toHaveAttribute ( "aria-live" , "polite" ) ;
5585 } ) ;
5686} ) ;
87+
88+ describe ( "MarketsPage — URL-backed filters" , ( ) => {
89+ it ( "hydrates search and category from the query string" , ( ) => {
90+ mockSearchParams = new URLSearchParams ( "q=argentina&category=Football" ) ;
91+
92+ render ( < MarketsPage /> ) ;
93+
94+ expect ( searchbox ( ) ) . toHaveValue ( "argentina" ) ;
95+ expect ( screen . getByRole ( "combobox" , { name : / f i l t e r b y c a t e g o r y / i } ) ) . toHaveTextContent (
96+ "Football" ,
97+ ) ;
98+ expect ( screen . getByText ( / w i l l a r g e n t i n a w i n / i) ) . toBeInTheDocument ( ) ;
99+ expect ( screen . queryByText ( / w i l l b r a z i l r e a c h / i) ) . not . toBeInTheDocument ( ) ;
100+ } ) ;
101+
102+ it ( "falls back to 'all' for an unknown category param instead of hiding every market" , ( ) => {
103+ mockSearchParams = new URLSearchParams ( "category=NotARealCategory" ) ;
104+
105+ render ( < MarketsPage /> ) ;
106+
107+ expect ( screen . getByRole ( "combobox" , { name : / f i l t e r b y c a t e g o r y / i } ) ) . toHaveTextContent (
108+ / a l l c a t e g o r i e s / i,
109+ ) ;
110+ expect ( screen . getByText ( / w i l l a r g e n t i n a w i n / i) ) . toBeInTheDocument ( ) ;
111+ expect ( screen . getByText ( / w i l l b r a z i l r e a c h / i) ) . toBeInTheDocument ( ) ;
112+ } ) ;
113+
114+ it ( "writes ?q= with router.replace after the search debounce, never router.push" , async ( ) => {
115+ jest . useFakeTimers ( ) ;
116+ const user = userEvent . setup ( { advanceTimers : ( ms : number ) => jest . advanceTimersByTime ( ms ) } ) ;
117+ try {
118+ render ( < MarketsPage /> ) ;
119+
120+ await user . type ( searchbox ( ) , "argentina" ) ;
121+ // Still inside the debounce window: no URL write yet.
122+ expect ( mockReplace ) . not . toHaveBeenCalled ( ) ;
123+
124+ act ( ( ) => {
125+ jest . advanceTimersByTime ( 300 ) ;
126+ } ) ;
127+
128+ expect ( mockReplace ) . toHaveBeenCalledTimes ( 1 ) ;
129+ expect ( mockReplace . mock . calls [ 0 ] [ 0 ] ) . toBe ( "/markets?q=argentina" ) ;
130+ expect ( mockPush ) . not . toHaveBeenCalled ( ) ;
131+ } finally {
132+ jest . useRealTimers ( ) ;
133+ }
134+ } ) ;
135+
136+ it ( "clears the query string immediately when the empty-state button resets filters" , async ( ) => {
137+ jest . useFakeTimers ( ) ;
138+ const user = userEvent . setup ( { advanceTimers : ( ms : number ) => jest . advanceTimersByTime ( ms ) } ) ;
139+ try {
140+ mockSearchParams = new URLSearchParams ( "q=zzznomatch&category=Football" ) ;
141+ render ( < MarketsPage /> ) ;
142+
143+ expect ( screen . getByRole ( "status" ) ) . toBeInTheDocument ( ) ;
144+
145+ await user . click ( screen . getByRole ( "button" , { name : / c l e a r a l l f i l t e r s / i } ) ) ;
146+
147+ expect ( mockReplace ) . toHaveBeenCalledWith ( "/markets" , { scroll : false } ) ;
148+ expect ( screen . queryByRole ( "status" ) ) . not . toBeInTheDocument ( ) ;
149+ } finally {
150+ jest . useRealTimers ( ) ;
151+ }
152+ } ) ;
153+ } ) ;
154+
155+ describe ( "MarketsPage — result announcements" , ( ) => {
156+ it ( "announces the matching count politely, once per change" , async ( ) => {
157+ jest . useFakeTimers ( ) ;
158+ const user = userEvent . setup ( { advanceTimers : ( ms : number ) => jest . advanceTimersByTime ( ms ) } ) ;
159+ try {
160+ render ( < MarketsPage /> ) ;
161+
162+ // The initial render is not a change, so the live region stays quiet.
163+ expect ( mockAnnounce ) . not . toHaveBeenCalled ( ) ;
164+
165+ await user . type ( searchbox ( ) , "arg" ) ;
166+
167+ expect ( mockAnnounce ) . toHaveBeenCalledTimes ( 1 ) ;
168+ expect ( mockAnnounce ) . toHaveBeenCalledWith ( {
169+ message : "1 market found" ,
170+ priority : "polite" ,
171+ } ) ;
172+
173+ // A trailing space trims to the same query: the count is unchanged, so
174+ // there must be no second announcement.
175+ await user . type ( searchbox ( ) , " " ) ;
176+ await waitFor ( ( ) => expect ( searchbox ( ) ) . toHaveValue ( "arg " ) ) ;
177+ expect ( mockAnnounce ) . toHaveBeenCalledTimes ( 1 ) ;
178+ } finally {
179+ jest . useRealTimers ( ) ;
180+ }
181+ } ) ;
182+ } ) ;
0 commit comments