anvilsign in

collin/strudel-claude

pre-demo / node-tui / vim.test.ts
1// Vim grammar tests. `node --test node-tui/vim.test.ts`
2//
3// The editing layer is a pure reducer precisely so it can be checked without a
4// terminal or an audio context — which matters, because "did `d2w` delete the
5// right span" is not a question you want to answer by squinting at a TUI.
6import { test } from 'node:test';
7import assert from 'node:assert/strict';
8
9import * as vim from './vim.ts';
10import * as buf from './buffer.ts';
11
12const KEY: vim.Key = {
13 input: '', ctrl: false, escape: false, return: false, backspace: false,
14 tab: false, leftArrow: false, rightArrow: false, upArrow: false, downArrow: false,
15};
16
17/** Feed a string of keystrokes. `<esc>` and `<cr>` stand in for those keys. */
18function press(state: vim.VimState, keys: string): vim.VimState {
19 let current = state;
20 const tokens = keys.match(/<esc>|<cr>|[\s\S]/g) ?? [];
21 for (const token of tokens) {
22 const key: vim.Key =
23 token === '<esc>' ? { ...KEY, escape: true }
24 : token === '<cr>' ? { ...KEY, return: true }
25 : { ...KEY, input: token };
26 current = vim.handleKey(current, key).state;
27 }
28 return current;
29}
30
31const text = (state: vim.VimState): string => buf.toText(state.buffer);
32const start = (source: string): vim.VimState => vim.createVimState(source);
33
34test('starts in normal mode and does not insert text', () => {
35 const state = press(start('hello'), 'jkl');
36 assert.equal(text(state), 'hello');
37 assert.equal(state.mode, 'normal');
38});
39
40test('i enters insert mode and types', () => {
41 const state = press(start('bd'), 'iX');
42 assert.equal(text(state), 'Xbd');
43 assert.equal(state.mode, 'insert');
44});
45
46test('escape leaves insert mode and steps the cursor back', () => {
47 const state = press(start('bd'), 'iX<esc>');
48 assert.equal(state.mode, 'normal');
49 assert.equal(buf.cursorOffset(state.buffer), 0);
50});
51
52test('A appends at end of line', () => {
53 assert.equal(text(press(start('s("bd")'), 'A.fast(2)<esc>')), 's("bd").fast(2)');
54});
55
56test('x deletes the character under the cursor', () => {
57 assert.equal(text(press(start('abc'), 'x')), 'bc');
58});
59
60test('x with a count deletes several', () => {
61 assert.equal(text(press(start('abcdef'), '3x')), 'def');
62});
63
64test('dw deletes to the start of the next word', () => {
65 assert.equal(text(press(start('foo bar baz'), 'dw')), 'bar baz');
66});
67
68test('d2w takes a count between operator and motion', () => {
69 assert.equal(text(press(start('foo bar baz'), 'd2w')), 'baz');
70});
71
72test('de is inclusive of the word end', () => {
73 assert.equal(text(press(start('foo bar'), 'de')), ' bar');
74});
75
76test('dd deletes the whole line', () => {
77 assert.equal(text(press(start('one\ntwo\nthree'), 'jdd')), 'one\nthree');
78});
79
80test('2dd deletes two lines', () => {
81 assert.equal(text(press(start('one\ntwo\nthree'), '2dd')), 'three');
82});
83
84test('dd on the last line does not leave a blank', () => {
85 assert.equal(text(press(start('one\ntwo'), 'jdd')), 'one');
86});
87
88test('D deletes to end of line, keeping the line', () => {
89 assert.equal(text(press(start('foo bar\nbaz'), 'wD')), 'foo \nbaz');
90});
91
92test('cw changes a word and lands in insert mode', () => {
93 const state = press(start('foo bar'), 'cwqux');
94 assert.equal(text(state), 'qux bar');
95 assert.equal(state.mode, 'insert');
96});
97
98test('o opens a line below and inherits indentation', () => {
99 const state = press(start(' s("bd")'), 'o.fast(2)');
100 assert.equal(text(state), ' s("bd")\n .fast(2)');
101});
102
103test('O opens a line above', () => {
104 assert.equal(text(press(start('two'), 'Oone<esc>')), 'one\ntwo');
105});
106
107test('yy then p duplicates a line below', () => {
108 assert.equal(text(press(start('one\ntwo'), 'yyp')), 'one\none\ntwo');
109});
110
111test('dd then P puts the line back where it was', () => {
112 assert.equal(text(press(start('one\ntwo'), 'ddP')), 'one\ntwo');
113});
114
115test('u undoes an edit', () => {
116 assert.equal(text(press(start('one\ntwo'), 'ddu')), 'one\ntwo');
117});
118
119test('undo is not confused by cursor-only motions', () => {
120 assert.equal(text(press(start('abc'), 'xlllu')), 'abc');
121});
122
123test('0 and $ move within the line', () => {
124 const state = press(start('hello'), '$');
125 assert.equal(state.buffer.col, 4);
126 assert.equal(press(state, '0').buffer.col, 0);
127});
128
129test('a bare 0 is a motion, but 10 is a count', () => {
130 const ten = press(start('a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk'), '10j');
131 assert.equal(ten.buffer.row, 10);
132});
133
134test('gg and G jump to the first and last line', () => {
135 const state = press(start('one\ntwo\nthree'), 'G');
136 assert.equal(state.buffer.row, 2);
137 assert.equal(press(state, 'gg').buffer.row, 0);
138});
139
140test('G with a count goes to that line', () => {
141 assert.equal(press(start('one\ntwo\nthree'), '2G').buffer.row, 1);
142});
143
144test('the cursor may not rest past the last character in normal mode', () => {
145 const state = press(start('ab\n'), '$lll');
146 assert.equal(state.buffer.col, 1);
147});
148
149test('visual mode selects and deletes', () => {
150 const state = press(start('foo bar'), 'vlld');
151 assert.equal(text(state), ' bar');
152 assert.equal(state.mode, 'normal');
153});
154
155test('V selects whole lines', () => {
156 assert.equal(text(press(start('one\ntwo\nthree'), 'Vjd')), 'three');
157});
158
159test('ctrl-e requests evaluation without touching the buffer', () => {
160 const result = vim.handleKey(start('s("bd")'), { ...KEY, ctrl: true, input: 'e' });
161 assert.equal(result.effects.evaluate, true);
162 assert.equal(text(result.state), 's("bd")');
163});
164
165test('ctrl-e works from insert mode too', () => {
166 const state = press(start('x'), 'i');
167 const result = vim.handleKey(state, { ...KEY, ctrl: true, input: 'e' });
168 assert.equal(result.effects.evaluate, true);
169 assert.equal(result.state.mode, 'insert');
170});
171
172test('enter evaluates from normal mode', () => {
173 const result = vim.handleKey(start('s("bd")'), { ...KEY, return: true });
174 assert.equal(result.effects.evaluate, true);
175 assert.equal(text(result.state), 's("bd")');
176 assert.equal(result.state.mode, 'normal');
177});
178
179test('enter evaluates from visual mode and drops a half-typed count', () => {
180 const result = vim.handleKey(press(start('one\ntwo'), 'v2'), { ...KEY, return: true });
181 assert.equal(result.effects.evaluate, true);
182 assert.equal(result.state.pending, '');
183 assert.equal(text(result.state), 'one\ntwo');
184});
185
186test('enter still inserts a newline in insert mode', () => {
187 const result = vim.handleKey(press(start('ab'), 'i'), { ...KEY, return: true });
188 assert.equal(result.effects.evaluate, undefined);
189 assert.equal(text(result.state), '\nab');
190});
191
192test('an unknown command is dropped rather than left pending', () => {
193 const state = press(start('abc'), 'Z');
194 assert.equal(state.pending, '');
195 assert.equal(text(state), 'abc');
196});
197
198test('a partial operator stays pending', () => {
199 assert.equal(press(start('abc'), 'd').pending, 'd');
200 assert.equal(press(start('abc'), 'd2').pending, 'd2');
201});
202
203test('word motions stop at punctuation, as vim does', () => {
204 // The cursor should land on the `(`, not skip the whole call.
205 const state = press(start('s("bd")'), 'w');
206 assert.equal(buf.cursorOffset(state.buffer), 1);
207});
208
209test('W treats punctuation as part of the word', () => {
210 const state = press(start('s("bd") fast'), 'W');
211 assert.equal(buf.cursorOffset(state.buffer), 8);
212});