Pad/internal/ui/pinch_test.go
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
Two feature bodies accumulated in the working tree:

1. Pinch to change the app font size, continuously (no snapping):
   - internal/ui/pinch_tracker.go: logic-free touch state machine.
     Two-mover formation (the resting palm can land first or last;
     movement is the only signal valid for both), pair = the mover
     pair whose distance changed most, baseline = press distance
     (formDist), lazy pending releases, survivor-scroll forwarding
     after a pair break. Robust to ~1 fps frames: a whole pinch can
     land in one drain (formDist/brokeFactor/lazy releases).
   - render.go: pinch probe (raw pointer events) + grab lifecycle so
     the pair is exclusive (scroll sees nothing of the pair) and the
     survivor's finger keeps working as a scroll after the pinch.
   - state.go/logic.go/session.go/frame.go: app-local float font
     scale, content-point pin (buffer byte + offset from baseline,
     not a layout point, so rewrap keeps the same character under
     the center), restore/font pins, session persistence.
   - pinch_test.go, pinch_font_test.go, tag_identity_test.go,
     real_draw_probe_test.go: unit + real-Renderer/real-Router tests.

2. Soft keyboard must not shift content:
   - Root cause: gioui.org/app calls Router.RevealFocus on any frame
     the viewport shrinks (IME open under adjustResize) and
     synthesizes a pointer.Scroll nudge aimed at the focused field's
     stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
     content jump.
   - Fix: main.go flags the shrink frame; render.go drains that one
     synthetic scroll for the gesture's tag before Update (scroll-
     range clamping cannot work: the router UNIONs ranges across
     frames). Finger scroll (pointer.Drag) and the flinger are
     untouched. reveal_focus_drain_test.go reproduces RevealFocus at
     the router level and verifies the drain + zero delta.

Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
2026-08-23 09:00:51 -04:00

511 lines
18 KiB
Go

package ui
import (
"math"
"testing"
"time"
"gioui.org/f32"
"gioui.org/io/pointer"
)
// pev builds a synthetic pointer event. Time is explicit: the Android driver
// supplies it per event and the freshness window runs on it.
func pev(kind pointer.Kind, id pointer.ID, x, y float32, at time.Duration) pointer.Event {
return pointer.Event{Kind: kind, PointerID: id, Position: f32.Point{X: x, Y: y}, Time: at}
}
// ms/s are time helpers so test timelines read as intended (a bare integer
// constant is nanoseconds — the first draft's "2000ms" was 2µs).
func ms(n int) time.Duration { return time.Duration(n) * time.Millisecond }
func s(n int) time.Duration { return time.Duration(n) * time.Second }
// runFrame feeds a batch of events (one frame's drain) through the tracker
// and returns the frame's factor, the grabs it asked for, and the
// survivor-finger scroll, mirroring consumePinchProbe.
func runFrame(t *pinchTracker, evts ...pointer.Event) (f float32, mid f32.Point, ok bool, grabs []pointer.ID, scroll int) {
for _, e := range evts {
if s := t.step(e); len(s.grabs) > 0 {
grabs = append(grabs, s.grabs...)
}
}
f, mid, ok, g2 := t.factor()
grabs = append(grabs, g2...)
scroll = t.survivorScroll()
return
}
// approx reports whether a and b are within 1e-3.
func approx(a, b float64) bool { return math.Abs(a-b) < 1e-3 }
// A clean two-finger pinch: the pair forms when BOTH fingers move (the
// two-mover rule), owes the spread that happened by then (baseline = press
// distance), then one factor per frame whose product telescopes to the total
// distance ratio — the property that makes the font track the fingers.
func TestTrackerPinchFactorSeries(t *testing.T) {
var tr pinchTracker
// Frame 1: both fingers down 200px apart. Pending: no pair, no grabs,
// no factor (a press alone is not a pinch).
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
)
if ok || len(grabs) != 0 {
t.Fatalf("press-only frame: ok=%v grabs=%v want nothing", ok, grabs)
}
// Frames 2-4: the pair spreads 200 -> 240 -> 300 -> 250. Frame 2 is the
// formation: both fingers moved 20px (movers), grabs issued, and the
// owed factor is measured against the PRESS distance (200).
f2, _, ok2, g2, _ := runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11),
)
if !ok2 || !approx(float64(f2), 240.0/200) {
t.Fatalf("f2=%v ok=%v want %v", f2, ok2, 240/200)
}
if len(g2) != 2 {
t.Fatalf("formation frame must grab both fingers, got %v", g2)
}
f3, mid3, ok3, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 50, 100, 20),
pev(pointer.Drag, 1, 350, 100, 21),
)
if !ok3 || !approx(float64(f3), 300.0/240) {
t.Fatalf("f3=%v ok=%v want %v", f3, ok3, 300/240)
}
if mid3 != (f32.Point{X: 200, Y: 100}) {
t.Fatalf("mid3=%v want (200,100)", mid3)
}
f4, _, ok4, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 125, 100, 30),
pev(pointer.Drag, 1, 375, 100, 31),
)
if !ok4 || !approx(float64(f4), 250.0/300) {
t.Fatalf("f4=%v ok=%v want %v", f4, ok4, 250.0/300)
}
total := f2 * f3 * f4
if !approx(float64(total), 250.0/200) {
t.Fatalf("product=%v want total ratio %v", total, 250/200)
}
}
// A single finger — press, hold, long scroll — must never produce a factor
// or a grab. This is the device regression "one-finger scroll changes the
// font": the old code paired the scroll finger with whatever stale pointer
// was in the map.
func TestTrackerSingleFingerNeverScales(t *testing.T) {
var tr pinchTracker
// A pinch earlier in the session...
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
)
runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11),
)
runFrame(&tr,
pev(pointer.Release, 0, 80, 100, 20),
pev(pointer.Release, 1, 320, 100, 21),
)
// ...now a single finger scrolls for many frames.
for i := 0; i < 20; i++ {
y := float32(400 - i*20)
_, _, ok, grabs, scroll := runFrame(&tr,
pev(pointer.Drag, 5, 200, y, time.Duration(100+i*10)))
if ok || len(grabs) != 0 || scroll != 0 {
t.Fatalf("frame %d: single finger produced factor ok=%v grabs=%v scroll=%v",
i, ok, grabs, scroll)
}
}
// Fresh pointer IDs (Android resets them after full release) — still nothing.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 200, 500, 500),
)
if ok || len(grabs) != 0 {
t.Fatalf("fresh single finger: ok=%v grabs=%v", ok, grabs)
}
for i := 0; i < 10; i++ {
_, _, ok, grabs, _ = runFrame(&tr,
pev(pointer.Drag, 0, 200, float32(500-i*15), time.Duration(510+i*10)))
if ok || len(grabs) != 0 {
t.Fatalf("scroll frame %d: ok=%v grabs=%v", i, ok, grabs)
}
}
}
// Device regression "sudden dramatic zoom before pinching": a finger that
// has been RESTING (palm edge, parked pinky) must not be paired with a new
// one. The freshness window prunes it; the pair forms only from fresh
// fingers.
func TestTrackerRestingFingerNotPaired(t *testing.T) {
var tr pinchTracker
// Palm rests at t=0.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 3, 600, 800, 0))
if ok || len(grabs) != 0 {
t.Fatal("one resting finger must not pinch")
}
// At t=2s the thumb lands: the palm is stale (>300ms) and pruned.
_, _, ok, grabs, _ = runFrame(&tr,
pev(pointer.Press, 0, 100, 100, s(2)),
pev(pointer.Drag, 3, 605, 800, s(2)+ms(1)), // palm still there, drifting
)
if ok || len(grabs) != 0 {
t.Fatalf("resting palm + new finger must not form a pair (ok=%v grabs=%v)", ok, grabs)
}
// At t=2.1s the index lands: two fresh fingers -> pending (formation
// waits for a third finger or the first movement; the palm is stale).
_, _, _, grabs, _ = runFrame(&tr,
pev(pointer.Press, 1, 300, 100, s(2)+ms(100)))
if len(grabs) != 0 {
t.Fatalf("pending pair must not grab yet, grabs=%v", grabs)
}
// The palm keeps drifting and the pair starts moving: the pair is
// (thumb, index) and its distance is unaffected by the palm.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 3, 640, 810, s(2)+ms(200)), // palm moves (pruned, ignored)
pev(pointer.Drag, 0, 80, 100, s(2)+ms(201)),
pev(pointer.Drag, 1, 320, 100, s(2)+ms(202)),
)
if len(grabs) != 2 {
t.Fatalf("thumb+index must form the pair on movement, grabs=%v", grabs)
}
if !ok || !approx(float64(f), 240.0/200) {
t.Fatalf("f=%v ok=%v want %v (palm must not enter the distance)", f, ok, 240/200)
}
}
// Device regression "pinch dies and becomes scroll": the pair must survive
// finger movement past the scroll slop. In the router this is guaranteed by
// the grabs (scroll is dropped from the pair's path); here we assert the
// state machine keeps emitting factors for a pair that moves a lot, and
// ignores drags of pointers that are not the pair.
func TestTrackerPinchSurvivesLargeMoves(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Big outward moves (far past the ~30px scroll slop), 5 frames.
want := []float32{1.5, 1.4, 1.3, 1.2, 1.1}
d := 200.0
for i := 0; i < 5; i++ {
d *= float64(want[i])
half := d / 2
_, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 200-float32(half), 100, time.Duration(10+i*10)),
pev(pointer.Drag, 1, 200+float32(half), 100, time.Duration(11+i*10)),
)
if !ok {
t.Fatalf("frame %d: factor stopped (pinch died)", i)
}
}
}
// Lifting one finger of the pair: no more factors (the pair is gone), the
// survivor's drags come out as scroll (forwarded), and returning a second
// finger re-forms the pair with a fresh baseline.
func TestTrackerSurvivorScrollAndReform(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Pinch in a bit.
runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11))
// Finger 0 lifts. Finger 1 survives.
_, _, ok, _, _ := runFrame(&tr,
pev(pointer.Release, 0, 80, 100, 20))
if ok {
t.Fatal("broken pair must not emit a factor")
}
// The survivor scrolls: 40px up over two frames -> +40, +25.
_, _, _, _, scroll := runFrame(&tr,
pev(pointer.Drag, 1, 320, 60, 30))
if scroll != 40 {
t.Fatalf("survivor scroll=%d want 40", scroll)
}
_, _, _, _, scroll = runFrame(&tr,
pev(pointer.Drag, 1, 320, 35, 40))
if scroll != 25 {
t.Fatalf("survivor scroll=%d want 25", scroll)
}
// A second finger returns: CANDIDATE for a re-form; the pair re-forms
// only when the new finger MOVES (two-mover rule — a palm resting on
// the survivor is not a pinch).
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 2, 50, 35, 50))
if ok || len(grabs) != 0 {
t.Fatalf("candidate press must not re-form yet (ok=%v grabs=%v)", ok, grabs)
}
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 1, 320, 35, 60),
pev(pointer.Drag, 2, 30, 35, 61)) // 20px from press: a real second finger
if len(grabs) != 1 || grabs[0] != 2 {
t.Fatalf("re-form grabs=%v want [2] (only the new finger)", grabs)
}
// Baseline = the distance at the candidate's PRESS (270 = 320-50); the
// spread to 290 by re-form time is owed, not lost.
if !ok || !approx(float64(f), 290.0/270) {
t.Fatalf("post-reform f=%v ok=%v want %v", f, ok, 290/270)
}
// Survivor lifts: fully idle again.
_, _, ok, _, scroll = runFrame(&tr,
pev(pointer.Release, 1, 320, 35, 70))
if ok || scroll != 0 {
t.Fatal("idle state must emit nothing")
}
}
// Device regression "dramatic zoom before the pinch starts": a NEW pinch
// must never emit a factor on its formation frame, even if the previous
// pinch ended at a very different distance (stale baseline).
func TestTrackerFreshBaselineEachPinch(t *testing.T) {
var tr pinchTracker
// Pinch A at ~200px.
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
runFrame(&tr,
pev(pointer.Release, 0, 100, 100, 10),
pev(pointer.Release, 1, 300, 100, 11))
// Pinch B starts 500px apart (the fingers landed far apart). The old
// code would have emitted 500/200 = 2.5x on the first frame.
_, _, ok, _, _ := runFrame(&tr,
pev(pointer.Press, 0, 0, 100, 100),
pev(pointer.Press, 1, 500, 100, 101))
if ok {
t.Fatal("second pinch's formation frame must not emit a factor (stale baseline)")
}
f, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, -20, 100, 110),
pev(pointer.Drag, 1, 520, 100, 111))
if !ok || !approx(float64(f), 540.0/500) {
t.Fatalf("f=%v ok=%v want %v (baseline = this pinch's own start)", f, ok, 540/500)
}
}
// A third finger landing DURING an active pinch is ignored (palm rest): the
// pair is fixed, its distance is untouched, and no extra grabs are issued.
func TestTrackerExtraFingerDuringPinchIgnored(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Palm lands and sits.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 4, 700, 900, 10))
if ok || len(grabs) != 0 {
t.Fatalf("extra finger during pinch: ok=%v grabs=%v", ok, grabs)
}
// Palm drifts a bit; the two pinch fingers move (forming the pair); the
// palm is not in the pair and its distance is untouched.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 4, 710, 910, 20), // 14px: a mover, but its distance to the fingers changes little
pev(pointer.Drag, 0, 80, 100, 21),
pev(pointer.Drag, 1, 320, 100, 22))
if len(grabs) != 2 {
t.Fatalf("pinch fingers must form the pair, grabs=%v", grabs)
}
if !ok || !approx(float64(f), 240.0/200) {
t.Fatalf("f=%v ok=%v want %v", f, ok, 240/200)
}
// Palm lifts: still nothing.
_, _, ok, _, _ = runFrame(&tr,
pev(pointer.Release, 4, 750, 950, 30))
if ok {
t.Fatal("palm release must not emit a factor")
}
}
// A global cancel (app switch) breaks the pair with no survivor.
func TestTrackerCancelBreaksPair(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11))
// App switch: cancels for both pointers.
_, _, ok, _, scroll := runFrame(&tr,
pev(pointer.Cancel, 0, 0, 0, 20),
pev(pointer.Cancel, 1, 0, 0, 21))
if ok || scroll != 0 {
t.Fatal("cancel must break the pair cleanly")
}
// The cancelled drags must not resurrect anything.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 0, 70, 100, 30))
if ok || len(grabs) != 0 {
t.Fatal("post-cancel drags must be inert")
}
}
// A finger that leaves the region pre-pinch is no longer a candidate:
// press-leave-press must not form a pair.
func TestTrackerLeaveCancelsCandidate(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0))
runFrame(&tr,
pev(pointer.Drag, 0, 500, 1500, 10), // leaves the region
pev(pointer.Leave, 0, 500, 1500, 11))
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 1, 300, 100, 20))
if ok || len(grabs) != 0 {
t.Fatalf("leaving finger must not pair with a new one (ok=%v grabs=%v)", ok, grabs)
}
}
// A pair broken by one finger lifting, then the survivor lifting too: the
// state must be fully idle (a later single-finger scroll emits nothing).
func TestTrackerFullyIdleAfterSurvivorLift(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
runFrame(&tr,
pev(pointer.Release, 0, 100, 100, 10))
runFrame(&tr,
pev(pointer.Drag, 1, 300, 60, 20)) // survivor scrolls
runFrame(&tr,
pev(pointer.Release, 1, 300, 60, 30))
for i := 0; i < 5; i++ {
_, _, ok, grabs, scroll := runFrame(&tr,
pev(pointer.Drag, 1, 300, float32(60-i*20), time.Duration(40+i*10)))
if ok || len(grabs) != 0 || scroll != 0 {
t.Fatalf("frame %d: not idle (ok=%v grabs=%v scroll=%v)", i, ok, grabs, scroll)
}
}
}
// The sanity clamp: a factor outside 0.1..10 (teleporting pair) is dropped
// and the baseline advances, so one bad frame cannot jump the font.
func TestTrackerSanityClamp(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Teleport: finger 0 jumps 3000px (ID-reuse noise) while finger 1 moves
// 20px: both are movers, the pair forms, distance 200 -> 2800, factor
// 14x — must be dropped, baseline advanced.
f, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 3100, 100, ms(10)),
pev(pointer.Drag, 1, 320, 100, ms(11)))
if ok {
t.Fatalf("implausible factor emitted: %v", f)
}
// Next frame is sane relative to the NEW baseline (2800px).
f, _, ok, _, _ = runFrame(&tr,
pev(pointer.Drag, 0, 3050, 100, ms(20)),
pev(pointer.Drag, 1, 325, 100, ms(21)))
if !ok || !approx(float64(f), 2725.0/2780) {
t.Fatalf("f=%v ok=%v want %v", f, ok, 2725.0/2780)
}
}
// A slow frame: the whole pinch (formation + spread + both releases) can
// arrive in ONE frame's drain (the Android driver replays historical samples
// and a low frame rate batches events). The factors must still be owed:
// the formation-frame movement relative to the formation distance, and the
// final movement settled when the pair breaks.
func TestTrackerSlowFrameFullPinch(t *testing.T) {
var tr pinchTracker
// Frame 1: both Presses (200px apart) and drags out to 300px, all in
// one drain. The pair forms at 200 and moved to 300 this frame:
// factor 300/200 owed.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
pev(pointer.Drag, 0, 50, 100, 2),
pev(pointer.Drag, 1, 350, 100, 3))
if !ok || !approx(float64(f), 300.0/200) {
t.Fatalf("f1=%v ok=%v want %v (formation-frame movement is owed)", f, ok, 300.0/200)
}
if len(grabs) != 2 {
t.Fatalf("grabs=%v want 2", grabs)
}
// Frame 2: spread to 400px, then both fingers lift — same drain. The
// 400/300 factor must be settled at the break, not lost.
f, _, ok, _, _ = runFrame(&tr,
pev(pointer.Drag, 0, 0, 100, 10),
pev(pointer.Drag, 1, 400, 100, 11),
pev(pointer.Release, 0, 0, 100, 12),
pev(pointer.Release, 1, 400, 100, 13))
if !ok || !approx(float64(f), 400.0/300) {
t.Fatalf("f2=%v ok=%v want %v (break settles the owed factor)", f, ok, 400.0/300)
}
// Fully idle after the break.
if _, _, ok, _, scroll := runFrame(&tr,
pev(pointer.Drag, 1, 400, 80, 20)); ok || scroll != 0 {
t.Fatal("tracker must be idle after both releases")
}
}
// A stationary pair that forms and then breaks without moving owes nothing.
func TestTrackerSlowFrameNoMovement(t *testing.T) {
var tr pinchTracker
if _, _, ok, _, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
pev(pointer.Release, 0, 100, 100, 2),
pev(pointer.Release, 1, 300, 100, 3)); ok {
t.Fatal("stationary pair that breaks must not emit a factor")
}
}
// The worst case (a ~1fps emulator frame): the ENTIRE pinch — formation,
// spread, and both releases — lands in one frame's drain. The owed factor
// is settled at the break against the formation distance.
func TestTrackerBornAndDeadInOneFrame(t *testing.T) {
var tr pinchTracker
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
pev(pointer.Drag, 0, 0, 100, 2),
pev(pointer.Drag, 1, 400, 100, 3),
pev(pointer.Release, 0, 0, 100, 4),
pev(pointer.Release, 1, 400, 100, 5))
if !ok || !approx(float64(f), 400.0/200) {
t.Fatalf("f=%v ok=%v want %v", f, ok, 400.0/200)
}
if len(grabs) != 2 {
t.Fatalf("grabs=%v want 2", grabs)
}
if _, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 1, 400, 80, 10)); ok {
t.Fatal("idle tracker must not emit")
}
}
// Palm-first three-finger: the palm edge is resting and still, then the two
// pinch fingers land (all within the freshness window). The pair must be the
// two NEWEST fingers — the pinch pair — not the palm.
func TestTrackerPalmFirstThreeFingers(t *testing.T) {
var tr pinchTracker
// Palm rests at t=0 (still), pinch fingers land 80/120ms later.
// Pending: no pair yet (movement decides).
_, _, _, grabs, _ := runFrame(&tr,
pev(pointer.Press, 3, 720, 400, 0),
pev(pointer.Press, 0, 570, 1200, ms(80)),
pev(pointer.Press, 1, 870, 1200, ms(120)))
if len(grabs) != 0 {
t.Fatalf("pending: no grabs yet, got %v", grabs)
}
// The pinch spreads 300 -> 596 while the palm stays put: the two MOVERS
// (the pinch fingers) form the pair, and the factor must track the
// pinch pair — not the palm.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 0, 422, 1200, ms(200)),
pev(pointer.Drag, 1, 1018, 1200, ms(201)),
pev(pointer.Drag, 3, 720, 400, ms(202))) // palm still
if len(grabs) != 2 || (grabs[0] != 0 && grabs[0] != 1) || (grabs[1] != 0 && grabs[1] != 1) {
t.Fatalf("pair must be the two pinch fingers, grabs=%v", grabs)
}
if !ok || !approx(float64(f), 596.0/300) {
t.Fatalf("f=%v ok=%v want %v (palm must not enter the distance)", f, ok, 596.0/300)
}
}