Pad/internal/test/e2e/idle_frames_test.go
Greg Pomerantz ac1c32e4c0 Add pre-release frame-regression profiling
Ensures the app never generates frames without a cause (frame emission
is event-driven; a spinner would burn CPU/battery for the app's entire
idle life). Verified on the emulator and the phone: healthy runs show
ZERO frames across all idle windows (PERF-PRESENT fps < 1).

- perf: rows whose previous frame is >= 100ms away are idle gaps, not
  slow frames — flagged in the CSV (new gap column) and kept out of the
  logcat summary's latency percentiles (reported as gaps=N maxGap=...).
  A frame >= 2s after the previous one flushes the CSV, so a burst's
  rows are persisted the moment the next burst starts (an idle tail or
  force-stop no longer loses the last burst).
- editor: debug 'open <path>' command (cmd-file poller, perf mode):
  opens a file from any page via the same OpenFile path as a browser
  tap — deterministic, no pixel tapping.
- e2e: TestNoFramesWhileIdle (browser + editor after load/scroll/find)
  asserts the logic emits ZERO frames across an idle window once
  settled — the headless contract, in the regular go-test suite.
- scripts/profile_emulator.sh: drives launch, 8s idle, open (seeded
  4000-line file), 16s idle, 3 scrolls on a device/emulator; groups the
  CSV into phases at idle gaps >= 2s and FAILs on a phase over its frame
  budget (1/s spinner exceeds a 16s idle budget; faster ones balloon a
  phase or show in PERF-PRESENT). Validated both ways: PASS on a healthy
  build (emulator + phone), FAIL on an injected 500ms frame spinner.
- doc: architecture.md §11 updated (gap handling, burst flush, debug
  open, frame-regression guard, 2026-08 measurements).
2026-08-20 14:18:08 -04:00

112 lines
3.8 KiB
Go

package e2e_test
import (
"strings"
"testing"
"time"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// TestNoFramesWhileIdle is the pre-release frame-regression guard. Frame
// emission is event-driven (architecture.md §2): once the logic goroutine
// has settled — every async result applied, every wrap-settle pass done —
// it must emit ZERO further frames while no input arrives. A non-zero
// delta means something is spinning (a ticker, a re-emission loop, a
// feedback ping-pong between layout and state) and would burn CPU and
// battery on a real device for the app's entire idle lifetime.
//
// The main-loop side (Gio presents) is measured by the same contract's
// on-device instrument: PERF-PRESENT logcat lines (cmd/pad, enabled by
// touching <sd>/PadPerf/enable; see scripts/profile_emulator.sh).
// waitFrameSettle returns the frame count once it has been unchanged for
// quietFor (the logic is quiescent). Fatals if it never settles.
func waitFrameSettle(t *testing.T, h *e2e.Harness, quietFor time.Duration) int {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
last := h.FrameCount()
lastChanged := time.Now()
for time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
if n := h.FrameCount(); n != last {
last, lastChanged = n, time.Now()
continue
}
if time.Since(lastChanged) >= quietFor {
return last
}
}
t.Fatal("frame count never settled within 10s")
return 0
}
// assertIdleFrames requires the frame count to be unchanged across an
// idleFor window that follows a settled (quietFor-stable) state.
func assertIdleFrames(t *testing.T, h *e2e.Harness, quietFor, idleFor time.Duration, phase string) {
t.Helper()
before := waitFrameSettle(t, h, quietFor)
time.Sleep(idleFor)
if after := h.FrameCount(); after != before {
t.Fatalf("%s: logic emitted %d frame(s) while idle (before=%d after=%d); emission must be event-driven",
phase, after-before, before, after)
}
}
// TestNoFramesWhileIdle_Browser: the file-browser page, fully loaded, must
// go completely quiet.
func TestNoFramesWhileIdle_Browser(t *testing.T) {
h := e2e.NewHarness()
h.Run()
defer h.Cleanup()
h.SendConfig(1080, 2400)
if _, err := h.WaitForFrameCount(1, 5*time.Second); err != nil {
t.Fatalf("no frames: %v", err)
}
assertIdleFrames(t, h, 300*time.Millisecond, time.Second, "browser")
}
// TestNoFramesWhileIdle_Editor: an open file after a load, a scroll, and a
// full find cycle (scan + navigate + close) must go completely quiet —
// the find settle machinery in particular must terminate, not re-trigger.
func TestNoFramesWhileIdle_Editor(t *testing.T) {
// 300 long lines: enough to scroll and to match a query at several
// positions.
var sb strings.Builder
for i := 0; i < 300; i++ {
sb.WriteString("line number 000: the quick brown fox jumps over the lazy dog by the river\n")
}
h, _ := realFileHarness(t, "idle.txt", sb.String())
defer h.Cleanup()
h.SendConfig(1080, 2400)
// Scroll (HandleScroll swallows input for 300ms after open).
time.Sleep(400 * time.Millisecond)
h.SendInput([]ui.InputEvent{{Handler: editor.HandleScroll, Data: 2000}})
// A full find cycle: open the bar, query, navigate, close.
h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: nil}})
h.Logic().FindQueryChan() <- "quick"
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any { return len(st.Editor.Find.Matches) })
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(int) > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("find never matched")
}
time.Sleep(20 * time.Millisecond)
}
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: nil}})
time.Sleep(100 * time.Millisecond)
h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: nil}})
assertIdleFrames(t, h, 300*time.Millisecond, time.Second, "editor")
}