11import { test , vi } from 'vitest' ;
22import assert from 'node:assert/strict' ;
3- import { GESTURE_SAMPLE_INTERVAL_MS } from '@agent-device/contracts/gesture-plan' ;
4- import { GESTURE_DURATION_MAX_MS } from '@agent-device/contracts/gesture-plan-types' ;
53import {
64 backAndroid ,
75 homeAndroid ,
@@ -81,14 +79,7 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
8179 async ( ) => {
8280 const outputs : Record < string , unknown > [ ] = [ ] ;
8381 for ( const durationMs of [ 0 , 15 ] ) {
84- // 'inertial' keeps the injected plan's durationMs equal to the honored move time, so the
85- // flooring this test targets is not conflated with the 'controlled' release tail below.
86- outputs . push (
87- await scrollAndroid ( ANDROID_EMULATOR , 'down' , {
88- durationMs,
89- releaseBehavior : 'inertial' ,
90- } ) ,
91- ) ;
82+ outputs . push ( await scrollAndroid ( ANDROID_EMULATOR , 'down' , { durationMs } ) ) ;
9283 }
9384 return outputs ;
9485 } ,
@@ -104,198 +95,63 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
10495 ) ;
10596} ) ;
10697
107- test ( 'scrollAndroid defaults to a controlled release: a quivering tail past the pan endpoint' , async ( ) => {
108- const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
109- await withAndroidAdbProvider (
110- {
111- exec : async ( ) => {
112- throw new Error ( 'adb must not run' ) ;
113- } ,
114- gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
115- touch : async ( request ) => {
116- touchCalls . push ( request ) ;
117- return { injected : true } ;
118- } ,
119- } ,
120- { serial : ANDROID_EMULATOR . id } ,
121- async ( ) => await scrollAndroid ( ANDROID_EMULATOR , 'down' , { pixels : 240 , durationMs : 120 } ) ,
122- ) ;
123-
124- assert . equal ( touchCalls . length , 1 ) ;
125- const [ touch ] = touchCalls ;
126- const samples = touch ! . pointers [ 0 ] ! . samples ;
127- const endpoint = samples . find ( ( sample ) => sample . offsetMs === 120 ) ! ;
128- const tail = samples . filter ( ( sample ) => sample . offsetMs > 120 ) ;
129-
130- // The plan carries a >=100ms tail past the honored 120ms move; the CLI-facing `durationMs` in
131- // the command result (asserted below) stays at the honored move time.
132- assert . equal ( touch ! . durationMs , 280 ) ;
133- assert . ok ( tail . length >= 100 / GESTURE_SAMPLE_INTERVAL_MS ) ;
134- for ( const sample of tail ) assert . equal ( sample . point . y , endpoint . point . y ) ;
135- const allPastEndpoint = [ endpoint , ...tail ] ;
136- for ( let index = 1 ; index < allPastEndpoint . length ; index += 1 ) {
137- assert . notEqual ( allPastEndpoint [ index ] ! . point . x , allPastEndpoint [ index - 1 ] ! . point . x ) ;
138- }
139- } ) ;
140-
141- test ( 'scrollAndroid composes the duration floor with the default controlled-release tail' , async ( ) => {
142- const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
143- await withAndroidAdbProvider (
144- {
145- exec : async ( ) => {
146- throw new Error ( 'adb must not run' ) ;
147- } ,
148- gestureViewport : async ( ) => ( { x : 0 , y : 0 , width : 1080 , height : 1920 } ) ,
149- touch : async ( request ) => {
150- touchCalls . push ( request ) ;
151- } ,
152- } ,
153- { serial : ANDROID_EMULATOR . id } ,
154- async ( ) => await scrollAndroid ( ANDROID_EMULATOR , 'down' , { durationMs : 0 } ) ,
155- ) ;
156-
157- // The move floors to the Android planner minimum (16ms) before the tail is appended, not after.
158- assert . equal ( touchCalls [ 0 ] ! . durationMs , GESTURE_SAMPLE_INTERVAL_MS + 160 ) ;
159- } ) ;
160-
161- test ( 'scrollAndroid runs the full controlled-release tail at the largest duration that still leaves it room' , async ( ) => {
162- const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
163- const maxControlledMoveMs = GESTURE_DURATION_MAX_MS - 160 ;
164- const result = await withAndroidAdbProvider (
165- {
166- exec : async ( ) => {
167- throw new Error ( 'adb must not run' ) ;
168- } ,
169- gestureViewport : async ( ) => ( { x : 0 , y : 0 , width : 1080 , height : 1920 } ) ,
170- touch : async ( request ) => {
171- touchCalls . push ( request ) ;
172- } ,
173- } ,
174- { serial : ANDROID_EMULATOR . id } ,
175- async ( ) =>
176- await scrollAndroid ( ANDROID_EMULATOR , 'down' , {
177- pixels : 1800 ,
178- durationMs : maxControlledMoveMs ,
179- } ) ,
180- ) ;
181-
182- // The requested move is honored in full, and the tail always runs at its full length — the
183- // dispatched plan lands exactly at GESTURE_DURATION_MAX_MS, never past it.
184- assert . equal ( result . durationMs , maxControlledMoveMs ) ;
185- const [ touch ] = touchCalls ;
186- assert . equal ( touch ! . durationMs , GESTURE_DURATION_MAX_MS ) ;
187- assert . equal ( touch ! . pointers [ 0 ] ! . samples . at ( - 1 ) ! . offsetMs , GESTURE_DURATION_MAX_MS ) ;
188- } ) ;
189-
190- test ( 'scrollAndroid rejects a controlled-release durationMs that would leave the release tail no room, without shortening the move' , async ( ) => {
191- await withAndroidAdbProvider (
192- {
193- exec : async ( ) => {
194- throw new Error ( 'adb must not run' ) ;
98+ test . each ( [ undefined , 'inertial' ] as const ) (
99+ 'scrollAndroid preserves path and duration with %s release' ,
100+ async ( releaseBehavior ) => {
101+ const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
102+ await withAndroidAdbProvider (
103+ {
104+ exec : async ( ) => {
105+ throw new Error ( 'adb must not run' ) ;
106+ } ,
107+ gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
108+ touch : async ( request ) => {
109+ touchCalls . push ( request ) ;
110+ } ,
195111 } ,
196- gestureViewport : async ( ) => ( { x : 0 , y : 0 , width : 1080 , height : 1920 } ) ,
197- touch : async ( ) => {
198- throw new Error ( 'touch must not run for a rejected request' ) ;
112+ { serial : ANDROID_EMULATOR . id } ,
113+ async ( ) => {
114+ for ( const direction of [ 'up' , 'down' , 'left' , 'right' ] as const ) {
115+ for ( const durationMs of [ 16 , 120 , 300 , 9841 , 10000 ] ) {
116+ await scrollAndroid ( ANDROID_EMULATOR , direction , {
117+ pixels : 240 ,
118+ durationMs,
119+ releaseBehavior,
120+ } ) ;
121+ }
122+ }
199123 } ,
200- } ,
201- { serial : ANDROID_EMULATOR . id } ,
202- async ( ) => {
203- await assert . rejects (
204- scrollAndroid ( ANDROID_EMULATOR , 'down' , {
205- pixels : 1800 ,
206- durationMs : GESTURE_DURATION_MAX_MS - 159 ,
207- } ) ,
208- / s c r o l l d u r a t i o n M s m u s t b e a t m o s t 9 8 4 0 f o r a c o n t r o l l e d r e l e a s e / ,
124+ ) ;
125+ for ( const touch of touchCalls ) {
126+ const samples = touch . pointers [ 0 ] ! . samples ;
127+ const start = samples [ 0 ] ! ;
128+ const end = samples . at ( - 1 ) ! ;
129+ assert . equal ( start . offsetMs , 0 ) ;
130+ assert . equal ( end . offsetMs , touch . durationMs ) ;
131+ const distance = ( a : typeof start , b : typeof start ) =>
132+ Math . hypot ( b . point . x - a . point . x , b . point . y - a . point . y ) ;
133+ assert . equal ( distance ( start , end ) , 240 ) ;
134+ const velocities = samples
135+ . slice ( 1 )
136+ . map (
137+ ( sample , index ) =>
138+ distance ( samples [ index ] ! , sample ) / ( sample . offsetMs - samples [ index ] ! . offsetMs ) ,
139+ ) ;
140+ if ( releaseBehavior === 'inertial' ) {
141+ for ( const velocity of velocities ) assert . ok ( Math . abs ( velocity - velocities [ 0 ] ! ) < 1e-8 ) ;
142+ continue ;
143+ }
144+ const firstMove = samples [ 1 ] ! ;
145+ assert . ok (
146+ distance ( start , firstMove ) <= ( ( 240 * firstMove . offsetMs ) / touch . durationMs ) * 1.1 ,
209147 ) ;
210- } ,
211- ) ;
212- } ) ;
213-
214- test ( "scrollAndroid accepts the full GESTURE_DURATION_MAX_MS for an 'inertial' release, which needs no tail" , async ( ) => {
215- const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
216- await withAndroidAdbProvider (
217- {
218- exec : async ( ) => {
219- throw new Error ( 'adb must not run' ) ;
220- } ,
221- gestureViewport : async ( ) => ( { x : 0 , y : 0 , width : 1080 , height : 1920 } ) ,
222- touch : async ( request ) => {
223- touchCalls . push ( request ) ;
224- } ,
225- } ,
226- { serial : ANDROID_EMULATOR . id } ,
227- async ( ) =>
228- await scrollAndroid ( ANDROID_EMULATOR , 'down' , {
229- pixels : 1800 ,
230- durationMs : GESTURE_DURATION_MAX_MS ,
231- releaseBehavior : 'inertial' ,
232- } ) ,
233- ) ;
234-
235- const [ touch ] = touchCalls ;
236- assert . equal ( touch ! . durationMs , GESTURE_DURATION_MAX_MS ) ;
237- } ) ;
238-
239- test ( 'scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed' , async ( ) => {
240- const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
241- await withAndroidAdbProvider (
242- {
243- exec : async ( ) => {
244- throw new Error ( 'adb must not run' ) ;
245- } ,
246- gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
247- touch : async ( request ) => {
248- touchCalls . push ( request ) ;
249- return { injected : true } ;
250- } ,
251- } ,
252- { serial : ANDROID_EMULATOR . id } ,
253- async ( ) => await scrollAndroid ( ANDROID_EMULATOR , 'left' , { pixels : 240 , durationMs : 120 } ) ,
254- ) ;
255-
256- assert . equal ( touchCalls . length , 1 ) ;
257- const [ touch ] = touchCalls ;
258- const samples = touch ! . pointers [ 0 ] ! . samples ;
259- const endpoint = samples . find ( ( sample ) => sample . offsetMs === 120 ) ! ;
260- const tail = samples . filter ( ( sample ) => sample . offsetMs > 120 ) ;
261-
262- assert . ok ( tail . length >= 100 / GESTURE_SAMPLE_INTERVAL_MS ) ;
263- // The scroll axis (x, for a horizontal scroll) stays exactly at the endpoint — zero velocity
264- // there by construction; only the orthogonal axis (y) jitters to dodge the resampling quirk.
265- for ( const sample of tail ) assert . equal ( sample . point . x , endpoint . point . x ) ;
266- const allPastEndpoint = [ endpoint , ...tail ] ;
267- for ( let index = 1 ; index < allPastEndpoint . length ; index += 1 ) {
268- assert . notEqual ( allPastEndpoint [ index ] ! . point . y , allPastEndpoint [ index - 1 ] ! . point . y ) ;
269- }
270- } ) ;
271-
272- test ( 'scrollAndroid honors an inertial release (scroll top/bottom): lifts at the pan endpoint' , async ( ) => {
273- const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
274- await withAndroidAdbProvider (
275- {
276- exec : async ( ) => {
277- throw new Error ( 'adb must not run' ) ;
278- } ,
279- gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
280- touch : async ( request ) => {
281- touchCalls . push ( request ) ;
282- return { injected : true } ;
283- } ,
284- } ,
285- { serial : ANDROID_EMULATOR . id } ,
286- async ( ) =>
287- await scrollAndroid ( ANDROID_EMULATOR , 'down' , {
288- pixels : 240 ,
289- durationMs : 120 ,
290- releaseBehavior : 'inertial' ,
291- } ) ,
292- ) ;
293-
294- assert . equal ( touchCalls . length , 1 ) ;
295- const [ touch ] = touchCalls ;
296- assert . equal ( touch ! . durationMs , 120 ) ;
297- assert . equal ( touch ! . pointers [ 0 ] ! . samples . at ( - 1 ) ! . offsetMs , 120 ) ;
298- } ) ;
148+ assert . ok ( velocities . at ( - 1 ) ! < Math . max ( ...velocities ) / 2 ) ;
149+ for ( let i = Math . ceil ( velocities . length / 2 ) ; i < velocities . length ; i += 1 ) {
150+ assert . ok ( velocities [ i ] ! <= velocities [ i - 1 ] ! + 1e-8 ) ;
151+ }
152+ }
153+ } ,
154+ ) ;
299155
300156test ( 'longPressAndroid sends a stationary semantic touch plan' , async ( ) => {
301157 const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
0 commit comments