Pad/internal/test/e2e/restore_test.go
Greg Pomerantz 6968f6a284 Restore scroll by logical line, not pixel offset (wrap-aware)
On device: scroll far down a wrapped file, relaunch, and the app lands
further DOWN than where the user left off — the deeper the scroll, the
further off.

Root cause: the persisted Scroll is a pixel offset in VISUAL-line space.
Restoring maps it through the WrapIndex (scrollDecompose -> LineForVisual),
but on relaunch every count is the estimate (1) until the line is shaped,
and shaping covers the visible window only — the lines ABOVE the restored
viewport are never shaped. With all-ones counts LineForVisual maps the
offset 1:1, landing a logical line deeper by every wrapped continuation
above the viewport, and the state is stable (the under-counted lines never
re-enter the window), so it never self-corrects.

The snapshot now persists wrap-independent coordinates: the logical line
at the viewport top (derived with the same mapping the layout uses,
against the current index, so it is exactly the shown line) plus the
sub-line remainder. The restore re-derives the offset as line*lh + sub,
which maps to the saved line under any wrap state (all-ones or populated).
The raw Dp offset is kept for pre-line-coordinate session files
(loadSession defaults the missing key to -1; BeginRestore rejects the
ambiguous zero value: a genuine line-0 snapshot always has Scroll < lh).

TestRestore_ScrollSurvivesWrapState reproduces it: 150 lines recorded as
wrapped x3, viewport at logical line 200 (visual 500); a relaunch with a
fresh WrapIndex must land the window on line 200. Fails pre-fix (window at
line 254, i.e. deeper) and passes with the fix.

Docs: spec §2.4 (line-based scroll persist + current save policy),
architecture §6.7 (why the offset is unrestorable by re-mapping).
2026-08-20 19:31:51 -04:00

699 lines
22 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_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)")
}
}