From 83d988afda61c471b97da48eb385e88ba949965f Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:02:09 +0530 Subject: [PATCH] fix(runner): a swipe whose endpoints coincide is not dispatched The scroll branch already refuses a zero-distance gesture, but Swipe clamped and dispatched unconditionally, so a collapsed swipe reached the driver as a 250ms press and hold the app reads as a tap and the step recorded an executed swipe. The guard reads the endpoints after clampGestureToSafeArea, because the clamp is one of the two ways a swipe collapses. --- internal/runner/gesture_distance_test.go | 152 +++++++++++++++++++++++ internal/runner/runner.go | 14 +++ 2 files changed, 166 insertions(+) create mode 100644 internal/runner/gesture_distance_test.go diff --git a/internal/runner/gesture_distance_test.go b/internal/runner/gesture_distance_test.go new file mode 100644 index 0000000..361dff2 --- /dev/null +++ b/internal/runner/gesture_distance_test.go @@ -0,0 +1,152 @@ +package runner + +import ( + "context" + "encoding/json" + "testing" + + mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock" + "github.com/priyanshujain/sanderling/internal/hierarchy" + "github.com/priyanshujain/sanderling/internal/verifier" +) + +// legacyAuthoredScrollWire is what @sanderling/spec 0.0.3 put on the wire for +// `Scroll({ in: container, direction: "down" })`: the container's own point as +// both endpoints. This binary reads pre-computed endpoints as authoritative, +// so the pair dispatches a 250ms press and hold that the app reads as a tap, +// with `direction` riding along unread. +const legacyAuthoredScrollWire = `{"kind":"Scroll","direction":"down",` + + `"fromX":540,"fromY":1200,"toX":540,"toY":1200,"durationMillis":250,` + + `"selector":"id:ledger"}` + +func TestApplyAction_ZeroDistanceScrollNeverReachesTheDriver(t *testing.T) { + action, err := verifier.DecodeAction(json.RawMessage(legacyAuthoredScrollWire)) + if err != nil { + t.Fatalf("DecodeAction: %v", err) + } + treeJSON := `{"attributes":{"bounds":"[0,0,1080,2400]"},"children":[ + {"attributes":{"resource-id":"com.fixture:id/ledger","scrollable":"true","bounds":"[0,400,1080,2000]"},"children":[],"enabled":true} + ]}` + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatalf("parse tree: %v", err) + } + driverMock := mockdriver.New() + + skipped, err := applyAction(context.Background(), driverMock, action, tree) + if err != nil { + t.Fatalf("applyAction: %v", err) + } + for _, dispatched := range driverMock.Actions() { + if dispatched.Kind == mockdriver.ActionSwipe { + t.Fatalf("a scroll travelling zero distance reached the driver as %v; "+ + "it is a press and hold, not a scroll", dispatched) + } + } + if skipped != actionSkippedZeroDistanceScroll { + t.Errorf("applyAction reported %q, want %q: a step that scrolled nothing "+ + "has to say so", skipped, actionSkippedZeroDistanceScroll) + } +} + +func TestApplyAction_ScrollWithRealDistanceStillReachesTheDriver(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{ + Kind: verifier.ActionKindScroll, + Direction: "down", + FromX: 540, + FromY: 1200, + ToX: 540, + ToY: 600, + DurationMillis: 250, + } + + mustDispatch(t, driverMock, action, nil) + found := false + for _, dispatched := range driverMock.Actions() { + if dispatched.Kind == mockdriver.ActionSwipe && dispatched.ToY == 600 { + found = true + } + } + if !found { + t.Errorf("the scroll never reached the driver: %v", driverMock.Actions()) + } +} + +func TestApplyAction_ZeroDistanceSwipeNeverReachesTheDriver(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{ + Kind: verifier.ActionKindSwipe, + FromX: 540, + FromY: 1200, + ToX: 540, + ToY: 1200, + DurationMillis: 250, + } + + skipped, err := applyAction(context.Background(), driverMock, action, nil) + if err != nil { + t.Fatalf("applyAction: %v", err) + } + if len(driverMock.Actions()) != 0 { + t.Fatalf("a swipe travelling zero distance reached the driver as %v; "+ + "it is a press and hold, not a swipe", driverMock.Actions()) + } + if skipped != actionSkippedZeroDistanceSwipe { + t.Errorf("applyAction reported %q, want %q", skipped, actionSkippedZeroDistanceSwipe) + } +} + +// clampGestureToSafeArea can collapse a swipe that was authored with real +// distance, so the guard has to read the endpoints it is about to dispatch +// rather than the ones the action carried. +func TestApplyAction_SwipeCollapsedByTheSafeAreaClampNeverReachesTheDriver(t *testing.T) { + tree, err := hierarchy.Parse(`{"attributes":{"bounds":"[0,0,1080,2400]"},"children":[]}`) + if err != nil { + t.Fatalf("parse tree: %v", err) + } + driverMock := mockdriver.New() + action := verifier.Action{ + Kind: verifier.ActionKindSwipe, + FromX: 540, + FromY: 2500, + ToX: 540, + ToY: 2600, + DurationMillis: 250, + } + + skipped, err := applyAction(context.Background(), driverMock, action, tree) + if err != nil { + t.Fatalf("applyAction: %v", err) + } + if len(driverMock.Actions()) != 0 { + t.Fatalf("a swipe the clamp collapsed onto one point reached the driver as %v", + driverMock.Actions()) + } + if skipped != actionSkippedZeroDistanceSwipe { + t.Errorf("applyAction reported %q, want %q", skipped, actionSkippedZeroDistanceSwipe) + } +} + +func TestApplyAction_SwipeWithRealDistanceStillReachesTheDriver(t *testing.T) { + driverMock := mockdriver.New() + action := verifier.Action{ + Kind: verifier.ActionKindSwipe, + FromX: 540, + FromY: 1800, + ToX: 540, + ToY: 600, + DurationMillis: 250, + } + + mustDispatch(t, driverMock, action, nil) + found := false + for _, dispatched := range driverMock.Actions() { + if dispatched.Kind == mockdriver.ActionSwipe && dispatched.ToY == 600 { + found = true + } + } + if !found { + t.Errorf("the swipe never reached the driver: %v", driverMock.Actions()) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index dd9ed16..05d4dce 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -975,6 +975,9 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A case verifier.ActionKindScroll: fromX, fromY, toX, toY := scrollEndpoints(action, tree) fromX, fromY, toX, toY = clampGestureToSafeArea(fromX, fromY, toX, toY, screenBounds(tree)) + if fromX == toX && fromY == toY { + return actionSkippedZeroDistanceScroll, nil + } duration := time.Duration(action.DurationMillis) * time.Millisecond if duration <= 0 { duration = 300 * time.Millisecond @@ -1032,6 +1035,9 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A duration = 250 * time.Millisecond } fromX, fromY, toX, toY := clampGestureToSafeArea(action.FromX, action.FromY, action.ToX, action.ToY, screenBounds(tree)) + if fromX == toX && fromY == toY { + return actionSkippedZeroDistanceSwipe, nil + } return "", drv.Swipe(ctx, fromX, fromY, toX, toY, duration) case verifier.ActionKindPressKey: if action.Key == "" { @@ -1766,6 +1772,14 @@ const ( actionSkippedUnresolvedSelector actionSkipReason = "unresolved_selector" actionSkippedMissingKey actionSkipReason = "missing_key" actionSkippedZeroDurationWait actionSkipReason = "zero_duration_wait" + // A gesture whose endpoints coincide is a press and hold the app reads as + // a tap, not a drag that moved nothing: dispatching it records an executed + // scroll or swipe for a gesture that could never travel. The two are + // reported apart because they arrive from different places, a scroll from + // a container whose own point became both endpoints and a swipe from + // authored coordinates or a clamp that collapsed them. + actionSkippedZeroDistanceScroll actionSkipReason = "zero_distance_scroll" + actionSkippedZeroDistanceSwipe actionSkipReason = "zero_distance_swipe" // The driver resolved the action's point and found no element there, so // the gesture was never dispatched. Recorded rather than counted as a // device fault: a run that acts on nothing has to say so.