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).
112 lines
3.8 KiB
Go
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")
|
|
}
|