More tests

This commit is contained in:
Timothy Warren 2023-11-16 16:00:03 -05:00
parent 301196352a
commit 8b5fb17603
19 changed files with 277 additions and 124 deletions

9
coverage.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
rm -fr /cov_profile/
deno test --allow-all --coverage=cov_profile
deno coverage cov_profile --lcov > cov_profile/cov_profile.lcov
genhtml -o cov_profile cov_profile/cov_profile.lcov
rm cov_profile/*.json
open cov_profile/index.html

View File

@ -48,7 +48,7 @@ deno-check:
# Test with deno
deno-test:
deno test --allow-all
deno test --allow-all --unstable
# Create test coverage report with deno
deno-coverage:

View File

@ -42,6 +42,7 @@ const BunFFI: IFFI = {
tcsetattr,
cfmakeraw,
getPointer: ptr,
close: cStdLib.close,
};
export default BunFFI;

View File

@ -1,9 +1,9 @@
import { IFIO } from '../common/runtime.ts';
import { IFileIO } from '../common/runtime.ts';
import { appendFileSync, readFileSync } from 'node:fs';
import { appendFile } from 'node:fs/promises';
const BunFileIO: IFIO = {
const BunFileIO: IFileIO = {
openFile: async (path: string): Promise<string> => {
const file = await Bun.file(path);
return await file.text();

View File

@ -4,6 +4,11 @@
import { ITerminal, ITerminalSize } from '../common/mod.ts';
import Ansi from '../common/ansi.ts';
const defaultTerminalSize: ITerminalSize = {
rows: 24,
cols: 80,
};
const BunTerminalIO: ITerminal = {
// Deno only returns arguments passed to the script, so
// remove the bun runtime executable, and entry script arguments
@ -32,8 +37,8 @@ const BunTerminalIO: ITerminal = {
const rawCode = (new TextDecoder()).decode(chunk);
const res = rawCode.trim().replace(/^.\[([0-9]+;[0-9]+)R$/, '$1');
const [srows, scols] = res.split(';');
const rows = parseInt(srows, 10);
const cols = parseInt(scols, 10);
const rows = parseInt(srows, 10) ?? 24;
const cols = parseInt(scols, 10) ?? 80;
// Clear the screen
await write(Ansi.ClearScreen + Ansi.ResetCursor);
@ -44,10 +49,7 @@ const BunTerminalIO: ITerminal = {
};
}
return {
rows: 24,
cols: 80,
};
return defaultTerminalSize;
},
writeStdout: async function write(s: string): Promise<void> {
const buffer = new TextEncoder().encode(s);

View File

@ -43,11 +43,13 @@ export const Ansi = {
moveCursorDown: (row: number): string => ANSI_PREFIX + `${row}B`,
};
const decoder = new TextDecoder();
export function readKey(raw: Uint8Array): string {
const parsed = decoder.decode(raw);
/**
* Convert input from ANSI escape sequences into a form
* that can be more easily mapped to editor commands
*
* @param parsed - the decoded chunk of input
*/
export function readKey(parsed: string): string {
// Return the input if it's unambiguous
if (parsed in KeyCommand) {
return parsed;

33
src/common/ansi_test.ts Normal file
View File

@ -0,0 +1,33 @@
import { getTestRunner } from './mod.ts';
import Ansi, { KeyCommand, readKey } from './ansi.ts';
getTestRunner().then((t) => {
t.test('Ansi.moveCursor', () => {
t.assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H');
});
t.test('Ansi.moveCursorForward', () => {
t.assertEquals(Ansi.moveCursorForward(2), '\x1b[2C');
});
t.test('Ansi.moveCursorDown', () => {
t.assertEquals(Ansi.moveCursorDown(7), '\x1b[7B');
});
t.test('readKey', () => {
// Ignore unhandled escape sequences
t.assertEquals(readKey('\x1b[]'), '\x1b[]');
// Pass explicitly mapped values right through
t.assertEquals(readKey(KeyCommand.ArrowUp), KeyCommand.ArrowUp);
t.assertEquals(readKey(KeyCommand.Home), KeyCommand.Home);
['\x1bOH', '\x1b[7~', '\x1b[1~', '\x1b[H'].forEach((code) => {
t.assertEquals(readKey(code), KeyCommand.Home);
});
['\x1bOF', '\x1b[8~', '\x1b[4~', '\x1b[F'].forEach((code) => {
t.assertEquals(readKey(code), KeyCommand.End);
});
});
});

View File

@ -1,5 +1,5 @@
import { strlen, truncate } from './utils.ts';
import { getRuntime } from './runtime.ts';
import { getRuntime, importForRuntime } from './runtime.ts';
class Buffer {
#b = '';
@ -12,7 +12,7 @@ class Buffer {
}
public appendLine(s = ''): void {
this.#b += (s ?? '') + '\r\n';
this.#b += s + '\r\n';
}
public clear(): void {
@ -23,7 +23,7 @@ class Buffer {
* Output the contents of the buffer into stdout
*/
public async flush() {
const { term } = await getRuntime();
const term = await importForRuntime('terminal_io');
await term.writeStdout(this.#b);
this.clear();
}

45
src/common/buffer_test.ts Normal file
View File

@ -0,0 +1,45 @@
import { getTestRunner } from './runtime.ts';
import Buffer from './buffer.ts';
getTestRunner().then((t) => {
t.test('Buffer exists', () => {
const b = new Buffer();
t.assertInstanceOf(b, Buffer);
t.assertEquals(b.strlen(), 0);
});
t.test('Buffer.appendLine', () => {
const b = new Buffer();
// Carriage return and line feed
b.appendLine();
t.assertEquals(b.strlen(), 2);
b.clear();
t.assertEquals(b.strlen(), 0);
b.appendLine('foo');
t.assertEquals(b.strlen(), 5);
});
t.test('Buffer.append', () => {
const b = new Buffer();
b.append('foobar');
t.assertEquals(b.strlen(), 6);
b.clear();
b.append('foobar', 3);
t.assertEquals(b.strlen(), 3);
});
t.test('Buffer.flush', async () => {
const b = new Buffer();
b.append('foobarbaz');
t.assertEquals(b.strlen(), 9);
await b.flush();
t.assertEquals(b.strlen(), 0);
});
});

View File

@ -1,7 +1,7 @@
import Ansi, { KeyCommand } from './ansi.ts';
import Buffer from './buffer.ts';
import Document from './document.ts';
import { IPoint, ITerminalSize, VERSION } from './mod.ts';
import { IPoint, ITerminalSize, logToFile, VERSION } from './mod.ts';
import { ctrl_key } from './utils.ts';
export class Editor {
@ -152,12 +152,17 @@ export class Editor {
private drawFileRow(y: number): void {
const row = this.#document.row(y);
let len = row?.chars.length ?? 0;
if (row === null) {
logToFile(`Warning: trying to draw non-existent row '${y}'`);
return this.drawPlaceholderRow(y);
}
let len = row.chars.length ?? 0;
if (len > this.#screen.cols) {
len = this.#screen.cols;
}
this.#buffer.append(row!.toString(), len);
this.#buffer.append(row.toString(), len);
this.#buffer.appendLine(Ansi.ClearLine);
}

View File

@ -4,6 +4,7 @@ import { getTermios } from './termios.ts';
import { Editor } from './editor.ts';
export async function main() {
const decoder = new TextDecoder();
const runTime = await getRuntime();
const { term, file, onExit, onEvent } = runTime;
@ -12,14 +13,13 @@ export async function main() {
t.enableRawMode();
onExit(() => {
t.disableRawMode();
t.cleanup();
});
// Setup error handler to log to file
onEvent('error', (error) => {
t.disableRawMode();
if (error instanceof ErrorEvent) {
file.appendFileSync('./scroll.err', JSON.stringify(error, null, 2));
}
file.appendFileSync('./scroll.err', JSON.stringify(error, null, 2));
});
const terminalSize = await term.getTerminalSize();
@ -39,7 +39,7 @@ export async function main() {
// The main event loop
for await (const chunk of term.inputLoop()) {
// Process input
const char = readKey(chunk);
const char = readKey(decoder.decode(chunk));
const shouldLoop = editor.processKeyPress(char);
if (!shouldLoop) {
return 0;

View File

@ -1,14 +1,25 @@
import { getTermios } from './termios.ts';
import { noop } from './utils.ts';
import { ITestBase } from './types.ts';
// ----------------------------------------------------------------------------
// Runtime adapter interfaces
// ----------------------------------------------------------------------------
export enum RunTimeType {
Bun = 'bun',
Deno = 'deno',
Unknown = 'common',
}
// ----------------------------------------------------------------------------
// Runtime adapter interfaces
// ----------------------------------------------------------------------------
/**
* The size of terminal in rows and columns
*/
export interface ITerminalSize {
rows: number;
cols: number;
}
/**
* The native functions for getting/setting terminal settings
*/
@ -33,14 +44,11 @@ export interface IFFI {
*/
// deno-lint-ignore no-explicit-any
getPointer(ta: any): unknown;
}
/**
* The size of terminal in rows and columns
*/
export interface ITerminalSize {
rows: number;
cols: number;
/**
* Closes the FFI handle
*/
close(): void;
}
/**
@ -51,10 +59,11 @@ export interface ITerminal {
* The arguments passed to the program on launch
*/
argv: string[];
/**
* The generator function returning chunks of input from the stdin stream
*/
inputLoop(): AsyncGenerator<Uint8Array, void, unknown>;
inputLoop(): AsyncGenerator<Uint8Array, void>;
/**
* Get the size of the terminal
@ -70,7 +79,7 @@ export interface ITerminal {
/**
* Runtime-specific file handling
*/
export interface IFIO {
export interface IFileIO {
openFile(path: string): Promise<string>;
openFileSync(path: string): string;
appendFile(path: string, contents: string): Promise<void>;
@ -99,7 +108,7 @@ export interface IRuntime {
/**
* Runtime-specific file system io
*/
file: IFIO;
file: IFileIO;
/**
* Set up an event handler
@ -132,6 +141,15 @@ export interface IRuntime {
let scrollRuntime: IRuntime | null = null;
export function logToFile(s: unknown) {
importForRuntime('file_io').then((f) => {
const raw = (typeof s === 'string') ? s : JSON.stringify(s, null, 2);
const output = raw + '\n';
f.appendFile('./scroll.log', output).then(noop);
});
}
/**
* Kill program, displaying an error message
* @param s
@ -148,7 +166,7 @@ export function die(s: string | Error): void {
/**
* Determine which Typescript runtime we are operating under
*/
export function getRuntimeType(): RunTimeType {
export function runtimeType(): RunTimeType {
let runtime = RunTimeType.Unknown;
if ('Deno' in globalThis) {
@ -166,7 +184,7 @@ export function getRuntimeType(): RunTimeType {
*/
export async function getRuntime(): Promise<IRuntime> {
if (scrollRuntime === null) {
const runtime = getRuntimeType();
const runtime = runtimeType();
const path = `../${runtime}/mod.ts`;
const pkg = await import(path);
@ -178,16 +196,30 @@ export async function getRuntime(): Promise<IRuntime> {
return Promise.resolve(scrollRuntime!);
}
/**
* Get the common test interface object
*/
export async function getTestRunner(): Promise<ITestBase> {
const runtime = runtimeType();
const path = `../${runtime}/test_base.ts`;
const pkg = await import(path);
if ('default' in pkg) {
return pkg.default;
}
return pkg;
}
/**
* Import a runtime-specific module
*
* eg. to load "src/bun/mod.ts", if the runtime is bun,
* e.g. to load "src/bun/mod.ts", if the runtime is bun,
* you can use like so `await importForRuntime('index')`;
*
* @param path - the path within the runtime module
*/
export const importForRuntime = async (path: string) => {
const runtime = getRuntimeType();
const runtime = runtimeType();
const suffix = '.ts';
const base = `../${runtime}/`;
@ -198,20 +230,10 @@ export const importForRuntime = async (path: string) => {
const cleanedPath = pathParts.join('/');
const importPath = base + cleanedPath + suffix;
return await import(importPath);
};
/**
* Import the default export for a runtime-specific module
* (this is just a simple wrapper of `importForRuntime`)
*
* @param path - the path within the runtime module
*/
export const importDefaultForRuntime = async (path: string) => {
const pkg = await importForRuntime(path);
const pkg = await import(importPath);
if ('default' in pkg) {
return pkg.default;
}
return null;
return pkg;
};

View File

@ -31,13 +31,13 @@ class Termios {
* The data for the termios struct we are manipulating
* @private
*/
readonly #termios: Uint8Array;
#termios: Uint8Array;
/**
* The pointer to the termios struct
* @private
*/
readonly #ptr: any;
#ptr;
constructor(ffi: IFFI) {
this.#ffi = ffi;
@ -51,6 +51,13 @@ class Termios {
this.#ptr = ffi.getPointer(this.#termios);
}
cleanup() {
this.#ptr = null;
this.#cookedTermios = new Uint8Array(0);
this.#termios = new Uint8Array(0);
this.#ffi.close();
}
enableRawMode() {
if (this.#inRawMode) {
throw new Error('Can not enable raw mode when in raw mode');

View File

@ -1,11 +1,30 @@
export function noop() {}
// ----------------------------------------------------------------------------
// Misc
// ----------------------------------------------------------------------------
export const noop = () => {};
// ----------------------------------------------------------------------------
// Strings
// ----------------------------------------------------------------------------
/**
* Get the codepoint of the first byte of a string. If the string
* is empty, this will return 256
*
* @param s - the string
*/
export function ord(s: string): number {
if (s.length > 0) {
return s.codePointAt(0)!;
}
return 256;
}
/**
* Split a string by graphemes, not just bytes
*
* @param s - the string to split into 'characters'
*/
export function chars(s: string): string[] {
@ -14,6 +33,7 @@ export function chars(s: string): string[] {
/**
* Get the 'character length' of a string, not its UTF16 byte count
*
* @param s - the string to check
*/
export function strlen(s: string): number {
@ -21,19 +41,12 @@ export function strlen(s: string): number {
}
/**
* Is the character part of ascii?
* Are all the characters in the string in ASCII range?
*
* @param char - a one character string to check
* @param char - string to check
*/
export function is_ascii(char: string): boolean {
return chars(char).every((char) => {
const point = char.codePointAt(0);
if (point === undefined) {
return false;
}
return point < 0x80;
});
return chars(char).every((char) => ord(char) < 0x80);
}
/**
@ -42,22 +55,19 @@ export function is_ascii(char: string): boolean {
* @param char - a one character string to check
*/
export function is_control(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
const code = ord(char);
return is_ascii(char) && (code === 0x7f || code < 0x20);
}
/**
* Get the key code for a ctrl chord
*
* @param char - a one character string
*/
export function ctrl_key(char: string): string {
// This is the normal use case, of course
if (is_ascii(char)) {
const point = char.codePointAt(0)!;
const point = ord(char);
return String.fromCodePoint(point & 0x1f);
}

View File

@ -1,59 +1,74 @@
import { importDefaultForRuntime, ITestBase } from './mod.ts';
import { getTestRunner } from './mod.ts';
import {
chars,
ctrl_key,
is_ascii,
is_control,
noop,
ord,
strlen,
truncate,
} from './utils.ts';
const t: ITestBase = await importDefaultForRuntime('test_base');
getTestRunner().then((t) => {
t.test('noop fn', () => {
t.assertExists(noop);
t.assertEquals(noop(), undefined);
});
// ---------------------------------------------------------------------------------------------------------------------
// Strings
// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Strings
// ---------------------------------------------------------------------------
t.test('ord fn returns 256 on invalid string', () => {
t.assertEquals(ord(''), 256);
});
t.test('chars fn properly splits strings into unicode characters', () => {
t.assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']);
});
t.test('ctrl_key fn returns expected values', () => {
const ctrl_a = ctrl_key('a');
t.assertTrue(is_control(ctrl_a));
t.assertEquals(ctrl_a, String.fromCodePoint(0x01));
const invalid = ctrl_key('😺');
t.assertFalse(is_control(invalid));
t.assertEquals(invalid, '😺');
});
t.test('is_ascii properly discerns ascii chars', () => {
t.assertTrue(is_ascii('asjyverkjhsdf1928374'));
t.assertFalse(is_ascii('😺acalskjsdf'));
});
t.test('is_control fn works as expected', () => {
t.assertFalse(is_control('abc'));
t.assertTrue(is_control(String.fromCodePoint(0x01)));
t.assertFalse(is_control('😺'));
});
t.test('strlen fn returns expected length for multibyte characters', () => {
t.assertEquals(strlen('😺😸😹'), 3);
t.assertNotEquals('😺😸😹'.length, strlen('😺😸😹'));
// Skin tone modifier + base character
t.assertEquals(strlen('🤰🏼'), 2);
t.assertNotEquals('🤰🏼'.length, strlen('🤰🏼'));
// This has 4 sub-characters, and 3 zero-width-joiners
t.assertEquals(strlen('👨‍👩‍👧‍👦'), 7);
t.assertNotEquals('👨‍👩‍👧‍👦'.length, strlen('👨‍👩‍👧‍👦'));
});
t.test('truncate shortens strings', () => {
t.assertEquals(truncate('😺😸😹', 1), '😺');
t.assertEquals(truncate('😺😸😹', 5), '😺😸😹');
t.assertEquals(truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧');
t.test('chars fn properly splits strings into unicode characters', () => {
t.assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']);
});
t.test('ctrl_key fn returns expected values', () => {
const ctrl_a = ctrl_key('a');
t.assertTrue(is_control(ctrl_a));
t.assertEquals(ctrl_a, String.fromCodePoint(0x01));
const invalid = ctrl_key('😺');
t.assertFalse(is_control(invalid));
t.assertEquals(invalid, '😺');
});
t.test('is_ascii properly discerns ascii chars', () => {
t.assertTrue(is_ascii('asjyverkjhsdf1928374'));
t.assertFalse(is_ascii('😺acalskjsdf'));
t.assertFalse(is_ascii('ab😺ac'));
});
t.test('is_control fn works as expected', () => {
t.assertFalse(is_control('abc'));
t.assertTrue(is_control(String.fromCodePoint(0x01)));
t.assertFalse(is_control('😺'));
});
t.test('strlen fn returns expected length for ascii strings', () => {
t.assertEquals(strlen('abc'), 'abc'.length);
});
t.test('strlen fn returns expected length for multibyte characters', () => {
t.assertEquals(strlen('😺😸😹'), 3);
t.assertNotEquals('😺😸😹'.length, strlen('😺😸😹'));
// Skin tone modifier + base character
t.assertEquals(strlen('🤰🏼'), 2);
t.assertNotEquals('🤰🏼'.length, strlen('🤰🏼'));
// This has 4 sub-characters, and 3 zero-width-joiners
t.assertEquals(strlen('👨‍👩‍👧‍👦'), 7);
t.assertNotEquals('👨‍👩‍👧‍👦'.length, strlen('👨‍👩‍👧‍👦'));
});
t.test('truncate shortens strings', () => {
t.assertEquals(truncate('😺😸😹', 1), '😺');
t.assertEquals(truncate('😺😸😹', 5), '😺😸😹');
t.assertEquals(truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧');
});
});

1
src/deno/deps.ts Normal file
View File

@ -0,0 +1 @@
export * as stdAssert from 'https://deno.land/std@0.205.0/assert/mod.ts';

View File

@ -39,6 +39,7 @@ const DenoFFI: IFFI = {
tcsetattr,
cfmakeraw,
getPointer: Deno.UnsafePointer.of,
close: cStdLib.close,
};
export default DenoFFI;

View File

@ -1,6 +1,6 @@
import { IFIO } from '../common/runtime.ts';
import { IFileIO } from '../common/runtime.ts';
const DenoFileIO: IFIO = {
const DenoFileIO: IFileIO = {
openFile: async function (path: string): Promise<string> {
const decoder = new TextDecoder('utf-8');
const data = await Deno.readFile(path);

View File

@ -1,13 +1,13 @@
import { ITestBase } from '../common/mod.ts';
import {
import { stdAssert } from './deps.ts';
const {
assertEquals,
assertExists,
assertInstanceOf,
AssertionError,
assertNotEquals,
assertStrictEquals,
} from 'https://deno.land/std/assert/mod.ts';
} = stdAssert;
class TestBase implements ITestBase {
test(name: string, fn: () => void): void {