Pad/internal/test/e2e/restore_test.go
Greg Pomerantz f54ca2f81a IME: map commits against the whole buffer; drop the renderer-side model
The renderer kept a mirror of the pushed IME snippet (the 'IME model')
to translate commit positions, but it transiently desynced from the
buffer on fling/tap sequences (observed as a few-byte mapping drift on
both the x86_64 emulator and the ARM phone), corrupting text. The model
string also sat on the main goroutine next to the JNI render path,
where the app observed states that were impossible for Go memory
(string contents changing between reads microseconds apart), pointing
at corruption in the native bridge layer.

Restructure along the lines of the Android InputConnection contract
and Gio's own reference editor (widget/editor.go):

- Commits carry absolute file runes (the pushed snippet's coordinate
  space) straight to the logic goroutine, which maps them to bytes
  against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan).
  Scrolling moves the window, not the buffer, so the mapping is exact
  mid-fling by construction — no mirror to desync.
- Drift guard in HandleIMECommit: a small commit (range <= 2 runes)
  is always anchored at the caret the IME was last told about; if the
  IME reports it ending elsewhere, its snippet text is stale (a
  dropped restartInput, as Gboard does during flings) and its
  position is in the stale text's coordinates — snap the commit to
  the cursor, the only position it cannot drift from.
- FlushIME simplifies to: push the snippet when the frame's
  (context+window) text differs from the last push (gioui dedupes
  against its own cache), force the selection re-push in the same
  frame. After a commit the frame text equals what the IME already
  holds locally, so the restart is naturally suppressed; a fling
  re-anchors the IME once per text change.
- Remove the renderer model (adoptFrame/ModelTranslate/
  ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle
  machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the
  window-relative imeRuneToByte.

Also fixed along the way (both found while chasing the corruption):

- real.ReadFileAt: loop over short reads. A single ReadAt on Android
  FUSE can return a short read, silently truncating a chunk and
  shifting every byte offset after it.
- logic: a late lazy-chunk result no longer clobbers a buffer that
  SetContent has already fully loaded.
- e2e: large-file IME test (1.6 MB file, fling + commit).
- app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio
  builds the mipmap/adaptive icon set.

Verified: go vet + staticcheck, go test -race (all packages), and the
emulator scenario loop (open moby excerpt, fling to mid-file, tap,
type 'a', byte-compare the saved file) 75/75 clean.
2026-09-13 11:57:38 -04:00

902 lines
29 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")
}
}
}
// 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)
// The harness runs against the package globals (TheState/TheLogic), so
// only ONE harness may be alive at a time: h1 is stopped (not deferred)
// as soon as its snapshot is in hand, before h2's NewLogic overwrites
// the globals out from under h1's still-running goroutines.
h1Cleaned := false
cleanup1 := func() {
if !h1Cleaned {
h1Cleaned = true
h1.Cleanup()
}
}
defer cleanup1()
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)
}
// h1's job is done: stop it before h2 overwrites the package globals.
cleanup1()
// 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_LinePinHoldsAcrossLateWrapFeedback reproduces the on-device
// report "relaunch lands further UP than where I left off" (Pixel 9 Pro):
// while the restore scroll is still armed, the top-of-file window is what's
// rendered, and its shaping feedback (real wrap counts for the lines ABOVE
// the restored line) can land before or just after the offset is applied.
// Those counts are correct data, but they change the offset-to-line mapping:
// the line-derived offset was synthesized for the all-estimate index, so
// once counts land below the pinned line the same offset maps to a shallower
// line and the viewport drifts up. The restore therefore pins the logical
// line: every wrap correction re-derives the offset as V(L)*lh + sub until
// the restored window itself has shaped (or a timeout, or the user scrolls).
func TestRestore_LinePinHoldsAcrossLateWrapFeedback(t *testing.T) {
const lines = 300
const lineLen = 17 // "line %03d content\n"
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)
}
// The harness runs at the default font scale, so the stateless variant
// matches what the owner will compute.
lh := float64(editor.EffectiveLineHeightAt(1))
snap := editor.SessionState{
File: "/wrap.txt",
Cursor: 0,
Scroll: float64(ui.Dp(500 * lh)), // the saving session's visual-space offset
ScrollLine: 200, // the logical line at the viewport top
ScrollSub: 0,
}
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(snap)
h.Run()
h.SendConfig(780, 1688)
h.SendScale(2.0)
defer h.Cleanup()
// Wait for the restore to land: with the fresh all-estimate index the
// offset maps to exactly line 200.
var winLine int
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 -1
}
return st.WindowStartLine
})
if err == nil {
winLine = v.(int)
if winLine == 200 {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if winLine != 200 {
t.Fatalf("restore did not land on line 200 (window at %d)", winLine)
}
// Replay the on-device interleaving: the top-of-file window (lines
// 0-49) was shaped while the scroll was still armed, and its feedback —
// real wrap counts (3 visual lines each for those lines) — arrives now,
// after the offset was applied.
editSeq, err := h.Inspect(func(st *editor.State) any {
return st.Editor.EditSeq
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
var wt strings.Builder
for i := 0; i < 50; i++ {
fmt.Fprintf(&wt, "line %03d content\n", i)
}
windowText := wt.String()
var starts []int
for i := 0; i < 50; i++ {
base := i * lineLen
starts = append(starts, base, base+5, base+10) // 3 visual lines per line
}
h.Logic().LayoutChan() <- ui.LayoutFeedback{
GlyphLayout: ui.GlyphLayout{
VisualLineStarts: starts,
},
WindowText: windowText,
WindowStartByte: 0,
WindowStartLine: 0,
EditSeq: editSeq.(uint64),
}
// The correction must NOT drag the viewport: the pin re-derives the
// offset as V(200)*lh, where V(200) = 50*3 + 150 = 300 under the
// corrected index, keeping line 200 at the top. Pre-fix, the applied
// offset (200*lh) mapped to line ~66 under the corrected index.
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Scroll float64
WinLine int
}{float64(st.ScrollOffset), st.WindowStartLine}
})
if err == nil {
got := v.(struct {
Scroll float64
WinLine int
})
if got.WinLine == 200 && got.Scroll > 299*lh && got.Scroll < 301*lh {
return // held
}
}
time.Sleep(20 * time.Millisecond)
}
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Scroll float64
WinLine int
}{float64(st.ScrollOffset), st.WindowStartLine}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Scroll float64
WinLine int
})
if got.WinLine != 200 {
t.Errorf("window at line %d after late wrap feedback, want 200: the line-pin must hold the viewport on the restored line (a shallower line is the pre-fix drift)", got.WinLine)
}
if got.Scroll < 499*lh || got.Scroll > 501*lh {
t.Errorf("scroll = %v after late wrap feedback, want ~300*lh (%v): V(200) under the corrected index", got.Scroll, 300*lh)
}
}
// 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)")
}
}
// TestRestore_BrowserPageSession covers the relaunch bug where a session
// ended on the browser page still re-opened the last edited file: a
// snapshot taken on the browser page must mark InBrowser (with the browser
// directory and no file), and a relaunch handed that snapshot must land
// back in the browser at the saved directory.
func TestRestore_BrowserPageSession(t *testing.T) {
// Snapshot on the browser page.
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) {
st.Browser.CurrentPath = "/Documents/Work"
}); err != nil {
t.Fatalf("WithState: %v", err)
}
v, err := h.Inspect(func(st *editor.State) any {
return h.Logic().SnapshotSession()
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
sess := v.(editor.SessionState)
if !sess.InBrowser {
t.Fatalf("browser-page snapshot: InBrowser = false, want true: %+v", sess)
}
if sess.File != "" {
t.Errorf("browser-page snapshot: File = %q, want \"\"", sess.File)
}
if sess.BrowserPath != "/Documents/Work" {
t.Errorf("browser-page snapshot: BrowserPath = %q, want \"/Documents/Work\"", sess.BrowserPath)
}
// Relaunch with that session: land in the browser at the saved dir.
h2 := e2e.NewHarness()
defer h2.Cleanup()
h2.Logic().BeginBrowserRestore(sess.BrowserPath)
h2.Run()
h2.SendConfig(780, 1688)
h2.SendScale(2.0)
if _, err := e2e.WaitForNewFrame(h2, 0, 5*time.Second); err != nil {
t.Fatalf("timeout waiting for frames: %v", err)
}
v, err = h2.Inspect(func(st *editor.State) any {
return st.Page() == editor.BrowserPage && st.Browser.CurrentPath == sess.BrowserPath
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if !v.(bool) {
t.Fatal("relaunch with a browser session did not land on the browser page at the saved directory")
}
}