mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
150 lines
5.7 KiB
TypeScript
150 lines
5.7 KiB
TypeScript
/**
|
|||
|
|
* @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' });
|
||
|
|
});
|
||
|
|
});
|