Files
Codeman/test/tui/tui-composer.test.ts
T

150 lines
5.7 KiB
TypeScript
Raw Permalink Normal View History

/**
* @fileoverview Unit tests for the single-line editor behind `p` and `/`.
*
* The interesting parts are the ones a terminal makes hard to see: a cursor
* that must not split a surrogate pair, a combining mark that belongs to the
* character before it, and the scroll window, which is the only reason a long
* prompt stays typeable in a footer one line tall.
*/
import { describe, it, expect } from 'vitest';
import {
composerBackspace,
composerDelete,
composerDeleteWord,
composerEnd,
composerHome,
composerInsert,
composerMove,
composerScroll,
composerStep,
composerText,
composerWindow,
createComposer,
} from '../../src/tui/tui-composer.js';
describe('editing', () => {
it('inserts at the cursor and keeps it after the insertion', () => {
let state = createComposer('abc');
expect(composerText(state)).toBe('abc');
expect(state.cursor).toBe(3);
state = composerMove(state, -1);
state = composerInsert(state, 'XY');
expect(composerText(state)).toBe('abXYc');
expect(state.cursor).toBe(4);
});
it('never lets a newline into a single-line editor', () => {
const state = composerInsert(createComposer(), 'one\ntwo\r\nthree');
expect(composerText(state)).toBe('one two three');
});
it('deletes whole characters, not code units', () => {
const state = composerBackspace(createComposer('a🙂'));
expect(composerText(state)).toBe('a');
expect(state.cursor).toBe(1);
});
it('deletes forward under the cursor and stops at the end', () => {
const state = composerHome(createComposer('abc'));
expect(composerText(composerDelete(state))).toBe('bc');
expect(composerText(composerDelete(createComposer('abc')))).toBe('abc');
});
it('deletes a word back over its trailing spaces', () => {
expect(composerText(composerDeleteWord(createComposer('fix the bug ')))).toBe('fix the ');
expect(composerText(composerDeleteWord(createComposer('word')))).toBe('');
expect(composerText(composerDeleteWord(createComposer('')))).toBe('');
});
it('clamps the cursor at both ends', () => {
const state = createComposer('abc');
expect(composerMove(state, 10).cursor).toBe(3);
expect(composerMove(state, -10).cursor).toBe(0);
expect(composerHome(state).cursor).toBe(0);
expect(composerEnd(composerHome(state)).cursor).toBe(3);
});
it('leaves a no-op edit as the same object, so nothing repaints', () => {
const state = createComposer('abc');
const atStart = composerHome(state);
expect(composerInsert(state, '')).toBe(state);
expect(composerMove(state, 1)).toBe(state);
expect(composerBackspace(atStart)).toBe(atStart);
});
});
describe('the scroll window', () => {
it('shows the whole text while it fits', () => {
const window = composerWindow(createComposer('short'), 20);
expect(window.text).toBe('short');
expect(window.cursorColumn).toBe(5);
expect(window.scroll).toBe(0);
});
it('scrolls just far enough to keep the cursor visible', () => {
// 10 columns of room, one reserved for the cursor itself.
const state = composerScroll(createComposer('0123456789abcdef'), 10);
const window = composerWindow(state, 10);
expect(window.scroll).toBe(7);
expect(window.text).toBe('789abcdef');
expect(window.cursorColumn).toBe(9);
});
it('scrolls back when the cursor moves left out of the window', () => {
let state = composerScroll(createComposer('0123456789abcdef'), 10);
expect(state.scroll).toBe(7);
state = composerScroll(composerHome(state), 10);
expect(state.scroll).toBe(0);
expect(composerWindow(state, 10).cursorColumn).toBe(0);
});
it('counts a double-width character as the two columns it takes', () => {
const state = composerScroll(createComposer('日本語です'), 6);
const window = composerWindow(state, 6);
// Five wide characters = 10 columns; the window holds the last three (6
// columns) minus the cell the cursor needs.
expect(window.cursorColumn).toBeLessThanOrEqual(5);
expect(window.text.length).toBeLessThanOrEqual(5);
expect(composerText(state)).toBe('日本語です');
});
it('survives a width of one', () => {
const state = composerScroll(createComposer('abc'), 1);
expect(() => composerWindow(state, 1)).not.toThrow();
expect(composerWindow(state, 1).cursorColumn).toBe(0);
});
});
describe('composerStep', () => {
const state = createComposer('ab');
it('reports Enter and Escape instead of acting on them', () => {
expect(composerStep(state, { type: 'enter' })).toEqual({ kind: 'submit', text: 'ab' });
expect(composerStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' });
expect(composerStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' });
});
it('maps the editing keys', () => {
expect(composerStep(state, { type: 'char', value: 'c' })).toEqual({
kind: 'edit',
state: expect.objectContaining({ cursor: 3 }),
});
expect(composerStep(state, { type: 'key', name: 'left' })).toEqual({
kind: 'edit',
state: expect.objectContaining({ cursor: 1 }),
});
expect(composerStep(state, { type: 'ctrl', key: 'u' })).toEqual({
kind: 'edit',
state: expect.objectContaining({ cursor: 0 }),
});
expect(composerText((composerStep(state, { type: 'ctrl', key: 'u' }) as { state: never }).state)).toBe('');
});
it('ignores keys that mean nothing to an editor', () => {
expect(composerStep(state, { type: 'tab' })).toEqual({ kind: 'ignore' });
expect(composerStep(state, { type: 'key', name: 'pageup' })).toEqual({ kind: 'ignore' });
expect(composerStep(state, { type: 'ctrl', key: 'x' })).toEqual({ kind: 'ignore' });
expect(composerStep(state, { type: 'mouse', kind: 'press', x: 1, y: 1, button: 0 })).toEqual({ kind: 'ignore' });
});
});