11// @vitest -environment jsdom
22import "@testing-library/jest-dom" ;
3- import { act , cleanup , fireEvent , render , screen } from "@testing-library/react" ;
3+ import { act , cleanup , render , screen } from "@testing-library/react" ;
44import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
55import { I18nProvider } from "@/contexts/I18nContext" ;
66import { useProjectStore } from "@/lib/ai-edition/store/projectStore" ;
@@ -45,13 +45,7 @@ describe("TransportBar reads the playhead from the store", () => {
4545 function Parent ( ) {
4646 parentRenders ++ ;
4747 return (
48- < TransportBar
49- playing = { false }
50- overrideTimeSec = { null }
51- clips = { clips }
52- onTogglePlay = { noop }
53- onSeek = { noop }
54- />
48+ < TransportBar playing = { false } overrideTimeSec = { null } clips = { clips } onTogglePlay = { noop } />
5549 ) ;
5650 }
5751
@@ -77,171 +71,9 @@ describe("TransportBar reads the playhead from the store", () => {
7771 useProjectStore . setState ( { currentTimeSec : 12.3 } ) ;
7872 render (
7973 < I18nProvider >
80- < TransportBar
81- playing = { false }
82- overrideTimeSec = { 4.5 }
83- clips = { clips }
84- onTogglePlay = { noop }
85- onSeek = { noop }
86- />
74+ < TransportBar playing = { false } overrideTimeSec = { 4.5 } clips = { clips } onTogglePlay = { noop } />
8775 </ I18nProvider > ,
8876 ) ;
8977 expect ( screen . getByText ( "0:04.5" ) ) . toBeInTheDocument ( ) ;
9078 } ) ;
9179} ) ;
92-
93- describe ( "le drag de la barre de progression est coalescé en rAF" , ( ) => {
94- beforeEach ( ( ) => {
95- useProjectStore . setState ( { currentTimeSec : 0 } ) ;
96- } ) ;
97-
98- afterEach ( ( ) => {
99- cleanup ( ) ;
100- vi . clearAllMocks ( ) ;
101- vi . unstubAllGlobals ( ) ;
102- } ) ;
103-
104- /** rAF piloté à la main : les callbacks ne partent que sur `flush()`, ce qui permet de
105- * compter ce qui se passe DANS une frame — impossible avec le vrai rAF en test. */
106- function stubRaf ( ) {
107- const pending : FrameRequestCallback [ ] = [ ] ;
108- vi . stubGlobal ( "requestAnimationFrame" , ( cb : FrameRequestCallback ) => {
109- pending . push ( cb ) ;
110- return pending . length ;
111- } ) ;
112- vi . stubGlobal ( "cancelAnimationFrame" , ( ) => {
113- // rien à annuler : `flush()` ne rejoue que ce qui reste en attente
114- } ) ;
115- return ( ) => {
116- const due = pending . splice ( 0 , pending . length ) ;
117- for ( const cb of due ) cb ( 0 ) ;
118- } ;
119- }
120-
121- // C'est LE défaut que ce chemin avait : `onChange` d'un `<input type="range">` se
122- // déclenche à la cadence du pointeur (jusqu'à 1000 Hz) et appelait `onSeek` à chaque
123- // fois. Or `onSeek` repose un `seekTarget` dans l'état de la racine de l'éditeur, donc
124- // re-rend tout et fait poser `<video>.currentTime`. La timeline fait les mêmes appels
125- // mais une fois par frame ; ce test verrouille cette parité.
126- it ( "n'émet qu'un seul seek par frame, quel que soit le nombre d'événements" , ( ) => {
127- const flush = stubRaf ( ) ;
128- const onSeek = vi . fn ( ) ;
129- render (
130- < I18nProvider >
131- < TransportBar
132- playing = { false }
133- overrideTimeSec = { null }
134- clips = { clips }
135- onTogglePlay = { noop }
136- onSeek = { onSeek }
137- />
138- </ I18nProvider > ,
139- ) ;
140- const input = screen . getByLabelText ( / s e e k / i) as HTMLInputElement ;
141-
142- act ( ( ) => {
143- fireEvent . pointerDown ( input ) ;
144- } ) ;
145- // Dix mouvements dans la même frame : un seul seek doit en sortir, et c'est le
146- // DERNIER qui compte — sans quoi la tête accuserait un retard permanent.
147- act ( ( ) => {
148- for ( const value of [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 ] ) {
149- fireEvent . change ( input , { target : { value : String ( value ) } } ) ;
150- }
151- } ) ;
152- expect ( onSeek ) . not . toHaveBeenCalled ( ) ;
153-
154- act ( ( ) => flush ( ) ) ;
155- expect ( onSeek ) . toHaveBeenCalledTimes ( 1 ) ;
156- expect ( onSeek ) . toHaveBeenLastCalledWith ( 10 ) ;
157- } ) ;
158-
159- // Le mouvement compris entre le dernier rAF et le relâchement serait perdu sans commit
160- // final : la tête s'arrêterait un cran avant le doigt.
161- it ( "pose la dernière position au relâchement" , ( ) => {
162- stubRaf ( ) ;
163- const onSeek = vi . fn ( ) ;
164- render (
165- < I18nProvider >
166- < TransportBar
167- playing = { false }
168- overrideTimeSec = { null }
169- clips = { clips }
170- onTogglePlay = { noop }
171- onSeek = { onSeek }
172- />
173- </ I18nProvider > ,
174- ) ;
175- const input = screen . getByLabelText ( / s e e k / i) as HTMLInputElement ;
176-
177- act ( ( ) => {
178- fireEvent . pointerDown ( input ) ;
179- fireEvent . change ( input , { target : { value : "7" } } ) ;
180- fireEvent . pointerUp ( input ) ;
181- } ) ;
182- expect ( onSeek ) . toHaveBeenCalledWith ( 7 ) ;
183- } ) ;
184-
185- // Hors drag il n'y a rien à coalescer : une flèche du clavier est un saut unique et
186- // doit prendre effet immédiatement, sans attendre une frame.
187- it ( "applique immédiatement un changement hors drag" , ( ) => {
188- stubRaf ( ) ;
189- const onSeek = vi . fn ( ) ;
190- render (
191- < I18nProvider >
192- < TransportBar
193- playing = { false }
194- overrideTimeSec = { null }
195- clips = { clips }
196- onTogglePlay = { noop }
197- onSeek = { onSeek }
198- />
199- </ I18nProvider > ,
200- ) ;
201- const input = screen . getByLabelText ( / s e e k / i) as HTMLInputElement ;
202-
203- act ( ( ) => {
204- fireEvent . change ( input , { target : { value : "3" } } ) ;
205- } ) ;
206- expect ( onSeek ) . toHaveBeenCalledTimes ( 1 ) ;
207- expect ( onSeek ) . toHaveBeenCalledWith ( 3 ) ;
208- } ) ;
209-
210- // Le remplissage et le curseur étaient positionnés uniquement par React depuis le store,
211- // donc en retard d'un commit à chaque mouvement — alors que la tête de lecture de la
212- // timeline, elle, est écrite directement dans le DOM et colle au pointeur. Ce test
213- // verrouille la parité : le visuel bouge AVANT le rAF, donc sans attendre React.
214- it ( "déplace le curseur dans le DOM avant tout rendu React" , ( ) => {
215- stubRaf ( ) ;
216- const onSeek = vi . fn ( ) ;
217- const { container } = render (
218- < I18nProvider >
219- < TransportBar
220- playing = { false }
221- overrideTimeSec = { null }
222- clips = { clips }
223- onTogglePlay = { noop }
224- onSeek = { onSeek }
225- />
226- </ I18nProvider > ,
227- ) ;
228- const input = screen . getByLabelText ( / s e e k / i) as HTMLInputElement ;
229- // Les deux éléments décorés : le remplissage (largeur) et le curseur (position).
230- const styled = Array . from ( container . querySelectorAll ( "div" ) ) . filter (
231- ( el ) => el . style . width !== "" || el . style . left !== "" ,
232- ) ;
233- expect ( styled . length ) . toBeGreaterThanOrEqual ( 2 ) ;
234-
235- act ( ( ) => {
236- fireEvent . pointerDown ( input ) ;
237- // La moitié de la durée (clip de 30 s) → 50 %.
238- fireEvent . change ( input , { target : { value : "15" } } ) ;
239- } ) ;
240-
241- // Aucun rAF n'a été vidé, donc aucune écriture au store et aucun rendu React : ce qui
242- // a bougé ne peut venir que de l'écriture DOM directe.
243- expect ( onSeek ) . not . toHaveBeenCalled ( ) ;
244- const moved = styled . filter ( ( el ) => el . style . width === "50%" || el . style . left === "50%" ) ;
245- expect ( moved . length ) . toBeGreaterThanOrEqual ( 2 ) ;
246- } ) ;
247- } ) ;
0 commit comments