- Viewport: swallow the opening tap/scroll (justOpenedAt window) and re-clamp ScrollOffset to [0,MaxScroll] in EditorLayout so a short file never opens past its content (blank viewport). - Chunked buffer: replace the fixed i*chunkSize slot model (which drifted after length-changing edits and could re-read stale disk for shifted tail chunks) with an ordered chunk slice + prefix-sum byte offsets. In-range files now load fully on open (SetContent), so there is no lazy load and no stale-disk re-read. Edits splice only the affected chunk(s). - Size guard: files > MaxEditableFileSize (50 MB) show a 'too large to edit' notice instead of loading; the browser still lists them. Edit handlers (KeyDown/ReplaceRange) are no-ops for too-large files. - Tests: rewrite chunked_buffer_test.go for prefix-sum correctness (insert/ delete across chunk boundaries, rune->byte after edit); fix the large-file e2e expectation to the shift-correct ground truth.
194 lines
6.3 KiB
Go
194 lines
6.3 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newTestBuffer builds a fully-loaded (in-range) buffer from content using the
|
|
// on-open path (SetContent), so all chunks are resident.
|
|
func newTestBuffer(t *testing.T, content []byte) *ChunkedBuffer {
|
|
t.Helper()
|
|
cb := NewChunkedBuffer("/test.txt", DefaultChunkSize, nil, "")
|
|
cb.SetContent(content)
|
|
return cb
|
|
}
|
|
|
|
// TestSetContent_MultipleChunks verifies the whole-file load splits content
|
|
// into ordered chunks and Content reconstructs it exactly.
|
|
func TestSetContent_MultipleChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
// 200KB / 64KB = 3 full chunks (64KB each) + 1 partial (8KB) = 4 chunks.
|
|
if got := len(cb.chunks); got != 4 {
|
|
t.Fatalf("expected 4 chunks, got %d", got)
|
|
}
|
|
if cb.FileLen() != int64(len(content)) {
|
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(content))
|
|
}
|
|
full, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent error: %v", err)
|
|
}
|
|
if full != string(content) {
|
|
t.Fatalf("FullContent mismatch after SetContent")
|
|
}
|
|
// Spot-check a range that spans the chunk-0/chunk-1 boundary.
|
|
seg := cb.Content(64*1024-10, 64*1024+10)
|
|
if seg != string(content[64*1024-10:64*1024+10]) {
|
|
t.Fatalf("Content across chunk boundary mismatch: got %q", seg)
|
|
}
|
|
}
|
|
|
|
// TestInsertShiftsLaterChunks is the core drift-fix test: an insert in chunk 0
|
|
// must shift every later byte, and Content must still return the correct byte
|
|
// at the shifted offset (the old fixed-slot i*chunkSize model failed here).
|
|
func TestInsertShiftsLaterChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
const insLen = 3
|
|
cb.Insert(0, strings.Repeat("X", insLen))
|
|
|
|
// A byte originally at position 100000 (chunk 1) is now at 100003.
|
|
want := content[100000]
|
|
if got := cb.Content(100000+insLen, 100001+insLen); got != string(want) {
|
|
t.Fatalf("byte at shifted offset 100003 = %q, want %q", got, string(want))
|
|
}
|
|
// The inserted prefix is present.
|
|
if got := cb.Content(0, insLen); got != strings.Repeat("X", insLen) {
|
|
t.Fatalf("prefix = %q, want %q", got, strings.Repeat("X", insLen))
|
|
}
|
|
// Full content is the insert + original.
|
|
full, _ := cb.FullContent()
|
|
if full != strings.Repeat("X", insLen)+string(content) {
|
|
t.Fatalf("FullContent after insert mismatch (len=%d, want %d)", len(full), insLen+len(content))
|
|
}
|
|
}
|
|
|
|
// TestDeleteShiftsLaterChunks verifies a deletion in an early chunk shifts
|
|
// later bytes left and Content/FullContent stay exact.
|
|
func TestDeleteShiftsLaterChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
const delLen = 100
|
|
cb.Delete(0, delLen)
|
|
|
|
// A byte originally at 100000 is now at 99900.
|
|
want := content[100000]
|
|
if got := cb.Content(100000-delLen, 100001-delLen); got != string(want) {
|
|
t.Fatalf("byte at shifted offset = %q, want %q", got, string(want))
|
|
}
|
|
full, _ := cb.FullContent()
|
|
if full != string(content[delLen:]) {
|
|
t.Fatalf("FullContent after delete mismatch (len=%d, want %d)", len(full), len(content)-delLen)
|
|
}
|
|
}
|
|
|
|
// TestDeleteSpanningChunks verifies a deletion that spans multiple chunk
|
|
// boundaries is exact.
|
|
func TestDeleteSpanningChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
// Delete 64KB+50 bytes starting at 30KB (spans chunks 0,1,2).
|
|
const start = 30 * 1024
|
|
const n = 64*1024 + 50
|
|
cb.Delete(start, n)
|
|
|
|
expected := make([]byte, 0, len(content)-n)
|
|
expected = append(expected, content[:start]...)
|
|
expected = append(expected, content[start+n:]...)
|
|
full, _ := cb.FullContent()
|
|
if !bytes.Equal([]byte(full), expected) {
|
|
t.Fatalf("FullContent after spanning delete mismatch (len=%d, want %d)", len(full), len(expected))
|
|
}
|
|
if cb.FileLen() != int64(len(expected)) {
|
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(expected))
|
|
}
|
|
}
|
|
|
|
// TestRuneIndexToByteAfterEdit verifies the IME rune->byte bridge stays correct
|
|
// after a length-changing edit (ASCII + multibyte).
|
|
func TestRuneIndexToByteAfterEdit(t *testing.T) {
|
|
// 2 chunks of ASCII so the edit crosses into chunk arithmetic.
|
|
var b bytes.Buffer
|
|
for i := 0; i < 100*1024; i++ {
|
|
b.WriteByte(byte('a' + i%26))
|
|
}
|
|
cb := newTestBuffer(t, b.Bytes())
|
|
|
|
// Insert a multibyte rune ("é" = 2 bytes) at rune 0.
|
|
cb.Insert(0, "é")
|
|
// Rune 0 is now 'é' (bytes 0..1). Rune 1 is the original first 'a' at byte 2.
|
|
if got := cb.RuneIndexToByte(0); got != 0 {
|
|
t.Fatalf("RuneIndexToByte(0)=%d, want 0", got)
|
|
}
|
|
if got := cb.RuneIndexToByte(1); got != 2 {
|
|
t.Fatalf("RuneIndexToByte(1)=%d, want 2", got)
|
|
}
|
|
// New rune R (R>0) is the original rune R-1 (originally at byte R-1), now
|
|
// at byte (R-1)+2 = R+1 (é is 1 rune, 2 bytes). Check one deep in chunk 1
|
|
// (past the 64KB boundary) to confirm the shift survives chunk boundaries.
|
|
deep := 70000
|
|
if got := cb.RuneIndexToByte(deep); got != deep+1 {
|
|
t.Fatalf("RuneIndexToByte(%d)=%d, want %d", deep, got, deep+1)
|
|
}
|
|
}
|
|
|
|
// TestContent_BoundsAndEmpty guards edge cases in the rewritten Content.
|
|
func TestContent_BoundsAndEmpty(t *testing.T) {
|
|
cb := newTestBuffer(t, []byte("hello world"))
|
|
if got := cb.Content(0, 5); got != "hello" {
|
|
t.Fatalf("Content(0,5)=%q, want hello", got)
|
|
}
|
|
if got := cb.Content(6, 11); got != "world" {
|
|
t.Fatalf("Content(6,11)=%q, want world", got)
|
|
}
|
|
// Out-of-range end clamps to fileLen.
|
|
if got := cb.Content(0, 1000); got != "hello world" {
|
|
t.Fatalf("Content(0,1000)=%q, want full", got)
|
|
}
|
|
// start >= end is empty.
|
|
if got := cb.Content(5, 5); got != "" {
|
|
t.Fatalf("Content(5,5)=%q, want empty", got)
|
|
}
|
|
// Empty buffer.
|
|
empty := newTestBuffer(t, nil)
|
|
if got := empty.Content(0, 10); got != "" {
|
|
t.Fatalf("empty Content=%q, want empty", got)
|
|
}
|
|
if got, _ := empty.FullContent(); got != "" {
|
|
t.Fatalf("empty FullContent=%q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestAppendAtEnd inserts at the very end of the buffer (pos == fileLen), which
|
|
// maps to the last chunk's end.
|
|
func TestAppendAtEnd(t *testing.T) {
|
|
cb := newTestBuffer(t, []byte("abcdef"))
|
|
cb.Insert(6, "XYZ")
|
|
full, _ := cb.FullContent()
|
|
if full != "abcdefXYZ" {
|
|
t.Fatalf("append at end = %q, want abcdefXYZ", full)
|
|
}
|
|
if cb.FileLen() != 9 {
|
|
t.Fatalf("FileLen=%d, want 9", cb.FileLen())
|
|
}
|
|
}
|