package editor import ( "testing" "pad/internal/io/pool" ) // findTestState builds a small state: 10 lines of "aa\n" (30 bytes) with a // find query "a" whose matches were scanned before the test's edits. func findTestState() { // NewLogic (used by other tests in this package) leaves the global // TheLogic pointing at a STOPPED logic; the find code must not dispatch // through it (findDispatchScan guards on nil). TheLogic = nil TheState = NewState() TheState.Editor.Filename = "t.txt" content := "aa\naa\naa\naa\naa\naa\naa\naa\naa\naa\n" TheState.Editor.Buffer = content TheState.Editor.ChunkedBuffer = nil e := &TheState.Editor e.Find = FindState{Visible: true, Query: "a", Cur: -1} // Every byte except newlines matches "a". for i := 0; i < len(content); i++ { if content[i] != '\n' { e.Find.Matches = append(e.Find.Matches, [2]int{i, i + 1}) } } } func TestFindEdit_ShiftAfter(t *testing.T) { findTestState() e := &TheState.Editor // Insert 4 bytes at offset 20: matches at/after 20 shift by 4; the // current match (pick one after the edit) keeps its identity. before := e.Find.Matches[len(e.Find.Matches)-1] e.Find.Cur = len(e.Find.Matches) - 1 e.findEdit(20, 20, 4) if got := e.Find.Matches[len(e.Find.Matches)-1]; got != [2]int{before[0] + 4, before[1] + 4} { t.Fatalf("last match %v, want %v shifted by 4", got, [2]int{before[0] + 4, before[1] + 4}) } if e.Find.Cur != len(e.Find.Matches)-1 { t.Fatalf("cur %d", e.Find.Cur) } // A match before the edit is untouched. if e.Find.Matches[0] != [2]int{0, 1} { t.Fatalf("first match %v", e.Find.Matches[0]) } } func TestFindEdit_DropOverlapping(t *testing.T) { findTestState() e := &TheState.Editor e.Find.Cur = 2 // byte 2 // Replace [2,4) with nothing: matches 2 and 3 (bytes 2,3) overlap and // are dropped; later matches shift left by 2; the current match is // dropped, so Cur resets to -1. e.findEdit(2, 4, 0) for _, m := range e.Find.Matches { if m[0] == 2 && m[1] == 4 { t.Fatalf("stale match %v survived an edit inside it", m) } } if e.Find.Cur != -1 { t.Fatalf("cur %d, want -1 (current match was edited away)", e.Find.Cur) } // The match that was at byte 4 is now at byte 2. if e.Find.Matches[0][0] != 0 || e.Find.Matches[1][0] != 1 || e.Find.Matches[2][0] != 2 { t.Fatalf("matches after delete: %v", e.Find.Matches[:5]) } } func TestFindStep_NextPrevWrap(t *testing.T) { findTestState() e := &TheState.Editor // No current match yet: next from caret 0 -> the match AT 0 (index 0). e.CursorPosition = 0 findStep(1) if e.Find.Cur != 0 { t.Fatalf("cur %d, want 0", e.Find.Cur) } if e.SelectionStart != 0 || e.SelectionEnd != 1 { t.Fatalf("selection [%d,%d), want [0,1)", e.SelectionStart, e.SelectionEnd) } // next -> 1, prev -> 0. findStep(1) findStep(-1) if e.Find.Cur != 0 { t.Fatalf("cur %d, want 0", e.Find.Cur) } // prev from 0 wraps to the LAST match. findStep(-1) if e.Find.Cur != len(e.Find.Matches)-1 { t.Fatalf("cur %d, want last (wrapped)", e.Find.Cur) } // next from last wraps to the FIRST. findStep(1) if e.Find.Cur != 0 { t.Fatalf("cur %d, want 0 (wrapped)", e.Find.Cur) } } func TestFindStep_NoMatches(t *testing.T) { findTestState() e := &TheState.Editor e.Find.Matches = nil e.CursorPosition = 5 findStep(1) if e.Find.Cur != -1 { t.Fatalf("cur %d, want -1 with no matches", e.Find.Cur) } } func TestApplySearchResult_GenGate(t *testing.T) { findTestState() e := &TheState.Editor e.Find.Gen = 3 // Stale generation: dropped, state untouched. stale := e.Find.Matches e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 2, Matches: [][2]int{}}}) if len(e.Find.Matches) != len(stale) { t.Fatalf("stale result was applied: %d matches", len(e.Find.Matches)) } // Current generation: applied; the surviving previous current match // keeps its position. e.Find.Cur = 1 e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 3, Matches: stale}}) if e.Find.Cur != 1 { t.Fatalf("cur %d, want 1 (kept)", e.Find.Cur) } } func TestApplySearchResult_FirstDiscoveryScrolls(t *testing.T) { findTestState() e := &TheState.Editor // No ChunkedBuffer/LineIndex in this unit state: scrollToFindMatch is a // no-op, so this only pins that first discovery selects match 0. e.Find.Matches = nil e.Find.Cur = -1 e.Find.Scanning = true e.Find.Gen = 1 e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{ Gen: 1, Matches: [][2]int{{10, 11}, {20, 21}}, }}) if e.Find.Scanning { t.Fatal("scanning flag not cleared") } if e.Find.Cur != 0 { t.Fatalf("cur %d, want 0 (first discovery)", e.Find.Cur) } } func TestFindShowClose_FocusTransitions(t *testing.T) { findTestState() TheState.FocusedElementID = "editor_text" TheState.Editor.Find.Visible = false TheState.Editor.findShow() if !TheState.Editor.Find.Visible { t.Fatal("find bar not shown") } if TheState.FocusedElementID != "find_bar" { t.Fatalf("focus %q, want find_bar", TheState.FocusedElementID) } TheState.Editor.findClose() if TheState.Editor.Find.Visible { t.Fatal("find bar still visible") } if TheState.FocusedElementID != "editor_text" { t.Fatalf("focus %q, want editor_text", TheState.FocusedElementID) } // The query survives close/reopen. q := TheState.Editor.Find.Query TheState.Editor.findShow() if TheState.Editor.Find.Query != q { t.Fatal("query lost across close/reopen") } } func TestFindReset_SupersedesInflight(t *testing.T) { findTestState() e := &TheState.Editor e.Find.Gen = 5 e.findReset() if e.Find.Visible || len(e.Find.Matches) != 0 { t.Fatal("findReset did not clear state") } if e.Find.Gen != 6 { t.Fatalf("gen %d, want 6 (in-flight scan must be superseded)", e.Find.Gen) } // The in-flight (gen 5) result must now be dropped. e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 5, Matches: [][2]int{{0, 1}}}}) if len(e.Find.Matches) != 0 { t.Fatal("superseded result was applied") } } func TestFindClear_EmptiesQueryKeepsBarOpen(t *testing.T) { findTestState() e := &TheState.Editor e.Find.Scanning = true // a scan of the old query is in flight e.Find.SettleByte = 5 oldGen := e.Find.Gen e.findClear() if !e.Find.Visible { t.Fatal("findClear closed the bar; it must stay open") } if e.Find.Query != "" || len(e.Find.Matches) != 0 || e.Find.Cur != -1 || e.Find.Scanning || e.Find.SettleByte != -1 { t.Fatalf("state after clear: %+v", e.Find) } if e.Find.Gen != oldGen+1 { t.Fatalf("gen %d, want %d (in-flight scan must be superseded)", e.Find.Gen, oldGen+1) } // The in-flight scan of the old query must now be dropped. e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: oldGen, Matches: [][2]int{{0, 1}}}}) if len(e.Find.Matches) != 0 { t.Fatal("cleared query's in-flight scan was applied") } }