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) } }