import Ansi, * as _Ansi from './ansi.ts'; import Buffer from './buffer.ts'; import Document from './document.ts'; import Editor from './editor.ts'; import { FileLang } from './filetype.ts'; import Option, { None, Some } from './option.ts'; import Position from './position.ts'; import Row from './row.ts'; import FileType, * as FT from './filetype.ts'; import * as Fn from './fns.ts'; import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts'; import { getTestRunner } from './runtime.ts'; import { HighlightType, SearchDirection } from './types.ts'; import fs from 'node:fs'; const { assertEquals, assertEquivalent, assertExists, assertInstanceOf, assertNotEquals, assertFalse, assertTrue, assertSome, assertNone, testSuite, } = await getTestRunner(); const THIS_FILE = './src/common/all_test.ts'; const KILO_FILE = './demo/kilo.c'; // ---------------------------------------------------------------------------- // Helper Function Tests // ---------------------------------------------------------------------------- const fnTest = () => { const { arrayInsert, noop, posSub, minSub, maxAdd, ord, strChars, ctrlKey, isControl, isAscii, isAsciiDigit, strlen, truncate, highlightToColor, } = Fn; return { 'arrayInsert() strings': () => { const a = ['😺', '😸', '😹']; const b = arrayInsert(a, 1, 'x'); const c = ['😺', 'x', '😸', '😹']; assertEquivalent(b, c); const d = arrayInsert(c, 17, 'y'); const e = ['😺', 'x', '😸', '😹', 'y']; assertEquivalent(d, e); assertEquivalent(arrayInsert([], 0, 'foo'), ['foo']); }, 'arrayInsert() numbers': () => { const a = [1, 3, 5]; const b = [1, 3, 4, 5]; assertEquivalent(arrayInsert(a, 2, 4), b); const c = [1, 2, 3, 4, 5]; assertEquivalent(arrayInsert(b, 1, 2), c); }, 'noop fn': () => { assertExists(noop); assertEquals(noop(), undefined); }, 'highlightToColor()': () => { [ HighlightType.Number, HighlightType.Match, HighlightType.String, HighlightType.SingleLineComment, HighlightType.MultiLineComment, HighlightType.Keyword1, HighlightType.Keyword2, HighlightType.Operator, HighlightType.None, ].forEach((type) => { assertTrue(highlightToColor(type).length > 0); }); }, 'posSub()': () => { assertEquals(posSub(14, 15), 0); assertEquals(posSub(15, 1), 14); }, 'minSub()': () => { assertEquals(minSub(13, 25, -1), -1); assertEquals(minSub(25, 13, 0), 12); }, 'maxAdd()': () => { assertEquals(maxAdd(99, 99, 75), 75); assertEquals(maxAdd(25, 74, 101), 99); }, 'ord()': () => { // Invalid output assertEquals(ord(''), 256); // Valid output assertEquals(ord('a'), 97); }, 'strChars() properly splits strings into unicode characters': () => { assertEquivalent(strChars('😺😸😹'), ['😺', '😸', '😹']); }, 'ctrlKey()': () => { const ctrl_a = ctrlKey('a'); assertTrue(isControl(ctrl_a)); assertEquals(ctrl_a, String.fromCodePoint(0x01)); const invalid = ctrlKey('😺'); assertFalse(isControl(invalid)); assertEquals(invalid, '😺'); }, 'isAscii()': () => { assertTrue(isAscii('asjyverkjhsdf1928374')); assertFalse(isAscii('😺acalskjsdf')); assertFalse(isAscii('ab😺ac')); }, 'isAsciiDigit()': () => { assertTrue(isAsciiDigit('1234567890')); assertFalse(isAsciiDigit('A1')); assertFalse(isAsciiDigit('/')); assertFalse(isAsciiDigit(':')); }, 'isControl()': () => { assertFalse(isControl('abc')); assertTrue(isControl(String.fromCodePoint(0x01))); assertFalse(isControl('😺')); }, 'strlen()': () => { // 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()': () => { assertEquals(truncate('😺😸😹', 1), '😺'); assertEquals(truncate('😺😸😹', 5), '😺😸😹'); assertEquals(truncate('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 5), 'πŸ‘¨β€πŸ‘©β€πŸ‘§'); }, }; }; const readKeyTest = () => { const { KeyCommand } = _Ansi; const { readKey, ctrlKey } = Fn; const encoder = new TextEncoder(); const testKeyMap = (codes: string[], expected: string) => { codes.forEach((code) => { assertEquals(readKey(encoder.encode(code)), expected); }); }; return { 'empty input': () => { assertEquals(readKey(new Uint8Array(0)), ''); }, 'passthrough': () => { // Ignore unhandled escape sequences assertEquals(readKey(encoder.encode('\x1b[]')), '\x1b[]'); // Pass explicitly mapped values right through assertEquals( readKey(encoder.encode(KeyCommand.ArrowUp)), KeyCommand.ArrowUp, ); assertEquals( readKey(encoder.encode(KeyCommand.Home)), KeyCommand.Home, ); assertEquals( readKey(encoder.encode(KeyCommand.Delete)), KeyCommand.Delete, ); // And pass through whatever else assertEquals(readKey(encoder.encode('foobaz')), 'foobaz'); }, 'Esc': () => testKeyMap(['\x1b', ctrlKey('l')], KeyCommand.Escape), 'Backspace': () => testKeyMap( [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), }; }; // ---------------------------------------------------------------------------- // Tests by module // ---------------------------------------------------------------------------- const ANSITest = () => { const { Ground } = _Ansi; return { 'color()': () => { assertEquals(Ansi.color.Blue, '\x1b[34m'); }, 'color256()': () => { assertEquals(Ansi.color256(128, Ground.Back), '\x1b[48;5;128m'); assertEquals(Ansi.color256(128, Ground.Fore), '\x1b[38;5;128m'); }, 'rgb()': () => { assertEquals(Ansi.rgb(32, 64, 128, Ground.Back), '\x1b[48;2;32;64;128m'); assertEquals(Ansi.rgb(32, 64, 128, Ground.Fore), '\x1b[38;2;32;64;128m'); }, 'moveCursor()': () => { assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); }, 'moveCursorForward()': () => { assertEquals(Ansi.moveCursorForward(2), '\x1b[2C'); }, 'moveCursorDown()': () => { assertEquals(Ansi.moveCursorDown(7), '\x1b[7B'); }, }; }; // ---------------------------------------------------------------------------- const BufferTest = { 'new Buffer': () => { const b = Buffer.default(); assertInstanceOf(b, Buffer); assertEquals(b.strlen(), 0); }, '.appendLine': () => { const b = Buffer.default(); // 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 = Buffer.default(); b.append('foobar'); assertEquals(b.strlen(), 6); b.clear(); b.append('foobar', 3); assertEquals(b.strlen(), 3); }, '.flush': async () => { const b = Buffer.default(); b.appendLine('foobarbaz' + Ansi.ClearLine); assertEquals(b.strlen(), 14); await b.flush(); assertEquals(b.strlen(), 0); }, }; // ---------------------------------------------------------------------------- const DocumentTest = { '.default': () => { const doc = Document.default(); assertEquals(doc.numRows, 0); assertTrue(doc.isEmpty()); assertEquivalent(doc.row(0), None); }, '.open': async () => { const oldDoc = Document.default(); oldDoc.insert(Position.default(), 'foobarbaz'); assertTrue(oldDoc.dirty); assertEquals(oldDoc.numRows, 1); const doc = await oldDoc.open(THIS_FILE); assertEquals(FileLang.TypeScript, doc.fileType); assertFalse(doc.dirty); assertFalse(doc.isEmpty()); assertTrue(doc.numRows > 1); }, '.save': async () => { const doc = await Document.default().open(THIS_FILE); doc.insertNewline(Position.default()); assertTrue(doc.dirty); await doc.save('test.file'); fs.rm('test.file', (err: any) => { assertNone(Option.from(err)); }); assertFalse(doc.dirty); }, '.find': async () => { const doc = await Document.default().open(KILO_FILE); // First search forward from the beginning of the file const query1 = doc.find( 'editor', Position.default(), SearchDirection.Forward, ); assertTrue(query1.isSome()); const pos1 = query1.unwrap(); assertEquivalent(pos1, Position.at(5, 27)); // Now search backwards from line 400 const query2 = doc.find( 'realloc', Position.at(44, 400), SearchDirection.Backward, ); assertTrue(query2.isSome()); const pos2 = query2.unwrap(); assertEquivalent(pos2, Position.at(11, 330)); // And backwards again const query3 = doc.find( 'editor', Position.from(pos2), SearchDirection.Backward, ); assertTrue(query3.isSome()); const pos3 = query3.unwrap(); assertEquivalent(pos3, Position.at(5, 328)); }, '.find - empty result': () => { const doc = Document.default(); doc.insertNewline(Position.default()); const query = doc.find('foo', Position.default(), SearchDirection.Forward); assertNone(query); const query2 = doc.find('bar', Position.at(0, 5), SearchDirection.Forward); assertNone(query2); }, '.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); // Update row doc.highlight(None, None); const row0 = doc.row(0).unwrap(); 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); }, '.insertNewline': () => { // Invalid insert location const doc = Document.default(); doc.insertNewline(Position.at(0, 3)); assertFalse(doc.dirty); assertTrue(doc.isEmpty()); // Add new empty row const doc2 = Document.default(); doc2.insertNewline(Position.default()); assertTrue(doc2.dirty); assertFalse(doc2.isEmpty()); // Split an existing line const doc3 = Document.default(); doc3.insert(Position.default(), 'foobar'); doc3.insertNewline(Position.at(3, 0)); assertEquals(doc3.numRows, 2); assertEquals(doc3.row(0).unwrap().toString(), 'foo'); assertEquals(doc3.row(1).unwrap().toString(), 'bar'); }, '.delete': () => { const doc = Document.default(); doc.insert(Position.default(), 'foobar'); doc.delete(Position.at(3, 0)); assertEquals(doc.row(0).unwrap().toString(), 'fooar'); // Merge next row const doc2 = Document.default(); doc2.insertNewline(Position.default()); doc2.insert(Position.at(0, 1), 'foobar'); doc2.delete(Position.at(0, 0)); assertEquals(doc2.row(0).unwrap().toString(), 'foobar'); // Invalid delete location const doc3 = Document.default(); doc3.insert(Position.default(), 'foobar'); doc3.delete(Position.at(0, 3)); assertEquals(doc3.row(0).unwrap().toString(), 'foobar'); }, }; // ---------------------------------------------------------------------------- const EditorTest = { 'new Editor': () => { const e = Editor.create(defaultTerminalSize); assertInstanceOf(e, Editor); }, '.open': async () => { const e = Editor.create(defaultTerminalSize); await e.open(THIS_FILE); assertInstanceOf(e, Editor); }, '.processKeyPress - letters': async () => { const e = Editor.create(defaultTerminalSize); const res = await e.processKeyPress('a'); assertTrue(res); }, '.processKeyPress - ctrl-q': async () => { // Dirty file (Need to clear confirmation messages) const e = Editor.create(defaultTerminalSize); await e.processKeyPress('d'); assertTrue(await e.processKeyPress(Fn.ctrlKey('q'))); assertTrue(await e.processKeyPress(Fn.ctrlKey('q'))); assertTrue(await e.processKeyPress(Fn.ctrlKey('q'))); assertFalse(await e.processKeyPress(Fn.ctrlKey('q'))); // Clean file const e2 = Editor.create(defaultTerminalSize); const res = await e2.processKeyPress(Fn.ctrlKey('q')); assertFalse(res); }, }; // ---------------------------------------------------------------------------- const FileTypeTest = { 'FileType.from()': () => { for (const [ext, typeClass] of FT.fileTypeMap.entries()) { const file = `test${ext}`; const syntax = FileType.from(file); assertInstanceOf(syntax, typeClass); } }, }; // ---------------------------------------------------------------------------- const OptionTest = { 'Option.from()': () => { assertNone(Option.from(null)); assertNone(Option.from()); assertEquivalent(Option.from(undefined), None); assertSome(Option.from('foo')); assertSome(Option.from(234)); assertSome(Option.from({})); assertSome(Some([1, 2, 3])); assertEquivalent(Option.from(Some('foo')), Some('foo')); assertEquivalent(Some(Some('bar')), Some('bar')); }, '.isSome': () => { assertFalse(None.isSome()); assertTrue(Option.from('foo').isSome()); assertTrue(Some('foo').isSome()); }, '.isNone': () => { assertTrue(None.isNone()); assertFalse(Option.from('foo').isNone()); assertFalse(Some('foo').isNone()); }, '.toString': () => { assertEquals(Some({}).toString(), 'Some ({})'); assertEquals(Some([1, 2, 3]).toString(), 'Some ([1,2,3])'); assertEquals(None.toString(), 'None'); }, '.isSomeAnd': () => { assertFalse(Option.from().isSomeAnd((_a) => true)); assertTrue(Option.from('foo').isSomeAnd((a) => typeof a === 'string')); }, '.isNoneAnd': () => { assertTrue(None.isNoneAnd(() => true)); assertFalse(None.isNoneAnd(() => false)); assertFalse(Some('x').isNoneAnd(() => true)); }, '.map': () => { const fn = (_a: any) => 'bar'; assertEquivalent(Some('bar'), Some('foo').map(fn)); assertNone(None.map(fn)); }, '.mapOr': () => { const fn = (_a: any) => 'bar'; assertEquals('bar', Some('foo').mapOr('baz', fn)); assertEquals('baz', None.mapOr('baz', fn)); }, '.mapOrElse': () => { const fn = (_a: any) => 'bar'; const defFn = () => 'baz'; assertEquals('bar', Some('foo').mapOrElse(defFn, fn)); assertEquals('baz', None.mapOrElse(defFn, fn)); }, '.unwrapOr': () => { assertEquals('foo', Some('foo').unwrapOr('bar')); assertEquals('bar', None.unwrapOr('bar')); }, '.unwrapOrElse': () => { const fn = () => 'bar'; assertEquals('foo', Some('foo').unwrapOrElse(fn)); assertEquals('bar', None.unwrapOrElse(fn)); }, '.and': () => { const optb = Some('bar'); assertEquivalent(optb, Some('foo').and(optb)); assertEquivalent(None, None.and(optb)); }, '.andThen': () => { const fn = (x: any) => Some(typeof x === 'string'); assertEquivalent(Some(true), Some('foo').andThen(fn)); assertNone(None.andThen(fn)); }, '.or': () => { const optb = Some('bar'); assertEquivalent(Some('foo'), Some('foo').or(optb)); assertEquivalent(optb, None.or(optb)); }, '.orElse': () => { const fn = () => Some('bar'); assertEquivalent(Some('foo'), Some('foo').orElse(fn)); assertEquivalent(Some('bar'), None.orElse(fn)); }, }; // ---------------------------------------------------------------------------- const PositionTest = { '.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); }, }; // ---------------------------------------------------------------------------- const RowTest = { '.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', FileType.default()); 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, FileType.default()).toString(), row2.toString()); }, '.find': () => { const normalRow = Row.from('\tFor whom the bell tolls'); assertEquivalent( normalRow.find('who', 0, SearchDirection.Forward), Some(5), ); assertEquals(normalRow.find('foo', 0, SearchDirection.Forward), None); const emojiRow = Row.from('\t😺😸😹'); assertEquivalent(emojiRow.find('😹', 0, SearchDirection.Forward), Some(3)); assertEquals(emojiRow.find('🀰🏼', 10, SearchDirection.Forward), None); }, '.find backwards': () => { const normalRow = Row.from('For whom the bell tolls'); assertEquivalent( normalRow.find('who', 23, SearchDirection.Backward), Some(4), ); assertEquals(normalRow.find('foo', 10, SearchDirection.Backward), None); const emojiRow = Row.from('😺😸😹'); assertEquivalent(emojiRow.find('😸', 2, SearchDirection.Backward), Some(1)); assertEquals(emojiRow.find('🀰🏼', 10, SearchDirection.Backward), None); }, '.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(None, FileType.default()); 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); }, }; // ---------------------------------------------------------------------------- // Test Suite Setup // ---------------------------------------------------------------------------- testSuite({ fns: fnTest(), 'readKey()': readKeyTest(), 'ANSI utils': ANSITest(), Buffer: BufferTest, Document: DocumentTest, Editor: EditorTest, FileType: FileTypeTest, Option: OptionTest, Position: PositionTest, Row: RowTest, });