On-device runs exposed three bugs the e2e suite could not (it never feeds layout feedback, and runs headless without size events): 1. WrapIndex poisoning from zero-width shapes. The first frames are built before the window size is known (px=0x0); the renderer shapes the editor window at zero width, where every line wraps into many visual lines. When that feedback arrives, applyWrapCounts writes the inflated counts to the window's lines. Normally the app stays on those lines and re-shapes them at a real width, which corrects the counts before anyone notices. A restored scroll moves the viewport away instead, so the poisoned counts persist and map the restored scroll offset to the wrong line (2000 landed on line 11 of 200). The frame now carries ViewportDegenerate (set at frame-build time, since feedback delivery lags shaping by a frame), and the main loop drops layout feedback for such frames. 2. Session saving suppressed forever after a successful restore. saveSessionIfChanged suppresses saves while l.session is set, and only abortRestore cleared it — the success path never did, so an app restored from a session never persisted new state. The snapshot has fully landed once the cursor/selection/find have landed with the content and the armed scroll has landed (or there was none); clear l.session at both points. 3. gofmt on restore_test.go (comment alignment).
474 lines
14 KiB
Go
474 lines
14 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_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")
|
|
}
|
|
}
|
|
}
|