Refactor text-buffer to use reducer (#2652)

This commit is contained in:
Sandy Tao 2025-07-03 17:53:17 -07:00 committed by GitHub
parent 8d3fec08e5
commit 32db5ba0e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 842 additions and 796 deletions

View File

@ -11,8 +11,150 @@ import {
Viewport,
TextBuffer,
offsetToLogicalPos,
textBufferReducer,
TextBufferState,
TextBufferAction,
} from './text-buffer.js';
const initialState: TextBufferState = {
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
undoStack: [],
redoStack: [],
clipboard: null,
selectionAnchor: null,
};
describe('textBufferReducer', () => {
it('should return the initial state if state is undefined', () => {
const action = { type: 'unknown_action' } as unknown as TextBufferAction;
const state = textBufferReducer(initialState, action);
expect(state).toEqual(initialState);
});
describe('set_text action', () => {
it('should set new text and move cursor to the end', () => {
const action: TextBufferAction = {
type: 'set_text',
payload: 'hello\nworld',
};
const state = textBufferReducer(initialState, action);
expect(state.lines).toEqual(['hello', 'world']);
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(5);
expect(state.undoStack.length).toBe(1);
});
it('should not create an undo snapshot if pushToUndo is false', () => {
const action: TextBufferAction = {
type: 'set_text',
payload: 'no undo',
pushToUndo: false,
};
const state = textBufferReducer(initialState, action);
expect(state.lines).toEqual(['no undo']);
expect(state.undoStack.length).toBe(0);
});
});
describe('insert action', () => {
it('should insert a character', () => {
const action: TextBufferAction = { type: 'insert', payload: 'a' };
const state = textBufferReducer(initialState, action);
expect(state.lines).toEqual(['a']);
expect(state.cursorCol).toBe(1);
});
it('should insert a newline', () => {
const stateWithText = { ...initialState, lines: ['hello'] };
const action: TextBufferAction = { type: 'insert', payload: '\n' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['', 'hello']);
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0);
});
});
describe('backspace action', () => {
it('should remove a character', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['a'],
cursorRow: 0,
cursorCol: 1,
};
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
});
it('should join lines if at the beginning of a line', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello', 'world'],
cursorRow: 1,
cursorCol: 0,
};
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['helloworld']);
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5);
});
});
describe('undo/redo actions', () => {
it('should undo and redo a change', () => {
// 1. Insert text
const insertAction: TextBufferAction = {
type: 'insert',
payload: 'test',
};
const stateAfterInsert = textBufferReducer(initialState, insertAction);
expect(stateAfterInsert.lines).toEqual(['test']);
expect(stateAfterInsert.undoStack.length).toBe(1);
// 2. Undo
const undoAction: TextBufferAction = { type: 'undo' };
const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction);
expect(stateAfterUndo.lines).toEqual(['']);
expect(stateAfterUndo.undoStack.length).toBe(0);
expect(stateAfterUndo.redoStack.length).toBe(1);
// 3. Redo
const redoAction: TextBufferAction = { type: 'redo' };
const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction);
expect(stateAfterRedo.lines).toEqual(['test']);
expect(stateAfterRedo.undoStack.length).toBe(1);
expect(stateAfterRedo.redoStack.length).toBe(0);
});
});
describe('create_undo_snapshot action', () => {
it('should create a snapshot without changing state', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello'],
cursorRow: 0,
cursorCol: 5,
};
const action: TextBufferAction = { type: 'create_undo_snapshot' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['hello']);
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5);
expect(state.undoStack.length).toBe(1);
expect(state.undoStack[0].lines).toEqual(['hello']);
expect(state.undoStack[0].cursorRow).toBe(0);
expect(state.undoStack[0].cursorCol).toBe(5);
});
});
});
// Helper to get the state from the hook
const getBufferState = (result: { current: TextBuffer }) => ({
text: result.current.text,
@ -644,11 +786,27 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).cursor).toEqual([0, 5]);
act(() => {
result.current.applyOperations([
{ type: 'backspace' },
{ type: 'backspace' },
{ type: 'backspace' },
]);
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
sequence: '\x7f',
});
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
sequence: '\x7f',
});
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
sequence: '\x7f',
});
});
expect(getBufferState(result).text).toBe('ab');
expect(getBufferState(result).cursor).toEqual([0, 2]);
@ -666,9 +824,7 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).cursor).toEqual([0, 5]);
act(() => {
result.current.applyOperations([
{ type: 'insert', payload: '\x7f\x7f\x7f' },
]);
result.current.insert('\x7f\x7f\x7f');
});
expect(getBufferState(result).text).toBe('ab');
expect(getBufferState(result).cursor).toEqual([0, 2]);
@ -686,9 +842,7 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).cursor).toEqual([0, 5]);
act(() => {
result.current.applyOperations([
{ type: 'insert', payload: '\x7fI\x7f\x7fNEW' },
]);
result.current.insert('\x7fI\x7f\x7fNEW');
});
expect(getBufferState(result).text).toBe('abcNEW');
expect(getBufferState(result).cursor).toEqual([0, 6]);
@ -774,11 +928,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
// Simulate pasting the long text multiple times
act(() => {
result.current.applyOperations([
{ type: 'insert', payload: longText },
{ type: 'insert', payload: longText },
{ type: 'insert', payload: longText },
]);
result.current.insert(longText);
result.current.insert(longText);
result.current.insert(longText);
});
const state = getBufferState(result);
@ -909,17 +1061,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
isValidPath: () => false,
}),
);
let success = true;
act(() => {
success = result.current.replaceRange(0, 5, 0, 3, 'fail'); // startCol > endCol in same line
result.current.replaceRange(0, 5, 0, 3, 'fail'); // startCol > endCol in same line
});
expect(success).toBe(false);
expect(getBufferState(result).text).toBe('test');
act(() => {
success = result.current.replaceRange(1, 0, 0, 0, 'fail'); // startRow > endRow
result.current.replaceRange(1, 0, 0, 0, 'fail'); // startRow > endRow
});
expect(success).toBe(false);
expect(getBufferState(result).text).toBe('test');
});

File diff suppressed because it is too large Load Diff