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