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 /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_ScrollLandsWhenScalePrecedesSize reproduces the on-device // startup ordering, where the first FrameEvent (and hence the ScaleEvent) // arrives before the size ConfigEvent. The scroll must still land, clamped // by the layout at the real scale: an earlier revision applied it at the // ScaleEvent against a not-yet-sized viewport and lost it. func TestRestore_ScrollLandsWhenScalePrecedesSize(t *testing.T) { content := strings.Repeat("hello world line\n", 100) dir := t.TempDir() diskPath := filepath.Join(dir, "late.txt") if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { t.Fatal(err) } h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Logic().BeginRestore(editor.SessionState{File: "/late.txt", Cursor: 123, Scroll: 100}) h.Run() h.SendScale(2.0) // scale first h.SendConfig(780, 1688) // size second (on-device ordering) defer h.Cleanup() 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() && st.ScrollOffset == ui.Dp(100) }) if err != nil { t.Fatalf("Inspect: %v", err) } if v.(bool) { return } time.Sleep(50 * time.Millisecond) } t.Fatal("timed out: scroll did not land when the scale event preceded the size") } // 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") } } } // TestRestore_ScrollSurvivesWrapState reproduces the on-device report // "relaunch lands further DOWN than where I left off": the snapshot's pixel // scroll lives in visual-line space (wrapped lines occupy several visual // lines, and the scroll-to-line mapping runs through the WrapIndex), but on // relaunch the counts of every line above the restored viewport are the // estimate (1) until shaped, and lines above the visible window are never // shaped. Mapping the saved offset through that fresh index lands a logical // line deeper by all the wrapped continuations above it. The snapshot // therefore persists the logical line at the viewport top (and the sub-line // remainder), and the restore derives the offset from the line. func TestRestore_ScrollSurvivesWrapState(t *testing.T) { const lines = 300 var b strings.Builder for i := 0; i < lines; i++ { fmt.Fprintf(&b, "line %03d content\n", i) } dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "wrap.txt"), []byte(b.String()), 0644); err != nil { t.Fatal(err) } // First session: open the file, record the first 150 lines as wrapped // into 3 visual lines each (as a shaping pass would), and scroll so the // viewport top sits on logical line 200. In visual space that is line // 150*3 + 50 = 500, so the pixel offset is 500 line-heights. saves := make(chan editor.SessionState, 16) h1 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h1.Logic().SetSessionSaver(func(s editor.SessionState) { saves <- s }) h1.Run() h1.SendConfig(780, 1688) h1.SendScale(2.0) defer h1.Cleanup() if err := h1.WithState(func(st *editor.State) { editor.OpenFile("/wrap.txt") }); err != nil { t.Fatalf("OpenFile: %v", err) } for i := 0; i < 100; i++ { v, err := h1.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer return cb != nil && cb.FileLen() > 0 && cb.LineIndex != nil }) if err == nil && v.(bool) { break } time.Sleep(20 * time.Millisecond) } // Set the wrapped counts and the deep scroll on the owner; the line // height comes from the owner too (EffectiveLineHeight reads state). lhAny, err := h1.Inspect(func(st *editor.State) any { w := st.Editor.ChunkedBuffer.WrapIndex for i := 0; i < 150; i++ { w.Set(i, 3) } st.ScrollOffset = ui.Dp(500 * float64(editor.EffectiveLineHeight())) return float64(editor.EffectiveLineHeight()) }) if err != nil { t.Fatalf("Inspect: %v", err) } lh := lhAny.(float64) // Wait past the rate limit and force a frame: the saver must capture // the deep scroll together with its logical line (200). Match on the // line, not the exact Dp: ui.Dp is float32-based, so ui.Dp(500*lh) and // 500*float64(lh) round differently. time.Sleep(300 * time.Millisecond) h1.SendConfig(780, 1688) snap := editor.SessionState{} deadline := time.After(5 * time.Second) for { select { case s := <-saves: if s.File == "/wrap.txt" && s.ScrollLine == 200 && s.Scroll > 0 { snap = s goto got } case <-deadline: t.Fatal("timed out: saver did not capture the deep-scroll snapshot") } } got: if snap.Scroll < 499*lh || snap.Scroll > 501*lh { t.Fatalf("snapshot Scroll = %v, want ~500*lh (%v): the pixel offset must stay the visual-space value", snap.Scroll, 500*lh) } if snap.ScrollLine != 200 { t.Fatalf("snapshot ScrollLine = %d, want 200 (the logical line at the viewport top under wrap)", snap.ScrollLine) } if snap.ScrollSub >= 0.01 { // 0 apart from ui.Dp float32 rounding t.Errorf("snapshot ScrollSub = %v, want ~0", snap.ScrollSub) } // Second session (the relaunch): the WrapIndex is fresh, every count the // estimate (1). The restore must land on logical line 200, not on visual // line 500 mapped 1:1 (which would be line 500, far deeper). h2 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h2.Logic().BeginRestore(snap) h2.Run() h2.SendConfig(780, 1688) h2.SendScale(2.0) defer h2.Cleanup() // The restore must land the scroll within one line of 200*lh (the ui.Dp // float32 rounding keeps an exact match out of reach); the exact // assertion is the window start line below. for i := 0; i < 100; i++ { v, err := h2.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil { return false } s := float64(st.ScrollOffset) return s > 199*lh && s < 201*lh }) if err == nil && v.(bool) { break } time.Sleep(20 * time.Millisecond) } // One more frame so the layout pass records the window start line for // the landed scroll. h2.SendConfig(780, 1688) time.Sleep(100 * time.Millisecond) v, err := h2.Inspect(func(st *editor.State) any { return struct { Scroll ui.Dp WinLine int }{st.ScrollOffset, st.WindowStartLine} }) if err != nil { t.Fatalf("Inspect: %v", err) } got := v.(struct { Scroll ui.Dp WinLine int }) if s := float64(got.Scroll); s < 199*lh || s > 201*lh { t.Errorf("restored scroll = %v, want ~200*lh (%v): the saved logical line's offset", got.Scroll, 200*lh) } if got.WinLine != 200 { t.Errorf("window starts at line %d, want 200 (the saved line); a deeper line is the pre-fix visual-space mapping", got.WinLine) } } // TestRestore_UrgentSaveOnSelection verifies the kill-race fix: a selection // appearing (the user just highlighted text) saves immediately, even inside // the rate-limit window that the file-open save opened. Without the urgent // path, killing the app (recents-wipe) less than a second after highlighting // lost the selection and cursor. func TestRestore_UrgentSaveOnSelection(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() defer l.Shutdown() // Open the file: the file change is itself urgent, so a save lands as // the load settles. Drain everything up to and including the loaded // snapshot. if _, ok := l.Inspect(func(st *editor.State) any { editor.OpenFile("/a.txt") return nil }); !ok { t.Fatal("Inspect timed out") } loaded := false for i := 0; i < 100 && !loaded; i++ { v, ok := l.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer return cb != nil && cb.FileLen() == 6 }) if ok { loaded = v.(bool) } if loaded { break } time.Sleep(20 * time.Millisecond) } if !loaded { t.Fatal("file did not load") } // Drain saves up to the first one describing the loaded file. deadline := time.After(3 * time.Second) for { select { case s := <-saves: if s.File == "/a.txt" { goto drainDone } case <-deadline: t.Fatal("timed out: no save for the opened file") } } drainDone: // Immediately (well inside any rate-limit window after the saves above), // make a selection appear and force a frame: the urgent path must save it // now, not after the interval elapses. if _, ok := l.Inspect(func(st *editor.State) any { st.Editor.SelectionAnchor = 1 st.Editor.SelectionStart = 1 st.Editor.SelectionEnd = 4 st.Editor.CursorPosition = 4 st.ScrollOffset = 3 return nil }); !ok { t.Fatal("Inspect timed out") } l.ConfigChan() <- editor.ConfigEvent{PixelWidth: 780, PixelHeight: 1688} // force emitFrame select { case s := <-saves: if s.SelStart == 1 && s.SelEnd == 4 && s.Cursor == 4 { return } t.Fatalf("unexpected snapshot: %+v", s) case <-time.After(2 * time.Second): t.Fatal("timed out: selection appearance was not saved immediately (urgent path)") } }