import Buffer from './buffer.ts'; import Document from './document.ts'; import Editor from './editor.ts'; import Row from './row.ts'; import { Ansi, KeyCommand } from './ansi.ts'; import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts'; import { getTestRunner } from './runtime.ts'; import { Position } from './types.ts'; import * as Fn from './fns.ts'; const { assertEquals, assertExists, assertInstanceOf, assertNotEquals, assertNull, assertFalse, assertTrue, testSuite, } = await getTestRunner(); const encoder = new TextEncoder(); const testKeyMap = (codes: string[], expected: string) => { codes.forEach((code) => { assertEquals(Fn.readKey(encoder.encode(code)), expected); }); }; testSuite({ 'ANSI utils': { 'moveCursor()': () => { assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); }, 'moveCursorForward()': () => { assertEquals(Ansi.moveCursorForward(2), '\x1b[2C'); }, 'moveCursorDown()': () => { assertEquals(Ansi.moveCursorDown(7), '\x1b[7B'); }, }, Buffer: { 'new Buffer': () => { const b = new Buffer(); assertInstanceOf(b, Buffer); assertEquals(b.strlen(), 0); }, '.appendLine': () => { const b = new Buffer(); // Carriage return and line feed b.appendLine(); assertEquals(b.strlen(), 2); b.clear(); assertEquals(b.strlen(), 0); b.appendLine('foo'); assertEquals(b.strlen(), 5); }, '.append': () => { const b = new Buffer(); b.append('foobar'); assertEquals(b.strlen(), 6); b.clear(); b.append('foobar', 3); assertEquals(b.strlen(), 3); }, '.flush': async () => { const b = new Buffer(); b.appendLine('foobarbaz' + Ansi.ClearLine); assertEquals(b.strlen(), 14); await b.flush(); assertEquals(b.strlen(), 0); }, }, Document: { '.default': () => { const doc = Document.default(); assertEquals(doc.numRows, 0); assertTrue(doc.isEmpty()); assertEquals(doc.row(0), null); }, '.insertRow': () => { const doc = Document.default(); doc.insertRow(undefined, 'foobar'); assertEquals(doc.numRows, 1); assertFalse(doc.isEmpty()); assertInstanceOf(doc.row(0), Row); }, '.insert': () => { const doc = Document.default(); assertFalse(doc.dirty); doc.insert(Position.at(0, 0), 'foobar'); assertEquals(doc.numRows, 1); assertTrue(doc.dirty); doc.insert(Position.at(2, 0), 'baz'); assertEquals(doc.numRows, 1); assertTrue(doc.dirty); doc.insert(Position.at(9, 0), 'buzz'); assertEquals(doc.numRows, 1); assertTrue(doc.dirty); const row0 = doc.row(0); assertEquals(row0?.toString(), 'foobazbarbuzz'); assertEquals(row0?.rstring(), 'foobazbarbuzz'); assertEquals(row0?.rsize, 13); doc.insert(Position.at(0, 1), 'Lorem Ipsum'); assertEquals(doc.numRows, 2); assertTrue(doc.dirty); }, '.delete': () => { const doc = Document.default(); doc.insert(Position.default(), 'foobar'); doc.delete(Position.at(3, 0)); assertEquals(doc.row(0)?.toString(), 'fooar'); }, }, Editor: { 'new Editor': () => { const e = new Editor(defaultTerminalSize); assertInstanceOf(e, Editor); }, }, Position: { '.default': () => { const p = Position.default(); assertEquals(p.x, 0); assertEquals(p.y, 0); }, '.at': () => { const p = Position.at(5, 7); assertEquals(p.x, 5); assertEquals(p.y, 7); }, '.from': () => { const p1 = Position.at(1, 2); const p2 = Position.from(p1); p1.x = 2; p1.y = 4; assertEquals(p1.x, 2); assertEquals(p1.y, 4); assertEquals(p2.x, 1); assertEquals(p2.y, 2); }, }, Row: { '.default': () => { const row = Row.default(); assertEquals(row.toString(), ''); }, '.from': () => { // From string const row = Row.from('xyz'); assertEquals(row.toString(), 'xyz'); // From existing Row assertEquals(Row.from(row).toString(), row.toString()); // From 'chars' assertEquals(Row.from(['😺', '😸', '😹']).toString(), '😺😸😹'); }, '.append': () => { const row = Row.from('foo'); row.append('bar'); assertEquals(row.toString(), 'foobar'); }, '.delete': () => { const row = Row.from('foof'); row.delete(3); assertEquals(row.toString(), 'foo'); row.delete(4); assertEquals(row.toString(), 'foo'); }, '.split': () => { // When you split a row, it's from the cursor position // (Kind of like if the string were one-indexed) const row = Row.from('foobar'); const row2 = Row.from('bar'); assertEquals(row.split(3).toString(), row2.toString()); }, '.find': () => { const normalRow = Row.from('For whom the bell tolls'); assertEquals(normalRow.find('who'), 4); assertNull(normalRow.find('foo')); const emojiRow = Row.from('😺😸😹'); assertEquals(emojiRow.find('😹'), 2); assertNull(emojiRow.find('🀰🏼')); }, '.byteIndexToCharIndex': () => { // Each 'character' is two bytes const row = Row.from('πŸ˜ΊπŸ˜ΈπŸ˜ΉπŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'); assertEquals(row.byteIndexToCharIndex(4), 2); assertEquals(row.byteIndexToCharIndex(2), 1); assertEquals(row.byteIndexToCharIndex(0), 0); // Return count on nonsense index assertEquals(Fn.strlen(row.toString()), 10); assertEquals(row.byteIndexToCharIndex(72), 10); const row2 = Row.from('foobar'); assertEquals(row2.byteIndexToCharIndex(2), 2); }, '.charIndexToByteIndex': () => { // Each 'character' is two bytes const row = Row.from('πŸ˜ΊπŸ˜ΈπŸ˜ΉπŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'); assertEquals(row.charIndexToByteIndex(2), 4); assertEquals(row.charIndexToByteIndex(1), 2); assertEquals(row.charIndexToByteIndex(0), 0); }, '.cxToRx, .rxToCx': () => { const row = Row.from('foo\tbar\tbaz'); row.update(); assertNotEquals(row.chars, row.rchars); assertNotEquals(row.size, row.rsize); assertEquals(row.size, 11); assertEquals(row.rsize, row.size + (SCROLL_TAB_SIZE * 2) - 2); const cx = 11; const aRx = row.cxToRx(cx); const rx = 11; const aCx = row.rxToCx(aRx); assertEquals(aCx, cx); assertEquals(aRx, rx); }, }, 'fns': { 'arrayInsert() strings': () => { const { arrayInsert } = Fn; const a = ['😺', '😸', '😹']; const b = arrayInsert(a, 1, 'x'); const c = ['😺', 'x', '😸', '😹']; assertEquals(b, c); const d = arrayInsert(c, 17, 'y'); const e = ['😺', 'x', '😸', '😹', 'y']; assertEquals(d, e); assertEquals(arrayInsert([], 0, 'foo'), ['foo']); }, 'arrayInsert() numbers': () => { const { arrayInsert } = Fn; const a = [1, 3, 5]; const b = [1, 3, 4, 5]; assertEquals(arrayInsert(a, 2, 4), b); const c = [1, 2, 3, 4, 5]; assertEquals(arrayInsert(b, 1, 2), c); }, 'noop fn': () => { assertExists(Fn.noop); assertEquals(Fn.noop(), undefined); }, 'posSub()': () => { assertEquals(Fn.posSub(14, 15), 0); assertEquals(Fn.posSub(15, 1), 14); }, 'minSub()': () => { assertEquals(Fn.minSub(13, 25, -1), -1); assertEquals(Fn.minSub(25, 13, 0), 12); }, 'maxAdd()': () => { assertEquals(Fn.maxAdd(99, 99, 75), 75); assertEquals(Fn.maxAdd(25, 74, 101), 99); }, 'ord()': () => { const { ord } = Fn; // Invalid output assertEquals(ord(''), 256); // Valid output assertEquals(ord('a'), 97); }, 'strChars() properly splits strings into unicode characters': () => { const { strChars } = Fn; assertEquals(strChars('😺😸😹'), ['😺', '😸', '😹']); }, 'ctrlKey()': () => { const { ctrlKey, isControl } = Fn; const ctrl_a = ctrlKey('a'); assertTrue(isControl(ctrl_a)); assertEquals(ctrl_a, String.fromCodePoint(0x01)); const invalid = ctrlKey('😺'); assertFalse(isControl(invalid)); assertEquals(invalid, '😺'); }, 'isAscii()': () => { const { isAscii } = Fn; assertTrue(isAscii('asjyverkjhsdf1928374')); assertFalse(isAscii('😺acalskjsdf')); assertFalse(isAscii('ab😺ac')); }, 'isAsciiDigit()': () => { const { isAsciiDigit } = Fn; assertTrue(isAsciiDigit('1234567890')); assertFalse(isAsciiDigit('A1')); assertFalse(isAsciiDigit('/')); assertFalse(isAsciiDigit(':')); }, 'isControl()': () => { const { isControl } = Fn; assertFalse(isControl('abc')); assertTrue(isControl(String.fromCodePoint(0x01))); assertFalse(isControl('😺')); }, 'strlen()': () => { const { strlen } = Fn; // Ascii length assertEquals(strlen('abc'), 'abc'.length); // Get number of visible unicode characters assertEquals(strlen('😺😸😹'), 3); assertNotEquals('😺😸😹'.length, strlen('😺😸😹')); // Skin tone modifier + base character assertEquals(strlen('🀰🏼'), 2); assertNotEquals('🀰🏼'.length, strlen('🀰🏼')); // This has 4 sub-characters, and 3 zero-width-joiners assertEquals(strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'), 7); assertNotEquals('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'.length, strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦')); }, 'truncate()': () => { const { truncate } = Fn; assertEquals(truncate('😺😸😹', 1), '😺'); assertEquals(truncate('😺😸😹', 5), '😺😸😹'); assertEquals(truncate('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 5), 'πŸ‘¨β€πŸ‘©β€πŸ‘§'); }, }, 'readKey()': { 'empty input': () => { assertEquals(Fn.readKey(new Uint8Array(0)), ''); }, 'passthrough': () => { // Ignore unhandled escape sequences assertEquals(Fn.readKey(encoder.encode('\x1b[]')), '\x1b[]'); // Pass explicitly mapped values right through assertEquals( Fn.readKey(encoder.encode(KeyCommand.ArrowUp)), KeyCommand.ArrowUp, ); assertEquals( Fn.readKey(encoder.encode(KeyCommand.Home)), KeyCommand.Home, ); assertEquals( Fn.readKey(encoder.encode(KeyCommand.Delete)), KeyCommand.Delete, ); // And pass through whatever else assertEquals(Fn.readKey(encoder.encode('foobaz')), 'foobaz'); }, 'Esc': () => testKeyMap(['\x1b', Fn.ctrlKey('l')], KeyCommand.Escape), 'Backspace': () => testKeyMap( [Fn.ctrlKey('h'), '\x7f'], KeyCommand.Backspace, ), 'Home': () => testKeyMap(['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'], KeyCommand.Home), 'End': () => testKeyMap(['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'], KeyCommand.End), 'Enter': () => testKeyMap(['\n', '\r', '\v'], KeyCommand.Enter), }, });