Pad/internal/test/e2e/restore_test.go
Greg Pomerantz e3690d6b98 Restore last file, cursor, scroll, selection and find state on relaunch (spec §7)
Persist a tiny JSON snapshot (SessionState) written by the cmd layer
($HOME/.pad/session.json off-Android, /storage/emulated/0/Pad/ on
Android) and call the logic-owned snapshot rate-limited (<=1/s, on
change) from emitFrame plus unconditionally at Shutdown. On launch the
cmd layer hands the snapshot to Logic.BeginRestore before Run; the
file re-opens through the normal openFile path and lands straight on
the editor page.

- Cursor/selection land with the content, clamped to a shrunk file
  (path-guarded so a late result for a replaced file cannot apply the
  snapshot to the wrong buffer); a missing file falls back to the
  browser.
- Scroll is applied only after the first ScaleEvent has been laid out:
  the size ConfigEvent precedes it and the one-way MaxScroll clamp in a
  wrong-unit layout would corrupt the offset (found by e2e).
- Find: query + open/closed + current match are persisted; results are
  regenerated by re-scanning and the saved current match is re-selected
  by byte offset (Find.Restoring/RestoreMatch) without re-scrolling the
  restored viewport. A closed-bar query re-scans on the next bar open
  instead (an eager scan would be dropped and leave Scanning stuck).
- The main-owned find_bar widget is seeded with the restored query so
  its first frame matches the logic-side query.

Docs: spec.md gains §2.4 and drops the §7 row; invariant 5 updated;
architecture.md gains §6.7. Tests: 7 e2e tests covering cursor/scroll/
selection restore, clamping, missing-file fallback, find-bar restore
(open/closed), and the saver/shutdown persist paths.
2026-08-20 16:01:02 -04:00

434 lines
13 KiB
Go

package e2e_test
// Relaunch state restoration (spec §7): the last file, cursor, scroll,
// selection and find bar state are persisted as a tiny JSON snapshot and
// restored on the next launch. These tests drive the same entry points the
// cmd layer uses: Logic.BeginRestore before Run (restore) and the session
// saver callback (persist).
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"pad/internal/editor"
"pad/internal/io/pool/real"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// restoreHarness writes content to <tempdir>/name, builds a harness over the
// real filesystem, hands sess to the logic BEFORE Run (as the cmd layer does
// on relaunch), starts it with the standard 390x844@2x window, and waits for
// the restored file to fully load (content + line index).
func restoreHarness(t *testing.T, name, content string, sess editor.SessionState) *e2e.Harness {
t.Helper()
dir := t.TempDir()
diskPath := filepath.Join(dir, name)
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
sess.File = "/" + name
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(sess)
h.Run()
h.SendConfig(780, 1688) // 390x844 @ 2x scale
h.SendScale(2.0)
// "Loaded" means both async results are fully applied: the ReadFile
// content AND the BuildLineIndex result (same contract as
// realFileHarness: FileLen>0 && LineIndex!=nil alone is a race).
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return false
}
full, err := cb.FullContent()
return err == nil && int64(len(full)) == cb.FileLen() &&
cb.LineIndex.Size == cb.FileLen()
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return h
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timed out waiting for restored file to load")
return nil
}
// TestRestore_FileCursorScrollSelection restores a file with a cursor, a
// non-zero scroll and a live selection; all three must land once the content
// arrives, on the editor page (not the browser).
func TestRestore_FileCursorScrollSelection(t *testing.T) {
content := strings.Repeat("hello world line\n", 100)
h := restoreHarness(t, "notes.txt", content, editor.SessionState{
Cursor: 123,
Scroll: 100,
SelStart: 100,
SelEnd: 110,
})
defer h.Cleanup()
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Page editor.Page
File string
Cursor int
Scroll ui.Dp
SelStart int
SelEnd int
Anchor int
}{st.Page(), st.Editor.Filename, st.Editor.CursorPosition, st.ScrollOffset,
st.Editor.SelectionStart, st.Editor.SelectionEnd, st.Editor.SelectionAnchor}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Page editor.Page
File string
Cursor int
Scroll ui.Dp
SelStart int
SelEnd int
Anchor int
})
if got.Page != editor.EditorPage {
t.Errorf("page = %v, want EditorPage (restore lands straight in the editor)", got.Page)
}
if got.File != "/notes.txt" {
t.Errorf("file = %q, want /notes.txt", got.File)
}
if got.Cursor != 123 {
t.Errorf("cursor = %d, want 123", got.Cursor)
}
if got.Scroll != ui.Dp(100) {
t.Errorf("scroll = %v, want 100", got.Scroll)
}
if got.SelStart != 100 || got.SelEnd != 110 || got.Anchor != 100 {
t.Errorf("selection = [%d,%d) anchor %d, want [100,110) anchor 100",
got.SelStart, got.SelEnd, got.Anchor)
}
}
// TestRestore_ClampsToShorterFile: the file was truncated since the last
// session; the cursor and selection must clamp to the shorter content, not
// restore past EOF.
func TestRestore_ClampsToShorterFile(t *testing.T) {
content := "0123456789" // 10 bytes
h := restoreHarness(t, "short.txt", content, editor.SessionState{
Cursor: 57, // far past EOF
SelStart: 5,
SelEnd: 99, // spans EOF
})
defer h.Cleanup()
v, err := h.Inspect(func(st *editor.State) any {
return struct{ Cursor, SelStart, SelEnd int }{
st.Editor.CursorPosition, st.Editor.SelectionStart, st.Editor.SelectionEnd,
}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct{ Cursor, SelStart, SelEnd int })
if got.Cursor != 10 {
t.Errorf("cursor = %d, want 10 (clamped to EOF)", got.Cursor)
}
if got.SelStart != 5 || got.SelEnd != 10 {
t.Errorf("selection = [%d,%d), want [5,10) (end clamped to EOF)", got.SelStart, got.SelEnd)
}
}
// TestRestore_MissingFileFallsBackToBrowser: the restored file was deleted
// since the last session; the app must land on the browser page with a clean
// editor, not an empty editor page.
func TestRestore_MissingFileFallsBackToBrowser(t *testing.T) {
dir := t.TempDir()
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(editor.SessionState{File: "/gone.txt", Cursor: 10})
h.Run()
defer h.Cleanup()
h.SendConfig(780, 1688)
h.SendScale(2.0)
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any {
return st.Page() == editor.BrowserPage && st.Editor.Filename == "" &&
st.Editor.ChunkedBuffer == nil
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return
}
if time.Now().After(deadline) {
t.Fatal("timed out: restore of a missing file did not fall back to the browser")
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_FindBar reopens the file with the find bar open, a query and a
// current match: the query must re-scan, select the saved current match (by
// byte offset) and NOT re-scroll the restored viewport.
func TestRestore_FindBar(t *testing.T) {
var b strings.Builder
for i := 0; i < 50; i++ {
fmt.Fprintf(&b, "filler line number %02d\n", i)
}
b.WriteString("needle at start of the interesting part\n")
b.WriteString("middle filler\n")
b.WriteString("needle in the middle of it\n")
b.WriteString("tail filler\n")
b.WriteString("needle at the end\n")
for i := 0; i < 50; i++ {
fmt.Fprintf(&b, "trailing filler %02d\n", i)
}
content := b.String()
// Offsets of the three "needle" occurrences.
first := strings.Index(content, "needle")
second := strings.Index(content[first+1:], "needle") + first + 1
third := strings.Index(content[second+1:], "needle") + second + 1
if first < 0 || second < 0 || third < 0 {
t.Fatal("test content lost its needles")
}
h := restoreHarness(t, "findme.txt", content, editor.SessionState{
FindQuery: "needle",
FindVisible: true,
FindCurByte: second,
// Cursor/scroll restored at the top: if the re-scan wrongly
// re-scrolls to the match, ScrollOffset would no longer be 0.
})
defer h.Cleanup()
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any {
f := &st.Editor.Find
return struct {
Visible, Scanning bool
N, Cur, SelStart, SelEnd int
Scroll ui.Dp
}{f.Visible, f.Scanning, len(f.Matches), f.Cur,
st.Editor.SelectionStart, st.Editor.SelectionEnd, st.ScrollOffset}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Visible, Scanning bool
N, Cur, SelStart, SelEnd int
Scroll ui.Dp
})
if !got.Scanning && got.N == 3 {
if !got.Visible {
t.Errorf("find bar visible = false, want true")
}
if got.Cur != 1 {
t.Errorf("current match = %d, want 1 (the saved current item)", got.Cur)
}
if got.SelStart != second || got.SelEnd != second+len("needle") {
t.Errorf("selection = [%d,%d), want [%d,%d)",
got.SelStart, got.SelEnd, second, second+len("needle"))
}
if got.Scroll != 0 {
t.Errorf("scroll = %v, want 0 (restore must not re-scroll to the match)", got.Scroll)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for the restore re-scan (matches=%d scanning=%v)", got.N, got.Scanning)
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_FindBarClosed keeps the query but does not open the bar: no
// scan may run at restore (it would be dropped and leave Scanning stuck);
// opening the bar afterwards must re-scan and produce the matches.
func TestRestore_FindBarClosed(t *testing.T) {
var b strings.Builder
for i := 0; i < 60; i++ {
fmt.Fprintf(&b, "padding line %02d\n", i)
}
b.WriteString("needle one\nneedle two\nneedle three\n")
content := b.String()
h := restoreHarness(t, "closed.txt", content, editor.SessionState{
FindQuery: "needle",
FindVisible: false,
})
defer h.Cleanup()
// Give any (wrong) eager scan time to dispatch, then check state.
time.Sleep(300 * time.Millisecond)
v, err := h.Inspect(func(st *editor.State) any {
f := &st.Editor.Find
return struct {
Visible, Scanning bool
Query string
N int
}{f.Visible, f.Scanning, f.Query, len(f.Matches)}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Visible, Scanning bool
Query string
N int
})
if got.Visible {
t.Errorf("find bar visible = true, want false")
}
if got.Query != "needle" {
t.Errorf("query = %q, want needle", got.Query)
}
if got.Scanning {
t.Errorf("scanning = true after a closed-bar restore; the scan would be dropped and never re-run")
}
if got.N != 0 {
t.Errorf("matches = %d, want 0 (no scan while the bar is closed)", got.N)
}
// Opening the bar now must re-scan (findShow) and find all three.
if err := h.WithState(func(st *editor.State) { editor.ToggleFind(nil) }); err != nil {
t.Fatalf("ToggleFind: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for {
n, err := h.Inspect(func(st *editor.State) any {
return len(st.Editor.Find.Matches)
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if n.(int) == 3 {
return
}
if time.Now().After(deadline) {
t.Fatal("timed out: opening the find bar did not re-scan the restored query")
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_SaverSnapshot verifies the persist side: a state change
// (cursor + selection + scroll) is picked up by the rate-limited saver as a
// snapshot with the right values.
func TestRestore_SaverSnapshot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "small.txt"), []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().SetSessionSaver(func(s editor.SessionState) { saves <- s })
h.Run()
h.SendConfig(780, 1688)
h.SendScale(2.0)
defer h.Cleanup()
// Open the file the usual way (browser-row tap path).
if err := h.WithState(func(st *editor.State) { editor.OpenFile("/small.txt") }); err != nil {
t.Fatalf("OpenFile: %v", err)
}
time.Sleep(300 * time.Millisecond) // let the load settle
// Change state on the owner, then force a frame (emitFrame drives the
// rate-limited save).
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 6
st.Editor.SelectionAnchor = 0
st.Editor.SelectionStart = 0
st.Editor.SelectionEnd = 5
st.ScrollOffset = 10
}); err != nil {
t.Fatalf("WithState: %v", err)
}
// The save is rate-limited to one per second; wait past the limit, then
// force a frame (emitFrame drives the check).
time.Sleep(1100 * time.Millisecond)
h.SendConfig(780, 1688)
deadline := time.Now().Add(5 * time.Second)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
t.Fatal("timed out: saver did not receive the expected snapshot")
}
select {
case s := <-saves:
if s.File == "/small.txt" && s.Cursor == 6 &&
s.SelStart == 0 && s.SelEnd == 5 && s.Scroll == 10 {
return
}
case <-time.After(remaining):
t.Fatal("timed out: saver did not receive the expected snapshot")
}
}
}
// TestRestore_ShutdownSavesFinalSnapshot verifies Shutdown's unconditional
// final save: the last state is persisted even without a rate-limited tick.
func TestRestore_ShutdownSavesFinalSnapshot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {})
l.SetSessionSaver(func(s editor.SessionState) { saves <- s })
go l.Run()
// Open the file (the goroutine-send keeps this off the owner's select).
if _, ok := l.Inspect(func(st *editor.State) any {
editor.OpenFile("/a.txt")
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
for i := 0; i < 100; i++ {
loaded, ok := l.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() == 6
})
if ok && loaded.(bool) {
break
}
time.Sleep(20 * time.Millisecond)
}
if _, ok := l.Inspect(func(st *editor.State) any {
st.Editor.CursorPosition = 3
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
l.Shutdown()
deadline := time.After(3 * time.Second)
for {
select {
case s := <-saves:
if s.File == "/a.txt" && s.Cursor == 3 {
return
}
case <-deadline:
t.Fatal("timed out: Shutdown did not persist a snapshot with the final state")
}
}
}