package vt import ( "errors" "math" "testing" renderer "github.com/bnema/vev-vt/core" ) func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { t.Helper() if err := history.Append(row, LineBound{End: len(row)}); err != nil { t.Fatalf("append history row: %v", err) } } func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { tests := []struct { name string maxRows int chunkRows int rows []string want []string }{ { name: "oldest chunks are evicted before newer chunks", maxRows: 4, chunkRows: 2, rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"}, want: []string{"cccc", "dddd", "eeee", "ffff"}, }, { name: "nonmultiple capacity evicts exactly the oldest row", maxRows: 5, chunkRows: 2, rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, want: []string{"1111", "2222", "3333", "4444", "5555"}, }, { name: "rows preserve append order across chunk boundaries", maxRows: 4, chunkRows: 2, rows: []string{"0000", "1111", "2222", "3333"}, want: []string{"0000", "1111", "2222", "3333"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows}) for _, text := range tt.rows { requireHistoryAppend(t, history, historyRow(text)) } view := history.View() if got := view.Len(); got > tt.maxRows { t.Fatalf("retained row count = %d, exceeds capacity %d", got, tt.maxRows) } if got := historyViewTexts(view); !equalStrings(got, tt.want) { t.Fatalf("view rows = %#v, want %#v", got, tt.want) } if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want { t.Fatalf("chunk count = %d, want %d", got, want) } }) } } func TestHistoryTailRotationAndStableViews(t *testing.T) { tests := []struct { name string }{ {name: "a full tail seals and a later append cannot change an existing view"}, {name: "rows passed to append are copied before the screen recycles them"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) first := historyRow("AAAA") requireHistoryAppend(t, history, first) requireHistoryAppend(t, history, historyRow("BBBB")) view := history.View() if got, want := view.ChunkCount(), 1; got != want { t.Fatalf("sealed chunk count = %d, want %d", got, want) } first[0].Rune = 'X' requireHistoryAppend(t, history, historyRow("CCCC")) if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { t.Fatalf("stable view rows = %#v, want %#v", got, want) } if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) { t.Fatalf("current view rows = %#v, want %#v", got, want) } }) } } func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { tests := []struct { name string }{ {name: "unchanged sealed chunks are shared by successive views"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) requireHistoryAppend(t, history, historyRow("AAAA")) requireHistoryAppend(t, history, historyRow("BBBB")) before := history.View() requireHistoryAppend(t, history, historyRow("CCCC")) after := history.View() if before.ChunkCount() == 0 || after.ChunkCount() == 0 { t.Fatal("sealed chunk missing from view") } if before.Chunk(0) != after.Chunk(0) { t.Fatal("unchanged sealed chunk was copied instead of reused") } }) } } func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) { tests := []struct { name string write []byte want []string }{ { name: "primary screen evictions enter terminal history", write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), want: []string{"AAAA"}, }, { name: "alternate screen scrolling never enters terminal history", write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), want: []string{"AAAA"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) screen.Write(tt.write) if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) { t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want) } }) } } func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { tests := []struct { name string top int bottom int wantRows int wantEvents int }{ { name: "interior scroll region does not enter global history", top: 1, bottom: 3, wantRows: 0, wantEvents: 0, }, { name: "top-edge scroll enters global history", top: 0, bottom: 3, wantRows: 1, wantEvents: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2}) for y := range s.frame.Height { s.frame.WriteRow(y, 0, historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)}))) } events := 0 s.OnLineEvicted = func([]renderer.Cell) { events++ } s.scrollUpRegion(tt.top, tt.bottom, 1) if got := s.History().View().Len(); got != tt.wantRows { t.Errorf("history rows = %d, want %d", got, tt.wantRows) } if events != tt.wantEvents { t.Errorf("eviction events = %d, want %d", events, tt.wantEvents) } }) } } func TestHistoryBoundsRowsAndBytesWithExactRowEviction(t *testing.T) { const budget = 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2}) for _, text := range []string{"aa", "bbb", "c", "dd"} { if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { t.Fatalf("append %q: %v", text, err) } } view := history.View() if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) { t.Fatalf("retained rows = %#v, want %#v", got, want) } if got, want := history.Len(), 2; got != want { t.Fatalf("retained rows = %d, want %d", got, want) } if got, want := history.Cells(), 3; got != want { t.Fatalf("retained cells = %d, want %d", got, want) } if got, want := view.Cells(), 3; got != want { t.Fatalf("view cells = %d, want %d", got, want) } } func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { tests := []struct { name string history *History }{ {name: "nil history"}, {name: "zero-value history", history: &History{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { row := historyRow("row") if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil { t.Fatalf("append error = %v, want nil", err) } if got := tt.history.Len(); got != 0 { t.Fatalf("retained rows = %d, want 0", got) } }) } } func TestHistoryRejectsRowLargerThanByteBudgetWithoutMutation(t *testing.T) { const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes history := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: budget, ChunkRows: 2}) kept := historyRow("ok") if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { t.Fatalf("append retained row: %v", err) } before := history.View() wide := historyRow("wide") err := history.Append(wide, LineBound{End: len(wide)}) if !errors.Is(err, ErrHistoryRowTooLarge) { t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err) } if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) } if got, want := history.Cells(), 2; got != want { t.Fatalf("retained cells after rejected append = %d, want %d", got, want) } } func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxBytes: 83}) var events []string screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) } screen.frame.WriteRow(0, 0, historyRow("AAAA")) screen.frame.WriteRow(1, 0, historyRow("BBBB")) screen.scrollUpRegion(0, 1, 1) if got := screen.History().Len(); got != 0 { t.Fatalf("oversized scrollback rows = %d, want 0", got) } if got, want := events, []string{"AAAA"}; !equalStrings(got, want) { t.Fatalf("scroll callbacks = %#v, want %#v", got, want) } if got := lineText(screen, 0); got != "BBBB" { t.Fatalf("screen did not scroll after history drop: row 0 = %q", got) } } func TestHistoryAppendRetainsBounds(t *testing.T) { row := func(s string) []renderer.Cell { cells := make([]renderer.Cell, 0, len(s)) for _, r := range s { cells = append(cells, renderer.Cell{Rune: r}) } return cells } tests := []struct { name string chunkRows int bounds []LineBound }{ { name: "within the mutable tail", chunkRows: 8, bounds: []LineBound{{End: 3, Soft: true}, {End: 2}}, }, { name: "across a sealed chunk boundary", chunkRows: 2, bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { h := NewHistory(HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20, ChunkRows: tc.chunkRows}) for i, b := range tc.bounds { if err := h.Append(row("abc"[:b.End]), b); err != nil { t.Fatalf("append %d: %v", i, err) } } view := h.SealAndView() for i, want := range tc.bounds { if got := view.Bound(i); got != want { t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) } } }) } } func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { h := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}) for i := range 5 { cells := []renderer.Cell{{Rune: rune('a' + i)}} if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil { t.Fatalf("append %d: %v", i, err) } } view := h.SealAndView() if view.Len() != 3 { t.Fatalf("Len() = %d, want 3", view.Len()) } // Rows 2, 3, 4 survived; their Soft flags were true, false, true. for i, want := range []bool{true, false, true} { if got := view.Bound(i).Soft; got != want { t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.Row(i)[0].Rune) } } } func TestHistorySlabAndTailAllocationBounds(t *testing.T) { if uint64(math.MaxInt) >= uint64(math.MaxUint32) { maxWidth := uint64(math.MaxUint32) width := int(maxWidth) if got := maxHistorySlabRows(width); got != 1 { t.Fatalf("maxHistorySlabRows(MaxUint32) = %d, want 1", got) } if got := maxHistorySlabRows(width / 2); got != 2 { t.Fatalf("maxHistorySlabRows(MaxUint32/2) = %d, want 2", got) } } history := NewHistory(HistoryConfig{MaxRows: 256, MaxBytes: 1 << 30, ChunkRows: 256}) row := make([]renderer.Cell, 10_000) if err := history.Append(row, LineBound{}); err != nil { t.Fatal(err) } if got, max := cap(history.tailCells), max(len(row), maxTailPreallocCells); got > max { t.Fatalf("wide-row tail capacity = %d, want at most %d", got, max) } } func TestHistorySealsCompactContiguousSlabs(t *testing.T) { const rows, columns = 256, 120 history := NewHistory(HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: rows}) row := make([]renderer.Cell, columns) for x := range row { row[x] = renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: true, Foreground: 4}} } for range rows { if err := history.Append(row, LineBound{End: columns}); err != nil { t.Fatal(err) } } view := history.View() chunk := view.Chunk(0) if view.ChunkCount() != 1 || chunk == nil || chunk.count != rows || chunk.width != columns { t.Fatalf("compact chunk = %#v, chunks %d", chunk, view.ChunkCount()) } // The original inline Cell occupied 72 bytes; retain the 4x reduction gate. if got, max := chunk.frameView().LogicalBytes(), uint64(rows*columns*72/4); got >= max { t.Fatalf("compact slab logical bytes = %d, want below %d", got, max) } if got := chunk.frameView().StyleCount(); got != 2 { t.Fatalf("page-local style count = %d, want default plus repeated style", got) } if err := chunk.CheckInvariants(); err != nil { t.Fatalf("compact chunk invariants: %v", err) } } func TestHistorySplitsCompactSlabsWhenRowWidthChanges(t *testing.T) { history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4}) for _, text := range []string{"aa", "bb", "ccc", "ddd"} { if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { t.Fatal(err) } } view := history.View() if got := view.ChunkCount(); got != 2 { t.Fatalf("ChunkCount() = %d, want one slab per consecutive width", got) } for i, want := range []string{"aa", "bb", "ccc", "ddd"} { if got := rowText(view.Row(i)); got != want { t.Fatalf("Row(%d) = %q, want %q", i, got, want) } } } func TestHistoryLargeRowLimitDoesNotInventByteBudget(t *testing.T) { history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) if got := history.ByteCap(); got != 0 { t.Fatalf("implicit byte capacity = %d, want no byte ceiling", got) } if got := history.Cap(); got != math.MaxInt { t.Fatalf("row ceiling = %d", got) } } func historyRow(text string) []renderer.Cell { row := make([]renderer.Cell, len(text)) for i, r := range text { row[i] = renderer.BlankCell() row[i].Rune = r } return row } func historyViewTexts(view HistoryView) []string { rows := make([]string, view.Len()) for i := range rows { rows[i] = rowText(view.Row(i)) } return rows } func rowText(row []renderer.Cell) string { runes := make([]rune, len(row)) for i, cell := range row { runes[i] = cell.Rune } return string(runes) }