jesseduffield.lazygit/pkg/gocui/escape_test.go
2026-09-06 17:32:48 +02:00

278 lines
10 KiB
Go

package gocui
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseOne(t *testing.T) {
var ei *escapeInterpreter
ei = newEscapeInterpreter(OutputNormal)
isEscape, err := ei.parseOne([]byte{'a'})
assert.Equal(t, false, isEscape)
assert.NoError(t, err)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b[0K")
_, ok := ei.instruction.(eraseInLineFromCursor)
assert.Equal(t, true, ok)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b[K")
_, ok = ei.instruction.(eraseInLineFromCursor)
assert.Equal(t, true, ok)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b[1K")
_, ok = ei.instruction.(noInstruction)
assert.Equal(t, true, ok)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b(B")
_, ok = ei.instruction.(noInstruction)
assert.Equal(t, true, ok)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b)0")
_, ok = ei.instruction.(noInstruction)
assert.Equal(t, true, ok)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b*A")
_, ok = ei.instruction.(noInstruction)
assert.Equal(t, true, ok)
ei = newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, "\x1b+K")
_, ok = ei.instruction.(noInstruction)
assert.Equal(t, true, ok)
}
func TestParseOneColours(t *testing.T) {
scenarios := []struct {
outputMode OutputMode
input string
expectedFg Attribute
expectedBg Attribute
}{
{OutputNormal, "\x1b[30m", ColorBlack, ColorDefault},
{OutputNormal, "\x1b[31m", ColorRed, ColorDefault},
{OutputNormal, "\x1b[32m", ColorGreen, ColorDefault},
{OutputNormal, "\x1b[33m", ColorYellow, ColorDefault},
{OutputNormal, "\x1b[34m", ColorBlue, ColorDefault},
{OutputNormal, "\x1b[35m", ColorMagenta, ColorDefault},
{OutputNormal, "\x1b[36m", ColorCyan, ColorDefault},
{OutputNormal, "\x1b[37m", ColorWhite, ColorDefault},
{OutputNormal, "\x1b[40m", ColorDefault, ColorBlack},
{OutputNormal, "\x1b[41m", ColorDefault, ColorRed},
{OutputNormal, "\x1b[42m", ColorDefault, ColorGreen},
{OutputNormal, "\x1b[43m", ColorDefault, ColorYellow},
{OutputNormal, "\x1b[44m", ColorDefault, ColorBlue},
{OutputNormal, "\x1b[45m", ColorDefault, ColorMagenta},
{OutputNormal, "\x1b[46m", ColorDefault, ColorCyan},
{OutputNormal, "\x1b[47m", ColorDefault, ColorWhite},
{OutputNormal, "\x1b[47;31m", ColorRed, ColorWhite},
{OutputNormal, "\x1b[90m", Get256Color(8), ColorDefault},
{OutputNormal, "\x1b[91m", Get256Color(9), ColorDefault},
{OutputNormal, "\x1b[92m", Get256Color(10), ColorDefault},
{OutputNormal, "\x1b[93m", Get256Color(11), ColorDefault},
{OutputNormal, "\x1b[94m", Get256Color(12), ColorDefault},
{OutputNormal, "\x1b[95m", Get256Color(13), ColorDefault},
{OutputNormal, "\x1b[96m", Get256Color(14), ColorDefault},
{OutputNormal, "\x1b[97m", Get256Color(15), ColorDefault},
{OutputNormal, "\x1b[100m", ColorDefault, Get256Color(8)},
{OutputNormal, "\x1b[101m", ColorDefault, Get256Color(9)},
{OutputNormal, "\x1b[102m", ColorDefault, Get256Color(10)},
{OutputNormal, "\x1b[103m", ColorDefault, Get256Color(11)},
{OutputNormal, "\x1b[104m", ColorDefault, Get256Color(12)},
{OutputNormal, "\x1b[105m", ColorDefault, Get256Color(13)},
{OutputNormal, "\x1b[106m", ColorDefault, Get256Color(14)},
{OutputNormal, "\x1b[107m", ColorDefault, Get256Color(15)},
{Output256, "\x1b[38;5;32m", Get256Color(32), ColorDefault},
{OutputTrue, "\x1b[38;5;32m", Get256Color(32), ColorDefault},
{OutputTrue, "\x1b[38;2;50;103;205m", NewRGBColor(50, 103, 205), ColorDefault},
{Output256, "\x1b[48;5;32m", ColorDefault, Get256Color(32)},
{OutputTrue, "\x1b[48;5;32m", ColorDefault, Get256Color(32)},
{OutputTrue, "\x1b[48;2;50;103;205m", ColorDefault, NewRGBColor(50, 103, 205)},
{OutputTrue, "\x1b[1;95;48;2;255;224;224m", Get256Color(13), NewRGBColor(255, 224, 224)},
}
for _, scenario := range scenarios {
ei := newEscapeInterpreter(scenario.outputMode)
parseEscRunes(t, ei, scenario.input)
assert.Equal(t, scenario.expectedFg, ei.curFgColor&AttrColorBits)
assert.Equal(t, scenario.expectedBg, ei.curBgColor)
}
// resetting colours
scenarios = []struct {
outputMode OutputMode
input string
expectedFg Attribute
expectedBg Attribute
}{
{OutputNormal, "\x1b[39m", ColorDefault, ColorRed},
{OutputNormal, "\x1b[49m", ColorRed, ColorDefault},
{OutputNormal, "\x1b[0m", ColorDefault, ColorDefault},
}
for _, scenario := range scenarios {
ei := newEscapeInterpreter(scenario.outputMode)
ei.curFgColor = ColorRed
ei.curBgColor = ColorRed
parseEscRunes(t, ei, scenario.input)
assert.Equal(t, scenario.expectedFg, ei.curFgColor)
assert.Equal(t, scenario.expectedBg, ei.curBgColor)
}
// setting attributes
attrScenarios := []struct {
outputMode OutputMode
input string
expectedAttr Attribute
}{
{OutputNormal, "\x1b[1m", AttrBold},
{OutputNormal, "\x1b[2m", AttrDim},
{OutputNormal, "\x1b[3m", AttrItalic},
{OutputNormal, "\x1b[4m", AttrUnderline},
{OutputNormal, "\x1b[5m", AttrBlink},
{OutputNormal, "\x1b[7m", AttrReverse},
{OutputNormal, "\x1b[9m", AttrStrikeThrough},
}
for _, scenario := range attrScenarios {
ei := newEscapeInterpreter(scenario.outputMode)
parseEscRunes(t, ei, scenario.input)
style := ei.curFgColor & AttrStyleBits
assert.Equal(t, scenario.expectedAttr, style)
}
}
func TestParseOneIgnoresUnknownSequences(t *testing.T) {
// Escape sequences the interpreter doesn't implement -- whether well-formed-but-unsupported
// (private modes, DECSCUSR, …) or outright malformed -- must be silently
// consumed rather than leaked into the view as literal text.
scenarios := []string{
"\x1b[?9001h", // DEC private-mode set (?-prefix)
"\x1b[?25l", // hide cursor
"\x1b[?25h", // show cursor
"\x1b[2;J", // erase display (unusual 2;J variant)
"\x1b[H", // cursor home — re-anchors to row 1 (no-op when already there)
"\x1bc", // RIS — single-char ESC sequence
"\x1b[;5H", // empty first param — defaults to row 1, no-op
"\x1b[ q", // intermediate byte with no params (DECSCUSR family)
"\x1b[0 q", // intermediate byte after a param
"\x1b[1;;m", // malformed SGR: empty middle param
"\x1b]8bogus\x07", // OSC 8 missing ';'
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
}
for _, input := range scenarios {
ei := newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, input)
// An unimplemented/malformed sequence must leave no trace: no
// pending instruction, no color change.
_, noop := ei.instruction.(noInstruction)
assert.True(t, noop, "input %q left a pending instruction", input)
assert.Equal(t, ColorDefault, ei.curFgColor, "input %q mutated fg color", input)
assert.Equal(t, ColorDefault, ei.curBgColor, "input %q mutated bg color", input)
}
}
func TestParseOneCursorPositioning(t *testing.T) {
// Cursor-positioning escapes that advance the row forward emit a
// cursorDown instruction; backward / same-row moves are ignored
// because the view's buffer is line-based.
scenarios := []struct {
input string
startRow int // parser's screenRow before parsing
wantAdvance int // 0 means "no instruction emitted"
}{
{"\x1b[5;1H", 1, 4}, // CUP — absolute row 5 from row 1
{"\x1b[5H", 1, 4}, // CUP with only the row param
{"\x1b[5;1H", 5, 0}, // CUP to the same row we're on — no-op
{"\x1b[2;1H", 5, 0}, // CUP backward — ignored
{"\x1b[5;1f", 1, 4}, // HVP alias for CUP
{"\x1b[5d", 1, 4}, // VPA — absolute row
{"\x1b[2d", 5, 0}, // VPA backward — ignored
{"\x1b[3B", 1, 3}, // CUD — relative
{"\x1b[B", 1, 1}, // CUD with default param of 1
{"\x1b[2E", 1, 2}, // CNL — relative
}
for _, s := range scenarios {
ei := newEscapeInterpreter(OutputNormal)
ei.screenRow = s.startRow
parseEscRunes(t, ei, s.input)
if s.wantAdvance == 0 {
_, noop := ei.instruction.(noInstruction)
assert.True(t, noop, "input %q at row %d should be a no-op", s.input, s.startRow)
} else {
cd, ok := ei.instruction.(cursorDown)
if assert.True(t, ok, "input %q at row %d should emit cursorDown", s.input, s.startRow) {
assert.Equal(t, s.wantAdvance, cd.n, "input %q at row %d", s.input, s.startRow)
}
}
}
}
func TestParseOneCursorHomeReanchors(t *testing.T) {
// ConPTY emits cursor-home ([H) after [2J at the start of every screen.
// In a view that isn't rewound in lockstep with ConPTY (the command log)
// screenRow has drifted, so home must re-anchor it to the current write
// position rather than be dropped as a backward move — otherwise the
// absolute CUPs that follow compute negative, dropped advances and the
// rows ConPTY positioned with collapse together.
ei := newEscapeInterpreter(OutputNormal)
ei.screenRow = 12 // accumulated drift from earlier command-log output
parseEscRunes(t, ei, "\x1b[H")
assert.Equal(t, 1, ei.screenRow, "home should re-anchor screenRow")
_, noop := ei.instruction.(noInstruction)
assert.True(t, noop, "home should not emit an instruction")
// A subsequent CUP now advances relative to the re-anchored origin.
parseEscRunes(t, ei, "\x1b[3;1H")
cd, ok := ei.instruction.(cursorDown)
if assert.True(t, ok, "CUP after home should emit cursorDown") {
assert.Equal(t, 2, cd.n)
}
}
func TestParseOneCursorForward(t *testing.T) {
// CUF (\x1b[NC) emits a cursorForward instruction so the view can
// materialize the N-cell gap as spaces. ConPTY uses this (often
// paired with ECH) to encode runs of default-colored spaces.
scenarios := []struct {
input string
wantN int
}{
{"\x1b[5C", 5},
{"\x1b[1C", 1},
{"\x1b[C", 1}, // no param defaults to 1
}
for _, s := range scenarios {
ei := newEscapeInterpreter(OutputNormal)
parseEscRunes(t, ei, s.input)
cf, ok := ei.instruction.(cursorForward)
if assert.True(t, ok, "input %q should emit cursorForward", s.input) {
assert.Equal(t, s.wantN, cf.n, "input %q", s.input)
}
}
}
func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) {
t.Helper()
for _, b := range []byte(runes) {
isEscape, err := ei.parseOne([]byte{b})
assert.Equal(t, true, isEscape)
assert.NoError(t, err)
}
}