11import { test , vi } from 'vitest' ;
22import assert from 'node:assert/strict' ;
3+ import { GESTURE_SAMPLE_INTERVAL_MS } from '@agent-device/contracts/gesture-plan' ;
34import {
45 backAndroid ,
56 homeAndroid ,
@@ -79,7 +80,14 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
7980 async ( ) => {
8081 const outputs : Record < string , unknown > [ ] = [ ] ;
8182 for ( const durationMs of [ 0 , 15 ] ) {
82- outputs . push ( await scrollAndroid ( ANDROID_EMULATOR , 'down' , { durationMs } ) ) ;
83+ // 'inertial' keeps the injected plan's durationMs equal to the honored move time, so the
84+ // flooring this test targets is not conflated with the 'controlled' release tail below.
85+ outputs . push (
86+ await scrollAndroid ( ANDROID_EMULATOR , 'down' , {
87+ durationMs,
88+ releaseBehavior : 'inertial' ,
89+ } ) ,
90+ ) ;
8391 }
8492 return outputs ;
8593 } ,
@@ -95,6 +103,121 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
95103 ) ;
96104} ) ;
97105
106+ test ( 'scrollAndroid defaults to a controlled release: a quivering tail past the pan endpoint' , async ( ) => {
107+ const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
108+ await withAndroidAdbProvider (
109+ {
110+ exec : async ( ) => {
111+ throw new Error ( 'adb must not run' ) ;
112+ } ,
113+ gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
114+ touch : async ( request ) => {
115+ touchCalls . push ( request ) ;
116+ return { injected : true } ;
117+ } ,
118+ } ,
119+ { serial : ANDROID_EMULATOR . id } ,
120+ async ( ) => await scrollAndroid ( ANDROID_EMULATOR , 'down' , { pixels : 240 , durationMs : 120 } ) ,
121+ ) ;
122+
123+ assert . equal ( touchCalls . length , 1 ) ;
124+ const [ touch ] = touchCalls ;
125+ const samples = touch ! . pointers [ 0 ] ! . samples ;
126+ const endpoint = samples . find ( ( sample ) => sample . offsetMs === 120 ) ! ;
127+ const tail = samples . filter ( ( sample ) => sample . offsetMs > 120 ) ;
128+
129+ // The plan carries a >=100ms tail past the honored 120ms move; the CLI-facing `durationMs` in
130+ // the command result (asserted below) stays at the honored move time.
131+ assert . equal ( touch ! . durationMs , 280 ) ;
132+ assert . ok ( tail . length >= 100 / GESTURE_SAMPLE_INTERVAL_MS ) ;
133+ for ( const sample of tail ) assert . equal ( sample . point . y , endpoint . point . y ) ;
134+ const allPastEndpoint = [ endpoint , ...tail ] ;
135+ for ( let index = 1 ; index < allPastEndpoint . length ; index += 1 ) {
136+ assert . notEqual ( allPastEndpoint [ index ] ! . point . x , allPastEndpoint [ index - 1 ] ! . point . x ) ;
137+ }
138+ } ) ;
139+
140+ test ( 'scrollAndroid composes the duration floor with the default controlled-release tail' , async ( ) => {
141+ const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
142+ await withAndroidAdbProvider (
143+ {
144+ exec : async ( ) => {
145+ throw new Error ( 'adb must not run' ) ;
146+ } ,
147+ gestureViewport : async ( ) => ( { x : 0 , y : 0 , width : 1080 , height : 1920 } ) ,
148+ touch : async ( request ) => {
149+ touchCalls . push ( request ) ;
150+ } ,
151+ } ,
152+ { serial : ANDROID_EMULATOR . id } ,
153+ async ( ) => await scrollAndroid ( ANDROID_EMULATOR , 'down' , { durationMs : 0 } ) ,
154+ ) ;
155+
156+ // The move floors to the Android planner minimum (16ms) before the tail is appended, not after.
157+ assert . equal ( touchCalls [ 0 ] ! . durationMs , GESTURE_SAMPLE_INTERVAL_MS + 160 ) ;
158+ } ) ;
159+
160+ test ( 'scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed' , async ( ) => {
161+ const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
162+ await withAndroidAdbProvider (
163+ {
164+ exec : async ( ) => {
165+ throw new Error ( 'adb must not run' ) ;
166+ } ,
167+ gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
168+ touch : async ( request ) => {
169+ touchCalls . push ( request ) ;
170+ return { injected : true } ;
171+ } ,
172+ } ,
173+ { serial : ANDROID_EMULATOR . id } ,
174+ async ( ) => await scrollAndroid ( ANDROID_EMULATOR , 'left' , { pixels : 240 , durationMs : 120 } ) ,
175+ ) ;
176+
177+ assert . equal ( touchCalls . length , 1 ) ;
178+ const [ touch ] = touchCalls ;
179+ const samples = touch ! . pointers [ 0 ] ! . samples ;
180+ const endpoint = samples . find ( ( sample ) => sample . offsetMs === 120 ) ! ;
181+ const tail = samples . filter ( ( sample ) => sample . offsetMs > 120 ) ;
182+
183+ assert . ok ( tail . length >= 100 / GESTURE_SAMPLE_INTERVAL_MS ) ;
184+ // The scroll axis (x, for a horizontal scroll) stays exactly at the endpoint — zero velocity
185+ // there by construction; only the orthogonal axis (y) jitters to dodge the resampling quirk.
186+ for ( const sample of tail ) assert . equal ( sample . point . x , endpoint . point . x ) ;
187+ const allPastEndpoint = [ endpoint , ...tail ] ;
188+ for ( let index = 1 ; index < allPastEndpoint . length ; index += 1 ) {
189+ assert . notEqual ( allPastEndpoint [ index ] ! . point . y , allPastEndpoint [ index - 1 ] ! . point . y ) ;
190+ }
191+ } ) ;
192+
193+ test ( 'scrollAndroid honors an inertial release (scroll top/bottom): lifts at the pan endpoint' , async ( ) => {
194+ const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
195+ await withAndroidAdbProvider (
196+ {
197+ exec : async ( ) => {
198+ throw new Error ( 'adb must not run' ) ;
199+ } ,
200+ gestureViewport : async ( ) => ( { x : 10 , y : 20 , width : 1080 , height : 1920 } ) ,
201+ touch : async ( request ) => {
202+ touchCalls . push ( request ) ;
203+ return { injected : true } ;
204+ } ,
205+ } ,
206+ { serial : ANDROID_EMULATOR . id } ,
207+ async ( ) =>
208+ await scrollAndroid ( ANDROID_EMULATOR , 'down' , {
209+ pixels : 240 ,
210+ durationMs : 120 ,
211+ releaseBehavior : 'inertial' ,
212+ } ) ,
213+ ) ;
214+
215+ assert . equal ( touchCalls . length , 1 ) ;
216+ const [ touch ] = touchCalls ;
217+ assert . equal ( touch ! . durationMs , 120 ) ;
218+ assert . equal ( touch ! . pointers [ 0 ] ! . samples . at ( - 1 ) ! . offsetMs , 120 ) ;
219+ } ) ;
220+
98221test ( 'longPressAndroid sends a stationary semantic touch plan' , async ( ) => {
99222 const touchCalls : Parameters < AndroidTouchInjector > [ 0 ] [ ] = [ ] ;
100223 const result = await withAndroidAdbProvider (
0 commit comments