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.
511 lines
18 KiB
Go
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)
|
|
}
|
|
}
|