diff --git a/modules.json b/modules.json index 34bd2e4f5..2ddd16fba 100644 --- a/modules.json +++ b/modules.json @@ -111,5 +111,10 @@ }, "communication": { "tabs": [] + }, + "testing": { + "tabs": [ + "Testing" + ] } } \ No newline at end of file diff --git a/src/bundles/testing/__tests__/index.ts b/src/bundles/testing/__tests__/index.ts new file mode 100644 index 000000000..b1da9e134 --- /dev/null +++ b/src/bundles/testing/__tests__/index.ts @@ -0,0 +1,48 @@ +import * as asserts from '../asserts'; +import * as testing from '../functions'; +import { list } from '../list'; + +beforeAll(() => { + testing.context.suiteResults = { + name: '', + results: [], + total: 0, + passed: 0, + }; + testing.context.allResults.results = []; + testing.context.runtime = 0; +}); + +test('context is created correctly', () => { + const mockTestFn = jest.fn(); + testing.describe('Testing 321', () => { + testing.it('Testing 123', mockTestFn); + }); + expect(testing.context.suiteResults.passed).toEqual(1); + expect(mockTestFn).toHaveBeenCalled(); +}); + +test('context fails correctly', () => { + testing.describe('Testing 123', () => { + testing.it('This test fails!', () => asserts.assert_equals(0, 1)); + }); + expect(testing.context.suiteResults.passed).toEqual(0); + expect(testing.context.suiteResults.total).toEqual(1); +}); + +test('assert works', () => { + expect(() => asserts.assert(() => true)).not.toThrow(); + expect(() => asserts.assert(() => false)).toThrow('Assert failed'); +}); + +test('assert_equals works', () => { + expect(() => asserts.assert_equals(1, 1)).not.toThrow(); + expect(() => asserts.assert_equals(0, 1)).toThrow('Expected'); + expect(() => asserts.assert_equals(1.00000000001, 1)).not.toThrow(); +}); + +test('assert_contains works', () => { + const list1 = list(1, 2, 3); + expect(() => asserts.assert_contains(list1, 2)).not.toThrow(); + expect(() => asserts.assert_contains(list1, 10)).toThrow(); +}); diff --git a/src/bundles/testing/asserts.ts b/src/bundles/testing/asserts.ts new file mode 100644 index 000000000..75c1531b1 --- /dev/null +++ b/src/bundles/testing/asserts.ts @@ -0,0 +1,85 @@ +import { is_pair, head, tail, is_list, is_null, member, length } from './list'; + +/** + * Asserts that a predicate returns true. + * @param pred An predicate function that returns true/false. + * @returns + */ +export function assert(pred: () => boolean) { + if (!pred()) { + throw new Error('Assert failed!'); + } +} + +/** + * Asserts the equality (===) of two parameters. + * @param expected The expected value. + * @param received The given value. + * @returns + */ +export function assert_equals(expected: any, received: any) { + const fail = () => { + throw new Error(`Expected \`${expected}\`, got \`${received}\`!`); + }; + if (typeof expected !== typeof received) { + fail(); + } + // approx checking for floats + if (typeof expected === 'number' && !Number.isInteger(expected)) { + if (Math.abs(expected - received) > 0.001) { + fail(); + } else { + return; + } + } + if (expected !== received) { + fail(); + } +} + +/** + * Asserts that `xs` contains `toContain`. + * @param xs The list to assert. + * @param toContain The element that `xs` is expected to contain. + */ +export function assert_contains(xs: any, toContain: any) { + const fail = () => { + throw new Error(`Expected \`${xs}\` to contain \`${toContain}\`.`); + }; + + if (is_null(xs)) { + fail(); + } else if (is_list(xs)) { + if (is_null(member(toContain, xs))) { + fail(); + } + } else if (is_pair(xs)) { + if (head(xs) === toContain || tail(xs) === toContain) { + return; + } + + // check the head, if it fails, checks the tail, if that fails, fail. + try { + assert_contains(head(xs), toContain); + return; + } catch (_) { + try { + assert_contains(tail(xs), toContain); + return; + } catch (__) { + fail(); + } + } + } else { + throw new Error(`First argument must be a list or a pair, got \`${xs}\`.`); + } +} + +/** + * Asserts that the given list has length `len`. + * @param list The list to assert. + * @param len The expected length of the list. + */ +export function assert_length(list: any, len: number) { + assert_equals(length(list), len); +} diff --git a/src/bundles/testing/functions.ts b/src/bundles/testing/functions.ts new file mode 100644 index 000000000..bb28f108f --- /dev/null +++ b/src/bundles/testing/functions.ts @@ -0,0 +1,82 @@ +import { TestContext, TestSuite, Test } from './types'; + +const handleErr = (err: any) => { + if (err.error && err.error.message) { + return (err.error as Error).message; + } + if (err.message) { + return (err as Error).message; + } + throw err; +}; + +export const context: TestContext = { + describe: (msg: string, suite: TestSuite) => { + const starttime = performance.now(); + context.suiteResults = { + name: msg, + results: [], + total: 0, + passed: 0, + }; + + suite(); + + context.allResults.results.push(context.suiteResults); + + const endtime = performance.now(); + context.runtime += endtime - starttime; + return context.allResults; + }, + + it: (msg: string, test: Test) => { + const name = `${msg}`; + let error = ''; + context.suiteResults.total += 1; + + try { + test(); + context.suiteResults.passed += 1; + } catch (err: any) { + error = handleErr(err); + } + + context.suiteResults.results.push({ + name, + error, + }); + }, + + suiteResults: { + name: '', + results: [], + total: 0, + passed: 0, + }, + + allResults: { + results: [], + toReplString: () => + `${context.allResults.results.length} suites completed in ${context.runtime} ms.`, + }, + + runtime: 0, +}; + +/** + * Defines a single test. + * @param str Description for this test. + * @param func Function containing tests. + */ +export function it(msg: string, func: Test) { + context.it(msg, func); +} + +/** + * Describes a test suite. + * @param str Description for this test. + * @param func Function containing tests. + */ +export function describe(msg: string, func: TestSuite) { + return context.describe(msg, func); +} diff --git a/src/bundles/testing/index.ts b/src/bundles/testing/index.ts new file mode 100644 index 000000000..9caa27c14 --- /dev/null +++ b/src/bundles/testing/index.ts @@ -0,0 +1,41 @@ +import { + assert_equals, + assert_not_equals, + assert_contains, + assert_approx_equals, + assert_greater, + assert_greater_equals, + assert_length, +} from './asserts'; +import { it, describe } from './functions'; +import { mock_fn } from './mocks'; + +/** + * Collection of unit-testing tools for Source. + * @author Jia Xiaodong + */ + +/** + * Increment a number by a value of 1. + * @param x the number to be incremented + * @returns the incremented value of the number + */ +function sample_function(x: number) { + return x + 1; +} + +// Un-comment the next line if your bundle requires the use of variables +// declared in cadet-frontend or js-slang. +export default () => ({ + sample_function, + it, + describe, + assert_equals, + assert_not_equals, + assert_contains, + assert_greater, + assert_greater_equals, + assert_approx_equals, + assert_length, + mock_fn, +}); diff --git a/src/bundles/testing/list.ts b/src/bundles/testing/list.ts new file mode 100644 index 000000000..fdc49241e --- /dev/null +++ b/src/bundles/testing/list.ts @@ -0,0 +1,372 @@ +/* eslint-disable @typescript-eslint/naming-convention, no-else-return, prefer-template, no-param-reassign, no-plusplus, operator-assignment, no-lonely-if */ +/* prettier-ignore */ +// list.js: Supporting lists in the Scheme style, using pairs made +// up of two-element JavaScript array (vector) + +// Author: Martin Henz + +// Note: this library is used in the externalLibs of cadet-frontend. +// It is distinct from the LISTS library of Source ยง2, which contains +// primitive and predeclared functions from Chapter 2 of SICP JS. + +// array test works differently for Rhino and +// the Firefox environment (especially Web Console) +export function array_test(x) : boolean { + if (Array.isArray === undefined) { + return x instanceof Array; + } else { + return Array.isArray(x); + } +} + +// pair constructs a pair using a two-element array +// LOW-LEVEL FUNCTION, NOT SOURCE +export function pair(x, xs): [any, any] { + return [x, xs]; +} + +// is_pair returns true iff arg is a two-element array +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_pair(x): boolean { + return array_test(x) && x.length === 2; +} + +// head returns the first component of the given pair, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function head(xs): any { + if (is_pair(xs)) { + return xs[0]; + } else { + throw new Error( + 'head(xs) expects a pair as argument xs, but encountered ' + xs + ); + } +} + +// tail returns the second component of the given pair +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function tail(xs) { + if (is_pair(xs)) { + return xs[1]; + } else { + throw new Error( + 'tail(xs) expects a pair as argument xs, but encountered ' + xs + ); + } +} + +// is_null returns true if arg is exactly null +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_null(xs) { + return xs === null; +} + +// is_list recurses down the list and checks that it ends with the empty list [] +// does not throw Value exceptions +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_list(xs) { + for (; ; xs = tail(xs)) { + if (is_null(xs)) { + return true; + } else if (!is_pair(xs)) { + return false; + } + } +} + +// list makes a list out of its arguments +// LOW-LEVEL FUNCTION, NOT SOURCE +export function list(...args) { + let the_list: any = null; + for (let i = args.length - 1; i >= 0; i--) { + the_list = pair(args[i], the_list); + } + return the_list; +} + +// list_to_vector returns vector that contains the elements of the argument list +// in the given order. +// list_to_vector throws an exception if the argument is not a list +// LOW-LEVEL FUNCTION, NOT SOURCE +export function list_to_vector(lst) { + const vector: any[] = []; + while (!is_null(lst)) { + vector.push(head(lst)); + lst = tail(lst); + } + return vector; +} + +// vector_to_list returns a list that contains the elements of the argument vector +// in the given order. +// vector_to_list throws an exception if the argument is not a vector +// LOW-LEVEL FUNCTION, NOT SOURCE +export function vector_to_list(vector) { + let result: any = null; + for (let i = vector.length - 1; i >= 0; i = i - 1) { + result = pair(vector[i], result); + } + return result; +} + +// returns the length of a given argument list +// throws an exception if the argument is not a list +export function length(xs) { + let i = 0; + while (!is_null(xs)) { + i += 1; + xs = tail(xs); + } + return i; +} + +// map applies first arg f to the elements of the second argument, +// assumed to be a list. +// f is applied element-by-element: +// map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] +// map throws an exception if the second argument is not a list, +// and if the second argument is a non-empty list and the first +// argument is not a function. +// tslint:disable-next-line:ban-types +export function map(f, xs) { + return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); +} + +// build_list takes a non-negative integer n as first argument, +// and a function fun as second argument. +// build_list returns a list of n elements, that results from +// applying fun to the numbers from 0 to n-1. +// tslint:disable-next-line:ban-types +export function build_list(n, fun) { + if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { + throw new Error( + 'build_list(n, fun) expects a positive integer as ' + + 'argument n, but encountered ' + + n + ); + } + + // tslint:disable-next-line:ban-types + function build(i, alreadyBuilt) { + if (i < 0) { + return alreadyBuilt; + } else { + return build(i - 1, pair(fun(i), alreadyBuilt)); + } + } + + return build(n - 1, null); +} + +// for_each applies first arg fun to the elements of the list passed as +// second argument. fun is applied element-by-element: +// for_each(fun,[1,[2,[]]]) results in the calls fun(1) and fun(2). +// for_each returns true. +// for_each throws an exception if the second argument is not a list, +// and if the second argument is a non-empty list and the +// first argument is not a function. +// tslint:disable-next-line:ban-types +export function for_each(fun, xs) { + if (!is_list(xs)) { + throw new Error( + 'for_each expects a list as argument xs, but encountered ' + xs + ); + } + for (; !is_null(xs); xs = tail(xs)) { + fun(head(xs)); + } + return true; +} + +// reverse reverses the argument list +// reverse throws an exception if the argument is not a list. +export function reverse(xs) { + if (!is_list(xs)) { + throw new Error( + 'reverse(xs) expects a list as argument xs, but encountered ' + xs + ); + } + let result: any = null; + for (; !is_null(xs); xs = tail(xs)) { + result = pair(head(xs), result); + } + return result; +} + +// append first argument list and second argument list. +// In the result, the [] at the end of the first argument list +// is replaced by the second argument list +// append throws an exception if the first argument is not a list +export function append(xs, ys) { + if (is_null(xs)) { + return ys; + } else { + return pair(head(xs), append(tail(xs), ys)); + } +} + +// member looks for a given first-argument element in a given +// second argument list. It returns the first postfix sublist +// that starts with the given element. It returns [] if the +// element does not occur in the list +export function member(v, xs) { + for (; !is_null(xs); xs = tail(xs)) { + if (head(xs) === v) { + return xs; + } + } + return null; +} + +// removes the first occurrence of a given first-argument element +// in a given second-argument list. Returns the original list +// if there is no occurrence. +export function remove(v, xs) { + if (is_null(xs)) { + return null; + } else { + if (v === head(xs)) { + return tail(xs); + } else { + return pair(head(xs), remove(v, tail(xs))); + } + } +} + +// Similar to remove. But removes all instances of v instead of just the first +export function remove_all(v, xs) { + if (is_null(xs)) { + return null; + } else { + if (v === head(xs)) { + return remove_all(v, tail(xs)); + } else { + return pair(head(xs), remove_all(v, tail(xs))); + } + } +} + +// for backwards-compatibility +// equal computes the structural equality +// over its arguments +export function equal(item1, item2) { + if (is_pair(item1) && is_pair(item2)) { + return equal(head(item1), head(item2)) && equal(tail(item1), tail(item2)); + } else { + return item1 === item2; + } +} + +// assoc treats the second argument as an association, +// a list of (index,value) pairs. +// assoc returns the first (index,value) pair whose +// index equal (using structural equality) to the given +// first argument v. Returns false if there is no such +// pair +export function assoc(v, xs) { + if (is_null(xs)) { + return false; + } else if (equal(v, head(head(xs)))) { + return head(xs); + } else { + return assoc(v, tail(xs)); + } +} + +// filter returns the sublist of elements of given list xs +// for which the given predicate function returns true. +// tslint:disable-next-line:ban-types +export function filter(pred, xs) { + if (is_null(xs)) { + return xs; + } else { + if (pred(head(xs))) { + return pair(head(xs), filter(pred, tail(xs))); + } else { + return filter(pred, tail(xs)); + } + } +} + +// enumerates numbers starting from start, +// using a step size of 1, until the number +// exceeds end. +export function enum_list(start, end) { + if (typeof start !== 'number') { + throw new Error( + 'enum_list(start, end) expects a number as argument start, but encountered ' + + start + ); + } + if (typeof end !== 'number') { + throw new Error( + 'enum_list(start, end) expects a number as argument start, but encountered ' + + end + ); + } + if (start > end) { + return null; + } else { + return pair(start, enum_list(start + 1, end)); + } +} + +// Returns the item in list lst at index n (the first item is at position 0) +export function list_ref(xs, n) { + if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { + throw new Error( + 'list_ref(xs, n) expects a positive integer as argument n, but encountered ' + + n + ); + } + for (; n > 0; --n) { + xs = tail(xs); + } + return head(xs); +} + +// accumulate applies given operation op to elements of a list +// in a right-to-left order, first apply op to the last element +// and an initial element, resulting in r1, then to the +// second-last element and r1, resulting in r2, etc, and finally +// to the first element and r_n-1, where n is the length of the +// list. +// accumulate(op,zero,list(1,2,3)) results in +// op(1, op(2, op(3, zero))) +export function accumulate(op, initial, sequence) { + if (is_null(sequence)) { + return initial; + } else { + return op(head(sequence), accumulate(op, initial, tail(sequence))); + } +} + +// set_head(xs,x) changes the head of given pair xs to be x, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function set_head(xs, x) { + if (is_pair(xs)) { + xs[0] = x; + return undefined; + } else { + throw new Error( + 'set_head(xs,x) expects a pair as argument xs, but encountered ' + xs + ); + } +} + +// set_tail(xs,x) changes the tail of given pair xs to be x, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function set_tail(xs, x) { + if (is_pair(xs)) { + xs[1] = x; + return undefined; + } else { + throw new Error( + 'set_tail(xs,x) expects a pair as argument xs, but encountered ' + xs + ); + } +} diff --git a/src/bundles/testing/mocks.ts b/src/bundles/testing/mocks.ts new file mode 100644 index 000000000..139f06aa9 --- /dev/null +++ b/src/bundles/testing/mocks.ts @@ -0,0 +1,32 @@ +import { pair, list, vector_to_list } from './list'; +/* eslint-disable import/prefer-default-export */ + +/** + * Mocks a function `fun`. + * @param fun The function to mock. + * @returns A pair whose head is the mocked function, and whose tail is another + * function that returns a list with details about the mocked function. + */ +export function mock_fn(fun: any) { + function details(count, retvals, arglist) { + return list( + 'times called', + count, + 'Return values', + retvals, + 'Arguments', + arglist + ); + } + let count = 0; + let retvals: any = null; + let arglist: any = null; + function fn(...args) { + count += 1; + const retval = fun.apply(fun, args); + retvals = pair(retval, retvals); + arglist = pair(vector_to_list(args), arglist); + return retval; + } + return pair(fn, () => details(count, retvals, arglist)); +} diff --git a/src/bundles/testing/types.ts b/src/bundles/testing/types.ts new file mode 100644 index 000000000..0d8102ccf --- /dev/null +++ b/src/bundles/testing/types.ts @@ -0,0 +1,29 @@ +export type ErrorLogger = ( + error: string[] | string, + isSlangError?: boolean +) => void; +export type Test = () => void; +export type TestSuite = () => void; +export type TestContext = { + describe: (msg: string, tests: TestSuite) => Results; + it: (msg: string, test: Test) => void; + // This holds the result of a single suite and is cleared on every run + suiteResults: SuiteResult; + // This holds the results of the entire suite + allResults: Results; + runtime: number; +}; +export type TestResult = { + name: string; + error: string; +}; +export type SuiteResult = { + name: string; + results: TestResult[]; + total: number; + passed: number; +}; +export type Results = { + results: SuiteResult[]; + toReplString: () => string; +}; diff --git a/src/tabs/Testing/index.tsx b/src/tabs/Testing/index.tsx new file mode 100644 index 000000000..2c9d70ce3 --- /dev/null +++ b/src/tabs/Testing/index.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import type { Results, SuiteResult } from '../../bundles/testing/types'; + +/** + * Tab for unit tests. + * @author Jia Xiaodong + */ + +type Props = { + result: any; +}; + +class TestSuitesTab extends React.PureComponent { + /** + * Converts the results of a test suite run into a table format in its own div. + */ + private static suiteResultToDiv(suiteResult: SuiteResult) { + const { name, results, total, passed } = suiteResult; + const colfixed = { + border: '1px solid gray', + overflow: 'hidden', + width: 200, + }; + const colauto = { + border: '1px solid gray', + overflow: 'hidden', + width: 'auto', + }; + + const rows = results.map(({ name: testname, error }, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {testname} + {error || 'Passed.'} + + )); + + const tablestyle = { + 'table-layout': 'fixed', + width: '100%', + }; + const table = ( + + + + + + + + {rows} +
Test caseMessages
+ ); + + const suitestyle = { + border: '1px solid white', + padding: 5, + margin: 5, + }; + return ( +
+

+ {name} +

+

+ Passed testcases: {passed}/{total} +

+ {table} +
+ ); + } + + public render() { + const { result: res } = this.props; + const block = res.results.map((suiteresult: SuiteResult) => + TestSuitesTab.suiteResultToDiv(suiteresult) + ); + + return ( +
+

The following is a report of your tests.

+ {block} +
+ ); + } +} + +export default { + /** + * This function will be called to determine if the component will be + * rendered. + * @param {DebuggerContext} context + * @returns {boolean} + */ + toSpawn: (context: any): boolean => { + function valid(value: any): value is Results { + try { + return ( + value instanceof Object && + Array.isArray(value.results) && + Array.isArray(value.results[0].results) + ); + } catch (e) { + return false; + } + } + return valid(context.result.value); + }, + + /** + * This function will be called to render the module tab in the side contents + * on Source Academy frontend. + * @param {DebuggerContext} context + */ + // eslint-disable-next-line react/destructuring-assignment + body: (context: any) => , + + /** + * The Tab's icon tooltip in the side contents on Source Academy frontend. + */ + label: 'Test suites', + + /** + * BlueprintJS IconName element's name, used to render the icon which will be + * displayed in the side contents panel. + * @see https://blueprintjs.com/docs/#icons + */ + iconName: 'lab-test', +};