On device (Pixel 9 Pro) the relaunch landed further UP than the saved position: while the restore scroll is still armed (scale + size + content can take ~600 ms), the top-of-file window is what gets rendered, and its shaping feedback — real wrap counts for lines ABOVE the restored line — lands before or just after the offset is applied. The counts are correct data, but they change V(line), so the line-derived offset (synthesized for the all-estimate index) maps to a shallower line and the viewport drifts up; the save then persists the drifted line and every subsequent relaunch lands there. Fix: the restore pins the logical line. Until the restored window's own shaping arrives (or a 2 s timeout, or the user scrolls / a search jumps), every accepted wrap correction re-derives the offset as VisualsBefore(line)*lh + sub under the corrected index. The pin refresh does not clamp to MaxScroll: the correction just grew the index, so the pre-layout clamp value is stale and would under-clamp the re-derived offset (the layout of the emitted frame clamps to the fresh value). Also: the apply-time offset is mapped through VisualsBefore under the current index (identical to line*lh while the index is all estimates), so pre-apply corrections are absorbed instead of gated away. Verified on device: saved line 420 -> restored 420 (was 333); saved 573 (near EOF, clamp territory) -> restored 573. New e2e regression TestRestore_LinePinHoldsAcrossLateWrapFeedback replays the late top-window feedback and fails pre-fix (window drifts 200 -> 66).
836 lines
26 KiB
Go
836 lines
26 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)
|
|
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_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)")
|
|
}
|
|
}
|