Files
patterm/internal/app/viewport_renderer_test.go
2026-05-15 00:28:06 +01:00

355 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"strings"
"testing"
)
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[3;1H" {
t.Fatalf("CUP home: got %q", got)
}
}
func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?1049hb\x1b[?1049lc")))
if got != "abc" {
t.Fatalf("alt-screen toggles: got %q", got)
}
}
func TestViewportRendererSwallowsOriginModeToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?6hb\x1b[?6lc")))
if strings.Contains(got, "\x1b[?6h") || strings.Contains(got, "\x1b[?6l") {
t.Fatalf("origin-mode toggles leaked to host: %q", got)
}
if !strings.Contains(got, "a") || !strings.Contains(got, "b") || !strings.Contains(got, "c") {
t.Fatalf("origin-mode toggles should not drop surrounding text: got %q", got)
}
if strings.Count(got, "\x1b[3;1H") != 2 {
t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got)
}
}
func TestViewportRendererSwallowsLeftRightMarginMode(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?69h\x1b[10;80sb\x1b[?69lc")))
if strings.Contains(got, "\x1b[?69h") || strings.Contains(got, "\x1b[10;80s") || strings.Contains(got, "\x1b[?69l") {
t.Fatalf("left/right margin controls leaked to host: %q", got)
}
if got != "abc" {
t.Fatalf("left/right margin controls should be swallowed without dropping text: got %q", got)
}
}
func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;10r\x1b[?6h\x1b[1;1H")))
if strings.Contains(got, "\x1b[?6h") {
t.Fatalf("origin-mode set leaked to host: %q", got)
}
if !strings.Contains(got, "\x1b[7;1H") {
t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 7: got %q", got)
}
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
// hostRows=7 leaves four viewport rows after the 2-row tab bar and
// 1-row status reservation.
vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
}
if strings.Count(got, "\x1b[20X") != 4 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") {
t.Fatalf("clear did not target viewport rows: %q", got)
}
}
func TestViewportRendererClearLineUsesEraseChars(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[K")))
if strings.Contains(got, "\x1b[K") {
t.Fatalf("host clear-line leaked through: %q", got)
}
if got != "\x1b[20X" {
t.Fatalf("clear-line: got %q want ECH", got)
}
}
func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[10G\x1b[K")))
if !strings.HasSuffix(got, "\x1b[11X") {
t.Fatalf("clear-line from col 10 should erase 11 cells: %q", got)
}
}
func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) {
// Reproduces the sidebar-wipe bug: claude's Ctrl+O expansion emits
// `CSI 0 J` (clear from cursor to end of screen). Forwarded verbatim,
// it would erase every host column to the right of the cursor —
// including the sidebar — because the cursor is at host coordinates
// but the J sequence isn't constrained to the viewport.
vr := newViewportRenderer(newTerminalLayout(40, 7))
got := string(vr.Render([]byte("\x1b[H\x1b[0J")))
if strings.Contains(got, "\x1b[0J") || strings.Contains(got, "\x1b[J") {
t.Fatalf("host clear-to-end leaked through: %q", got)
}
// childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge).
// Each of the 4 viewport rows should get a 19-cell erase.
// childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved).
// 4 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 4 erases of 11 cells each.
count := strings.Count(got, "\x1b[11X")
if count != 4 {
t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got)
}
}
func TestViewportRendererClearToStartIsViewportOnly(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(40, 7))
// Park the cursor mid-viewport, then issue `CSI 1 J`.
got := string(vr.Render([]byte("\x1b[3;5H\x1b[1J")))
if strings.Contains(got, "\x1b[1J") {
t.Fatalf("host clear-to-start leaked through: %q", got)
}
// Two full rows above (childCols-wide erase, 11 cells each) plus a
// 5-cell erase on the cursor row.
if !strings.Contains(got, "\x1b[11X") {
t.Fatalf("expected viewport-wide ECH for rows above cursor: %q", got)
}
if !strings.Contains(got, "\x1b[5X") {
t.Fatalf("expected 5-cell ECH on cursor row: %q", got)
}
}
func TestViewportRendererTracksPrintableCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("hello\x1b[K")))
if !strings.HasSuffix(got, "\x1b[15X") {
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
}
}
func TestViewportRendererClampsCUPColumn(t *testing.T) {
// Layout: hostCols=120, sidebar present → childCols=91. A child that
// thinks its viewport is the full host width could emit a CUP to col
// 95 (inside the sidebar). The renderer must clamp the emitted CUP
// column so the host cursor never lands in the sidebar.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H")))
if !strings.Contains(got, "\x1b[7;91H") {
t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got)
}
}
func TestViewportRendererClampsCHAColumn(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[110G")))
if !strings.Contains(got, "\x1b[91G") {
t.Fatalf("CHA col 110 should clamp to 91 (childCols): got %q", got)
}
}
func TestViewportRendererDropsPrintablesPastViewport(t *testing.T) {
// A child whose internal column state drifted past the viewport
// (childCols=91 here) might CUP to col 95 and stream text. The CUP
// column is clamped to the viewport edge, but tracking still
// considers the cursor "past" — so subsequent printables must drop
// rather than walk into the sidebar columns.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95HCLOBBER")))
if strings.Contains(got, "CLOBBER") || strings.Contains(got, "LOBBER") {
t.Fatalf("printables past childCols should be dropped: got %q", got)
}
}
func TestViewportRendererKeepsPrintablesUpToViewportEdge(t *testing.T) {
// Writing exactly childCols glyphs from col 1 must reach the right
// edge unchanged — the drop kicks in only after the cursor passes
// the last viewport column.
vr := newViewportRenderer(newTerminalLayout(120, 40))
in := append([]byte("\x1b[5;1H"), bytesRepeat('x', 91)...)
got := string(vr.Render(in))
if strings.Count(got, "x") != 91 {
t.Fatalf("91 'x' glyphs from col 1 should all be emitted: got %q", got)
}
}
func TestViewportRendererDropsUTF8GlyphPastViewport(t *testing.T) {
// A 3-byte UTF-8 glyph (U+2500 BOX DRAWINGS LIGHT HORIZONTAL) starting
// past the viewport must be dropped as a unit — leaking even one
// continuation byte would feed a malformed sequence to the host.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H─x")))
if strings.Contains(got, "─") {
t.Fatalf("UTF-8 glyph past viewport should be dropped: got %q", got)
}
if strings.Contains(got, "x") {
t.Fatalf("trailing ASCII past viewport should also be dropped: got %q", got)
}
}
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll
// region scrolls the region down — across all columns — pushing
// sidebar content out of place. The renderer must flag the chunk
// so the sidebar cache gets invalidated and repainted afterwards.
vr := newViewportRenderer(newTerminalLayout(120, 40))
if vr.TookScrollAction() {
t.Fatalf("scroll flag set before any input")
}
_ = vr.Render([]byte("\x1b[1;1H"))
if vr.TookScrollAction() {
t.Fatalf("plain CUP should not flag scroll")
}
_ = vr.Render([]byte("\x1bM"))
if !vr.TookScrollAction() {
t.Fatalf("RI (ESC M) should flag scroll")
}
if vr.TookScrollAction() {
t.Fatalf("flag should reset after read")
}
}
func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
cases := map[string][]byte{
"IND": []byte("\x1bD"),
"NEL": []byte("\x1bE"),
"SU": []byte("\x1b[3S"),
"SD": []byte("\x1b[2T"),
}
for name, in := range cases {
t.Run(name, func(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(in)
if !vr.TookScrollAction() {
t.Fatalf("%s should flag scroll", name)
}
})
}
}
func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[37;1H\n"))
if !vr.TookScrollAction() {
t.Fatalf("LF at viewport bottom should flag scroll")
}
}
func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[36;1H\n"))
if vr.TookScrollAction() {
t.Fatalf("LF before viewport bottom should not flag scroll")
}
}
func TestViewportRendererFlagsLineFeedAtCustomScrollBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[5;10r\x1b[9;1H\n"))
if vr.TookScrollAction() {
t.Fatalf("LF before custom scroll bottom should not flag scroll")
}
_ = vr.Render([]byte("\n"))
if !vr.TookScrollAction() {
t.Fatalf("LF at custom scroll bottom should flag scroll")
}
}
// Long claude sessions can leave the child cursor at viewport row 1 and
// then emit CSI A (cursor up) with a large step before redrawing. The
// raw CSI A would walk the host cursor into the tab bar; the next
// printable would then write into row 1 / row 2. Clamp the step at the
// viewport top so the host cursor stays inside the viewport.
func TestViewportRendererClampsCUUAtViewportTop(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to viewport row 1 then CUU by 50.
got := string(vr.Render([]byte("\x1b[1;1H\x1b[50ACLOBBER")))
if !strings.Contains(got, "\x1b[3;1H") {
t.Fatalf("expected CUP shifted to mainTop: got %q", got)
}
// The CUU should have been swallowed (n clamped to 0 from row 1).
if strings.Contains(got, "\x1b[50A") {
t.Fatalf("CUU 50 from viewport row 1 leaked: got %q", got)
}
// And the subsequent printables should land inside the viewport,
// not above it.
if !strings.Contains(got, "CLOBBER") {
t.Fatalf("printables should still be emitted after clamped CUU: got %q", got)
}
}
func TestViewportRendererClampsCUUPartial(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to viewport row 5, then CUU by 50 → safe step is 4.
got := string(vr.Render([]byte("\x1b[5;1H\x1b[50A")))
if !strings.Contains(got, "\x1b[4A") {
t.Fatalf("CUU 50 from row 5 should clamp to 4: got %q", got)
}
if strings.Contains(got, "\x1b[50A") {
t.Fatalf("unclamped CUU leaked: got %q", got)
}
}
func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) {
// childRows=37 for layout(120, 40). Park cursor at row 37, ask for
// 10 down → safe step is 0.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[37;1H\x1b[10B")))
if strings.Contains(got, "\x1b[10B") {
t.Fatalf("CUD past viewport bottom should be dropped: got %q", got)
}
}
func TestViewportRendererClampsCPLAndHomesColumn(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to row 1 col 50 then CPL by 5 → step clamped to 0, but col
// must still reset to 1 (CR emitted).
got := string(vr.Render([]byte("\x1b[1;50H\x1b[5F")))
if strings.Contains(got, "\x1b[5F") {
t.Fatalf("CPL 5 from row 1 should not leak: got %q", got)
}
if !strings.Contains(got, "\r") {
t.Fatalf("CPL should home column to 1 with CR: got %q", got)
}
}
func TestViewportRendererClampsCNL(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to row 35 then CNL by 50 → safe step is 2 (childRows-35).
got := string(vr.Render([]byte("\x1b[35;10H\x1b[50E")))
if !strings.Contains(got, "\x1b[2E") {
t.Fatalf("CNL 50 from row 35 should clamp to 2: got %q", got)
}
}
func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
// We rely on the host terminal performing the scroll inside the
// DECSTBM region; the renderer must not eat or transform RI. If a
// future change ever rewrites RI, this test catches the regression.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1bM")))
if got != "\x1bM" {
t.Fatalf("RI should pass through unchanged: got %q", got)
}
}