Files
Codeman/test/tui/tui-composer.test.ts
T
Codeman maintainer bb73400afa feat: add the TUI's editor, approval and digest pure cores
Three small pure modules the phase-2 verbs are built on:

- tui-composer: the single-line editor behind `p` and `/`, holding text as
  code points so a cursor can never split a surrogate pair, with the scroll
  window derived from the width rather than remembered.
- tui-approvals: what an approvals-inbox item's card says, which keys are
  live for it (a digit answers only when the server parsed that option, and
  an idle prompt answers to none of them), and which ids the bell has not
  rung for yet.
- tui-digest: the away digest as compact lines, counts first and one line
  per entry, with a capped tail per section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:13:58 +02:00

151 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' });
});
});