package editor import ( "fmt" "math/rand" "strings" "testing" "pad/internal/io/pool/types" ) // TestWrapIndex_EditBookkeeping verifies the WrapIndex maintenance inside // UpdateLineIndexAfterInsert/Delete against an independent oracle built from // a shadow copy of the file content. // // The oracle works on the shadow's lines: // - after each edit, the surviving lines are identified by maximal // prefix/suffix content alignment (a single contiguous edit has a unique // changed block); // - every line inside the changed block must have been reset to the // estimate (1) — missing that stamp leaves a stale wrap count, which is // exactly the scroll-jump bug this whole mechanism exists to prevent; // - surviving lines must keep their pre-edit count (or be conservatively // re-stamped to 1, which the next shaping pass corrects). // // Structural invariant checked every op: WrapIndex.Len() == // LineIndex.LineCount() (the two indexes must track the same line set). func TestWrapIndex_EditBookkeeping(t *testing.T) { // Distinct-ish line contents so content alignment is reliable. words := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"} var named []string for i := 0; i < 40; i++ { named = append(named, fmt.Sprintf("%s-%d-%s", words[i%len(words)], i, words[(i*3)%len(words)])) } content := strings.Join(named, "\n") + "\n" // The LineIndex convention: a trailing "\n" opens an empty trailing line // ("a\nb\n" has three lines: "a", "b", ""). strings.Split matches it. lines := strings.Split(content, "\n") cb := NewChunkedBuffer("/bookkeeping.txt", DefaultChunkSize, nil, "") cb.SetContent([]byte(content)) offsets := []int32{0} for i := 0; i < len(content); i++ { if content[i] == '\n' { offsets = append(offsets, int32(i+1)) } } cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(content))) w := NewWrapIndex(len(lines)) // Seed with a non-trivial pattern so a lost/shifted count is visible. for i := range lines { w.Set(i, int32(1+(i*7)%5)) } cb.WrapIndex = w rng := rand.New(rand.NewSource(99)) texts := []string{"x", "xy", "x\ny", "x\ny\nz", "\n", "a\nb", "no-newline-here"} for op := 0; op < 400; op++ { // Snapshot pre-edit state. oldCounts := make([]int32, w.Len()) for i := range oldCounts { oldCounts[i] = w.Get(i) } oldLines := splitLines(content) nBefore := w.Len() var newContent string if op%2 == 0 { // Insert pos := rng.Intn(len(content) + 1) text := texts[rng.Intn(len(texts))] content = content[:pos] + text + content[pos:] cb.Insert(pos, text) cb.UpdateLineIndexAfterInsert(pos, text) newContent = content } else { // Delete a := rng.Intn(len(content)) b := a + 1 + rng.Intn(min(8, len(content)-a)) content = content[:a] + content[b:] cb.Delete(a, b-a) cb.UpdateLineIndexAfterDelete(a, b) newContent = content } // Structural: the wrap index must track the line index. if w.Len() != cb.LineIndex.LineCount() { t.Fatalf("op %d: WrapIndex.Len() = %d, LineIndex.LineCount() = %d", op, w.Len(), cb.LineIndex.LineCount()) } newLines := splitLines(newContent) if w.Len() != len(newLines) { t.Fatalf("op %d: WrapIndex.Len() = %d, shadow lines = %d", op, w.Len(), len(newLines)) } // Oracle: maximal prefix/suffix alignment of old vs new lines. j := 0 for j < len(oldLines) && j < len(newLines) && oldLines[j] == newLines[j] { j++ } d := len(newLines) - len(oldLines) tail := 0 for tail < len(newLines)-j && j+tail < len(newLines) && newLines[len(newLines)-1-tail] == oldLines[len(oldLines)-1-tail] { tail++ } // Survivors: new[0:j] == old[0:j]; new[len-tail:] == old[len_old-tail:]. // Changed block: new[j : len(new)-tail]. for i := 0; i < len(newLines); i++ { var want int32 var survivorOld int // -1 = changed block if i < j { survivorOld = i } else if i >= len(newLines)-tail { survivorOld = i - d } else { survivorOld = -1 } got := w.Get(i) if survivorOld < 0 { // Changed line: must have been reset to the estimate. if got != 1 { t.Fatalf("op %d: line %d changed (content %q) but count = %d, want 1 (stale count would cause a scroll jump)", op, i, newLines[i], got) } } else { want = oldCounts[survivorOld] if got != want && got != 1 { t.Fatalf("op %d: line %d is a survivor (content %q) but count = %d, want %d (or 1)", op, i, newLines[i], got, want) } } } _ = nBefore } } // splitLines splits content into its logical lines using the LineIndex // convention: each '\n' opens a new line, so a trailing '\n' leaves an empty // trailing line ("a\nb\n" -> ["a","b",""] and "" -> [""]). func splitLines(content string) []string { return strings.Split(content, "\n") }