bubbletea/key_test.go

695 lines
16 KiB
Go

package tea
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"math/rand"
"reflect"
"runtime"
"sort"
"strings"
"sync"
"testing"
"time"
)
func TestKeyString(t *testing.T) {
t.Run("alt+space", func(t *testing.T) {
if got := KeyMsg(Key{
Type: KeySpace,
Alt: true,
}).String(); got != "alt+ " {
t.Fatalf(`expected a "alt+ ", got %q`, got)
}
})
t.Run("runes", func(t *testing.T) {
if got := KeyMsg(Key{
Type: KeyRunes,
Runes: []rune{'a'},
}).String(); got != "a" {
t.Fatalf(`expected an "a", got %q`, got)
}
})
t.Run("invalid", func(t *testing.T) {
if got := KeyMsg(Key{
Type: KeyType(99999),
}).String(); got != "" {
t.Fatalf(`expected a "", got %q`, got)
}
})
}
func TestKeyTypeString(t *testing.T) {
t.Run("space", func(t *testing.T) {
if got := KeySpace.String(); got != " " {
t.Fatalf(`expected a " ", got %q`, got)
}
})
t.Run("invalid", func(t *testing.T) {
if got := KeyType(99999).String(); got != "" {
t.Fatalf(`expected a "", got %q`, got)
}
})
}
type seqTest struct {
seq []byte
msg Msg
}
// buildBaseSeqTests returns sequence tests that are valid for the
// detectSequence() function.
func buildBaseSeqTests() []seqTest {
td := []seqTest{}
for seq, key := range sequences {
key := key
td = append(td, seqTest{[]byte(seq), KeyMsg(key)})
if !key.Alt {
key.Alt = true
td = append(td, seqTest{[]byte("\x1b" + seq), KeyMsg(key)})
}
}
// Add all the control characters.
for i := keyNUL + 1; i <= keyDEL; i++ {
if i == keyESC {
// Not handled in detectSequence(), so not part of the base test
// suite.
continue
}
td = append(td, seqTest{[]byte{byte(i)}, KeyMsg{Type: i}})
td = append(td, seqTest{[]byte{'\x1b', byte(i)}, KeyMsg{Type: i, Alt: true}})
if i == keyUS {
i = keyDEL - 1
}
}
// Additional special cases.
td = append(td,
// Unrecognized CSI sequence.
seqTest{
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
},
// A lone space character.
seqTest{
[]byte{' '},
KeyMsg{Type: KeySpace, Runes: []rune(" ")},
},
// An escape character with the alt modifier.
seqTest{
[]byte{'\x1b', ' '},
KeyMsg{Type: KeySpace, Runes: []rune(" "), Alt: true},
},
)
return td
}
func TestDetectSequence(t *testing.T) {
td := buildBaseSeqTests()
for _, tc := range td {
t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
hasSeq, width, msg := detectSequence(tc.seq)
if !hasSeq {
t.Fatalf("no sequence found")
}
if width != len(tc.seq) {
t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq))
}
if !reflect.DeepEqual(tc.msg, msg) {
t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg)
}
})
}
}
func TestDetectOneMsg(t *testing.T) {
td := buildBaseSeqTests()
// Add tests for the inputs that detectOneMsg() can parse, but
// detectSequence() cannot.
td = append(td,
// Mouse event.
seqTest{
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
MouseMsg{X: 32, Y: 16, Type: MouseWheelUp, Button: MouseButtonWheelUp, Action: MouseActionPress},
},
// SGR Mouse event.
seqTest{
[]byte("\x1b[<0;33;17M"),
MouseMsg{X: 32, Y: 16, Type: MouseLeft, Button: MouseButtonLeft, Action: MouseActionPress},
},
// Runes.
seqTest{
[]byte{'a'},
KeyMsg{Type: KeyRunes, Runes: []rune("a")},
},
seqTest{
[]byte{'\x1b', 'a'},
KeyMsg{Type: KeyRunes, Runes: []rune("a"), Alt: true},
},
seqTest{
[]byte{'a', 'a', 'a'},
KeyMsg{Type: KeyRunes, Runes: []rune("aaa")},
},
// Multi-byte rune.
seqTest{
[]byte("☃"),
KeyMsg{Type: KeyRunes, Runes: []rune("☃")},
},
seqTest{
[]byte("\x1b☃"),
KeyMsg{Type: KeyRunes, Runes: []rune("☃"), Alt: true},
},
// Standalone control chacters.
seqTest{
[]byte{'\x1b'},
KeyMsg{Type: KeyEscape},
},
seqTest{
[]byte{byte(keySOH)},
KeyMsg{Type: KeyCtrlA},
},
seqTest{
[]byte{'\x1b', byte(keySOH)},
KeyMsg{Type: KeyCtrlA, Alt: true},
},
seqTest{
[]byte{byte(keyNUL)},
KeyMsg{Type: KeyCtrlAt},
},
seqTest{
[]byte{'\x1b', byte(keyNUL)},
KeyMsg{Type: KeyCtrlAt, Alt: true},
},
// Invalid characters.
seqTest{
[]byte{'\x80'},
unknownInputByteMsg(0x80),
},
)
if runtime.GOOS != "windows" {
// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
// This is incorrect, but it makes our test fail if we try it out.
td = append(td, seqTest{
[]byte{'\xfe'},
unknownInputByteMsg(0xfe),
})
}
for _, tc := range td {
t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
width, msg := detectOneMsg(tc.seq, false /* canHaveMoreData */)
if width != len(tc.seq) {
t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq))
}
if !reflect.DeepEqual(tc.msg, msg) {
t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg)
}
})
}
}
func TestReadLongInput(t *testing.T) {
input := strings.Repeat("a", 1000)
msgs := testReadInputs(t, bytes.NewReader([]byte(input)))
if len(msgs) != 1 {
t.Errorf("expected 1 messages, got %d", len(msgs))
}
km := msgs[0]
k := Key(km.(KeyMsg))
if k.Type != KeyRunes {
t.Errorf("expected key runes, got %d", k.Type)
}
if len(k.Runes) != 1000 || !reflect.DeepEqual(k.Runes, []rune(input)) {
t.Errorf("unexpected runes: %+v", k)
}
if k.Alt {
t.Errorf("unexpected alt")
}
}
func TestReadInput(t *testing.T) {
type test struct {
keyname string
in []byte
out []Msg
}
testData := []test{
{"a",
[]byte{'a'},
[]Msg{
KeyMsg{
Type: KeyRunes,
Runes: []rune{'a'},
},
},
},
{" ",
[]byte{' '},
[]Msg{
KeyMsg{
Type: KeySpace,
Runes: []rune{' '},
},
},
},
{"a alt+a",
[]byte{'a', '\x1b', 'a'},
[]Msg{
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
},
},
{"a alt+a a",
[]byte{'a', '\x1b', 'a', 'a'},
[]Msg{
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
},
},
{"ctrl+a",
[]byte{byte(keySOH)},
[]Msg{
KeyMsg{
Type: KeyCtrlA,
},
},
},
{"ctrl+a ctrl+b",
[]byte{byte(keySOH), byte(keySTX)},
[]Msg{
KeyMsg{Type: KeyCtrlA},
KeyMsg{Type: KeyCtrlB},
},
},
{"alt+a",
[]byte{byte(0x1b), 'a'},
[]Msg{
KeyMsg{
Type: KeyRunes,
Alt: true,
Runes: []rune{'a'},
},
},
},
{"abcd",
[]byte{'a', 'b', 'c', 'd'},
[]Msg{
KeyMsg{
Type: KeyRunes,
Runes: []rune{'a', 'b', 'c', 'd'},
},
},
},
{"up",
[]byte("\x1b[A"),
[]Msg{
KeyMsg{
Type: KeyUp,
},
},
},
{"wheel up",
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
[]Msg{
MouseMsg{
X: 32,
Y: 16,
Type: MouseWheelUp,
Button: MouseButtonWheelUp,
Action: MouseActionPress,
},
},
},
{"left motion release",
[]byte{
'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
},
[]Msg{
MouseMsg(MouseEvent{
X: 32,
Y: 16,
Type: MouseLeft,
Button: MouseButtonLeft,
Action: MouseActionMotion,
}),
MouseMsg(MouseEvent{
X: 64,
Y: 32,
Type: MouseRelease,
Button: MouseButtonNone,
Action: MouseActionRelease,
}),
},
},
{"shift+tab",
[]byte{'\x1b', '[', 'Z'},
[]Msg{
KeyMsg{
Type: KeyShiftTab,
},
},
},
{"enter",
[]byte{'\r'},
[]Msg{KeyMsg{Type: KeyEnter}},
},
{"alt+enter",
[]byte{'\x1b', '\r'},
[]Msg{
KeyMsg{
Type: KeyEnter,
Alt: true,
},
},
},
{"insert",
[]byte{'\x1b', '[', '2', '~'},
[]Msg{
KeyMsg{
Type: KeyInsert,
},
},
},
{"alt+ctrl+a",
[]byte{'\x1b', byte(keySOH)},
[]Msg{
KeyMsg{
Type: KeyCtrlA,
Alt: true,
},
},
},
{"?CSI[45 45 45 45 88]?",
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
[]Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
},
// Powershell sequences.
{"up",
[]byte{'\x1b', 'O', 'A'},
[]Msg{KeyMsg{Type: KeyUp}},
},
{"down",
[]byte{'\x1b', 'O', 'B'},
[]Msg{KeyMsg{Type: KeyDown}},
},
{"right",
[]byte{'\x1b', 'O', 'C'},
[]Msg{KeyMsg{Type: KeyRight}},
},
{"left",
[]byte{'\x1b', 'O', 'D'},
[]Msg{KeyMsg{Type: KeyLeft}},
},
{"alt+enter",
[]byte{'\x1b', '\x0d'},
[]Msg{KeyMsg{Type: KeyEnter, Alt: true}},
},
{"alt+backspace",
[]byte{'\x1b', '\x7f'},
[]Msg{KeyMsg{Type: KeyBackspace, Alt: true}},
},
{"ctrl+@",
[]byte{'\x00'},
[]Msg{KeyMsg{Type: KeyCtrlAt}},
},
{"alt+ctrl+@",
[]byte{'\x1b', '\x00'},
[]Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}},
},
{"esc",
[]byte{'\x1b'},
[]Msg{KeyMsg{Type: KeyEsc}},
},
{"alt+esc",
[]byte{'\x1b', '\x1b'},
[]Msg{KeyMsg{Type: KeyEsc, Alt: true}},
},
{"[a b] o",
[]byte{
'\x1b', '[', '2', '0', '0', '~',
'a', ' ', 'b',
'\x1b', '[', '2', '0', '1', '~',
'o',
},
[]Msg{
KeyMsg{Type: KeyRunes, Runes: []rune("a b"), Paste: true},
KeyMsg{Type: KeyRunes, Runes: []rune("o")},
},
},
{"[a\x03\nb]",
[]byte{
'\x1b', '[', '2', '0', '0', '~',
'a', '\x03', '\n', 'b',
'\x1b', '[', '2', '0', '1', '~'},
[]Msg{
KeyMsg{Type: KeyRunes, Runes: []rune("a\x03\nb"), Paste: true},
},
},
}
if runtime.GOOS != "windows" {
// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
// This is incorrect, but it makes our test fail if we try it out.
testData = append(testData,
test{"?0xfe?",
[]byte{'\xfe'},
[]Msg{unknownInputByteMsg(0xfe)},
},
test{"a ?0xfe? b",
[]byte{'a', '\xfe', ' ', 'b'},
[]Msg{
KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
unknownInputByteMsg(0xfe),
KeyMsg{Type: KeySpace, Runes: []rune{' '}},
KeyMsg{Type: KeyRunes, Runes: []rune{'b'}},
},
},
)
}
for i, td := range testData {
t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
msgs := testReadInputs(t, bytes.NewReader(td.in))
var buf strings.Builder
for i, msg := range msgs {
if i > 0 {
buf.WriteByte(' ')
}
if s, ok := msg.(fmt.Stringer); ok {
buf.WriteString(s.String())
} else {
fmt.Fprintf(&buf, "%#v:%T", msg, msg)
}
}
title := buf.String()
if title != td.keyname {
t.Errorf("expected message titles:\n %s\ngot:\n %s", td.keyname, title)
}
if len(msgs) != len(td.out) {
t.Fatalf("unexpected message list length: got %d, expected %d\n%#v", len(msgs), len(td.out), msgs)
}
if !reflect.DeepEqual(td.out, msgs) {
t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, msgs)
}
})
}
}
func testReadInputs(t *testing.T, input io.Reader) []Msg {
// We'll check that the input reader finishes at the end
// without error.
var wg sync.WaitGroup
var inputErr error
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
wg.Wait()
if inputErr != nil && !errors.Is(inputErr, io.EOF) {
t.Fatalf("unexpected input error: %v", inputErr)
}
}()
// The messages we're consuming.
msgsC := make(chan Msg)
// Start the reader in the background.
wg.Add(1)
go func() {
defer wg.Done()
inputErr = readInputs(ctx, msgsC, input)
msgsC <- nil
}()
var msgs []Msg
loop:
for {
select {
case msg := <-msgsC:
if msg == nil {
// end of input marker for the test.
break loop
}
msgs = append(msgs, msg)
case <-time.After(2 * time.Second):
t.Errorf("timeout waiting for input event")
break loop
}
}
return msgs
}
// randTest defines the test input and expected output for a sequence
// of interleaved control sequences and control characters.
type randTest struct {
data []byte
lengths []int
names []string
}
// seed is the random seed to randomize the input. This helps check
// that all the sequences get ultimately exercised.
var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
// genRandomData generates a randomized test, with a random seed unless
// the seed flag was set.
func genRandomData(logfn func(int64), length int) randTest {
// We'll use a random source. However, we give the user the option
// to override it to a specific value for reproduceability.
s := *seed
if s == 0 {
s = time.Now().UnixNano()
}
// Inform the user so they know what to reuse to get the same data.
logfn(s)
return genRandomDataWithSeed(s, length)
}
// genRandomDataWithSeed generates a randomized test with a fixed seed.
func genRandomDataWithSeed(s int64, length int) randTest {
src := rand.NewSource(s)
r := rand.New(src)
// allseqs contains all the sequences, in sorted order. We sort
// to make the test deterministic (when the seed is also fixed).
type seqpair struct {
seq string
name string
}
var allseqs []seqpair
for seq, key := range sequences {
allseqs = append(allseqs, seqpair{seq, key.String()})
}
sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
// res contains the computed test.
var res randTest
for len(res.data) < length {
alt := r.Intn(2)
prefix := ""
esclen := 0
if alt == 1 {
prefix = "alt+"
esclen = 1
}
kind := r.Intn(3)
switch kind {
case 0:
// A control character.
if alt == 1 {
res.data = append(res.data, '\x1b')
}
res.data = append(res.data, 1)
res.names = append(res.names, prefix+"ctrl+a")
res.lengths = append(res.lengths, 1+esclen)
case 1, 2:
// A sequence.
seqi := r.Intn(len(allseqs))
s := allseqs[seqi]
if strings.HasPrefix(s.name, "alt+") {
esclen = 0
prefix = ""
alt = 0
}
if alt == 1 {
res.data = append(res.data, '\x1b')
}
res.data = append(res.data, s.seq...)
res.names = append(res.names, prefix+s.name)
res.lengths = append(res.lengths, len(s.seq)+esclen)
}
}
return res
}
// TestDetectRandomSequencesLex checks that the lex-generated sequence
// detector works over concatenations of random sequences.
func TestDetectRandomSequencesLex(t *testing.T) {
runTestDetectSequence(t, detectSequence)
}
func runTestDetectSequence(
t *testing.T, detectSequence func(input []byte) (hasSeq bool, width int, msg Msg),
) {
for i := 0; i < 10; i++ {
t.Run("", func(t *testing.T) {
td := genRandomData(func(s int64) { t.Logf("using random seed: %d", s) }, 1000)
t.Logf("%#v", td)
// tn is the event number in td.
// i is the cursor in the input data.
// w is the length of the last sequence detected.
for tn, i, w := 0, 0, 0; i < len(td.data); tn, i = tn+1, i+w {
hasSequence, width, msg := detectSequence(td.data[i:])
if !hasSequence {
t.Fatalf("at %d (ev %d): failed to find sequence", i, tn)
}
if width != td.lengths[tn] {
t.Errorf("at %d (ev %d): expected width %d, got %d", i, tn, td.lengths[tn], width)
}
w = width
s, ok := msg.(fmt.Stringer)
if !ok {
t.Errorf("at %d (ev %d): expected stringer event, got %T", i, tn, msg)
} else {
if td.names[tn] != s.String() {
t.Errorf("at %d (ev %d): expected event %q, got %q", i, tn, td.names[tn], s.String())
}
}
}
})
}
}
// TestDetectRandomSequencesLex checks that the map-based sequence
// detector works over concatenations of random sequences.
func TestDetectRandomSequencesMap(t *testing.T) {
runTestDetectSequence(t, detectSequence)
}
// BenchmarkDetectSequenceMap benchmarks the map-based sequence
// detector.
func BenchmarkDetectSequenceMap(b *testing.B) {
td := genRandomDataWithSeed(123, 10000)
for i := 0; i < b.N; i++ {
for j, w := 0, 0; j < len(td.data); j += w {
_, w, _ = detectSequence(td.data[j:])
}
}
}