11import { CarouselProps } from "../../typings/CarouselProps" ;
22import { CarouselStyle , defaultCarouselStyle } from "../ui/styles" ;
3- import { render } from "@testing-library/react-native" ;
4- import { Text , View } from "react-native" ;
3+ import { fireEvent , render } from "@testing-library/react-native" ;
4+ import { FlatList , Text , View } from "react-native" ;
55import { buildWidgetValue , ListValueBuilder } from "@mendix/piw-utils-internal" ;
66import { Carousel } from "../Carousel" ;
77
@@ -24,29 +24,246 @@ describe("Carousel", () => {
2424 } ;
2525 } ) ;
2626
27- /* TODO: enzyme or react-native-testing-lib still not allowing mocking states with hooks
28- Thus we can't snapshot test the actual carousel
29- */
27+ // Carousel.tsx renders nothing but a loading indicator until it has measured its own width via onLayout, so
28+ // every test beyond "renders loading" needs to fire that event first, same as Gallery's tests do.
29+ const fireContainerLayout = (
30+ carousel : ReturnType < typeof render > ,
31+ layout : { width : number ; height : number }
32+ ) : void => {
33+ fireEvent ( carousel . getByTestId ( defaultProps . name ) , "layout" , {
34+ nativeEvent : { layout : { x : 0 , y : 0 , ...layout } }
35+ } ) ;
36+ } ;
3037
3138 it ( "renders loading" , ( ) => {
3239 expect ( render ( < Carousel { ...defaultProps } /> ) . toJSON ( ) ) . toMatchSnapshot ( ) ;
3340 } ) ;
3441
35- // it("renders without pagination", () => {
36- // expect(render(<Carousel {...defaultProps} showPagination={false} />).toJSON()).toMatchSnapshot();
37- // });
38- //
39- // it("renders full width", () => {
40- // expect(render(<Carousel {...defaultProps} layout={"fullWidth"} />).toJSON()).toMatchSnapshot();
41- // });
42- //
43- // it("renders numbered pagination if item size is more than 5", () => {
44- // const props = {
45- // ...defaultProps,
46- // contentSource: listValueBuilder.withAmountOfItems(6)
47- // };
48- // expect(render(<Carousel {...props} />).toJSON()).toMatchSnapshot();
49- // });
42+ it ( "renders cards with dot pagination once it has measured its width" , ( ) => {
43+ const carousel = render ( < Carousel { ...defaultProps } /> ) ;
44+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
45+
46+ expect ( carousel . getByTestId ( "carousel$carousel" ) ) . toBeDefined ( ) ;
47+ expect ( carousel . getAllByRole ( "button" ) ) . toHaveLength ( 2 ) ;
48+ expect ( carousel . getByTestId ( "carousel$pagination" ) ) . toBeDefined ( ) ;
49+ } ) ;
50+
51+ it ( "renders without pagination" , ( ) => {
52+ const carousel = render ( < Carousel { ...defaultProps } showPagination = { false } /> ) ;
53+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
54+
55+ expect ( carousel . queryByTestId ( "carousel$pagination" ) ) . toBeNull ( ) ;
56+ } ) ;
57+
58+ it ( "renders full width" , ( ) => {
59+ const carousel = render ( < Carousel { ...defaultProps } layout = "fullWidth" /> ) ;
60+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
61+
62+ expect ( carousel . toJSON ( ) ) . toMatchSnapshot ( ) ;
63+ } ) ;
64+
65+ it ( "renders numbered pagination if item count is more than 5" , ( ) => {
66+ const carousel = render ( < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } /> ) ;
67+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
68+
69+ // The "N/M" text itself is hidden from screen readers (see the accessibility describe block below); the
70+ // count still has to be shown somewhere, so it's asserted via the pagination container's own a11y label.
71+ const pagination = carousel . getByTestId ( "carousel$pagination" ) ;
72+ expect ( pagination . props . accessibilityLabel ) . toBe ( "Slide 1 of 6" ) ;
73+ expect ( carousel . queryAllByRole ( "button" ) ) . toHaveLength ( 0 ) ;
74+ } ) ;
75+
76+ it ( "tracks the active slide once the list settles on it" , ( ) => {
77+ const carousel = render ( < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } /> ) ;
78+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
79+ // Card layout sizes each item to 70% of the measured width.
80+ const itemWidth = 300 * 0.7 ;
81+
82+ fireEvent ( carousel . getByTestId ( "carousel$carousel" ) , "momentumScrollEnd" , {
83+ nativeEvent : { contentOffset : { x : itemWidth } }
84+ } ) ;
85+
86+ expect ( carousel . getByTestId ( "carousel$pagination" ) . props . accessibilityLabel ) . toBe ( "Slide 2 of 6" ) ;
87+ } ) ;
88+
89+ it ( "scrolls to the slide behind a tapped dot" , ( ) => {
90+ const scrollToIndex = jest . spyOn ( FlatList . prototype , "scrollToIndex" ) . mockImplementation ( ( ) => undefined ) ;
91+ const carousel = render ( < Carousel { ...defaultProps } /> ) ;
92+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
93+
94+ fireEvent . press ( carousel . getAllByRole ( "button" ) [ 1 ] ) ;
95+
96+ expect ( scrollToIndex ) . toHaveBeenCalledWith ( { index : 1 , animated : true } ) ;
97+ scrollToIndex . mockRestore ( ) ;
98+ } ) ;
99+
100+ describe ( "short drags" , ( ) => {
101+ // Card layout sizes each item to 70% of the measured width.
102+ const itemWidth = 300 * 0.7 ;
103+
104+ const drag = ( carousel : ReturnType < typeof render > , from : number , to : number ) : void => {
105+ const list = carousel . getByTestId ( "carousel$carousel" ) ;
106+ fireEvent ( list , "scrollBeginDrag" , { nativeEvent : { contentOffset : { x : from } } } ) ;
107+ fireEvent ( list , "scrollEndDrag" , { nativeEvent : { contentOffset : { x : to } } } ) ;
108+ } ;
109+
110+ it ( "steps to the neighbouring slide once a drag passes the threshold, even short of half a card" , ( ) => {
111+ const scrollToIndex = jest . spyOn ( FlatList . prototype , "scrollToIndex" ) . mockImplementation ( ( ) => undefined ) ;
112+ const carousel = render (
113+ < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } />
114+ ) ;
115+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
116+
117+ drag ( carousel , 0 , 40 ) ;
118+ expect ( scrollToIndex ) . toHaveBeenLastCalledWith ( { index : 1 , animated : true } ) ;
119+ expect ( carousel . getByTestId ( "carousel$pagination" ) . props . accessibilityLabel ) . toBe ( "Slide 2 of 6" ) ;
120+
121+ drag ( carousel , itemWidth , itemWidth - 40 ) ;
122+ expect ( scrollToIndex ) . toHaveBeenLastCalledWith ( { index : 0 , animated : true } ) ;
123+ expect ( carousel . getByTestId ( "carousel$pagination" ) . props . accessibilityLabel ) . toBe ( "Slide 1 of 6" ) ;
124+ scrollToIndex . mockRestore ( ) ;
125+ } ) ;
126+
127+ it ( "leaves the slide alone for a drag under the threshold or past either end" , ( ) => {
128+ const scrollToIndex = jest . spyOn ( FlatList . prototype , "scrollToIndex" ) . mockImplementation ( ( ) => undefined ) ;
129+ const carousel = render ( < Carousel { ...defaultProps } /> ) ;
130+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
131+
132+ drag ( carousel , 0 , 10 ) ;
133+ drag ( carousel , 0 , - 40 ) ;
134+ drag ( carousel , itemWidth , itemWidth + 40 ) ;
135+
136+ expect ( scrollToIndex ) . not . toHaveBeenCalled ( ) ;
137+ scrollToIndex . mockRestore ( ) ;
138+ } ) ;
139+ } ) ;
140+
141+ describe ( "accessibility" , ( ) => {
142+ it ( "labels each dot by its slide number, with the active one marked selected" , ( ) => {
143+ const carousel = render ( < Carousel { ...defaultProps } /> ) ;
144+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
145+
146+ const dots = carousel . getAllByRole ( "button" ) ;
147+ expect ( dots . map ( dot => dot . props . accessibilityLabel ) ) . toEqual ( [ "Go to slide 1" , "Go to slide 2" ] ) ;
148+ expect ( dots [ 0 ] . props . accessibilityState ) . toEqual ( { selected : true } ) ;
149+ expect ( dots [ 1 ] . props . accessibilityState ) . toEqual ( { selected : false } ) ;
150+ } ) ;
151+
152+ it ( "hides every slide but the active one from screen readers" , ( ) => {
153+ const carousel = render ( < Carousel { ...defaultProps } /> ) ;
154+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
155+
156+ // Hidden elements are excluded by default - as a screen reader would skip them too - so finding the
157+ // (intentionally) hidden one back has to opt in.
158+ const active = carousel . getByTestId ( "carousel$content$0" ) ;
159+ const inactive = carousel . getByTestId ( "carousel$content$1" , { includeHiddenElements : true } ) ;
160+ expect ( active . props . accessibilityElementsHidden ) . toBe ( false ) ;
161+ expect ( active . props . importantForAccessibility ) . toBe ( "auto" ) ;
162+ expect ( inactive . props . accessibilityElementsHidden ) . toBe ( true ) ;
163+ expect ( inactive . props . importantForAccessibility ) . toBe ( "no-hide-descendants" ) ;
164+ } ) ;
165+
166+ it ( "moves the hidden-from-screen-readers slide once the active one changes" , ( ) => {
167+ const carousel = render (
168+ < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } />
169+ ) ;
170+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
171+ const itemWidth = 300 * 0.7 ;
172+
173+ fireEvent ( carousel . getByTestId ( "carousel$carousel" ) , "momentumScrollEnd" , {
174+ nativeEvent : { contentOffset : { x : itemWidth } }
175+ } ) ;
176+
177+ expect (
178+ carousel . getByTestId ( "carousel$content$0" , { includeHiddenElements : true } ) . props
179+ . accessibilityElementsHidden
180+ ) . toBe ( true ) ;
181+ expect ( carousel . getByTestId ( "carousel$content$1" ) . props . accessibilityElementsHidden ) . toBe ( false ) ;
182+ } ) ;
183+
184+ it ( "keeps the last slide reachable when the datasource shrinks under the active one" , ( ) => {
185+ const carousel = render (
186+ < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } />
187+ ) ;
188+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
189+ const itemWidth = 300 * 0.7 ;
190+
191+ fireEvent ( carousel . getByTestId ( "carousel$carousel" ) , "momentumScrollEnd" , {
192+ nativeEvent : { contentOffset : { x : 4 * itemWidth } }
193+ } ) ;
194+ carousel . rerender ( < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 2 ) } /> ) ;
195+
196+ expect ( carousel . getByTestId ( "carousel$content$1" ) . props . accessibilityElementsHidden ) . toBe ( false ) ;
197+ const dots = carousel . getAllByRole ( "button" ) ;
198+ expect ( dots [ 1 ] . props . accessibilityState ) . toEqual ( { selected : true } ) ;
199+ } ) ;
200+
201+ it ( "steps forward and backward on a TalkBack/VoiceOver increment or decrement action" , ( ) => {
202+ const scrollToIndex = jest . spyOn ( FlatList . prototype , "scrollToIndex" ) . mockImplementation ( ( ) => undefined ) ;
203+ const carousel = render (
204+ < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } />
205+ ) ;
206+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
207+ const itemWidth = 300 * 0.7 ;
208+ const list = carousel . getByTestId ( "carousel$carousel" ) ;
209+ // scrollToIndex is mocked, so nothing actually moves the list; each step settles it manually at
210+ // the index the previous action asked for, same as a real scroll settling before the next gesture.
211+ const step = ( actionName : "increment" | "decrement" ) : void => {
212+ const callsBefore = scrollToIndex . mock . calls . length ;
213+ fireEvent ( carousel . getByTestId ( "carousel$pagination" ) , "accessibilityAction" , {
214+ nativeEvent : { actionName }
215+ } ) ;
216+ // Already at that end: the action was a no-op, so there is nothing to settle.
217+ if ( scrollToIndex . mock . calls . length === callsBefore ) {
218+ return ;
219+ }
220+ const index = scrollToIndex . mock . lastCall ! [ 0 ] . index as number ;
221+ fireEvent ( list , "momentumScrollEnd" , { nativeEvent : { contentOffset : { x : index * itemWidth } } } ) ;
222+ } ;
223+
224+ step ( "increment" ) ;
225+ expect ( scrollToIndex ) . toHaveBeenLastCalledWith ( { index : 1 , animated : true } ) ;
226+
227+ step ( "decrement" ) ;
228+ expect ( scrollToIndex ) . toHaveBeenLastCalledWith ( { index : 0 , animated : true } ) ;
229+
230+ scrollToIndex . mockRestore ( ) ;
231+ } ) ;
232+
233+ it ( "does not step past either end of the carousel" , ( ) => {
234+ const scrollToIndex = jest . spyOn ( FlatList . prototype , "scrollToIndex" ) . mockImplementation ( ( ) => undefined ) ;
235+ const carousel = render (
236+ < Carousel { ...defaultProps } contentSource = { listValueBuilder . withAmountOfItems ( 6 ) } />
237+ ) ;
238+ fireContainerLayout ( carousel , { width : 300 , height : 150 } ) ;
239+ const itemWidth = 300 * 0.7 ;
240+ const list = carousel . getByTestId ( "carousel$carousel" ) ;
241+ const step = ( actionName : "increment" | "decrement" ) : void => {
242+ const callsBefore = scrollToIndex . mock . calls . length ;
243+ fireEvent ( carousel . getByTestId ( "carousel$pagination" ) , "accessibilityAction" , {
244+ nativeEvent : { actionName }
245+ } ) ;
246+ // Already at that end: the action was a no-op, so there is nothing to settle.
247+ if ( scrollToIndex . mock . calls . length === callsBefore ) {
248+ return ;
249+ }
250+ const index = scrollToIndex . mock . lastCall ! [ 0 ] . index as number ;
251+ fireEvent ( list , "momentumScrollEnd" , { nativeEvent : { contentOffset : { x : index * itemWidth } } } ) ;
252+ } ;
253+
254+ // Already on the first slide: decrementing has nowhere to go, so the no-op scroll is skipped.
255+ step ( "decrement" ) ;
256+ expect ( scrollToIndex ) . not . toHaveBeenCalled ( ) ;
257+
258+ // Incrementing past the last slide (index 5 of 6) stays put rather than running off the end.
259+ for ( let i = 0 ; i < 6 ; i ++ ) {
260+ step ( "increment" ) ;
261+ }
262+ expect ( scrollToIndex ) . toHaveBeenLastCalledWith ( { index : 5 , animated : true } ) ;
263+
264+ scrollToIndex . mockRestore ( ) ;
265+ } ) ;
266+ } ) ;
50267
51268 it ( "preserves expected style key set" , ( ) => {
52269 const keys = Object . keys ( defaultCarouselStyle ) . sort ( ) ;
0 commit comments