diff --git a/devserver/package.json b/devserver/package.json index 6994f0c66b..d8205175b8 100644 --- a/devserver/package.json +++ b/devserver/package.json @@ -10,13 +10,13 @@ }, "dependencies": { "@blueprintjs/core": "^6.0.0", - "@blueprintjs/icons": "^6.0.0", "@commander-js/extra-typings": "^14.0.0", "@sourceacademy/modules-lib": "workspace:^", "@vitejs/plugin-react": "^6.0.1", "ace-builds": "^1.25.1", "classnames": "^2.3.1", "commander": "^14.0.0", + "es-toolkit": "^1.44.0", "js-slang": "^1.0.85", "re-resizable": "^6.9.11", "react": "^18.3.1", @@ -37,9 +37,6 @@ "vitest": "4.1.0", "vitest-browser-react": "^2.1.0" }, - "peerDependencies": { - "es-toolkit": "^1.44.0" - }, "scripts": { "dev": "vite", "lint": "eslint src", diff --git a/devserver/src/components/Playground.tsx b/devserver/src/components/Playground.tsx index 763a9daa4f..96f567ac05 100644 --- a/devserver/src/components/Playground.tsx +++ b/devserver/src/components/Playground.tsx @@ -1,12 +1,12 @@ import { Button, Classes, Intent, OverlayToaster, Popover, Tooltip, type ToastProps } from '@blueprintjs/core'; -import { Settings } from '@blueprintjs/icons'; import classNames from 'classnames'; +import { throttle } from 'es-toolkit'; import { SourceDocumentation, getNames, runInContext, type Context } from 'js-slang'; // Importing this straight from js-slang doesn't work for whatever reason import createContext from 'js-slang/dist/createContext'; +import { Chapter, Variant } from 'js-slang/dist/langs'; import { ModuleInternalError } from 'js-slang/dist/modules/errors'; import { setModulesStaticURL } from 'js-slang/dist/modules/loader'; -import { Chapter, Variant } from 'js-slang/dist/types'; import { stringify } from 'js-slang/dist/utils/stringify'; import React from 'react'; import mockModuleContext from '../mockModuleContext'; @@ -18,6 +18,7 @@ import { ControlBarRefreshButton } from './controlBar/ControlBarRefreshButton'; import { ControlBarRunButton } from './controlBar/ControlBarRunButton'; import testTabContent from './sideContent/TestTab'; import loadDynamicTabs from './sideContent/importers'; +import { getBundleDocsUsingVite, getBundleUsingVite, getModulesManifest } from './sideContent/importers/importers'; import type { SideContentTab } from './sideContent/types'; const refreshSuccessToast: ToastProps = { @@ -49,6 +50,10 @@ const createContextHelper = (onConsoleLog: (arg: string) => void) => { return tempContext; }; +const updateEditorLocalStorageValue = throttle((newValue: string) => { + localStorage.setItem('editorValue', newValue); +}, 100); + const Playground: React.FC = () => { const consoleLogs = React.useRef([]); const [moduleBackend, setModuleBackend] = React.useState(null); @@ -61,8 +66,7 @@ const Playground: React.FC = () => { } }, []); - const [useCompiledTabs, setUseCompiledTabs] = React.useState(!!localStorage.getItem('compiledTabs')); - + const [useCompiled, setUseCompiled] = React.useState(!!localStorage.getItem('useCompiled')); const [dynamicTabs, setDynamicTabs] = React.useState([]); const [selectedTabId, setSelectedTab] = React.useState(testTabContent.id); const [codeContext, setCodeContext] = React.useState(createContextHelper(str => consoleLogs.current.push(str))); @@ -72,6 +76,9 @@ const Playground: React.FC = () => { const toaster = React.useRef(null); + const manifestImporter = useCompiled ? undefined : getModulesManifest; + const docsImporter = useCompiled ? undefined : getBundleDocsUsingVite; + const showToast = (props: ToastProps) => { if (toaster.current) { toaster.current.show({ @@ -81,43 +88,41 @@ const Playground: React.FC = () => { } }; - const getAutoComplete = React.useCallback((row: number, col: number, callback: any) => { - getNames(editorValue, row, col, codeContext) - .then(([editorNames, displaySuggestions]) => { - if (!displaySuggestions) { - callback(); - return; - } + const getAutoComplete = async (row: number, col: number, callback: any) => { + const [editorNames, displaySuggestions] = await getNames(editorValue, row, col, codeContext, { manifestImporter, docsImporter }); + if (!displaySuggestions) { + callback(); + return; + } - const editorSuggestions = editorNames.map((editorName: any) => ({ - ...editorName, - caption: editorName.name, - value: editorName.name, - score: editorName.score ? editorName.score + 1000 : 1000, - name: undefined - })); + const editorSuggestions = editorNames.map((editorName: any) => ({ + ...editorName, + caption: editorName.name, + value: editorName.name, + score: editorName.score ? editorName.score + 1000 : 1000, + name: undefined + })); - const builtins: Record = SourceDocumentation.builtins[Chapter.SOURCE_4]; - const builtinSuggestions = Object.entries(builtins) - .map(([builtin, thing]) => ({ - ...thing, - caption: builtin, - value: builtin, - score: 100, - name: builtin, - docHTML: thing.description - })); + const builtins: Record = SourceDocumentation.builtins[Chapter.SOURCE_4]; + const builtinSuggestions = Object.entries(builtins) + .map(([builtin, thing]) => ({ + ...thing, + caption: builtin, + value: builtin, + score: 100, + name: builtin, + docHTML: thing.description + })); - callback(null, [ - ...builtinSuggestions, - ...editorSuggestions - ]); - }); - }, [editorValue, codeContext]); + callback(null, [ + ...builtinSuggestions, + ...editorSuggestions + ]); + }; const loadTabs = async () => { try { - const tabs = await loadDynamicTabs(codeContext, useCompiledTabs); + const tabs = await loadDynamicTabs(codeContext, useCompiled); setDynamicTabs(tabs); const newIds = tabs.map(({ id }) => id); @@ -127,52 +132,55 @@ const Playground: React.FC = () => { setSelectedTab(testTabContent.id); } setAlerts(newIds); - } catch (error) { showToast(errorToast); console.log(error); } }; - const evalCode = () => { + const evalCode = async () => { codeContext.errors = []; codeContext.moduleContexts = mockModuleContext.moduleContexts = {}; consoleLogs.current = []; - runInContext(editorValue, codeContext, { + const result = await runInContext(editorValue, codeContext, { importOptions: { - loadTabs: useCompiledTabs - } - }) - .then((result) => { - if (codeContext.errors.length > 0) { - showToast(errorToast); - } else { - loadTabs() - .then(() => showToast(evalSuccessToast)); + loadTabs: useCompiled, + sourceBundleImporter: useCompiled ? undefined : getBundleUsingVite, + docsImporter, + resolverOptions: { + manifestImporter, } + } + }); - if (result.status === 'finished') { - setReplOutput({ - type: 'result', - // code: editorValue, - consoleLogs: consoleLogs.current, - value: stringify(result.value) - }); - } else if (result.status === 'error') { - codeContext.errors.forEach(error => { - if (error instanceof ModuleInternalError) { - console.error(error.error); - } - }); + if (codeContext.errors.length > 0) { + showToast(errorToast); + } else { + loadTabs() + .then(() => showToast(evalSuccessToast)); + } - setReplOutput({ - type: 'errors', - errors: codeContext.errors, - consoleLogs: consoleLogs.current - }); + if (result.status === 'finished') { + setReplOutput({ + type: 'result', + // code: editorValue, + consoleLogs: consoleLogs.current, + value: stringify(result.value) + }); + } else if (result.status === 'error') { + codeContext.errors.forEach(error => { + if (error instanceof ModuleInternalError) { + console.error(error.error); } }); + + setReplOutput({ + type: 'errors', + errors: codeContext.errors, + consoleLogs: consoleLogs.current + }); + } }; const resetEditor = () => { @@ -206,22 +214,19 @@ const Playground: React.FC = () => { setModulesStaticURL(value); localStorage.setItem('backend', value); }} - useCompiledForTabs={useCompiledTabs} + useCompiled={useCompiled} onUseCompiledChange={value => { - setUseCompiledTabs(value); - localStorage.setItem('compiledTabs', value ? 'true' : ''); + setUseCompiled(value); + localStorage.setItem('useCompiled', value ? 'true' : ''); }} />} - renderTarget={({ isOpen: _isOpen, ...targetProps }) => { - return ( - - -

+

} + icon='arrow-right' tabIndex={0} onClick={() => { changeStep(currentStep + 1); diff --git a/lib/modules-lib/src/tabs/NumberSelector.tsx b/lib/modules-lib/src/tabs/NumberSelector.tsx index d2d87ff18b..48facdf83c 100644 --- a/lib/modules-lib/src/tabs/NumberSelector.tsx +++ b/lib/modules-lib/src/tabs/NumberSelector.tsx @@ -24,6 +24,8 @@ export type NumberSelectorProps = { /** * React component for wrapping around a {@link EditableText} to provide automatic * validation for number values + * + * @category Components */ export default function NumberSelector({ value, diff --git a/lib/modules-lib/src/tabs/PlayButton.tsx b/lib/modules-lib/src/tabs/PlayButton.tsx index 3b76ec719e..9a948df277 100644 --- a/lib/modules-lib/src/tabs/PlayButton.tsx +++ b/lib/modules-lib/src/tabs/PlayButton.tsx @@ -1,23 +1,49 @@ -/* [Imports] */ -import { Icon, Tooltip } from '@blueprintjs/core'; -import { Pause, Play } from '@blueprintjs/icons'; +import { Icon, Tooltip, type IconProps } from '@blueprintjs/core'; import ButtonComponent, { type ButtonComponentProps } from './ButtonComponent'; -/* [Exports] */ export type PlayButtonProps = ButtonComponentProps & { isPlaying: boolean; + + /** + * Tooltip string for the button when `isPlaying` is true. Defaults to `Pause`. + */ + playingText?: string; + + /** + * Tooltip string for the button when `isPlaying` is false. Defaults to `Play`. + */ + pausedText?: string; + + /** + * Icon for the button when `isPlaying` is true. Defaults to `pause`. + */ + playingIcon?: IconProps['icon']; + + /** + * Icon for the button when `isPlaying` is false. Defaults to `play`. + */ + pausedIcon?: IconProps['icon']; }; -/* [Main] */ -export default function PlayButton(props: PlayButtonProps) { +/** + * A {@link ButtonComponent|Button} that toggles between two states: playing and not playing. + * + * @category Components + */ +export default function PlayButton({ + playingText = 'Pause', + playingIcon = 'pause', + pausedText = 'Play', + pausedIcon = 'play', + isPlaying, + ...props +}: PlayButtonProps) { return - : } - /> + ; } diff --git a/lib/modules-lib/src/tabs/WebGLCanvas.tsx b/lib/modules-lib/src/tabs/WebGLCanvas.tsx index 936e3a5a5e..ccc96c1593 100644 --- a/lib/modules-lib/src/tabs/WebGLCanvas.tsx +++ b/lib/modules-lib/src/tabs/WebGLCanvas.tsx @@ -12,6 +12,8 @@ export type WebGLCanvasProps = DetailedHTMLProps( (props, ref) => { diff --git a/lib/modules-lib/src/tabs/index.ts b/lib/modules-lib/src/tabs/index.ts index 847b702fb4..38fd6cce1f 100644 --- a/lib/modules-lib/src/tabs/index.ts +++ b/lib/modules-lib/src/tabs/index.ts @@ -1,7 +1,7 @@ /** * Reusable React Components and styling utilities designed for use with SA Module Tabs - * @module Tabs - * @title Tabs Library + * @module tabs + * @disableGroups */ // This file is necessary so that the documentation generated by typedoc comes out in diff --git a/lib/modules-lib/src/tabs/useAnimation.ts b/lib/modules-lib/src/tabs/useAnimation.ts index 3445441e27..c74bb9592c 100644 --- a/lib/modules-lib/src/tabs/useAnimation.ts +++ b/lib/modules-lib/src/tabs/useAnimation.ts @@ -102,9 +102,9 @@ function useRerender() { /** * Hook for animations based around the `requestAnimationFrame` function. Calls the provided callback periodically. + * @category Hooks * @returns Animation Hook utilities */ - export function useAnimation({ animationDuration, autoLoop, @@ -172,7 +172,7 @@ export function useAnimation({ * - Sets elapsed to 0 and draws the 0 frame to the canvas * - Sets lastFrameTimestamp to null * - Cancels the current animation request - * - If there was a an animation callback scheduled, call `requestFrame` again + * - If there was an animation callback scheduled, call `requestFrame` again */ function reset() { setElapsed(0); diff --git a/lib/modules-lib/src/tabs/utils.ts b/lib/modules-lib/src/tabs/utils.ts index 73258e564c..d8758b3379 100644 --- a/lib/modules-lib/src/tabs/utils.ts +++ b/lib/modules-lib/src/tabs/utils.ts @@ -2,6 +2,7 @@ import type { DebuggerContext, ModuleSideContent } from '../types'; /** * Helper function for extracting the state object for your bundle + * @category Utilities * @template T The type of your bundle's state object * @param debuggerContext DebuggerContext as returned by the frontend * @param name Name of your bundle @@ -13,6 +14,7 @@ export function getModuleState(debuggerContext: DebuggerContext, name: string /** * Helper for typing tabs + * @category Utilities */ export function defineTab(tab: T) { return tab; diff --git a/lib/modules-lib/src/types/index.ts b/lib/modules-lib/src/types/index.ts index 82bea25336..583e90872f 100644 --- a/lib/modules-lib/src/types/index.ts +++ b/lib/modules-lib/src/types/index.ts @@ -1,4 +1,4 @@ -import type { IconName } from '@blueprintjs/icons'; +import type { IconName } from '@blueprintjs/core'; import type { Context } from 'js-slang'; import type React from 'react'; diff --git a/lib/modules-lib/src/utilities.ts b/lib/modules-lib/src/utilities.ts index f1641ff242..60d22dacaa 100644 --- a/lib/modules-lib/src/utilities.ts +++ b/lib/modules-lib/src/utilities.ts @@ -4,6 +4,7 @@ * @title Utilities */ +import { InvalidCallbackError, InvalidNumberParameterError, type InvalidNumberParameterErrorOptions } from './errors'; import type { DebuggerContext } from './types'; /** @@ -30,6 +31,11 @@ export function radiansToDegrees(radians: number): number { * @returns Tuple of three numbers representing the R, G and B components */ export function hexToColor(hex: string, func_name?: string): [r: number, g: number, b: number] { + if (typeof hex !== 'string') { + func_name = func_name ?? hexToColor.name; + throw new Error(`${func_name}: Expected a string, got ${typeof hex}`); + } + const regex = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/igu; const groups = regex.exec(hex); @@ -72,8 +78,10 @@ type TupleOfLengthHelper = export type TupleOfLength = TupleOfLengthHelper; /** - * Type guard for checking that a function has the specified number of parameters. Of course at runtime parameter types - * are not checked, so this is only useful when combined with TypeScript types. + * Type guard for checking that the provided value is a function and that it has the specified number of parameters. + * Of course at runtime parameter types are not checked, so this is only useful when combined with TypeScript types. + * + * If the function's length property is undefined, the parameter count check is skipped. */ export function isFunctionOfLength any>(f: (...args: any) => any, l: Parameters['length']): f is T; export function isFunctionOfLength(f: unknown, l: T): f is (...args: TupleOfLength) => unknown; @@ -81,3 +89,117 @@ export function isFunctionOfLength(f: unknown, l: number) { // TODO: Need a variation for rest parameters return typeof f === 'function' && f.length === l; } + +/** + * Assertion version of {@link isFunctionOfLength} + * + * @param f Value to validate + * @param l Number of parameters that `f` is expected to have + * @param func_name Function within which the validation is occurring + * @param type_name Optional alias for the function type + * @param param_name Name of the parameter that's being validated + */ +export function assertFunctionOfLength any>( + f: (...args: any) => any, + l: Parameters['length'], + func_name: string, + type_name?: string, + param_name?: string +): asserts f is T; +export function assertFunctionOfLength( + f: unknown, + l: T, + func_name: string, + type_name?: string, + param_name?: string +): asserts f is (...args: TupleOfLength) => unknown; +export function assertFunctionOfLength( + f: unknown, + l: number, + func_name: string, + type_name?: string, + param_name?: string +) { + if (!isFunctionOfLength(f, l)) { + throw new InvalidCallbackError(type_name ?? l, f, func_name, param_name); + } +} + +/** + * Function for checking if a given value is a number and that it also potentially satisfies a bunch of other criteria: + * - Within a given range of [min, max] + * - Is an integer + * - Is not NaN + */ +export function isNumberWithinRange(value: unknown, min?: number, max?: number, integer?: boolean): value is number; +export function isNumberWithinRange(value: unknown, options: InvalidNumberParameterErrorOptions): value is number; +export function isNumberWithinRange( + value: unknown, + arg0?: InvalidNumberParameterErrorOptions | number, + max?: number, + integer: boolean = true +): value is number { + let options: InvalidNumberParameterErrorOptions; + + if (typeof arg0 === 'number' || typeof arg0 === 'undefined') { + options = { + min: arg0, + max, + integer, + }; + } else { + options = arg0; + options.integer = arg0.integer ?? true; + } + + if (typeof value !== 'number' || Number.isNaN(value)) return false; + + if (options.max !== undefined && value > options.max) return false; + if (options.min !== undefined && value < options.min) return false; + + return !options.integer || Number.isInteger(value); +} + +interface AssertNumberWithinRangeOptions extends InvalidNumberParameterErrorOptions { + func_name: string; + param_name?: string; +} + +/** + * Assertion version of {@link isNumberWithinRange} + */ +export function assertNumberWithinRange( + value: unknown, + func_name: string, + min?: number, + max?: number, + integer?: boolean, + param_name?: string +): asserts value is number; +export function assertNumberWithinRange(value: unknown, options: AssertNumberWithinRangeOptions): asserts value is number; +export function assertNumberWithinRange( + value: unknown, + arg0: AssertNumberWithinRangeOptions | string, + min?: number, + max?: number, + integer?: boolean, + param_name?: string +): asserts value is number { + let options: AssertNumberWithinRangeOptions; + + if (typeof arg0 === 'string') { + options = { + func_name: arg0, + min, + max, + integer: integer ?? true, + param_name + }; + } else { + options = arg0; + } + + if (!isNumberWithinRange(value, options)) { + throw new InvalidNumberParameterError(value, options, options.func_name, options.param_name); + } +} diff --git a/lib/modules-lib/typedoc.config.js b/lib/modules-lib/typedoc.config.js index 42650769be..2ce2b739e4 100644 --- a/lib/modules-lib/typedoc.config.js +++ b/lib/modules-lib/typedoc.config.js @@ -1,5 +1,7 @@ import { OptionDefaults } from 'typedoc'; +// typedoc options reference: https://typedoc.org/documents/Options.html + /** * @type { * import('typedoc').TypeDocOptions & @@ -23,6 +25,15 @@ const typedocOptions = { readme: 'none', router: 'module', skipErrorChecking: true, + externalSymbolLinkMappings: { + '@blueprintjs/core': { + EditableText: 'https://blueprintjs.com/docs/#core/components/editable-text', + Switch: 'https://blueprintjs.com/docs/#core/components/switch' + }, + 'js-slang': { + RuntimeSourceError: '#' + } + }, // This lets us define some custom block tags blockTags: [ @@ -42,9 +53,17 @@ const typedocOptions = { parametersFormat: 'htmlTable', typeAliasPropertiesFormat: 'htmlTable', useCodeBlocks: true, + + // Organizational Options + categorizeByGroup: true, + categoryOrder: ['*', 'Other'], + navigation: { + includeCategories: true, + includeGroups: false + }, sort: [ 'alphabetical', - 'kind' + 'kind', ] }; diff --git a/lib/modules-lib/vitest.config.ts b/lib/modules-lib/vitest.config.ts index 3f4642b168..560815f49d 100644 --- a/lib/modules-lib/vitest.config.ts +++ b/lib/modules-lib/vitest.config.ts @@ -12,8 +12,9 @@ export default mergeConfig( include: [ '@blueprintjs/core', '@blueprintjs/icons', - 'es-toolkit', 'vitest-browser-react', + 'js-slang/dist/errors/runtimeSourceError', + 'js-slang/dist/utils/stringify' ] }, plugins: [react()], diff --git a/src/archive/.vscode/settings.json b/src/archive/.vscode/settings.json new file mode 100644 index 0000000000..f2370a27cb --- /dev/null +++ b/src/archive/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsserver.enable": false +} \ No newline at end of file diff --git a/src/bundles/arcade_2d/package.json b/src/bundles/arcade_2d/package.json index 85e18de9be..b362d0a8d9 100644 --- a/src/bundles/arcade_2d/package.json +++ b/src/bundles/arcade_2d/package.json @@ -20,8 +20,9 @@ "lint": "buildtools lint .", "tsc": "buildtools tsc .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/binary_tree/package.json b/src/bundles/binary_tree/package.json index 9e68b5c26a..c59f9fed37 100644 --- a/src/bundles/binary_tree/package.json +++ b/src/bundles/binary_tree/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85" }, "devDependencies": { @@ -19,8 +20,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/binary_tree/src/__tests__/index.test.ts b/src/bundles/binary_tree/src/__tests__/index.test.ts index 4b7562ca24..53437a4042 100644 --- a/src/bundles/binary_tree/src/__tests__/index.test.ts +++ b/src/bundles/binary_tree/src/__tests__/index.test.ts @@ -9,7 +9,7 @@ describe(funcs.is_tree, () => { }); it('returns false when argument is a list of 4 elements', () => { - const arg = list(0, funcs.make_empty_tree(), funcs.make_empty_tree(), funcs.make_empty_tree()); + const arg = list(0, funcs.make_empty_tree(), funcs.make_empty_tree(), funcs.make_empty_tree()); expect(funcs.is_tree(arg)).toEqual(false); }); @@ -17,7 +17,7 @@ describe(funcs.is_tree, () => { const not_tree = list(0, 1, 2); expect(funcs.is_tree(not_tree)).toEqual(false); - const also_not_tree = list(1, not_tree, null); + const also_not_tree = list(1, not_tree, null); expect(funcs.is_tree(also_not_tree)).toEqual(false); }); @@ -47,13 +47,23 @@ describe(funcs.is_tree, () => { }); }); +describe(funcs.make_tree, () => { + it('throws an error when \'left\' is not a tree', () => { + expect(() => funcs.make_tree(0, 0 as any, null)).toThrow('make_tree: Expected binary tree for left, got 0.'); + }); + + it('throws an error when \'right\' is not a tree', () => { + expect(() => funcs.make_tree(0, null, 0 as any)).toThrow('make_tree: Expected binary tree for right, got 0.'); + }); +}); + describe(funcs.entry, () => { it('throws when argument is not a tree', () => { - expect(() => funcs.entry(0 as any)).toThrowError('entry expects binary tree, received: 0'); + expect(() => funcs.entry(0 as any)).toThrow('entry: Expected binary tree, got 0.'); }); it('throws when argument is an empty tree', () => { - expect(() => funcs.entry(null)).toThrowError('entry received an empty binary tree!'); + expect(() => funcs.entry(null)).toThrow('entry: Expected non-empty binary tree, got null.'); }); it('works', () => { @@ -64,11 +74,11 @@ describe(funcs.entry, () => { describe(funcs.left_branch, () => { it('throws when argument is not a tree', () => { - expect(() => funcs.left_branch(0 as any)).toThrowError('left_branch expects binary tree, received: 0'); + expect(() => funcs.left_branch(0 as any)).toThrow('left_branch: Expected binary tree, got 0.'); }); it('throws when argument is an empty tree', () => { - expect(() => funcs.left_branch(null)).toThrowError('left_branch received an empty binary tree!'); + expect(() => funcs.left_branch(null)).toThrow('left_branch: Expected non-empty binary tree, got null.'); }); it('works (simple)', () => { @@ -87,11 +97,11 @@ describe(funcs.left_branch, () => { describe(funcs.right_branch, () => { it('throws when argument is not a tree', () => { - expect(() => funcs.right_branch(0 as any)).toThrowError('right_branch expects binary tree, received: 0'); + expect(() => funcs.right_branch(0 as any)).toThrow('right_branch: Expected binary tree, got 0.'); }); it('throws when argument is an empty tree', () => { - expect(() => funcs.right_branch(null)).toThrowError('right_branch received an empty binary tree!'); + expect(() => funcs.right_branch(null)).toThrow('right_branch: Expected non-empty binary tree, got null.'); }); it('works (simple)', () => { diff --git a/src/bundles/binary_tree/src/functions.ts b/src/bundles/binary_tree/src/functions.ts index 06f4507ca5..9f479edebe 100644 --- a/src/bundles/binary_tree/src/functions.ts +++ b/src/bundles/binary_tree/src/functions.ts @@ -1,4 +1,5 @@ -import { head, is_list, is_pair, list, tail } from 'js-slang/dist/stdlib/list'; +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { head, is_null, is_pair, tail } from 'js-slang/dist/stdlib/list'; import type { BinaryTree, EmptyBinaryTree, NonEmptyBinaryTree } from './types'; /** @@ -26,7 +27,15 @@ export function make_empty_tree(): BinaryTree { * @returns A binary tree */ export function make_tree(value: any, left: BinaryTree, right: BinaryTree): BinaryTree { - return list(value, left, right); + if (!is_tree(left)) { + throw new InvalidParameterTypeError('binary tree', left, make_tree.name, 'left'); + } + + if (!is_tree(right)) { + throw new InvalidParameterTypeError('binary tree', right, make_tree.name, 'right'); + } + + return [value, [left, [right, null]]]; } /** @@ -39,19 +48,18 @@ export function make_tree(value: any, left: BinaryTree, right: BinaryTree): Bina * ``` * @param value Value to be tested */ -export function is_tree(value: any): value is BinaryTree { - // TODO: value parameter should be of type unknown - if (!is_list(value)) return false; - +export function is_tree(value: unknown): value is BinaryTree { if (is_empty_tree(value)) return true; + if (!is_pair(value)) return false; + const left = tail(value); - if (!is_list(left) || !is_tree(head(left))) return false; + if (!is_pair(left) || !is_tree(head(left))) return false; const right = tail(left); if (!is_pair(right) || !is_tree(head(right))) return false; - return tail(right) === null; + return is_null(tail(right)); } /** @@ -65,17 +73,17 @@ export function is_tree(value: any): value is BinaryTree { * @param value Value to be tested * @returns bool */ -export function is_empty_tree(value: BinaryTree): value is EmptyBinaryTree { +export function is_empty_tree(value: unknown): value is EmptyBinaryTree { return value === null; } function throwIfNotNonEmptyTree(value: unknown, func_name: string): asserts value is NonEmptyBinaryTree { if (!is_tree(value)) { - throw new Error(`${func_name} expects binary tree, received: ${value}`); + throw new InvalidParameterTypeError('binary tree', value, func_name); } if (is_empty_tree(value)) { - throw new Error(`${func_name} received an empty binary tree!`); + throw new InvalidParameterTypeError('non-empty binary tree', value, func_name); } } @@ -91,7 +99,7 @@ function throwIfNotNonEmptyTree(value: unknown, func_name: string): asserts valu */ export function entry(t: BinaryTree): any { throwIfNotNonEmptyTree(t, entry.name); - return t[0]; + return head(t); } /** @@ -106,7 +114,7 @@ export function entry(t: BinaryTree): any { */ export function left_branch(t: BinaryTree): BinaryTree { throwIfNotNonEmptyTree(t, left_branch.name); - return head(tail(t)); + return head(tail(t)!); } /** @@ -121,5 +129,5 @@ export function left_branch(t: BinaryTree): BinaryTree { */ export function right_branch(t: BinaryTree): BinaryTree { throwIfNotNonEmptyTree(t, right_branch.name); - return head(tail(tail(t))); + return head(tail(tail(t)!)!); } diff --git a/src/bundles/communication/package.json b/src/bundles/communication/package.json index 76cda1bc53..fe40f82310 100644 --- a/src/bundles/communication/package.json +++ b/src/bundles/communication/package.json @@ -22,8 +22,9 @@ "test": "buildtools test --project .", "tsc": "buildtools tsc .", "lint": "buildtools lint .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/copy_gc/package.json b/src/bundles/copy_gc/package.json index 7119962f82..1d72b2cb4f 100644 --- a/src/bundles/copy_gc/package.json +++ b/src/bundles/copy_gc/package.json @@ -16,8 +16,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/csg/package.json b/src/bundles/csg/package.json index bd88d203d9..9f74c91090 100644 --- a/src/bundles/csg/package.json +++ b/src/bundles/csg/package.json @@ -24,8 +24,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/csg/src/functions.ts b/src/bundles/csg/src/functions.ts index b50f41fba6..ea14b97e21 100644 --- a/src/bundles/csg/src/functions.ts +++ b/src/bundles/csg/src/functions.ts @@ -13,13 +13,7 @@ import { import { extrudeLinear } from '@jscad/modeling/src/operations/extrusions'; import { serialize } from '@jscad/stl-serializer'; import { degreesToRadians, hexToColor } from '@sourceacademy/modules-lib/utilities'; -import { - head, - is_list, - list, - tail, - type List -} from 'js-slang/dist/stdlib/list'; +import { is_list, list_to_vector, vector_to_list, type List } from 'js-slang/dist/stdlib/list'; import save from 'save-file'; import { Core } from './core'; import type { Solid } from './jscad/types'; @@ -44,20 +38,6 @@ import { When a user passes in a List, we convert it to arrays here so that the rest of the underlying code is free to operate with arrays. */ -export function listToArray(l: List): Operable[] { - const operables: Operable[] = []; - while (l !== null) { - const operable: Operable = head(l); - operables.push(operable); - l = tail(l); - } - return operables; -} - -export function arrayToList(array: Operable[]): List { - return list(...array); -} - /* [Exports] */ // [Variables - Colors] @@ -544,12 +524,12 @@ export function scale( * * @category Utilities */ -export function group(operables: List): Group { +export function group(operables: List): Group { if (!is_list(operables)) { throw new Error('Only lists of Operables can be grouped'); } - return new Group(listToArray(operables)); + return new Group(list_to_vector(operables)); } /** @@ -566,7 +546,7 @@ export function ungroup(g: Group): List { throw new Error('Only Groups can be ungrouped'); } - return arrayToList(g.ungroup()); + return vector_to_list(g.ungroup()); } /** diff --git a/src/bundles/curve/package.json b/src/bundles/curve/package.json index 5a047f83c8..78ad3d2b52 100644 --- a/src/bundles/curve/package.json +++ b/src/bundles/curve/package.json @@ -25,8 +25,9 @@ "prepare": "yarn tsc", "test": "buildtools test --project .", "tsc": "buildtools tsc .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/curve/src/__tests__/curve.test.ts b/src/bundles/curve/src/__tests__/curve.test.ts index ae0ca283b9..da477030ed 100644 --- a/src/bundles/curve/src/__tests__/curve.test.ts +++ b/src/bundles/curve/src/__tests__/curve.test.ts @@ -1,3 +1,4 @@ +import { InvalidCallbackError, InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import { stringify } from 'js-slang/dist/utils/stringify'; import { describe, expect, it, test } from 'vitest'; import type { Color, Curve } from '../curves_webgl'; @@ -28,11 +29,7 @@ describe('Ensure that invalid curves and animations error gracefully', () => { test('Curve that takes multiple parameters should throw error', () => { expect(() => drawers.draw_connected(200)(((t, u) => funcs.make_point(t, u)) as any)) - .toThrow( - 'The provided curve is not a valid Curve function. ' + - 'A Curve function must take exactly one parameter (a number t between 0 and 1) ' + - 'and return a Point or 3D Point depending on whether it is a 2D or 3D curve.' - ); + .toThrow(InvalidCallbackError); }); test('Using 3D render functions with animate_curve should throw errors', () => { @@ -77,23 +74,28 @@ describe('Render function creators', () => { }); it('throws when numPoints is less than 0', () => { - expect(() => func(0)).toThrowError( - `${name}: The number of points must be a positive integer less than or equal to 65535. Got: 0` + expect(() => func(-1)).toThrowError( + `${name}: Expected integer between 0 and 65535, got -1.` ); }); it('throws when numPoints is greater than 65535', () => { expect(() => func(70000)).toThrowError( - `${name}: The number of points must be a positive integer less than or equal to 65535. Got: 70000` + `${name}: Expected integer between 0 and 65535, got 70000.` ); }); it('throws when numPoints is not an integer', () => { expect(() => func(3.14)).toThrowError( - `${name}: The number of points must be a positive integer less than or equal to 65535. Got: 3.14` + `${name}: Expected integer between 0 and 65535, got 3.14.` ); }); + test('returned render function throws when called with an invalid curve', () => { + const creator = func(200); + expect(() => creator(0 as any)).toThrowError(`${name}: Expected Curve, got 0.`); + }); + test('returned render functions have nice string representations', () => { const renderFunc = func(250); if (renderFunc.is3D) { @@ -120,7 +122,8 @@ describe('Coloured Points', () => { }); it('throws when argument is not a point', () => { - expect(() => funcs.r_of(0 as any)).toThrowError('r_of expects a point as argument'); + expect(() => funcs.r_of(0 as any)).toThrowError(InvalidParameterTypeError); + // expect(() => funcs.r_of(0 as any)).toThrowError('r_of: Expected Point, got 0'); }); }); @@ -131,7 +134,8 @@ describe('Coloured Points', () => { }); it('throws when argument is not a point', () => { - expect(() => funcs.g_of(0 as any)).toThrowError('g_of expects a point as argument'); + expect(() => funcs.g_of(0 as any)).toThrowError(InvalidParameterTypeError); + // expect(() => funcs.g_of(0 as any)).toThrowError('g_of: Expected Point, got 0'); }); }); @@ -142,7 +146,8 @@ describe('Coloured Points', () => { }); it('throws when argument is not a point', () => { - expect(() => funcs.b_of(0 as any)).toThrowError('b_of expects a point as argument'); + expect(() => funcs.b_of(0 as any)).toThrowError(InvalidParameterTypeError); + // expect(() => funcs.b_of(0 as any)).toThrowError('b_of: Expected Point, got 0'); }); }); }); @@ -156,6 +161,11 @@ describe(funcs.unit_line_at, () => { expect(y).toEqual(0.5); } }); + + it('throws an error when argument is not a number', () => { + expect(() => funcs.unit_line_at('a' as any)) + .toThrowError('unit_line_at: Expected number, got "a".'); + }); }); describe(funcs.translate, () => { @@ -193,6 +203,11 @@ describe(funcs.translate, () => { expect(g).toBeCloseTo(0.5); } }); + + test('toReplString representation', () => { + const transformer = funcs.translate(1, 1, 1); + expect(stringify(transformer)).toEqual(''); + }); }); describe(funcs.scale, () => { @@ -222,6 +237,11 @@ describe(funcs.scale, () => { expect(g).toBeCloseTo(0.5); } }); + + test('toReplString representation', () => { + const transformer = funcs.scale(1, 1, 1); + expect(stringify(transformer)).toEqual(''); + }); }); describe(funcs.put_in_standard_position, () => { @@ -238,4 +258,9 @@ describe(funcs.put_in_standard_position, () => { expect(xn).toBeCloseTo(1, 1); expect(yn).toBeCloseTo(0, 1); }); + + test('toReplString representation', () => { + const transformer = funcs.put_in_standard_position(x => funcs.make_point(x, x)); + expect(stringify(transformer)).toEqual(''); + }); }); diff --git a/src/bundles/curve/src/drawers.ts b/src/bundles/curve/src/drawers.ts index 1aa95b4593..ad565bfea9 100644 --- a/src/bundles/curve/src/drawers.ts +++ b/src/bundles/curve/src/drawers.ts @@ -1,4 +1,4 @@ -import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; +import { assertFunctionOfLength, assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; import context from 'js-slang/context'; import { generateCurve, type Curve, type CurveDrawn } from './curves_webgl'; @@ -18,7 +18,7 @@ context.moduleContexts.curve.state = { drawnCurves }; -function createDrawFunction( +function getRenderFunctionCreator( scaleMode: ScaleMode, drawMode: DrawMode, space: CurveSpace, @@ -26,21 +26,10 @@ function createDrawFunction( name: string ): RenderFunctionCreator { function renderFuncCreator(numPoints: number) { - if (numPoints <= 0 || numPoints > 65535 || !Number.isInteger(numPoints)) { - throw new Error( - `${name}: The number of points must be a positive integer less than or equal to 65535. ` + - `Got: ${numPoints}` - ); - } + assertNumberWithinRange(numPoints, name, 0, 65535); function renderFunc(curve: Curve) { - if (!isFunctionOfLength(curve, 1)) { - throw new Error( - 'The provided curve is not a valid Curve function. ' + - 'A Curve function must take exactly one parameter (a number t between 0 and 1) ' + - 'and return a Point or 3D Point depending on whether it is a 2D or 3D curve.' - ); - } + assertFunctionOfLength(curve, 1, name, 'Curve'); const curveDrawn = generateCurve( scaleMode, @@ -59,12 +48,7 @@ function createDrawFunction( } renderFunc.is3D = space === '3D'; - - const stringifier = () => `<${space === '3D' ? '3D' : ''}RenderFunction(${numPoints})>`; - - // Retain both properties for compatibility - renderFunc.toString = stringifier; - renderFunc.toReplString = stringifier; + renderFunc.toReplString = () => `<${space === '3D' ? '3D' : ''}RenderFunction(${numPoints})>`; return renderFunc; } @@ -89,10 +73,10 @@ function createDrawFunction( /** @hidden */ export class RenderFunctionCreators { @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_connected = createDrawFunction('none', 'lines', '2D', false, 'draw_connected'); + static draw_connected = getRenderFunctionCreator('none', 'lines', '2D', false, 'draw_connected'); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_connected_full_view = createDrawFunction( + static draw_connected_full_view = getRenderFunctionCreator( 'stretch', 'lines', '2D', @@ -101,7 +85,7 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_connected_full_view_proportional = createDrawFunction( + static draw_connected_full_view_proportional = getRenderFunctionCreator( 'fit', 'lines', '2D', @@ -110,10 +94,10 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_points = createDrawFunction('none', 'points', '2D', false, 'draw_points'); + static draw_points = getRenderFunctionCreator('none', 'points', '2D', false, 'draw_points'); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_points_full_view = createDrawFunction( + static draw_points_full_view = getRenderFunctionCreator( 'stretch', 'points', '2D', @@ -122,7 +106,7 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_points_full_view_proportional = createDrawFunction( + static draw_points_full_view_proportional = getRenderFunctionCreator( 'fit', 'points', '2D', @@ -131,7 +115,7 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_3D_connected = createDrawFunction( + static draw_3D_connected = getRenderFunctionCreator( 'none', 'lines', '3D', @@ -140,7 +124,7 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_3D_connected_full_view = createDrawFunction( + static draw_3D_connected_full_view = getRenderFunctionCreator( 'stretch', 'lines', '3D', @@ -149,7 +133,7 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_3D_connected_full_view_proportional = createDrawFunction( + static draw_3D_connected_full_view_proportional = getRenderFunctionCreator( 'fit', 'lines', '3D', @@ -158,10 +142,10 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_3D_points = createDrawFunction('none', 'points', '3D', false, 'draw_3D_points'); + static draw_3D_points = getRenderFunctionCreator('none', 'points', '3D', false, 'draw_3D_points'); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_3D_points_full_view = createDrawFunction( + static draw_3D_points_full_view = getRenderFunctionCreator( 'stretch', 'points', '3D', @@ -170,7 +154,7 @@ export class RenderFunctionCreators { ); @functionDeclaration('numPoints: number', '(func: Curve) => Curve') - static draw_3D_points_full_view_proportional = createDrawFunction( + static draw_3D_points_full_view_proportional = getRenderFunctionCreator( 'fit', 'points', '3D', @@ -396,6 +380,8 @@ class CurveAnimators { throw new Error(`${animate_curve.name} cannot be used with 3D draw function!`); } + assertFunctionOfLength(func, 1, CurveAnimators.animate_curve.name, 'CurveAnimation'); + const anim = new AnimatedCurve(duration, fps, func, drawer, false); drawnCurves.push(anim); return anim; @@ -412,6 +398,8 @@ class CurveAnimators { throw new Error(`${animate_3D_curve.name} cannot be used with 2D draw function!`); } + assertFunctionOfLength(func, 1, CurveAnimators.animate_3D_curve.name, 'CurveAnimation'); + const anim = new AnimatedCurve(duration, fps, func, drawer, true); drawnCurves.push(anim); return anim; @@ -425,6 +413,7 @@ class CurveAnimators { * @param drawer Draw function to the generated curves with * @param func Curve generating function. Takes in a timestamp value and returns a curve * @returns Curve Animation + * @function */ export const animate_curve = CurveAnimators.animate_curve; @@ -435,5 +424,6 @@ export const animate_curve = CurveAnimators.animate_curve; * @param drawer Draw function to the generated curves with * @param func Curve generating function. Takes in a timestamp value and returns a curve * @returns 3D Curve Animation + * @function */ export const animate_3D_curve = CurveAnimators.animate_3D_curve; diff --git a/src/bundles/curve/src/functions.ts b/src/bundles/curve/src/functions.ts index ff13a3a224..bc1b0c8e51 100644 --- a/src/bundles/curve/src/functions.ts +++ b/src/bundles/curve/src/functions.ts @@ -1,3 +1,5 @@ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { assertFunctionOfLength, assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; import { clamp } from 'es-toolkit'; import { Point, type Curve } from './curves_webgl'; import { functionDeclaration } from './type_interface'; @@ -5,18 +7,44 @@ import type { CurveTransformer } from './types'; function throwIfNotPoint(obj: unknown, func_name: string): asserts obj is Point { if (!(obj instanceof Point)) { - throw new Error(`${func_name} expects a point as argument`); + throw new InvalidParameterTypeError('Point', obj, func_name); } } +function throwIfNotCurve(obj: unknown, func_name: string, param_name?: string): asserts obj is Curve { + assertFunctionOfLength(obj, 1, func_name, 'Curve', param_name); +} + +function defineCurveTransformer(f: (arg: Curve) => Curve, name?: string): CurveTransformer { + const transformer: CurveTransformer = curve => { + throwIfNotCurve(curve, 'CurveTransformer'); + return f(curve); + }; + + transformer.toReplString = () => ''; + + if (name !== undefined) { + Object.defineProperty(transformer, 'name', { value: name }); + } + + return transformer; +} + class CurveFunctions { @functionDeclaration('x: number, y: number', 'Point') static make_point(x: number, y: number): Point { + assertNumberWithinRange(x, { func_name: CurveFunctions.make_point.name, param_name: 'x', integer: false }); + assertNumberWithinRange(y, { func_name: CurveFunctions.make_point.name, param_name: 'y', integer: false }); + return new Point(x, y, 0, [0, 0, 0, 1]); } @functionDeclaration('x: number, y: number, z: number', 'Point') static make_3D_point(x: number, y: number, z: number): Point { + assertNumberWithinRange(x, { func_name: CurveFunctions.make_3D_point.name, param_name: 'x', integer: false }); + assertNumberWithinRange(y, { func_name: CurveFunctions.make_3D_point.name, param_name: 'y', integer: false }); + assertNumberWithinRange(z, { func_name: CurveFunctions.make_3D_point.name, param_name: 'z', integer: false }); + return new Point(x, y, z, [0, 0, 0, 1]); } @@ -53,6 +81,9 @@ class CurveFunctions { @functionDeclaration('curve1: Curve, curve2: Curve', 'Curve') static connect_ends(curve1: Curve, curve2: Curve): Curve { + throwIfNotCurve(curve1, CurveFunctions.connect_ends.name, 'curve1'); + throwIfNotCurve(curve2, CurveFunctions.connect_ends.name, 'curve2'); + const startPointOfCurve2 = curve2(0); const endPointOfCurve1 = curve1(1); return connect_rigidly( @@ -67,12 +98,19 @@ class CurveFunctions { @functionDeclaration('curve1: Curve, curve2: Curve', 'Curve') static connect_rigidly(curve1: Curve, curve2: Curve): Curve { - return (t) => (t < 1 / 2 ? curve1(2 * t) : curve2(2 * t - 1)); + throwIfNotCurve(curve1, CurveFunctions.connect_rigidly.name, 'curve1'); + throwIfNotCurve(curve2, CurveFunctions.connect_rigidly.name, 'curve2'); + + return t => (t < 0.5 ? curve1(2 * t) : curve2(2 * t - 1)); } @functionDeclaration('x0: number, y0: number, z0: number', '(c: Curve) => Curve') static translate(x0: number, y0: number, z0: number): CurveTransformer { - return curve => t => { + assertNumberWithinRange(x0, { func_name: CurveFunctions.translate.name, param_name: 'x0', integer: false }); + assertNumberWithinRange(y0, { func_name: CurveFunctions.translate.name, param_name: 'y0', integer: false }); + assertNumberWithinRange(z0, { func_name: CurveFunctions.translate.name, param_name: 'z0', integer: false }); + + return defineCurveTransformer(curve => t => { const ct = curve(t); return new Point( x0 + ct.x, @@ -80,14 +118,14 @@ class CurveFunctions { z0 + ct.z, [ct.color[0], ct.color[1], ct.color[2], 1] ); - }; + }); } @functionDeclaration('curve: Curve', 'Curve') - static invert: CurveTransformer = original => t => original(1 - t); + static invert: CurveTransformer = defineCurveTransformer(original => t => original(1 - t), 'invert'); @functionDeclaration('curve: Curve', 'Curve') - static put_in_standard_position: CurveTransformer = curve => { + static put_in_standard_position: CurveTransformer = defineCurveTransformer(curve => { const start_point = curve(0); const curve_started_at_origin = translate( -x_of(start_point), @@ -103,18 +141,23 @@ class CurveFunctions { )(curve_started_at_origin); const end_point_on_x_axis = x_of(curve_ended_at_x_axis(1)); return scale_proportional(1 / end_point_on_x_axis)(curve_ended_at_x_axis); - }; + }, 'put_in_standard_position'); @functionDeclaration('a: number, b: number, c: number', '(c: Curve) => Curve') static rotate_around_origin_3D(a: number, b: number, c: number): CurveTransformer { + assertNumberWithinRange(a, { func_name: CurveFunctions.rotate_around_origin_3D.name, integer: false, param_name: 'a' }); const cthx = Math.cos(a); const sthx = Math.sin(a); + + assertNumberWithinRange(b, { func_name: CurveFunctions.rotate_around_origin_3D.name, integer: false, param_name: 'b' }); const cthy = Math.cos(b); const sthy = Math.sin(b); + + assertNumberWithinRange(c, { func_name: CurveFunctions.rotate_around_origin_3D.name, integer: false, param_name: 'c' }); const cthz = Math.cos(c); const sthz = Math.sin(c); - return curve => t => { + return defineCurveTransformer(curve => t => { const ct = curve(t); const coord = [ct.x, ct.y, ct.z]; const mat = [ @@ -139,15 +182,18 @@ class CurveFunctions { zf += mat[2][i] * coord[i]; } return new Point(xf, yf, zf, [ct.color[0], ct.color[1], ct.color[2], 1]); - }; + }); } @functionDeclaration('a: number', '(c: Curve) => Curve') static rotate_around_origin(a: number): CurveTransformer { + assertNumberWithinRange(a, { func_name: CurveFunctions.rotate_around_origin.name, integer: false }); + // 1 args const cth = Math.cos(a); const sth = Math.sin(a); - return curve => t => { + + return defineCurveTransformer(curve => t => { const ct = curve(t); return new Point( cth * ct.x - sth * ct.y, @@ -155,12 +201,16 @@ class CurveFunctions { ct.z, [ct.color[0], ct.color[1], ct.color[2], 1] ); - }; + }); } @functionDeclaration('x: number, y: number, z: number', '(c: Curve) => Curve') static scale(x: number, y: number, z: number): CurveTransformer { - return curve => t => { + assertNumberWithinRange(x, { func_name: CurveFunctions.scale.name, param_name: 'x', integer: false }); + assertNumberWithinRange(y, { func_name: CurveFunctions.scale.name, param_name: 'y', integer: false }); + assertNumberWithinRange(z, { func_name: CurveFunctions.scale.name, param_name: 'z', integer: false }); + + return defineCurveTransformer(curve => t => { const ct = curve(t); return new Point( @@ -169,7 +219,7 @@ class CurveFunctions { z * ct.z, [ct.color[0], ct.color[1], ct.color[2], 1] ); - }; + }); } @functionDeclaration('s: number', '(c: Curve) => Curve') @@ -214,22 +264,19 @@ class CurveFunctions { } @functionDeclaration('t: number', 'Point') - static unit_circle: Curve = t => { - return make_point(Math.cos(2 * Math.PI * t), Math.sin(2 * Math.PI * t)); - }; + static unit_circle: Curve = t => make_point(Math.cos(2 * Math.PI * t), Math.sin(2 * Math.PI * t)); @functionDeclaration('t: number', 'Point') static unit_line: Curve = t => make_point(t, 0); @functionDeclaration('t: number', 'Curve') static unit_line_at(y: number): Curve { + assertNumberWithinRange(y, { func_name: CurveFunctions.unit_line_at.name, integer: false }); return t => make_point(t, y); } @functionDeclaration('t: number', 'Point') - static arc: Curve = t => { - return make_point(Math.sin(Math.PI * t), Math.cos(Math.PI * t)); - }; + static arc: Curve = t => make_point(Math.sin(Math.PI * t), Math.cos(Math.PI * t)); } /** @@ -238,6 +285,7 @@ class CurveFunctions { * @param x x-coordinate of new point * @param y y-coordinate of new point * @returns with x and y as coordinates + * @function * @example * ``` * const point = make_point(0.5, 0.5); @@ -251,6 +299,7 @@ export const make_point = CurveFunctions.make_point; * @param x x-coordinate of new point * @param y y-coordinate of new point * @param z z-coordinate of new point + * @function * @returns with x, y and z as coordinates * @example * ``` @@ -269,6 +318,7 @@ export const make_3D_point = CurveFunctions.make_3D_point; * @param r red component of new point * @param g green component of new point * @param b blue component of new point + * @function * @returns with x and y as coordinates, and r, g and b as RGB values * @example * ``` @@ -288,6 +338,7 @@ export const make_color_point = CurveFunctions.make_color_point; * @param r red component of new point * @param g green component of new point * @param b blue component of new point + * @function * @returns with x, y and z as coordinates, and r, g and b as RGB values * @example * ``` @@ -301,6 +352,7 @@ export const make_3D_color_point = CurveFunctions.make_3D_color_point; * * @param pt given point * @returns x-coordinate of the Point + * @function * @example * ``` * const point = make_color_point(1, 2, 3, 50, 100, 150); @@ -327,6 +379,7 @@ export const y_of = CurveFunctions.y_of; * * @param pt given point * @returns z-coordinate of the Point + * @function * @example * ``` * const point = make_color_point(1, 2, 3, 50, 100, 150); @@ -353,6 +406,7 @@ export const r_of = CurveFunctions.r_of; * * @param pt given point * @returns Green component of the Point as a value between [0,255] + * @function * @example * ``` * const point = make_color_point(1, 2, 3, 50, 100, 150); @@ -366,6 +420,7 @@ export const g_of = CurveFunctions.g_of; * * @param pt given point * @returns Blue component of the Point as a value between [0,255] + * @function * @example * ``` * const point = make_color_point(1, 2, 3, 50, 100, 150); @@ -409,6 +464,7 @@ export const translate = CurveFunctions.translate; * @param b given angle * @param c given angle * @returns function that takes a Curve and returns a Curve + * @function */ export const rotate_around_origin_3D = CurveFunctions.rotate_around_origin_3D; @@ -420,6 +476,7 @@ export const rotate_around_origin_3D = CurveFunctions.rotate_around_origin_3D; * * @param a given angle * @returns function that takes a Curve and returns a Curve + * @function */ export const rotate_around_origin = CurveFunctions.rotate_around_origin; @@ -433,6 +490,7 @@ export const rotate_around_origin = CurveFunctions.rotate_around_origin; * @param y scaling factor in y-direction * @param z scaling factor in z-direction * @returns function that takes a Curve and returns a Curve + * @function */ export const scale = CurveFunctions.scale; @@ -442,6 +500,7 @@ export const scale = CurveFunctions.scale; * * @param s scaling factor * @returns function that takes a Curve and returns a Curve + * @function */ export const scale_proportional = CurveFunctions.scale_proportional; @@ -469,6 +528,7 @@ export const put_in_standard_position = CurveFunctions.put_in_standard_position; * @param curve1 first Curve * @param curve2 second Curve * @returns result Curve + * @function */ export const connect_rigidly = CurveFunctions.connect_rigidly; @@ -483,6 +543,7 @@ export const connect_rigidly = CurveFunctions.connect_rigidly; * @param curve1 first Curve * @param curve2 second Curve * @returns result Curve + * @function */ export const connect_ends = CurveFunctions.connect_ends; @@ -512,6 +573,7 @@ export const unit_line = CurveFunctions.unit_line; * * @param y fraction between 0 and 1 * @returns horizontal Curve + * @function */ export const unit_line_at = CurveFunctions.unit_line_at; diff --git a/src/bundles/curve/src/index.ts b/src/bundles/curve/src/index.ts index 001782b89f..a9816e9da6 100644 --- a/src/bundles/curve/src/index.ts +++ b/src/bundles/curve/src/index.ts @@ -1,10 +1,10 @@ /** - * drawing *curves*, i.e. collections of *points*, on a canvas in a tools tab + * Module for drawing *curves*, i.e. collections of *points*, on a canvas in a tools tab * * A *point* is defined by its coordinates (x, y and z), and the color assigned to * it (r, g, and b). A few constructors for points is given, for example - * `make_color_point`. Selectors allow access to the coordinates and color - * components, for example `x_of`. + * {@link make_color_point}. Selectors allow access to the coordinates and color + * components, for example {@link x_of}. * * A *curve* is a * unary function which takes a number argument within the unit interval `[0,1]` @@ -12,13 +12,13 @@ * is always `C(0)`, and the ending point is always `C(1)`. * * A *curve transformation* is a function that takes a curve as argument and - * returns a curve. Examples of curve transformations are `scale` and `translate`. + * returns a curve. Examples of curve transformations are {@link scale|scale} and {@link translate|translate}. * - * A *curve drawer* is function that takes a number argument and returns + * A *render function* is function that takes a number argument and returns * a function that takes a curve as argument and visualises it in the output screen is * shown in the Source Academy in the tab with the "Curves Canvas" icon (image). * The following [example](https://share.sourceacademy.org/unitcircle) uses - * the curve drawer `draw_connected_full_view` to display a curve called + * the render function {@link draw_connected_full_view|draw_connected_full_view} to display a curve called * `unit_circle`. * ``` * import { make_point, draw_connected_full_view } from "curve"; @@ -34,6 +34,13 @@ * @author Lee Zheng Han * @author Ng Yong Xiang */ + +import { draw_connected_full_view } from './drawers'; +import { scale, translate, x_of } from './functions'; + +// import and re-export to make links in the module summary work +export { draw_connected_full_view, scale, translate, x_of }; + export { arc, b_of, @@ -48,13 +55,10 @@ export { put_in_standard_position, r_of, rotate_around_origin, - scale, scale_proportional, - translate, unit_circle, unit_line, unit_line_at, - x_of, y_of, z_of } from './functions'; @@ -69,7 +73,6 @@ export { draw_3D_points_full_view, draw_3D_points_full_view_proportional, draw_connected, - draw_connected_full_view, draw_connected_full_view_proportional, draw_points, draw_points_full_view, diff --git a/src/bundles/curve/src/types.ts b/src/bundles/curve/src/types.ts index 52ca36015e..3980163501 100644 --- a/src/bundles/curve/src/types.ts +++ b/src/bundles/curve/src/types.ts @@ -6,7 +6,9 @@ export type CurveModuleState = { }; /** A function that takes in CurveFunction and returns a tranformed CurveFunction. */ -export type CurveTransformer = (c: Curve) => Curve; +export interface CurveTransformer extends ReplResult { + (c: Curve): Curve; +} export type DrawMode = 'lines' | 'points'; export type ScaleMode = 'fit' | 'none' | 'stretch'; diff --git a/src/bundles/game/package.json b/src/bundles/game/package.json index 18514e535d..12f7d88532 100644 --- a/src/bundles/game/package.json +++ b/src/bundles/game/package.json @@ -20,8 +20,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/game/src/functions.ts b/src/bundles/game/src/functions.ts index dd265ab19d..c3068e318b 100644 --- a/src/bundles/game/src/functions.ts +++ b/src/bundles/game/src/functions.ts @@ -15,7 +15,7 @@ */ import context from 'js-slang/context'; -import { accumulate, head, is_pair, tail, type List } from 'js-slang/dist/stdlib/list'; +import { for_each, head, is_pair, tail, type List, type Pair } from 'js-slang/dist/stdlib/list'; import Phaser from 'phaser'; import { defaultGameParams, @@ -177,15 +177,16 @@ export function prepend_remote_url(asset_key: string): string { * @param lst the list to be turned into object config. * @returns object config */ -export function create_config(lst: List): ObjectConfig { +export function create_config(lst: List>): ObjectConfig { const config = {}; - accumulate((xs: [any, any], _) => { + + for_each(xs => { if (!is_pair(xs)) { throw_error('config element is not a pair!'); } config[head(xs)] = tail(xs); - return null; - }, null, lst); + }, lst); + return config; } diff --git a/src/bundles/mark_sweep/package.json b/src/bundles/mark_sweep/package.json index 69e1990bb9..9d73fa56bb 100644 --- a/src/bundles/mark_sweep/package.json +++ b/src/bundles/mark_sweep/package.json @@ -16,8 +16,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/midi/package.json b/src/bundles/midi/package.json index ecda58a0c2..87fd5ff29c 100644 --- a/src/bundles/midi/package.json +++ b/src/bundles/midi/package.json @@ -7,6 +7,7 @@ "typescript": "^5.8.2" }, "dependencies": { + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85" }, "type": "module", @@ -16,8 +17,9 @@ "prepare": "yarn tsc", "tsc": "buildtools tsc .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "exports": { ".": "./dist/index.js", diff --git a/src/bundles/midi/src/__tests__/index.test.ts b/src/bundles/midi/src/__tests__/index.test.ts index 1ef04b31f6..30ef854419 100644 --- a/src/bundles/midi/src/__tests__/index.test.ts +++ b/src/bundles/midi/src/__tests__/index.test.ts @@ -1,32 +1,32 @@ import { list_to_vector } from 'js-slang/dist/stdlib/list'; import { describe, expect, test } from 'vitest'; -import { letter_name_to_midi_note, midi_note_to_letter_name } from '..'; +import * as funcs from '..'; import { major_scale, minor_scale } from '../scales'; import { Accidental, type Note, type NoteWithOctave } from '../types'; import { noteToValues } from '../utils'; describe('scales', () => { test('major_scale', () => { - const c0 = letter_name_to_midi_note('C0'); + const c0 = funcs.letter_name_to_midi_note('C0'); const scale = major_scale(c0); expect(list_to_vector(scale)).toMatchObject([12, 14, 16, 17, 19, 21, 23, 24]); }); test('minor_scale', () => { - const a0 = letter_name_to_midi_note('A0'); + const a0 = funcs.letter_name_to_midi_note('A0'); const scale = minor_scale(a0); expect(list_to_vector(scale)).toMatchObject([21, 23, 24, 26, 28, 29, 31, 33]); }); }); -describe(midi_note_to_letter_name, () => { +describe(funcs.midi_note_to_letter_name, () => { describe('Test with sharps', () => { test.each([ [12, 'C0'], [13, 'C#0'], [36, 'C2'], [69, 'A4'], - ] as [number, NoteWithOctave][])('%i should equal %s', (note, noteName) => expect(midi_note_to_letter_name(note, 'sharp')).toEqual(noteName)); + ] as [number, NoteWithOctave][])('%i should equal %s', (note, noteName) => expect(funcs.midi_note_to_letter_name(note, Accidental.SHARP)).toEqual(noteName)); }); describe('Test with flats', () => { @@ -35,7 +35,7 @@ describe(midi_note_to_letter_name, () => { [13, 'Db0'], [36, 'C2'], [69, 'A4'], - ] as [number, NoteWithOctave][])('%i should equal %s', (note, noteName) => expect(midi_note_to_letter_name(note, 'flat')).toEqual(noteName)); + ] as [number, NoteWithOctave][])('%i should equal %s', (note, noteName) => expect(funcs.midi_note_to_letter_name(note, Accidental.FLAT)).toEqual(noteName)); }); }); @@ -49,13 +49,72 @@ describe(noteToValues, () => { // Leaving out octave should set it to 4 automatically ['a', 'A', Accidental.NATURAL, 4] ] as [NoteWithOctave, Note, Accidental, number][])('%s', (note, expectedNote, expectedAccidental, expectedOctave) => { - const [actualNote, actualAccidental, actualOctave] = noteToValues(note); + const [actualNote, actualAccidental, actualOctave] = noteToValues(note, ''); expect(actualNote).toEqual(expectedNote); expect(actualAccidental).toEqual(expectedAccidental); expect(actualOctave).toEqual(expectedOctave); }); test('Invalid note should throw an error', () => { - expect(() => noteToValues('Fb9' as any)).toThrowError('noteToValues: Invalid Note with Octave: Fb9'); + expect(() => noteToValues('Fb9' as any, 'noteToValues')).toThrowError('noteToValues: Invalid Note with Octave: Fb9'); + }); +}); + +describe(funcs.add_octave_to_note, () => { + test('Valid note and octave', () => { + expect(funcs.add_octave_to_note('C', 4)).toEqual('C4'); + expect(funcs.add_octave_to_note('F#', 0)).toEqual('F#0'); + }); + + test('Invalid octave should throw an error', () => { + expect(() => funcs.add_octave_to_note('C', -1)).toThrowError('add_octave_to_note: Expected integer greater than 0 for octave, got -1.'); + expect(() => funcs.add_octave_to_note('C', 2.5)).toThrowError('add_octave_to_note: Expected integer greater than 0 for octave, got 2.5.'); + }); +}); + +describe(funcs.get_octave, () => { + test('Valid note with octave', () => { + expect(funcs.get_octave('C4')).toEqual(4); + expect(funcs.get_octave('F#0')).toEqual(0); + + // If octave is left out, it should default to 4 + expect(funcs.get_octave('F')).toEqual(4); + }); + + test('Invalid note should throw an error', () => { + expect(() => funcs.get_octave('Fb9' as any)).toThrowError('get_octave: Invalid Note with Octave: Fb9'); + }); +}); + +describe(funcs.key_signature_to_keys, () => { + test('Valid key signatures', () => { + expect(funcs.key_signature_to_keys(Accidental.SHARP, 0)).toEqual('C'); + expect(funcs.key_signature_to_keys(Accidental.SHARP, 2)).toEqual('D'); + expect(funcs.key_signature_to_keys(Accidental.FLAT, 3)).toEqual('Eb'); + }); + + test('Invalid number of accidentals should throw an error', () => { + expect(() => funcs.key_signature_to_keys(Accidental.SHARP, -1)).toThrowError('key_signature_to_keys: Expected integer between 0 and 6 for numAccidentals, got -1.'); + expect(() => funcs.key_signature_to_keys(Accidental.SHARP, 7)).toThrowError('key_signature_to_keys: Expected integer between 0 and 6 for numAccidentals, got 7.'); + expect(() => funcs.key_signature_to_keys(Accidental.SHARP, 2.5)).toThrowError('key_signature_to_keys: Expected integer between 0 and 6 for numAccidentals, got 2.5.'); + }); + + test('Invalid accidental should throw an error', () => { + expect(() => funcs.key_signature_to_keys('invalid' as any, 2)).toThrowError('key_signature_to_keys: Expected sharp or flat for accidental, got "invalid".'); + }); +}); + +describe(funcs.is_note_with_octave, () => { + test('Valid NoteWithOctaves', () => { + expect(funcs.is_note_with_octave('C4')).toBe(true); + expect(funcs.is_note_with_octave('F#0')).toBe(true); + expect(funcs.is_note_with_octave('Ab9')).toBe(true); + expect(funcs.is_note_with_octave('C')).toBe(true); + expect(funcs.is_note_with_octave('F#')).toBe(true); + }); + + test('Invalid NoteWithOctaves', () => { + expect(funcs.is_note_with_octave('Invalid')).toBe(false); + expect(funcs.is_note_with_octave(123)).toBe(false); }); }); diff --git a/src/bundles/midi/src/index.ts b/src/bundles/midi/src/index.ts index 83d6f6af23..bd49e28b40 100644 --- a/src/bundles/midi/src/index.ts +++ b/src/bundles/midi/src/index.ts @@ -6,8 +6,18 @@ * @author leeyi45 */ -import { Accidental, type MIDINote, type NoteWithOctave } from './types'; -import { midiNoteToNoteName, noteToValues } from './utils'; +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; +import { Accidental, type MIDINote, type Note, type NoteWithOctave } from './types'; +import { midiNoteToNoteName, noteToValues, parseNoteWithOctave } from './utils'; + +/** + * Returns a boolean value indicating whether the given value is a {@link NoteWithOctave|note name with octave}. + */ +export function is_note_with_octave(value: unknown): value is NoteWithOctave { + const res = parseNoteWithOctave(value); + return res !== null; +} /** * Converts a letter name to its corresponding MIDI note. @@ -76,41 +86,125 @@ export function letter_name_to_midi_note(note: NoteWithOctave): MIDINote { } /** - * Convert a MIDI note into its letter representation + * Convert a {@link MIDINote|MIDI note} into its {@link NoteWithOctave|letter representation} + * * @param midiNote Note to convert * @param accidental Whether to return the letter as with a sharp or with a flat * @function + * @example + * ``` + * midi_note_to_letter_name(61, SHARP); // Returns "C#4" + * midi_note_to_letter_name(61, FLAT); // Returns "Db4" + * + * // Notes without accidentals return the same letter name + * // regardless of whether SHARP or FLAT is passed in + * midi_note_to_letter_name(60, FLAT); // Returns "C4" + * midi_note_to_letter_name(60, SHARP); // Returns "C4" + * ``` */ -export function midi_note_to_letter_name(midiNote: MIDINote, accidental: 'flat' | 'sharp'): NoteWithOctave { +export function midi_note_to_letter_name(midiNote: MIDINote, accidental: Accidental.FLAT | Accidental.SHARP): NoteWithOctave { const octave = Math.floor(midiNote / 12) - 1; const note = midiNoteToNoteName(midiNote, accidental, midi_note_to_letter_name.name); return `${note}${octave}`; } /** - * Converts a MIDI note to its corresponding frequency. + * Converts a {@link MIDINote|MIDI note} to its corresponding frequency. * * @param note given MIDI note * @returns the frequency of the MIDI note * @function - * @example midi_note_to_frequency(69); // Returns 440 + * @example + * ``` + * midi_note_to_frequency(69); // Returns 440 + * ``` */ export function midi_note_to_frequency(note: MIDINote): number { + assertNumberWithinRange(note, midi_note_to_frequency.name); // A4 = 440Hz = midi note 69 return 440 * 2 ** ((note - 69) / 12); } /** - * Converts a letter name to its corresponding frequency. + * Converts a {@link NoteWithOctave|note name} to its corresponding frequency. * * @param note given letter name - * @returns the corresponding frequency - * @example letter_name_to_frequency("A4"); // Returns 440 + * @returns the corresponding frequency (in Hz) + * @example + * ``` + * letter_name_to_frequency('A4'); // Returns 440 + * ``` */ export function letter_name_to_frequency(note: NoteWithOctave): number { return midi_note_to_frequency(letter_name_to_midi_note(note)); } +/** + * Takes the given {@link Note|Note} and adds the octave number to it. + * @example + * ``` + * add_octave_to_note('C', 4); // Returns "C4" + * ``` + */ +export function add_octave_to_note(note: Note, octave: number): NoteWithOctave { + assertNumberWithinRange(octave, add_octave_to_note.name, 0, undefined, true, 'octave'); + return `${note}${octave}`; +} + +/** + * Gets the octave number from a given {@link NoteWithOctave|note name with octave}. + */ +export function get_octave(note: NoteWithOctave): number { + const [,, octave] = noteToValues(note, get_octave.name); + return octave; +} + +/** + * Gets the letter name from a given {@link NoteWithOctave|note name with octave} (without the accidental). + * @example + * ``` + * get_note_name('C#4'); // Returns "C" + * get_note_name('Eb3'); // Returns "E" + * ``` + */ +export function get_note_name(note: NoteWithOctave): Note { + const [noteName] = noteToValues(note, get_note_name.name); + return noteName; +} + +/** + * Gets the accidental from a given {@link NoteWithOctave|note name with octave}. + */ +export function get_accidental(note: NoteWithOctave): Accidental { + const [, accidental] = noteToValues(note, get_accidental.name); + return accidental; +} + +/** + * Converts the key signature to the corresponding key + * @example + * ``` + * key_signature_to_keys(SHARP, 2); // Returns "D", since the key of D has 2 sharps + * key_signature_to_keys(FLAT, 3); // Returns "Eb", since the key of Eb has 3 flats + * ``` + */ +export function key_signature_to_keys(accidental: Accidental.FLAT | Accidental.SHARP, numAccidentals: number): Note { + assertNumberWithinRange(numAccidentals, key_signature_to_keys.name, 0, 6, true, 'numAccidentals'); + + switch (accidental) { + case Accidental.SHARP: { + const keys: Note[] = ['C', 'G', 'D', 'A', 'E', 'B', 'F#']; + return keys[numAccidentals]; + } + case Accidental.FLAT: { + const keys: Note[] = ['C', 'F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb']; + return keys[numAccidentals]; + } + default: + throw new InvalidParameterTypeError('sharp or flat', accidental, key_signature_to_keys.name, 'accidental'); + } +} + export * from './scales'; /** diff --git a/src/bundles/midi/src/utils.ts b/src/bundles/midi/src/utils.ts index 7504409b7d..11fc4390bf 100644 --- a/src/bundles/midi/src/utils.ts +++ b/src/bundles/midi/src/utils.ts @@ -1,23 +1,22 @@ import { Accidental, type MIDINote, type Note, type NoteName, type NoteWithOctave } from './types'; -export function noteToValues(note: NoteWithOctave, func_name: string = noteToValues.name) { +export function parseNoteWithOctave(note: NoteWithOctave): [NoteName, Accidental, number]; +export function parseNoteWithOctave(note: unknown): [NoteName, Accidental, number] | null; +export function parseNoteWithOctave(note: unknown): [NoteName, Accidental, number] | null { + if (typeof note !== 'string') return null; + const match = /^([A-Ga-g])([#♮b]?)(\d*)$/.exec(note); - if (match === null) throw new Error(`${func_name}: Invalid Note with Octave: ${note}`); + if (match === null) return null; const [, noteName, accidental, octaveStr] = match; switch (accidental) { case Accidental.SHARP: { - if (noteName === 'B' || noteName === 'E') { - throw new Error(`${func_name}: Invalid Note with Octave: ${note}`); - } - + if (noteName === 'B' || noteName === 'E') return null; break; } case Accidental.FLAT: { - if (noteName === 'F' || noteName === 'C') { - throw new Error(`${func_name}: Invalid Note with Octave: ${note}`); - } + if (noteName === 'F' || noteName === 'C') return null; break; } } @@ -30,30 +29,42 @@ export function noteToValues(note: NoteWithOctave, func_name: string = noteToVal ] as [NoteName, Accidental, number]; } -export function midiNoteToNoteName(midiNote: MIDINote, accidental: 'flat' | 'sharp', func_name: string): Note { +export function noteToValues(note: NoteWithOctave, func_name: string): [NoteName, Accidental, number] { + const res = parseNoteWithOctave(note); + if (res === null) { + throw new Error(`${func_name}: Invalid Note with Octave: ${note}`); + } + return res; +} + +export function midiNoteToNoteName( + midiNote: MIDINote, + accidental: Accidental.FLAT | Accidental.SHARP, + func_name: string +): Note { switch (midiNote % 12) { case 0: return 'C'; case 1: - return accidental === 'sharp' ? `C${Accidental.SHARP}` : `D${Accidental.FLAT}`; + return accidental === Accidental.SHARP ? `C${Accidental.SHARP}` : `D${Accidental.FLAT}`; case 2: return 'D'; case 3: - return accidental === 'sharp' ? `D${Accidental.SHARP}` : `E${Accidental.FLAT}`; + return accidental === Accidental.SHARP ? `D${Accidental.SHARP}` : `E${Accidental.FLAT}`; case 4: return 'E'; case 5: return 'F'; case 6: - return accidental === 'sharp' ? `F${Accidental.SHARP}` : `G${Accidental.FLAT}`; + return accidental === Accidental.SHARP ? `F${Accidental.SHARP}` : `G${Accidental.FLAT}`; case 7: return 'G'; case 8: - return accidental === 'sharp' ? `G${Accidental.SHARP}` : `A${Accidental.FLAT}`; + return accidental === Accidental.SHARP ? `G${Accidental.SHARP}` : `A${Accidental.FLAT}`; case 9: return 'A'; case 10: - return accidental === 'sharp' ? `A${Accidental.SHARP}` : `B${Accidental.FLAT}`; + return accidental === Accidental.SHARP ? `A${Accidental.SHARP}` : `B${Accidental.FLAT}`; case 11: return 'B'; default: diff --git a/src/bundles/nbody/package.json b/src/bundles/nbody/package.json index 7b0cdc5e48..243f905de9 100644 --- a/src/bundles/nbody/package.json +++ b/src/bundles/nbody/package.json @@ -23,8 +23,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/painter/package.json b/src/bundles/painter/package.json index 913d66234d..54455f040a 100644 --- a/src/bundles/painter/package.json +++ b/src/bundles/painter/package.json @@ -21,8 +21,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/physics_2d/package.json b/src/bundles/physics_2d/package.json index 2dbea63753..81943a5307 100644 --- a/src/bundles/physics_2d/package.json +++ b/src/bundles/physics_2d/package.json @@ -20,8 +20,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/pix_n_flix/package.json b/src/bundles/pix_n_flix/package.json index 050cd9c16e..7d6af18267 100644 --- a/src/bundles/pix_n_flix/package.json +++ b/src/bundles/pix_n_flix/package.json @@ -13,6 +13,9 @@ "vitest": "4.1.0", "vitest-browser-react": "^2.1.0" }, + "dependencies": { + "@sourceacademy/modules-lib": "workspace:^" + }, "type": "module", "exports": { ".": "./dist/index.js", @@ -23,8 +26,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/pix_n_flix/src/__tests__/index.test.tsx b/src/bundles/pix_n_flix/src/__tests__/index.test.tsx index 84b47f273f..f98d1b23e6 100644 --- a/src/bundles/pix_n_flix/src/__tests__/index.test.tsx +++ b/src/bundles/pix_n_flix/src/__tests__/index.test.tsx @@ -70,24 +70,40 @@ describe('pixel manipulation functions', () => { it('works', () => { expect(funcs.alpha_of([0, 0, 0, 255])).toEqual(255); }); + + it('throws error when argument is not a pixel', () => { + expect(() => funcs.alpha_of(0 as any)).toThrow('alpha_of: Expected pixel, got 0.'); + }); }); describe(funcs.red_of, () => { it('works', () => { expect(funcs.red_of([255, 0, 0, 0])).toEqual(255); }); + + it('throws error when argument is not a pixel', () => { + expect(() => funcs.red_of(0 as any)).toThrow('red_of: Expected pixel, got 0.'); + }); }); describe(funcs.green_of, () => { it('works', () => { expect(funcs.green_of([0, 255, 0, 0])).toEqual(255); }); + + it('throws error when argument is not a pixel', () => { + expect(() => funcs.green_of(0 as any)).toThrow('green_of: Expected pixel, got 0.'); + }); }); describe(funcs.blue_of, () => { it('works', () => { expect(funcs.blue_of([0, 0, 255, 0])).toEqual(255); }); + + it('throws error when argument is not a pixel', () => { + expect(() => funcs.blue_of(0 as any)).toThrow('blue_of: Expected pixel, got 0.'); + }); }); describe(funcs.set_rgba, () => { @@ -98,6 +114,10 @@ describe('pixel manipulation functions', () => { expect(pixel[i]).toEqual(i + 1); } }); + + it('throws error when first argument is not a pixel', () => { + expect(() => funcs.set_rgba(0 as any, 1, 2, 3, 4)).toThrow('set_rgba: Expected pixel for pixel, got 0.'); + }); }); }); @@ -153,6 +173,19 @@ describe(funcs.writeToBuffer, () => { }); }); +describe(funcs.install_filter, () => { + it('throws an error when passed an invalid filter', () => { + expect(() => funcs.install_filter(0 as any)).toThrow('install_filter: Expected filter, got 0.'); + }); +}); + +describe(funcs.compose_filter, () => { + it('throws an error when passed invalid filters', () => { + expect(() => funcs.compose_filter(0 as any, (_s, _d) => {})).toThrow('compose_filter: Expected filter for filter1, got 0.'); + expect(() => funcs.compose_filter((_s, _d) => {}, 0 as any)).toThrow('compose_filter: Expected filter for filter2, got 0.'); + }); +}); + describe('video functions', () => { test('startVideo and stopVideo', ({ fixtures: { errLogger } }) => { const filter = vi.fn(funcs.copy_image); @@ -197,4 +230,11 @@ describe('video functions', () => { expect(FPS).toEqual(60); }); }); + + describe(funcs.set_loop_count, () => { + it('throws an error when given not an integer', () => { + expect(() => funcs.set_loop_count('a' as any)).toThrow('set_loop_count: Expected integer, got "a".'); + expect(() => funcs.set_loop_count(0.5)).toThrow('set_loop_count: Expected integer, got 0.5.'); + }); + }); }); diff --git a/src/bundles/pix_n_flix/src/functions.ts b/src/bundles/pix_n_flix/src/functions.ts index 5e062b1c8a..3c1dcd1905 100644 --- a/src/bundles/pix_n_flix/src/functions.ts +++ b/src/bundles/pix_n_flix/src/functions.ts @@ -1,3 +1,6 @@ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { assertFunctionOfLength, assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; +import context from 'js-slang/context'; import { DEFAULT_FPS, DEFAULT_HEIGHT, @@ -21,7 +24,6 @@ import { type Pixel, type Pixels, type Queue, - type StartPacket, type TabsPacket, type VideoElement } from './types'; @@ -107,7 +109,8 @@ function setupData(): void { export function isPixelValid(pixel: Pixel): boolean { let ok = true; for (let i = 0; i < 4; i += 1) { - if (pixel[i] >= 0 && pixel[i] <= 255) { + const value = pixel[i]; + if (typeof value === 'number' && value >= 0 && value <= 255) { continue; } ok = false; @@ -281,7 +284,7 @@ function setAspectRatioDimensions(w: number, h: number): void { /** @hidden */ function loadMedia(): void { - if (!navigator.mediaDevices.getUserMedia) { + if (!navigator.mediaDevices?.getUserMedia) { const errMsg = 'The browser you are using does not support getUserMedia'; console.error(errMsg); errorLogger(errMsg, false); @@ -290,8 +293,7 @@ function loadMedia(): void { // If video is already part of bundle state if (videoElement.srcObject) return; - navigator.mediaDevices - .getUserMedia({ video: true }) + navigator.mediaDevices?.getUserMedia({ video: true }) .then((stream) => { videoElement.srcObject = stream; videoElement.onloadedmetadata = () => setAspectRatioDimensions( @@ -503,6 +505,16 @@ function deinit(): void { }); } +function throwIfNotPixel(obj: unknown, func_name: string, param_name?: string): asserts obj is Pixel { + if ( + !Array.isArray(obj) || + obj.length !== 4 || + obj.some(each => typeof each !== 'number') + ) { + throw new InvalidParameterTypeError('pixel', obj, func_name, param_name); + } +} + // ============================================================================= // Module's Exposed Functions // ============================================================================= @@ -510,16 +522,23 @@ function deinit(): void { /** * Starts processing the image or video using the installed filter. */ -export function start(): StartPacket { +export function start() { + if (!context.moduleContexts.pix_n_flix.state) { + context.moduleContexts.pix_n_flix.state = { + pixnflix: { + init, + deinit, + startVideo, + stopVideo, + updateFPS, + updateVolume, + updateDimensions + } + }; + } + return { toReplString: () => '[Pix N Flix]', - init, - deinit, - startVideo, - stopVideo, - updateFPS, - updateVolume, - updateDimensions }; } @@ -530,7 +549,7 @@ export function start(): StartPacket { * @returns The red component as a number between 0 and 255 */ export function red_of(pixel: Pixel): number { - // returns the red value of pixel respectively + throwIfNotPixel(pixel, red_of.name); return pixel[0]; } @@ -541,7 +560,7 @@ export function red_of(pixel: Pixel): number { * @returns The green component as a number between 0 and 255 */ export function green_of(pixel: Pixel): number { - // returns the green value of pixel respectively + throwIfNotPixel(pixel, green_of.name); return pixel[1]; } @@ -552,7 +571,7 @@ export function green_of(pixel: Pixel): number { * @returns The blue component as a number between 0 and 255 */ export function blue_of(pixel: Pixel): number { - // returns the blue value of pixel respectively + throwIfNotPixel(pixel, blue_of.name); return pixel[2]; } @@ -563,7 +582,7 @@ export function blue_of(pixel: Pixel): number { * @returns The alpha component as a number between 0 and 255 */ export function alpha_of(pixel: Pixel): number { - // returns the alpha value of pixel respectively + throwIfNotPixel(pixel, alpha_of.name); return pixel[3]; } @@ -584,6 +603,12 @@ export function set_rgba( b: number, a: number ): void { + throwIfNotPixel(pixel, set_rgba.name, 'pixel'); + assertNumberWithinRange(r, set_rgba.name, 0, 255, true, 'r'); + assertNumberWithinRange(g, set_rgba.name, 0, 255, true, 'g'); + assertNumberWithinRange(b, set_rgba.name, 0, 255, true, 'b'); + assertNumberWithinRange(a, set_rgba.name, 0, 255, true, 'a'); + // assigns the r,g,b values to this pixel pixel[0] = r; pixel[1] = g; @@ -618,7 +643,7 @@ export function image_width(): number { * @param src Source image * @param dest Destination image */ -export function copy_image(src: Pixels, dest: Pixels): void { +export function copy_image(src: Pixels, dest: Pixels) { for (let i = 0; i < HEIGHT; i += 1) { for (let j = 0; j < WIDTH; j += 1) { dest[i][j] = src[i][j]; @@ -638,6 +663,7 @@ export function copy_image(src: Pixels, dest: Pixels): void { * @param _filter The filter to be installed */ export function install_filter(_filter: Filter): void { + assertFunctionOfLength(_filter, 2, install_filter.name, 'filter'); filter = _filter; } @@ -657,6 +683,9 @@ export function reset_filter(): void { * @returns The filter equivalent to applying filter1 and then filter2 */ export function compose_filter(filter1: Filter, filter2: Filter): Filter { + assertFunctionOfLength(filter1, 2, compose_filter.name, 'filter', 'filter1'); + assertFunctionOfLength(filter2, 2, compose_filter.name, 'filter', 'filter2'); + return (src, dest) => { const temp = new_image(); filter1(src, temp); @@ -670,11 +699,12 @@ export function compose_filter(filter1: Filter, filter2: Filter): Filter { * @param pause_time Time in ms after the video starts. */ export function pause_at(pause_time: number): void { - // prevent negative pause_time + assertNumberWithinRange(pause_time, pause_at.name, 0); + lateEnqueue(() => { setTimeout( tabsPackage.onClickStill, - pause_time >= 0 ? pause_time : -pause_time + pause_time ); }); } @@ -687,6 +717,9 @@ export function pause_at(pause_time: number): void { * @param height The height of the displayed images (default value: 400) */ export function set_dimensions(width: number, height: number): void { + assertNumberWithinRange(width, set_dimensions.name, MIN_WIDTH, MAX_WIDTH, true, 'width'); + assertNumberWithinRange(height, set_dimensions.name, MIN_HEIGHT, MAX_HEIGHT, true, 'height'); + enqueue(() => updateDimensions(width, height)); } @@ -697,6 +730,7 @@ export function set_dimensions(width: number, height: number): void { * @param fps FPS of video (default value: 10) */ export function set_fps(fps: number): void { + assertNumberWithinRange(fps, set_fps.name, MIN_FPS, MAX_FPS); enqueue(() => updateFPS(fps)); } @@ -707,7 +741,14 @@ export function set_fps(fps: number): void { * @param volume Volume of video (Default value of 50) */ export function set_volume(volume: number): void { - enqueue(() => updateVolume(Math.max(0, Math.min(100, volume) / 100.0))); + assertNumberWithinRange(volume, set_volume.name); + + if (volume > 100) volume = 100; + else if (volume < 0) volume = 0; + + volume /= 100; + + enqueue(() => updateVolume(volume)); } /** @@ -725,6 +766,10 @@ export function use_local_file(): void { * @param URL URL of the image */ export function use_image_url(URL: string): void { + if (typeof URL !== 'string') { + throw new InvalidParameterTypeError('string', URL, use_image_url.name); + } + inputFeed = InputFeed.ImageURL; url = URL; } @@ -736,6 +781,10 @@ export function use_image_url(URL: string): void { * @param URL URL of the video */ export function use_video_url(URL: string): void { + if (typeof URL !== 'string') { + throw new InvalidParameterTypeError('string', URL, use_video_url.name); + } + inputFeed = InputFeed.VideoURL; url = URL; } @@ -755,6 +804,10 @@ export function get_video_time(): number { * @param _keepAspectRatio to keep aspect ratio. (Default value of true) */ export function keep_aspect_ratio(_keepAspectRatio: boolean): void { + if (typeof _keepAspectRatio !== 'boolean') { + throw new InvalidParameterTypeError('boolean', URL, keep_aspect_ratio.name); + } + keepAspectRatio = _keepAspectRatio; } @@ -765,5 +818,7 @@ export function keep_aspect_ratio(_keepAspectRatio: boolean): void { * @param n number of times the video repeats after the first iteration. If n < 1, n will be taken to be 1. (Default value of Infinity) */ export function set_loop_count(n: number): void { + assertNumberWithinRange(n, set_loop_count.name); + LOOP_COUNT = n; } diff --git a/src/bundles/pix_n_flix/src/types.ts b/src/bundles/pix_n_flix/src/types.ts index 3aaddf838b..108a760bc3 100644 --- a/src/bundles/pix_n_flix/src/types.ts +++ b/src/bundles/pix_n_flix/src/types.ts @@ -1,3 +1,5 @@ +import type { ReplResult } from '@sourceacademy/modules-lib/types'; + export type VideoElement = HTMLVideoElement & { srcObject?: MediaStream }; export type ImageElement = HTMLImageElement; export type CanvasElement = HTMLCanvasElement; @@ -23,7 +25,8 @@ export type BundlePacket = { inputFeed: InputFeed; }; export type Queue = () => void; -export type StartPacket = { + +export interface StartPacket extends ReplResult { toReplString: () => string; init: ( image: ImageElement, @@ -38,7 +41,17 @@ export type StartPacket = { updateFPS: (fps: number) => void; updateVolume: (volume: number) => void; updateDimensions: (width: number, height: number) => void; -}; +} + +export interface PixNFlixModuleState { + pixnflix: StartPacket | null; +} + export type Pixel = [r: number, g: number, b: number, a: number]; export type Pixels = Pixel[][]; + +/** + * A `void` returning function that takes the pixel data in `src`, + * transforms it, and then writes the output to `dest`. + */ export type Filter = (src: Pixels, dest: Pixels) => void; diff --git a/src/bundles/plotly/package.json b/src/bundles/plotly/package.json index 778e9fcffe..e2d69f6bed 100644 --- a/src/bundles/plotly/package.json +++ b/src/bundles/plotly/package.json @@ -5,6 +5,7 @@ "dependencies": { "@sourceacademy/bundle-curve": "workspace:^", "@sourceacademy/bundle-sound": "workspace:^", + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85", "plotly.js-dist": "^3.0.0" }, @@ -23,8 +24,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/plotly/src/__tests__/index.test.ts b/src/bundles/plotly/src/__tests__/index.test.ts new file mode 100644 index 0000000000..778d24d696 --- /dev/null +++ b/src/bundles/plotly/src/__tests__/index.test.ts @@ -0,0 +1,18 @@ +import { list, pair } from 'js-slang/dist/stdlib/list'; +import { describe, expect, it } from 'vitest'; +import * as funcs from '../functions'; + +describe(funcs.add_fields_to_data, () => { + it('works', () => { + const data = {}; + funcs.add_fields_to_data(data, list( + pair('x', 0), + pair('y', 1), + pair('z', 2), + )); + + expect(data).toHaveProperty('x', 0); + expect(data).toHaveProperty('y', 1); + expect(data).toHaveProperty('z', 2); + }); +}); diff --git a/src/bundles/plotly/src/functions.ts b/src/bundles/plotly/src/functions.ts index f7e07f1fe9..e94a4f7b04 100644 --- a/src/bundles/plotly/src/functions.ts +++ b/src/bundles/plotly/src/functions.ts @@ -7,6 +7,7 @@ import type { Curve } from '@sourceacademy/bundle-curve/curves_webgl'; import { get_duration, get_wave, is_sound } from '@sourceacademy/bundle-sound/functions'; import type { Sound } from '@sourceacademy/bundle-sound/types'; import context from 'js-slang/context'; +import { accumulate, head, is_pair, tail, type List } from 'js-slang/dist/stdlib/list'; import Plotly, { type Data, type Layout } from 'plotly.js-dist'; import { generatePlot } from './curve_functions'; import { @@ -97,7 +98,8 @@ export function new_plot_json(data: any): void { * @param divId The id of the div element on which the plot will be displayed */ function draw_new_plot(data: ListOfPairs, divId: string) { - const plotlyData = convert_to_plotly_data(data); + const plotlyData: Data = {}; + add_fields_to_data(plotlyData, data); Plotly.newPlot(divId, [plotlyData]); } @@ -110,30 +112,28 @@ function draw_new_plot_json(data: any, divId: string) { Plotly.newPlot(divId, data); } -/** - * @param data The list of pairs given by the user - * @returns The converted data that can be used by the plotly.js function - */ -function convert_to_plotly_data(data: ListOfPairs): Data { - const convertedData: Data = {}; - if (Array.isArray(data) && data.length === 2) { - add_fields_to_data(convertedData, data); - } - return convertedData; -} - /** * @param convertedData Stores the Javascript object which is used by plotly.js * @param data The list of pairs data used by source + * @hidden */ +export function add_fields_to_data(convertedData: Data, data: ListOfPairs) { + accumulate((entry, result) => { + if (!is_pair(entry)) { + throw new Error(`${add_fields_to_data.name}: Expected list of pairs, got ${entry}`); + } -function add_fields_to_data(convertedData: Data, data: ListOfPairs) { - if (Array.isArray(data) && data.length === 2 && data[0].length === 2) { - const field = data[0][0]; - const value = data[0][1]; - convertedData[field] = value; - add_fields_to_data(convertedData, data[1]); - } + const field = head(entry); + + if (typeof field !== 'string') { + throw new Error(`${add_fields_to_data.name}: Expected head of pair to be string, got ${field}`); + } + + const value = tail(entry); + + result[field] = value; + return result; + }, convertedData, data as List); } function createPlotFunction( @@ -214,7 +214,7 @@ export const draw_connected_3d = createPlotFunction( * Curve at num sample points. The Drawing consists of isolated points, and does not connect them. * When a program evaluates to a Drawing, the Source system displays it graphically, in a window, * - * * @param num determines the number of points, lower than 65535, to be sampled. + * @param num determines the number of points, lower than 65535, to be sampled. * Including 0 and 1, there are `num + 1` evenly spaced sample points * @function * @returns function of type 2D Curve → Drawing @@ -241,7 +241,7 @@ export const draw_points_2d = createPlotFunction( * 3D Curve at num sample points. The Drawing consists of isolated points, and does not connect them. * When a program evaluates to a Drawing, the Source system displays it graphically, in a window, * - * * @param num determines the number of points, lower than 65535, to be sampled. + * @param num determines the number of points, lower than 65535, to be sampled. * Including 0 and 1, there are `num + 1` evenly spaced sample points * @function * @returns function of type 3D Curve → Drawing diff --git a/src/bundles/repeat/package.json b/src/bundles/repeat/package.json index f65d342dab..caa1343b3f 100644 --- a/src/bundles/repeat/package.json +++ b/src/bundles/repeat/package.json @@ -1,11 +1,14 @@ { "name": "@sourceacademy/bundle-repeat", - "version": "1.0.0", + "version": "1.1.0", "private": true, "devDependencies": { "@sourceacademy/modules-buildtools": "workspace:^", "typescript": "^5.8.2" }, + "dependencies": { + "@sourceacademy/modules-lib": "workspace:^" + }, "type": "module", "exports": { ".": "./dist/index.js", @@ -16,8 +19,9 @@ "test": "buildtools test --project .", "tsc": "buildtools tsc .", "lint": "buildtools lint .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/repeat/src/__tests__/index.test.ts b/src/bundles/repeat/src/__tests__/index.test.ts index 3a3cff9e81..f234db8c71 100644 --- a/src/bundles/repeat/src/__tests__/index.test.ts +++ b/src/bundles/repeat/src/__tests__/index.test.ts @@ -1,19 +1,43 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import * as funcs from '../functions'; -import { repeat, thrice, twice } from '../functions'; +vi.spyOn(funcs, 'repeat'); -// Test functions -test('repeat works correctly and repeats function n times', () => { - expect(repeat((x: number) => x + 1, 5)(1)) - .toBe(6); +describe(funcs.repeat, () => { + test('repeat works correctly and repeats unary function n times', () => { + expect(funcs.repeat((x: number) => x + 1, 5)(1)) + .toEqual(6); + }); + + test('returns the identity function when n = 0', () => { + expect(funcs.repeat((x: number) => x + 1, 0)(0)).toEqual(0); + }); + + test('throws an error when the function doesn\'t take 1 parameter', () => { + expect(() => funcs.repeat((x: number, y: number) => x + y, 2)) + .toThrowError('repeat: Expected function with 1 parameter, got (x, y) => x + y.'); + + expect(() => funcs.repeat(() => 2, 2)) + .toThrowError('repeat: Expected function with 1 parameter, got () => 2.'); + }); + + test('throws an error when provided incorrect values', () => { + expect(() => funcs.repeat((x: number) => x, -1)) + .toThrowError('repeat: Expected integer greater than 0, got -1.'); + + expect(() => funcs.repeat((x: number) => x, 1.5)) + .toThrowError('repeat: Expected integer greater than 0, got 1.5.'); + }); }); test('twice works correctly and repeats function twice', () => { - expect(twice((x: number) => x + 1)(1)) - .toBe(3); + expect(funcs.twice((x: number) => x + 1)(1)) + .toEqual(3); + expect(funcs.repeat).not.toHaveBeenCalled(); }); test('thrice works correctly and repeats function thrice', () => { - expect(thrice((x: number) => x + 1)(1)) - .toBe(4); + expect(funcs.thrice((x: number) => x + 1)(1)) + .toEqual(4); + expect(funcs.repeat).not.toHaveBeenCalled(); }); diff --git a/src/bundles/repeat/src/functions.ts b/src/bundles/repeat/src/functions.ts index 04b659da1b..0c7e935a89 100644 --- a/src/bundles/repeat/src/functions.ts +++ b/src/bundles/repeat/src/functions.ts @@ -3,6 +3,22 @@ * @module repeat */ +import { assertFunctionOfLength, assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; + +/** + * Represents a function that takes in 1 parameter and returns a + * value of the same type + */ +type UnaryFunction = (x: T) => T; + +/** + * Internal implementation of the repeat function that doesn't perform type checking + * @hidden + */ +export function repeat_internal(f: UnaryFunction, n: number): UnaryFunction { + return n === 0 ? x => x : x => f(repeat_internal(f, n - 1)(x)); +} + /** * Returns a new function which when applied to an argument, has the same effect * as applying the specified function to the same argument n times. @@ -16,7 +32,10 @@ * @returns the new function that has the same effect as func repeated n times */ export function repeat(func: Function, n: number): Function { - return n === 0 ? (x: any) => x : (x: any) => func(repeat(func, n - 1)(x)); + assertFunctionOfLength(func, 1, repeat.name); + assertNumberWithinRange(n, repeat.name, 0); + + return repeat_internal(func, n); } /** @@ -31,7 +50,8 @@ export function repeat(func: Function, n: number): Function { * @returns the new function that has the same effect as `(x => func(func(x)))` */ export function twice(func: Function): Function { - return repeat(func, 2); + assertFunctionOfLength(func, 1, twice.name); + return repeat_internal(func, 2); } /** @@ -46,5 +66,6 @@ export function twice(func: Function): Function { * @returns the new function that has the same effect as `(x => func(func(func(x))))` */ export function thrice(func: Function): Function { - return repeat(func, 3); + assertFunctionOfLength(func, 1, thrice.name); + return repeat_internal(func, 3); } diff --git a/src/bundles/repl/package.json b/src/bundles/repl/package.json index 43f1d31c8f..80479e7af1 100644 --- a/src/bundles/repl/package.json +++ b/src/bundles/repl/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85" }, "devDependencies": { @@ -17,10 +18,11 @@ "scripts": { "tsc": "buildtools tsc .", "build": "buildtools build bundle .", + "compile": "buildtools compile", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "buildtools serve" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/repl/src/__tests__/index.test.ts b/src/bundles/repl/src/__tests__/index.test.ts index fc288676e6..9d87f6093e 100644 --- a/src/bundles/repl/src/__tests__/index.test.ts +++ b/src/bundles/repl/src/__tests__/index.test.ts @@ -1,61 +1,160 @@ import * as slang from 'js-slang'; +import { pair } from 'js-slang/dist/stdlib/list'; import { stringify } from 'js-slang/dist/utils/stringify'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as funcs from '../functions'; import { ProgrammableRepl } from '../programmable_repl'; +const repl = funcs.INSTANCE; +const tabRerender = vi.fn(() => {}); + +repl.tabRerenderer = tabRerender; + +vi.spyOn(repl, 'easterEggFunction'); + +beforeEach(() => { + repl.outputStrings.splice(0, repl.outputStrings.length); + repl.evalFunction = null; + repl.customizedEditorProps = { + backgroundImageUrl: null, + backgroundColorAlpha: 1, + fontSize: 17 + }; + + tabRerender.mockClear(); +}); + describe(ProgrammableRepl, () => { - const replIt = it.extend<{ repl: ProgrammableRepl }>({ - repl: ({}, use) => { - const repl = new ProgrammableRepl(); - repl.setTabReactComponentInstance({ - setState: () => {} - }); - return use(repl); - } - }); - - replIt('calls js-slang when default_js_slang is the evaluator', ({ repl }) => { - vi.spyOn(slang, 'runFilesInContext').mockResolvedValueOnce({ - status: 'error' + it('calls js-slang when default_js_slang is the evaluator', async () => { + vi.spyOn(slang, 'runInContext').mockResolvedValueOnce({ + status: 'error', + context: {} as any }); repl.InvokeREPL_Internal(funcs.default_js_slang); - repl.runCode(); + await repl.runCode('display();', slang.createContext()); - expect(slang.runFilesInContext).toHaveBeenCalledOnce(); + expect(slang.runInContext).toHaveBeenCalledOnce(); + expect(tabRerender).toHaveBeenCalledOnce(); }); - replIt('calls the evaluator when another evaluator is provided', ({ repl }) => { + it('calls the evaluator when another evaluator is provided', async () => { const evaller = vi.fn(() => 0); repl.InvokeREPL_Internal(evaller); - repl.runCode(); + await repl.runCode('display();', {} as any); expect(evaller).toHaveBeenCalledOnce(); + expect(tabRerender).toHaveBeenCalledOnce(); }); - replIt('calls the easter egg function when no evaluator is provided', ({ repl }) => { - vi.spyOn(repl, 'easterEggFunction'); - repl.runCode(); + it('calls the easter egg function when no evaluator is provided', async () => { + await repl.runCode('display();', {} as any); expect(repl.easterEggFunction).toHaveBeenCalledOnce(); + expect(tabRerender).toHaveBeenCalledOnce(); + }); + + describe(funcs.rich_repl_display, () => { + it('works when passed a string', () => { + expect(() => funcs.rich_repl_display('test')).not.toThrow(); + expect(repl.outputStrings).toEqual([{ + content: 'test', + color: '', + outputMethod: 'richtext' + }]); + }); + + it('works when passed a pair', () => { + expect(() => funcs.rich_repl_display(pair('test', 'clrt#112233'))).not.toThrow(); + expect(repl.outputStrings).toEqual([{ + content: 'test', + color: '', + outputMethod: 'richtext' + }]); + }); + + it('works when passed multiple pairs', () => { + expect(() => funcs.rich_repl_display( + pair(pair('test', 'clrt#112233'), 'bold') + )).not.toThrow(); + + expect(repl.outputStrings).toEqual([ + { + content: 'test', + color: '', + outputMethod: 'richtext' + } + ]); + }); + + it('throws an error when not passed proper content', () => { + expect(() => funcs.rich_repl_display(0 as any)).toThrow(); + expect(() => funcs.rich_repl_display(pair(0, 0) as any)).toThrow(); + }); + + it('throws an error when given an invalid colour directive', () => { + expect(() => funcs.rich_repl_display(pair('test', 'clrx#112233'))).toThrow('rich_repl_display: Unknown colour type "clrx".'); + expect(() => funcs.rich_repl_display(pair('test', 'clrt#gggggg'))) + .toThrow('rich_repl_display: Invalid html colour string "#gggggg". It should start with # and followed by 6 characters representing a hex number.'); + }); }); }); describe(funcs.default_js_slang, () => { it('default_js_slang throws when called', () => { expect(() => funcs.default_js_slang('')) - .toThrowError('Invaild Call: Function "default_js_slang" can not be directly called by user\'s code in editor. You should use it as the parameter of the function "set_evaluator"'); + .toThrow('Invaild Call: Function "default_js_slang" can not be directly called by user\'s code in editor. You should use it as the parameter of the function "set_evaluator"'); }); }); describe(funcs.set_evaluator, () => { it('returns a value that indicates that the repl is initialized', () => { - expect(stringify(funcs.set_evaluator(() => 0))).toEqual(''); + const f = (_t: string) => 0; + expect(stringify(funcs.set_evaluator(f))).toEqual(''); + expect(repl.evalFunction).toBe(f); }); it('throws when the parameter isn\'t a function', () => { expect(() => funcs.set_evaluator(0 as any)) - .toThrowError('set_evaluator expects a function as parameter'); + .toThrow('set_evaluator: Expected function with 1 parameter, got 0.'); + }); +}); + +describe(funcs.set_background_image, () => { + it('sets the background image and alpha', () => { + funcs.set_background_image('https://example.com/image.png', 0.5); + expect(repl.customizedEditorProps.backgroundImageUrl).toBe('https://example.com/image.png'); + expect(repl.customizedEditorProps.backgroundColorAlpha).toBe(0.5); + }); + + it('throws when the alpha is out of range', () => { + expect(() => funcs.set_background_image('https://example.com/image.png', -0.1)) + .toThrow('set_background_image: Expected number between 0 and 1 for background_color_alpha, got -0.1.'); + + expect(() => funcs.set_background_image('https://example.com/image.png', 1.5)) + .toThrow('set_background_image: Expected number between 0 and 1 for background_color_alpha, got 1.5.'); + }); + + it('throws when the image url isn\'t a string', () => { + expect(() => funcs.set_background_image(0 as any, 0.5)) + .toThrow('set_background_image: Expected string for img_url, got 0.'); + }); +}); + +describe(funcs.set_font_size, () => { + it('sets the font size', () => { + funcs.set_font_size(20); + expect(repl.customizedEditorProps.fontSize).toBe(20); + }); + + it('throws when the font size is invalid', () => { + expect(() => funcs.set_font_size(-1)) + .toThrow('set_font_size: Expected integer greater than 0, got -1.'); + + expect(() => funcs.set_font_size(0.5)) + .toThrow('set_font_size: Expected integer greater than 0, got 0.5.'); + + expect(() => funcs.set_font_size('invalid' as any)) + .toThrow('set_font_size: Expected integer greater than 0, got "invalid".'); }); }); diff --git a/src/bundles/repl/src/functions.ts b/src/bundles/repl/src/functions.ts index b2302e6c5d..7c295b06e7 100644 --- a/src/bundles/repl/src/functions.ts +++ b/src/bundles/repl/src/functions.ts @@ -4,11 +4,16 @@ * @author Wang Zihan */ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { assertFunctionOfLength, assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; import context from 'js-slang/context'; +import type { Value } from 'js-slang/dist/types'; +import { stringify } from 'js-slang/dist/utils/stringify'; import { COLOR_REPL_DISPLAY_DEFAULT } from './config'; -import { ProgrammableRepl } from './programmable_repl'; +import { ProgrammableRepl, processRichDisplayContent, type RichDisplayContent } from './programmable_repl'; -const INSTANCE = new ProgrammableRepl(); +/* Exported for testing */ +export const INSTANCE = new ProgrammableRepl(); context.moduleContexts.repl.state = INSTANCE; /** @@ -25,9 +30,8 @@ context.moduleContexts.repl.state = INSTANCE; * @category Main */ export function set_evaluator(evalFunc: (code: string) => any) { - if (typeof evalFunc !== 'function') { - throw new Error(`${set_evaluator.name} expects a function as parameter`); - } + assertFunctionOfLength(evalFunc, 1, set_evaluator.name); + INSTANCE.evalFunction = evalFunc; return { toReplString: () => '' @@ -36,25 +40,32 @@ export function set_evaluator(evalFunc: (code: string) => any) { /** * Display message in Programmable Repl Tab - * If you give a pair as the parameter, it will use the given pair to generate rich text and use rich text display mode to display the string in Programmable Repl Tab with undefined return value (see module description for more information). - * If you give other things as the parameter, it will simply display the toString value of the parameter in Programmable Repl Tab and returns the displayed string itself. + * If given a pair as the parameter, it will use the given pair to generate rich text and use rich text display mode to display + * the string in Programmable Repl Tab * * **Rich Text Display** - * - First you need to `import { repl_display } from "repl";` + * - First you need to `import { rich_repl_display } from "repl";` * - Format: pair(pair("string",style),style)... * - Examples: * * ```js * // A large italic underlined "Hello World" - * repl_display(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic")); + * rich_repl_display(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic")); * * // A large italic underlined "Hello World" in blue - * repl_display(pair(pair(pair(pair(pair("Hello World", "underline"),"italic"), "bold"), "gigantic"), "clrt#0000ff")); + * rich_repl_display(pair(pair(pair(pair(pair("Hello World", "underline"),"italic"), "bold"), "gigantic"), "clrt#0000ff")); * * // A large italic underlined "Hello World" with orange foreground and purple background - * repl_display(pair(pair(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic"), "clrb#A000A0"),"clrt#ff9700")); + * rich_repl_display(pair(pair(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic"), "clrb#A000A0"),"clrt#ff9700")); + * ``` + * To display rich text from within REPL code, you should use the `raw_display` function instead: + * + * ```js + * raw_display(pair("Hello World", "gigantic")); * ``` * + * If the content you provided isn't valid as rich text, then it will be treated as a regular object and displayed as regular text. + * * - Coloring: * - `clrt` stands for text color, `clrb` stands for background color. The color string are in hexadecimal begin with "#" and followed by 6 hexadecimal digits. * - Example: `pair("123","clrt#ff0000")` will produce a red "123"; `pair("456","clrb#00ff00")` will produce a green "456". @@ -72,12 +83,25 @@ export function set_evaluator(evalFunc: (code: string) => any) { * @param content the content you want to display * @category Main */ -export function repl_display(content: any): any { - if (INSTANCE.richDisplayInternal(content) === 'not_rich_text_pair') { - INSTANCE.pushOutputString(content.toString(), COLOR_REPL_DISPLAY_DEFAULT, 'plaintext');// students may set the value of the parameter "str" to types other than a string (for example "repl_display(1)" ). So here I need to first convert the parameter "str" into a string before preceding. - return content; - } - return undefined; +export function rich_repl_display(content: RichDisplayContent): RichDisplayContent { + const result = processRichDisplayContent(content, rich_repl_display.name); + const output = `', 'script', 'javascript', 'eval', 'document', 'window', 'console', 'location']; + for (const word of forbiddenWords) { + if (tmp.indexOf(word) !== -1) { + return word; + } + } + return 'safe'; +} + +const pairStyleToCssStyle: { [pairStyle: string]: string } = { + bold: 'font-weight:bold;', + italic: 'font-style:italic;', + small: 'font-size: 14px;', + medium: 'font-size: 20px;', + large: 'font-size: 25px;', + gigantic: 'font-size: 50px;', + underline: 'text-decoration: underline;' +}; + +/** + * Checks if the given string is a valid hex color identifier + */ +function checkColorStringValidity(htmlColor: string) { + return /#[0-9a-f]{6}/.test(htmlColor.toLowerCase()); +} + +export function processRichDisplayContent(pair_rich_text: RichDisplayContent, func_name: string): string { + if (typeof pair_rich_text === 'string') { + // There MUST be a safe check on users' strings, because users may insert something that can be interpreted as executable JavaScript code when outputing rich text. + const safeCheckResult = xssStringCheck(pair_rich_text); + if (safeCheckResult !== 'safe') { + throw new Error(`${func_name}: For safety, the character/word ${safeCheckResult} is not allowed in rich text output. Please remove it or use plain text output mode and try again.`); + } + return `">${pair_rich_text}`; + } + + if (!is_pair(pair_rich_text)) { + throw new InvalidParameterTypeError('pair or string', pair_rich_text, func_name); + } + + const config_str = tail(pair_rich_text); + if (typeof config_str !== 'string') { + throw new Error(`${func_name}: The tail in style pair should always be a string, but got ${config_str}.`); + } + let style = ''; + if (config_str.substring(0, 3) === 'clr') { + let prefix: string; + switch (config_str[3]) { + case 't': { + prefix = 'color'; + break; + } + case 'b': { + prefix = 'background-color'; + break; + } + default: + throw new Error(`${func_name}: Unknown colour type "${config_str.substring(0, 4)}".`); + } + + const colorHex = config_str.substring(4); + + if (!checkColorStringValidity(colorHex)) { + throw new Error(`${func_name}: Invalid html colour string "${colorHex}". It should start with # and followed by 6 characters representing a hex number.`); + } + + style = `${prefix}:${colorHex};`; + } else { + style = pairStyleToCssStyle[config_str]; + if (style === undefined) { + throw new Error(`${func_name}: Found undefined style "${config_str}" while processing rich text.`); + } + } + return style + processRichDisplayContent(head(pair_rich_text), func_name); +} + export class ProgrammableRepl { - public evalFunction: (code: string) => any; - public userCodeInEditor: string; - public outputStrings: any[]; - private _editorInstance; - private _tabReactComponent: any; + public evalFunction: ((code: string) => any) | null; + public outputStrings: OutputStringEntry[]; + public tabRerenderer?: () => void; + + /** + * Function to call when user code is updated **after** the editor + * has been rendered + */ + public updateUserCode?: (newCode: string) => void; + + /** + * Code that gets displayed when the editor is first rendered. If this is not set, the editor + * will try and load from `localStorage`. + */ + public defaultCode?: string; + // I store editorHeight value separately in here although it is already stored in the module's Tab React component state because I need to keep the editor height // when the Tab component is re-mounted due to the user drags the area between the module's Tab and Source Academy's original REPL to resize the module's Tab height. public editorHeight: number; - public customizedEditorProps = { - backgroundImageUrl: 'no-background-image', + public customizedEditorProps: CustomEditorProps = { + backgroundImageUrl: null, backgroundColorAlpha: 1, fontSize: 17 }; constructor() { - this.evalFunction = () => this.easterEggFunction(); - this.userCodeInEditor = this.getSavedEditorContent(); + this.evalFunction = null; this.outputStrings = []; - this._editorInstance = null;// To be set when calling "SetEditorInstance" in the ProgrammableRepl Tab React Component render function. this.editorHeight = DEFAULT_EDITOR_HEIGHT; - developmentLog(this); } InvokeREPL_Internal(evalFunc: (code: string) => any) { this.evalFunction = evalFunc; } - runCode() { + async runCode(code: string, context: Context) { this.outputStrings = []; let retVal: any; - try { - if (evaluatorSymbol in this.evalFunction) { - retVal = this.runInJsSlang(this.userCodeInEditor); - } else { - retVal = this.evalFunction(this.userCodeInEditor); + + if (this.evalFunction === null) { + retVal = this.easterEggFunction(); + } else if (evaluatorSymbol in this.evalFunction) { + const evalResult = await this.runInJsSlang(code, context); + + if (evalResult.status !== 'finished') { + this.reRenderTab(); + return; } - } catch (exception: any) { - developmentLog(exception); - // If the exception has a start line of -1 and an undefined error property, then this exception is most likely to be "incorrect number of arguments" caused by incorrect number of parameters in the evaluator entry function provided by students with set_evaluator. - if (exception.location.start.line === -1 && exception.error === undefined) { - this.pushOutputString('Error: Unable to use your evaluator to run the code. Does your evaluator entry function contain and only contain exactly one parameter?', COLOR_ERROR_MESSAGE); - } else { + + retVal = evalResult.value; + } else { + try { + retVal = this.evalFunction(code); + } catch (exception: any) { + console.error(exception); this.pushOutputString(`Line ${exception.location.start.line.toString()}: ${exception.error?.message}`, COLOR_ERROR_MESSAGE); + this.reRenderTab(); + return; } - this.reRenderTab(); - return; - } - if (typeof retVal === 'string') { - retVal = `"${retVal}"`; } + // Here must use plain text output mode because retVal contains strings from the users. - this.pushOutputString(retVal, COLOR_RUN_CODE_RESULT); + this.pushOutputString(typeof retVal === 'string' ? retVal: stringify(retVal), COLOR_RUN_CODE_RESULT); this.reRenderTab(); - developmentLog('RunCode finished'); - } - - updateUserCode(code: string) { - this.userCodeInEditor = code; } - // Rich text output method allow output strings to have html tags and css styles. - pushOutputString(content: string, textColor: string, outputMethod: string = 'plaintext') { + /** + * Method for outputting to the REPL instance's own REPL output area. + * Rich text output method allow output strings to have html tags and css styles. + */ + pushOutputString(content: string, textColor: string, outputMethod: OutputStringMethods = 'plaintext') { const tmp = { content: content === undefined ? 'undefined' : content === null ? 'null' : content, color: textColor, @@ -81,164 +190,63 @@ export class ProgrammableRepl { this.outputStrings.push(tmp); } - setEditorInstance(instance: any) { - if (instance === undefined) return; // It seems that when calling this function in gui->render->ref, the React internal calls this function for multiple times (at least two times) , and in at least one call the parameter 'instance' is set to 'undefined'. If I don't add this if statement, the program will throw a runtime error when rendering tab. - this._editorInstance = instance; - this._editorInstance.on('guttermousedown', (e) => { - const breakpointLine = e.getDocumentPosition().row; - developmentLog(breakpointLine); - }); - - this._editorInstance.setOptions({ fontSize: `${this.customizedEditorProps.fontSize.toString()}pt` }); - } - - richDisplayInternal(pair_rich_text) { - developmentLog(pair_rich_text); - const head = (pair) => pair[0]; - const tail = (pair) => pair[1]; - const is_pair = (obj) => obj instanceof Array && obj.length === 2; - if (!is_pair(pair_rich_text)) return 'not_rich_text_pair'; - function checkColorStringValidity(htmlColor: string) { - if (htmlColor.length !== 7) return false; - if (htmlColor[0] !== '#') return false; - for (let i = 1; i < 7; i++) { - const char = htmlColor[i]; - developmentLog(` ${char}`); - if (!((char >= '0' && char <= '9') || (char >= 'A' && char <= 'F') || (char >= 'a' && char <= 'f'))) { - return false; - } - } - return true; - } - function recursiveHelper(thisInstance, param): string { - if (typeof param === 'string') { - // There MUST be a safe check on users' strings, because users may insert something that can be interpreted as executable JavaScript code when outputing rich text. - const safeCheckResult = thisInstance.userStringSafeCheck(param); - if (safeCheckResult !== 'safe') { - throw new Error(`For safety matters, the character/word ${safeCheckResult} is not allowed in rich text output. Please remove it or use plain text output mode and try again.`); - } - developmentLog(head(param)); - return `">${param}`; - } - if (!is_pair(param)) { - throw new Error(`Unexpected data type ${typeof param} when processing rich text. It should be a pair.`); - } else { - const pairStyleToCssStyle: { [pairStyle: string]: string } = { - bold: 'font-weight:bold;', - italic: 'font-style:italic;', - small: 'font-size: 14px;', - medium: 'font-size: 20px;', - large: 'font-size: 25px;', - gigantic: 'font-size: 50px;', - underline: 'text-decoration: underline;' - }; - if (typeof tail(param) !== 'string') { - throw new Error(`The tail in style pair should always be a string, but got ${typeof tail(param)}.`); - } - let style = ''; - if (tail(param) - .substring(0, 3) === 'clr') { - let prefix = ''; - if (tail(param)[3] === 't') prefix = 'color:'; - else if (tail(param)[3] === 'b') prefix = 'background-color:'; - else throw new Error('Error when decoding rich text color data'); - const colorHex = tail(param) - .substring(4); - if (!checkColorStringValidity(colorHex)) { - throw new Error(`Invalid html color string ${colorHex}. It should start with # and followed by 6 characters representing a hex number.`); - } - style = `${prefix + colorHex};`; - } else { - style = pairStyleToCssStyle[tail(param)]; - if (style === undefined) { - throw new Error(`Found undefined style ${tail(param)} during processing rich text.`); - } - } - return style + recursiveHelper(thisInstance, head(param)); - } - } - this.pushOutputString(`', 'script', 'javascript', 'eval', 'document', 'window', 'console', 'location']; - for (const word of forbiddenWords) { - if (tmp.indexOf(word) !== -1) { - return word; - } - } - return 'safe'; - } - /* Directly invoking Source Academy's builtin js-slang runner. Needs hard-coded support from js-slang part for the "sourceRunner" function and "backupContext" property in the content object for this to work. */ - runInJsSlang(code: string): string { - developmentLog('js-slang context:'); - // console.log(context); + async runInJsSlang(code: string, context: Context): Promise { const options: Partial = { originalMaxExecTime: 1000, stepLimit: 1000, throwInfiniteLoops: true, useSubst: false }; - context.prelude = 'const display=(x)=>repl_display(x);'; - context.errors = []; // Here if I don't manually clear the "errors" array in context, the remaining errors from the last evaluation will stop the function "preprocessFileImports" in preprocessor.ts of js-slang thus stop the whole evaluation. - const sourceFile: Record = { - '/ReplModuleUserCode.js': code - }; - runFilesInContext(sourceFile, '/ReplModuleUserCode.js', context, options) - .then((evalResult) => { - if (evalResult.status === 'suspended-cse-eval') { - throw new Error('This should not happen'); - } - if (evalResult.status !== 'error') { - this.pushOutputString('js-slang program finished with value:', COLOR_RUN_CODE_RESULT); - // Here must use plain text output mode because evalResult.value contains strings from the users. - this.pushOutputString(evalResult.value === undefined ? 'undefined' : evalResult.value.toString(), COLOR_RUN_CODE_RESULT); - } else { - const errors = context.errors; - console.log(errors); - const errorCount = errors.length; - for (let i = 0; i < errorCount; i++) { - const error = errors[i]; - if (error.explain() - .indexOf('Name repl_display not declared.') !== -1) { - this.pushOutputString('[Error] It seems that you haven\'t imported the function "repl_display" correctly when calling "set_evaluator" in Source Academy\'s main editor.', COLOR_ERROR_MESSAGE); - } else this.pushOutputString(`Line ${error.location.start.line}: ${error.type} Error: ${error.explain()} (${error.elaborate()})`, COLOR_ERROR_MESSAGE); + const evalContext = createContext( + context.chapter, + context.variant, + context.languageOptions, + context.externalSymbols, + context.externalContext, + { + rawDisplay: value => { + if (is_pair(value)) { + try { + // Try to decode the value as rich display content + const result = processRichDisplayContent(value as any, 'display'); + const output = ` { it('throws when argument is not rune', () => { - expect(() => display.anaglyph(0 as any)).toThrowError('anaglyph expects a rune as argument'); + expect(() => display.anaglyph(0 as any)).toThrowError('anaglyph: Expected Rune, got 0.'); }); it('returns the rune passed to it', () => { @@ -16,7 +16,7 @@ describe(display.anaglyph, () => { describe(display.hollusion, () => { it('throws when argument is not rune', () => { - expect(() => display.hollusion(0 as any)).toThrowError('hollusion expects a rune as argument'); + expect(() => display.hollusion(0 as any)).toThrowError('hollusion: Expected Rune, got 0.'); }); it('returns the rune passed to it', () => { @@ -26,7 +26,7 @@ describe(display.hollusion, () => { describe(display.show, () => { it('throws when argument is not rune', () => { - expect(() => display.show(0 as any)).toThrowError('show expects a rune as argument'); + expect(() => display.show(0 as any)).toThrowError('show: Expected Rune, got 0.'); }); it('returns the rune passed to it', () => { @@ -55,25 +55,25 @@ describe(funcs.color, () => { }); it('throws when argument is not rune', () => { - expect(() => funcs.color(0 as any, 0, 0, 0)).toThrowError('color expects a rune as argument'); + expect(() => funcs.color(0 as any, 0, 0, 0)).toThrowError('color: Expected Rune, got 0.'); }); it('throws when any color parameter is invalid', () => { - expect(() => funcs.color(funcs.heart, 100, 0, 0)).toThrowError('r cannot be greater than 1!'); - expect(() => funcs.color(funcs.heart, 0, -1, 0)).toThrowError('g cannot be less than 0!'); - expect(() => funcs.color(funcs.heart, 0, 0, 'hi' as any)).toThrowError('b must be a number!'); + expect(() => funcs.color(funcs.heart, 100, 0, 0)).toThrowError('color: Expected number between 0 and 1 for r, got 100.'); + expect(() => funcs.color(funcs.heart, 0, -1, 0)).toThrowError('color: Expected number between 0 and 1 for g, got -1.'); + expect(() => funcs.color(funcs.heart, 0, 0, 'hi' as any)).toThrowError('color: Expected number between 0 and 1 for b, got "hi".'); }); }); describe(funcs.beside_frac, () => { it('throws when argument is not rune', () => { - expect(() => funcs.beside_frac(0, 0 as any, funcs.heart)).toThrowError('beside_frac expects a rune as argument'); - expect(() => funcs.beside_frac(0, funcs.heart, 0 as any)).toThrowError('beside_frac expects a rune as argument'); + expect(() => funcs.beside_frac(0, 0 as any, funcs.heart)).toThrowError('beside_frac: Expected Rune, got 0.'); + expect(() => funcs.beside_frac(0, funcs.heart, 0 as any)).toThrowError('beside_frac: Expected Rune, got 0.'); }); it('throws when frac is out of range', () => { - expect(() => funcs.beside_frac(-1, funcs.heart, funcs.heart)).toThrowError('beside_frac: frac cannot be less than 0!'); - expect(() => funcs.beside_frac(10, funcs.heart, funcs.heart)).toThrowError('beside_frac: frac cannot be greater than 1!'); + expect(() => funcs.beside_frac(-1, funcs.heart, funcs.heart)).toThrowError('beside_frac: Expected number between 0 and 1 for frac, got -1.'); + expect(() => funcs.beside_frac(10, funcs.heart, funcs.heart)).toThrowError('beside_frac: Expected number between 0 and 1 for frac, got 10.'); }); }); @@ -88,13 +88,13 @@ describe(funcs.beside, () => { describe(funcs.stack_frac, () => { it('throws when argument is not rune', () => { - expect(() => funcs.stack_frac(0, 0 as any, funcs.heart)).toThrowError('stack_frac expects a rune as argument'); - expect(() => funcs.stack_frac(0, funcs.heart, 0 as any)).toThrowError('stack_frac expects a rune as argument'); + expect(() => funcs.stack_frac(0, 0 as any, funcs.heart)).toThrowError('stack_frac: Expected Rune, got 0.'); + expect(() => funcs.stack_frac(0, funcs.heart, 0 as any)).toThrowError('stack_frac: Expected Rune, got 0.'); }); it('throws when frac is out of range', () => { - expect(() => funcs.stack_frac(-1, funcs.heart, funcs.heart)).toThrowError('stack_frac: frac cannot be less than 0!'); - expect(() => funcs.stack_frac(10, funcs.heart, funcs.heart)).toThrowError('stack_frac: frac cannot be greater than 1!'); + expect(() => funcs.stack_frac(-1, funcs.heart, funcs.heart)).toThrowError('stack_frac: Expected number between 0 and 1 for frac, got -1.'); + expect(() => funcs.stack_frac(10, funcs.heart, funcs.heart)).toThrowError('stack_frac: Expected number between 0 and 1 for frac, got 10.'); }); }); @@ -102,11 +102,11 @@ describe(funcs.stackn, () => { vi.spyOn(funcs.RuneFunctions, 'stack_frac'); it('throws when argument is not rune', () => { - expect(() => funcs.stackn(0, 0 as any)).toThrowError('stackn expects a rune as argument'); + expect(() => funcs.stackn(0, 0 as any)).toThrowError('stackn: Expected Rune, got 0.'); }); it('throws when n is not an integer', () => { - expect(() => funcs.stackn(0.1, funcs.heart)).toThrowError('stackn expects an integer'); + expect(() => funcs.stackn(0.1, funcs.heart)).toThrowError('stackn: Expected integer, got 0.1.'); }); it('simply returns when n <= 1', () => { @@ -123,21 +123,32 @@ describe(funcs.stackn, () => { describe(funcs.repeat_pattern, () => { it('simply returns if n <= 0', () => { - const mockPattern = vi.fn(); + const mockPattern = vi.fn(x => x); expect(funcs.repeat_pattern(0, mockPattern, funcs.blank)).toBe(funcs.blank); expect(mockPattern).not.toHaveBeenCalled(); }); + + it('works', () => { + const mockPattern = vi.fn(x => x); + expect(funcs.repeat_pattern(5, mockPattern, funcs.blank)).toBe(funcs.blank); + expect(mockPattern).toHaveBeenCalledTimes(5); + }); + + it('throws if initial is not a rune', () => { + expect(() => funcs.repeat_pattern(5, x => x, 0 as any)) + .toThrowError('repeat_pattern: Expected Rune for initial, got 0.'); + }); }); describe(funcs.overlay_frac, () => { it('throws when argument is not rune', () => { - expect(() => funcs.overlay_frac(0, 0 as any, funcs.heart)).toThrowError('overlay_frac expects a rune as argument'); - expect(() => funcs.overlay_frac(0, funcs.heart, 0 as any)).toThrowError('overlay_frac expects a rune as argument'); + expect(() => funcs.overlay_frac(0, 0 as any, funcs.heart)).toThrowError('overlay_frac: Expected Rune for rune1, got 0.'); + expect(() => funcs.overlay_frac(0, funcs.heart, 0 as any)).toThrowError('overlay_frac: Expected Rune for rune2, got 0.'); }); it('throws when frac is out of range', () => { - expect(() => funcs.overlay_frac(-1, funcs.heart, funcs.heart)).toThrowError('overlay_frac: frac cannot be less than 0!'); - expect(() => funcs.overlay_frac(10, funcs.heart, funcs.heart)).toThrowError('overlay_frac: frac cannot be greater than 1!'); + expect(() => funcs.overlay_frac(-1, funcs.heart, funcs.heart)).toThrowError('overlay_frac: Expected number between 0 and 1 for frac, got -1.'); + expect(() => funcs.overlay_frac(10, funcs.heart, funcs.heart)).toThrowError('overlay_frac: Expected number between 0 and 1 for frac, got 10.'); }); }); @@ -150,7 +161,7 @@ describe('Colouring functions', () => { describe.each(colourers)('%s', (_, f) => { it('throws when argument is not rune', () => { - expect(() => f(0 as any)).toThrowError(`${f.name} expects a rune as argument`); + expect(() => f(0 as any)).toThrowError(`${f.name}: Expected Rune, got 0.`); }); it('does not modify the original rune', () => { diff --git a/src/bundles/rune/src/display.ts b/src/bundles/rune/src/display.ts index c00eb000de..0e82791a75 100644 --- a/src/bundles/rune/src/display.ts +++ b/src/bundles/rune/src/display.ts @@ -1,3 +1,4 @@ +import { assertFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import context from 'js-slang/context'; import { AnaglyphRune, HollusionRune } from './functions'; import { AnimatedRune, NormalRune, Rune, type DrawnRune, type RuneAnimation } from './rune'; @@ -43,7 +44,9 @@ class RuneDisplay { @functionDeclaration('duration: number, fps: number, func: RuneAnimation', 'AnimatedRune') static animate_rune(duration: number, fps: number, func: RuneAnimation) { - const anim = new AnimatedRune(duration, fps, (n) => { + assertFunctionOfLength(func, 1, RuneDisplay.animate_rune.name, 'RuneAnimation'); + + const anim = new AnimatedRune(duration, fps, n => { const rune = func(n); throwIfNotRune(RuneDisplay.animate_rune.name, rune); return new NormalRune(rune); @@ -54,7 +57,9 @@ class RuneDisplay { @functionDeclaration('duration: number, fps: number, func: RuneAnimation', 'AnimatedRune') static animate_anaglyph(duration: number, fps: number, func: RuneAnimation) { - const anim = new AnimatedRune(duration, fps, (n) => { + assertFunctionOfLength(func, 1, RuneDisplay.animate_anaglyph.name, 'RuneAnimation'); + + const anim = new AnimatedRune(duration, fps, n => { const rune = func(n); throwIfNotRune(RuneDisplay.animate_anaglyph.name, rune); return new AnaglyphRune(rune); diff --git a/src/bundles/rune/src/functions.ts b/src/bundles/rune/src/functions.ts index a4d3ea6695..df38ef4c0c 100644 --- a/src/bundles/rune/src/functions.ts +++ b/src/bundles/rune/src/functions.ts @@ -1,3 +1,5 @@ +import { repeat_internal } from '@sourceacademy/bundle-repeat/functions'; +import { assertFunctionOfLength, assertNumberWithinRange } from '@sourceacademy/modules-lib/utilities'; import { clamp } from 'es-toolkit'; import { mat4, vec3 } from 'gl-matrix'; import { @@ -35,15 +37,7 @@ export type RuneModuleState = { }; function throwIfNotFraction(val: unknown, param_name: string, func_name: string): asserts val is number { - if (typeof val !== 'number') throw new Error(`${func_name}: ${param_name} must be a number!`); - - if (val < 0) { - throw new Error(`${func_name}: ${param_name} cannot be less than 0!`); - } - - if (val > 1) { - throw new Error(`${func_name}: ${param_name} cannot be greater than 1!`); - } + assertNumberWithinRange(val, func_name, 0, 1, false, param_name); } // ============================================================================= @@ -109,6 +103,9 @@ export class RuneFunctions { rune: Rune ): Rune { throwIfNotRune(RuneFunctions.scale_independent.name, rune); + assertNumberWithinRange(ratio_x, { func_name: RuneFunctions.scale_independent.name, param_name: 'ratio_x', integer: false }); + assertNumberWithinRange(ratio_y, { func_name: RuneFunctions.scale_independent.name, param_name: 'ratio_y', integer: false }); + const scaleVec = vec3.fromValues(ratio_x, ratio_y, 1); const scaleMat = mat4.create(); mat4.scale(scaleMat, scaleMat, scaleVec); @@ -158,8 +155,8 @@ export class RuneFunctions { @functionDeclaration('frac: number, rune1: Rune, rune2: Rune', 'Rune') static stack_frac(frac: number, rune1: Rune, rune2: Rune): Rune { - throwIfNotRune(RuneFunctions.stack_frac.name, rune1); - throwIfNotRune(RuneFunctions.stack_frac.name, rune2); + throwIfNotRune(RuneFunctions.stack_frac.name, rune1, 'rune1'); + throwIfNotRune(RuneFunctions.stack_frac.name, rune2, 'rune2'); throwIfNotFraction(frac, 'frac', RuneFunctions.stack_frac.name); const upper = RuneFunctions.translate(0, -(1 - frac), RuneFunctions.scale_independent(1, frac, rune1)); @@ -171,21 +168,23 @@ export class RuneFunctions { @functionDeclaration('rune1: Rune, rune2: Rune', 'Rune') static stack(rune1: Rune, rune2: Rune): Rune { - throwIfNotRune(RuneFunctions.stack.name, rune1); - throwIfNotRune(RuneFunctions.stack.name, rune2); + throwIfNotRune(RuneFunctions.stack.name, rune1, 'rune1'); + throwIfNotRune(RuneFunctions.stack.name, rune2, 'rune2'); return RuneFunctions.stack_frac(1 / 2, rune1, rune2); } @functionDeclaration('n: number, rune: Rune', 'Rune') static stackn(n: number, rune: Rune): Rune { throwIfNotRune(RuneFunctions.stackn.name, rune); - if (!Number.isInteger(n)) { - throw new Error(`${RuneFunctions.stackn.name} expects an integer!`); - } + + assertNumberWithinRange(n, { + func_name: RuneFunctions.stackn.name + }); if (n <= 1) { return rune; } + return RuneFunctions.stack_frac(1 / n, rune, RuneFunctions.stackn(n - 1, rune)); } @@ -209,8 +208,8 @@ export class RuneFunctions { @functionDeclaration('frac: number, rune1: Rune, rune2: Rune', 'Rune') static beside_frac(frac: number, rune1: Rune, rune2: Rune): Rune { - throwIfNotRune(RuneFunctions.beside_frac.name, rune1); - throwIfNotRune(RuneFunctions.beside_frac.name, rune2); + throwIfNotRune(RuneFunctions.beside_frac.name, rune1, 'rune1'); + throwIfNotRune(RuneFunctions.beside_frac.name, rune2, 'rune2'); throwIfNotFraction(frac, 'frac', RuneFunctions.beside_frac.name); const left = RuneFunctions.translate(-(1 - frac), 0, RuneFunctions.scale_independent(frac, 1, rune1)); @@ -222,8 +221,8 @@ export class RuneFunctions { @functionDeclaration('rune1: Rune, rune2: Rune', 'Rune') static beside(rune1: Rune, rune2: Rune): Rune { - throwIfNotRune(RuneFunctions.beside.name, rune1); - throwIfNotRune(RuneFunctions.beside.name, rune2); + throwIfNotRune(RuneFunctions.beside.name, rune1, 'rune1'); + throwIfNotRune(RuneFunctions.beside.name, rune2, 'rune2'); return RuneFunctions.beside_frac(0.5, rune1, rune2); } @@ -254,11 +253,10 @@ export class RuneFunctions { pattern: (a: Rune) => Rune, initial: Rune ): Rune { - if (n <= 0) { - return initial; - } - - return pattern(RuneFunctions.repeat_pattern(n - 1, pattern, initial)); + throwIfNotRune(RuneFunctions.repeat_pattern.name, initial, 'initial'); + assertFunctionOfLength(pattern, 1, RuneFunctions.repeat_pattern.name); + const repeated = repeat_internal(pattern, n); + return repeated(initial); } // ============================================================================= @@ -269,8 +267,8 @@ export class RuneFunctions { static overlay_frac(frac: number, rune1: Rune, rune2: Rune): Rune { // to developer: please read https://www.tutorialspoint.com/webgl/webgl_basics.htm to understand the webgl z-axis interpretation. // The key point is that positive z is closer to the screen. Hence, the image at the back should have smaller z value. Primitive runes have z = 0. - throwIfNotRune(RuneFunctions.overlay_frac.name, rune1); - throwIfNotRune(RuneFunctions.overlay_frac.name, rune2); + throwIfNotRune(RuneFunctions.overlay_frac.name, rune1, 'rune1'); + throwIfNotRune(RuneFunctions.overlay_frac.name, rune2, 'rune2'); throwIfNotFraction(frac, 'frac', RuneFunctions.overlay_frac.name); // by definition, when frac == 0 or 1, the back rune will overlap with the front rune. @@ -305,8 +303,8 @@ export class RuneFunctions { @functionDeclaration('rune1: Rune, rune2: Rune', 'Rune') static overlay(rune1: Rune, rune2: Rune): Rune { - throwIfNotRune(RuneFunctions.overlay.name, rune1); - throwIfNotRune(RuneFunctions.overlay.name, rune2); + throwIfNotRune(RuneFunctions.overlay.name, rune1, 'rune1'); + throwIfNotRune(RuneFunctions.overlay.name, rune2, 'rune2'); return RuneFunctions.overlay_frac(0.5, rune1, rune2); } @@ -955,7 +953,7 @@ export const red = RuneColours.red; * @param {function} pattern - Unary function from Rune to Rune * @param {Rune} initial - The initial Rune * @returns {Rune} - Result of n times application of pattern to initial: - * pattern(pattern(...pattern(pattern(initial))...)) + * `pattern(pattern(...pattern(pattern(initial))...))` * @function * * @category Main diff --git a/src/bundles/rune/src/runes_ops.ts b/src/bundles/rune/src/runes_ops.ts index 2f327ceb4a..5b053b7250 100644 --- a/src/bundles/rune/src/runes_ops.ts +++ b/src/bundles/rune/src/runes_ops.ts @@ -1,14 +1,17 @@ /** * This file contains the bundle's private functions for runes. */ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import { hexToColor as hexToColorUtil } from '@sourceacademy/modules-lib/utilities'; import { Rune } from './rune'; // ============================================================================= // Utility Functions // ============================================================================= -export function throwIfNotRune(name: string, rune: unknown): asserts rune is Rune { - if (!(rune instanceof Rune)) throw new Error(`${name} expects a rune as argument.`); +export function throwIfNotRune(func_name: string, rune: unknown, param_name?: string): asserts rune is Rune { + if (!(rune instanceof Rune)) { + throw new InvalidParameterTypeError('Rune', rune, func_name, param_name); + } } // ============================================================================= diff --git a/src/bundles/rune_in_words/package.json b/src/bundles/rune_in_words/package.json index 941518069d..af40652333 100644 --- a/src/bundles/rune_in_words/package.json +++ b/src/bundles/rune_in_words/package.json @@ -16,8 +16,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/scrabble/package.json b/src/bundles/scrabble/package.json index baa402eebe..32021590c4 100644 --- a/src/bundles/scrabble/package.json +++ b/src/bundles/scrabble/package.json @@ -16,8 +16,9 @@ "test": "buildtools test --project .", "tsc": "buildtools tsc .", "lint": "buildtools lint .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/scrabble/src/functions.ts b/src/bundles/scrabble/src/functions.ts index 4ec5162bd6..7315b45f26 100644 --- a/src/bundles/scrabble/src/functions.ts +++ b/src/bundles/scrabble/src/functions.ts @@ -1,12 +1,5 @@ import scrabble_words_raw from './words.json' with { type: 'json' }; -/** - * The `scrabble` Source Module provides the allowable - * words in Scrabble in a list and in an array, according to - * https://github.com/benjamincrom/scrabble/blob/master/scrabble/dictionary.json - * @module scrabble - */ - /** * `scrabble_words` is an array of strings, each representing * an allowed word in Scrabble. diff --git a/src/bundles/scrabble/src/index.ts b/src/bundles/scrabble/src/index.ts index 28147eda01..bbdf8e09e3 100644 --- a/src/bundles/scrabble/src/index.ts +++ b/src/bundles/scrabble/src/index.ts @@ -1,7 +1,10 @@ /** - * Scrabble words for Source Academy - * @author Martin Henz + * The `scrabble` Source Module provides the allowable + * words in Scrabble in a list and in an array, according to + * https://github.com/benjamincrom/scrabble/blob/master/scrabble/dictionary.json + * * @module scrabble + * @author Martin Henz */ export { scrabble_words, diff --git a/src/bundles/sound/package.json b/src/bundles/sound/package.json index 720c5f43e7..cb05808913 100644 --- a/src/bundles/sound/package.json +++ b/src/bundles/sound/package.json @@ -1,14 +1,14 @@ { "name": "@sourceacademy/bundle-sound", - "version": "1.0.0", + "version": "1.1.0", "private": true, "dependencies": { "@sourceacademy/bundle-midi": "workspace:^", + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85" }, "devDependencies": { "@sourceacademy/modules-buildtools": "workspace:^", - "@sourceacademy/modules-lib": "workspace:^", "typescript": "^5.8.2" }, "type": "module", @@ -23,8 +23,9 @@ "lint": "buildtools lint .", "tsc": "buildtools tsc .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/sound/src/__tests__/recording.test.ts b/src/bundles/sound/src/__tests__/recording.test.ts index b90d0fe763..3788787514 100644 --- a/src/bundles/sound/src/__tests__/recording.test.ts +++ b/src/bundles/sound/src/__tests__/recording.test.ts @@ -42,12 +42,14 @@ describe(funcs.init_record, () => { test('sets stream correctly when permission is accepted', async () => { expect(funcs.init_record()).toEqual('obtaining recording permission'); await expect.poll(() => funcs.globalVars.stream).toBe(mockStream); + expect(mockedGetUserMedia).toHaveBeenCalledOnce(); }); test('sets stream to false when permission is rejected', async () => { mockedGetUserMedia.mockRejectedValueOnce(''); expect(funcs.init_record()).toEqual('obtaining recording permission'); await expect.poll(() => funcs.globalVars.stream).toEqual(false); + expect(mockedGetUserMedia).toHaveBeenCalledOnce(); }); }); @@ -63,12 +65,12 @@ describe('Recording functions', () => { describe(funcs.record, () => { test('throws error if called without init_record', () => { - expect(() => funcs.record(0)).toThrowError('record: Call init_record(); to obtain permission to use microphone'); + expect(() => funcs.record(0)).toThrow('record: Call init_record(); to obtain permission to use microphone'); }); test('throws error if called concurrently with another sound', () => { - funcs.play_wave(() => 0, 10); - expect(() => funcs.record(1)).toThrowError('record: Cannot record while another sound is playing!'); + funcs.play_wave(_t => 0, 10); + expect(() => funcs.record(1)).toThrow('record: Cannot record while another sound is playing!'); }); test(`${funcs.record.name} works`, async () => { @@ -89,7 +91,7 @@ describe('Recording functions', () => { mockAudioContext.close(); // End the recording signal playing // Resolving the promise before processing is done throws an error - expect(soundPromise).toThrowError('recording still being processed'); + expect(soundPromise).toThrow('recording still being processed'); expect(stringify(soundPromise)).toEqual(''); const mockRecordedSound = funcs.silence_sound(0); @@ -101,12 +103,12 @@ describe('Recording functions', () => { describe(funcs.record_for, () => { test('throws error if called without init_record', () => { - expect(() => funcs.record_for(0, 0)).toThrowError('record_for: Call init_record(); to obtain permission to use microphone'); + expect(() => funcs.record_for(0, 0)).toThrow('record_for: Call init_record(); to obtain permission to use microphone'); }); test('throws error if called concurrently with another sound', () => { - funcs.play_wave(() => 0, 10); - expect(() => funcs.record_for(1, 1)).toThrowError('record_for: Cannot record while another sound is playing!'); + funcs.play_wave(_t => 0, 10); + expect(() => funcs.record_for(1, 1)).toThrow('record_for: Cannot record while another sound is playing!'); }); test(`${funcs.record_for.name} works`, async () => { diff --git a/src/bundles/sound/src/__tests__/sound.test.ts b/src/bundles/sound/src/__tests__/sound.test.ts index ea9af73b35..17238aa622 100644 --- a/src/bundles/sound/src/__tests__/sound.test.ts +++ b/src/bundles/sound/src/__tests__/sound.test.ts @@ -1,6 +1,7 @@ +import { stringify } from 'js-slang/dist/utils/stringify'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import * as funcs from '../functions'; -import * as play_in_tab from '../play_in_tab'; +import { play_in_tab } from '../play_in_tab'; import type { Sound, Wave } from '../types'; import { mockAudioContext } from './utils'; @@ -8,17 +9,22 @@ vi.stubGlobal('AudioContext', function () { return mockAudioContext; }); describe(funcs.make_sound, () => { it('Should error gracefully when duration is negative', () => { - expect(() => funcs.make_sound(() => 0, -1)) - .toThrow('make_sound: Sound duration must be greater than or equal to 0'); + expect(() => funcs.make_sound(_t => 0, -1)) + .toThrow('make_sound: Expected number greater than 0 for duration, got -1.'); }); it('Should not error when duration is zero', () => { - expect(() => funcs.make_sound(() => 0, 0)).not.toThrow(); + expect(() => funcs.make_sound(_t => 0, 0)).not.toThrow(); }); it('Should error gracefully when wave is not a function', () => { expect(() => funcs.make_sound(true as any, 1)) - .toThrow('make_sound expects a wave, got true'); + .toThrow('make_sound: Expected Wave, got true'); + }); + + it('Should error if the provided function does not take exactly one parameter', () => { + expect(() => funcs.make_sound(((_t, _u) => 0) as any, 1)) + .toThrow('make_sound: Expected Wave, got (_t, _u) => 0.'); }); }); @@ -33,84 +39,94 @@ describe('Concurrent playback functions', () => { describe(funcs.play, () => { it('Should error gracefully when duration is negative', () => { - const sound: Sound = [() => 0, -1]; + const sound: Sound = [_t => 0, -1]; expect(() => funcs.play(sound)) - .toThrow('play: duration of sound is negative'); + .toThrow('play: Expected number greater than 0 for duration, got -1.'); }); it('Should not error when duration is zero', () => { - const sound = funcs.make_sound(() => 0, 0); + const sound = funcs.make_sound(_t => 0, 0); expect(() => funcs.play(sound)).not.toThrow(); }); it('Should throw error when given not a sound', () => { - expect(() => funcs.play(0 as any)).toThrow('play is expecting sound, but encountered 0'); + expect(() => funcs.play(0 as any)).toThrow('play: Expected sound, got 0.'); + }); + + it('Should throw error if sound returns non-number', () => { + expect(() => funcs.play(funcs.make_sound(t => t > 0.5 ? 1 : 'a' as any, 5))) + .toThrow('play: Provided Sound returned a non-numeric value "a".'); }); test('Concurrently playing two sounds should error', () => { const sound = funcs.silence_sound(10); expect(() => funcs.play(sound)).not.toThrow(); - expect(() => funcs.play(sound)).toThrowError('play: Previous sound still playing'); + expect(() => funcs.play(sound)).toThrow('play: Previous sound still playing'); }); }); describe(funcs.play_wave, () => { it('Should error gracefully when duration is negative', () => { - expect(() => funcs.play_wave(() => 0, -1)) - .toThrow('play_wave: Sound duration must be greater than or equal to 0'); + expect(() => funcs.play_wave(_t => 0, -1)) + .toThrow('play_wave: Expected number greater than 0 for duration, got -1.'); }); it('Should error gracefully when duration is not a number', () => { - expect(() => funcs.play_wave(() => 0, true as any)) - .toThrow('play_wave expects a number for duration, got true'); + expect(() => funcs.play_wave(_t => 0, true as any)) + .toThrow('play_wave: Expected number greater than 0 for duration, got true.'); }); it('Should error gracefully when wave is not a function', () => { expect(() => funcs.play_wave(true as any, 0)) - .toThrow('play_wave expects a wave, got true'); + .toThrow('play_wave: Expected Wave, got true'); }); test('Concurrently playing two sounds should error', () => { - const wave: Wave = () => 0; + const wave: Wave = _t => 0; expect(() => funcs.play_wave(wave, 10)).not.toThrow(); - expect(() => funcs.play_wave(wave, 10)).toThrowError('play: Previous sound still playing'); + expect(() => funcs.play_wave(wave, 10)).toThrow('play: Previous sound still playing'); }); }); describe(funcs.stop, () => { test('Calling stop without ever calling any playback functions should not throw an error', () => { - expect(funcs.stop).not.toThrowError(); + expect(funcs.stop).not.toThrow(); }); it('sets isPlaying to false', () => { funcs.globalVars.isPlaying = true; - funcs.stop(); + expect(funcs.stop).not.toThrow(); expect(funcs.globalVars.isPlaying).toEqual(false); }); }); }); -describe(play_in_tab.play_in_tab, () => { +describe(play_in_tab, () => { it('Should error gracefully when duration is negative', () => { - const sound = [() => 0, -1]; - expect(() => play_in_tab.play_in_tab(sound as any)) - .toThrow('play_in_tab: duration of sound is negative'); + const sound = [_t => 0, -1]; + expect(() => play_in_tab(sound as any)) + .toThrow('play_in_tab: Expected number greater than 0 for duration, got -1.'); }); it('Should not error when duration is zero', () => { - const sound = funcs.make_sound(() => 0, 0); - expect(() => play_in_tab.play_in_tab(sound)).not.toThrow(); + const sound = funcs.make_sound(_t => 0, 0); + expect(() => play_in_tab(sound)).not.toThrow(); + }); + + it('Should throw error if sound returns non-number', () => { + expect(() => play_in_tab(funcs.make_sound(t => t > 0.5 ? 1 : 'a' as any, 5))) + .toThrow('play_in_tab: Provided Sound returned a non-numeric value "a".'); }); it('Should throw error when given not a sound', () => { - expect(() => play_in_tab.play_in_tab(0 as any)).toThrow('play_in_tab is expecting sound, but encountered 0'); + expect(() => play_in_tab(0 as any)).toThrow('play_in_tab: Expected Sound, got 0.'); }); test('Multiple calls does not cause an error', () => { const sound = funcs.silence_sound(10); - expect(() => play_in_tab.play_in_tab(sound)).not.toThrow(); - expect(() => play_in_tab.play_in_tab(sound)).not.toThrow(); - expect(() => play_in_tab.play_in_tab(sound)).not.toThrow(); + expect(() => play_in_tab(sound)).not.toThrow(); + expect(() => play_in_tab(sound)).not.toThrow(); + expect(() => play_in_tab(sound)).not.toThrow(); }); }); @@ -127,8 +143,8 @@ function evaluateSound(sound: Sound) { describe(funcs.simultaneously, () => { it('works with sounds of the same duration', () => { - const sound0 = funcs.make_sound(() => 1, 10); - const sound1 = funcs.make_sound(() => 0, 10); + const sound0 = funcs.make_sound(_t => 1, 10); + const sound1 = funcs.make_sound(_t => 0, 10); const newSound = funcs.simultaneously([sound0, [sound1, null]]); const points = evaluateSound(newSound); @@ -141,8 +157,8 @@ describe(funcs.simultaneously, () => { }); it('works with sounds of different durations', () => { - const sound0 = funcs.make_sound(() => 1, 10); - const sound1 = funcs.make_sound(() => 2, 5); + const sound0 = funcs.make_sound(_t => 1, 10); + const sound1 = funcs.make_sound(_t => 2, 5); const newSound = funcs.simultaneously([sound0, [sound1, null]]); const points = evaluateSound(newSound); @@ -161,8 +177,8 @@ describe(funcs.simultaneously, () => { describe(funcs.consecutively, () => { it('works', () => { - const sound0 = funcs.make_sound(() => 1, 2); - const sound1 = funcs.make_sound(() => 2, 1); + const sound0 = funcs.make_sound(_t => 1, 2); + const sound1 = funcs.make_sound(_t => 2, 1); const newSound = funcs.consecutively([sound0, [sound1, null]]); const points = evaluateSound(newSound); @@ -175,3 +191,15 @@ describe(funcs.consecutively, () => { expect(points[2]).toEqual(2); }); }); + +describe('Sound transformers', () => { + describe(funcs.phase_mod, () => { + it('throws when given not a sound', () => { + expect(() => funcs.phase_mod(0, 1, 1)(0 as any)).toThrow('SoundTransformer: Expected Sound, got 0'); + }); + + test('returned transformer toReplString representation', () => { + expect(stringify(funcs.phase_mod(0, 1, 1))).toEqual(''); + }); + }); +}); diff --git a/src/bundles/sound/src/functions.ts b/src/bundles/sound/src/functions.ts index 70e7c4f7c4..b467ae59cd 100644 --- a/src/bundles/sound/src/functions.ts +++ b/src/bundles/sound/src/functions.ts @@ -1,4 +1,7 @@ import { midi_note_to_frequency } from '@sourceacademy/bundle-midi'; +import type { MIDINote } from '@sourceacademy/bundle-midi/types'; +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { assertFunctionOfLength, assertNumberWithinRange, isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import { accumulate, head, @@ -6,10 +9,13 @@ import { is_pair, length, list, + map, pair, tail, - type List + type List, + type Pair } from 'js-slang/dist/stdlib/list'; +import { stringify } from 'js-slang/dist/utils/stringify'; import type { Sound, SoundProducer, @@ -187,9 +193,7 @@ export function init_record(): string { * @param buffer - pause before recording, in seconds */ export function record(buffer: number): () => SoundPromise { - if (typeof buffer !== 'number' || buffer < 0) { - throw new Error(`${record.name}: Expected a positive number for buffer, got ${buffer}`); - } + assertNumberWithinRange(buffer, record.name, 0, undefined, false); if (globalVars.isPlaying) { throw new Error(`${record.name}: Cannot record while another sound is playing!`); @@ -218,7 +222,6 @@ export function record(buffer: number): () => SoundPromise { // TODO: Remove when ReplResult is properly implemented promise.toReplString = () => ''; - promise.toString = () => ''; return promise; }; } @@ -275,8 +278,6 @@ export function record_for(duration: number, buffer: number): SoundPromise { }; promise.toReplString = () => ''; - // TODO: Remove when ReplResult is properly implemented - promise.toString = () => ''; return promise; } @@ -284,23 +285,15 @@ export function record_for(duration: number, buffer: number): SoundPromise { * Throws an exception if duration is not a number or if * number is negative */ -function validateDuration(func_name: string, duration: unknown): asserts duration is number { - if (typeof duration !== 'number') { - throw new Error(`${func_name} expects a number for duration, got ${duration}`); - } - - if (duration < 0) { - throw new Error(`${func_name}: Sound duration must be greater than or equal to 0`); - } +export function validateDuration(func_name: string, duration: unknown): asserts duration is number { + assertNumberWithinRange(duration, func_name, 0, undefined, false, 'duration'); } /** * Throws an exception if wave is not a function */ function validateWave(func_name: string, wave: unknown): asserts wave is Wave { - if (typeof wave !== 'function') { - throw new Error(`${func_name} expects a wave, got ${wave}`); - } + assertFunctionOfLength(wave, 1, func_name, 'Wave'); } /** @@ -312,13 +305,16 @@ function validateWave(func_name: string, wave: unknown): asserts wave is Wave { * @param wave wave function of the Sound * @param duration duration of the Sound * @returns with wave as wave function and duration as duration - * @example const s = make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5); + * @example + * ``` + * const s = make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5); + * ``` */ export function make_sound(wave: Wave, duration: number): Sound { validateDuration(make_sound.name, duration); validateWave(make_sound.name, wave); - return pair((t: number) => (t >= duration ? 0 : wave(t)), duration); + return pair(t => (t >= duration ? 0 : wave(t)), duration); } /** @@ -326,7 +322,10 @@ export function make_sound(wave: Wave, duration: number): Sound { * * @param sound given Sound * @returns the wave function of the Sound - * @example get_wave(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns t => Math_sin(2 * Math_PI * 440 * t) + * @example + * ``` + * get_wave(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns t => Math_sin(2 * Math_PI * 440 * t) + * ``` */ export function get_wave(sound: Sound): Wave { return head(sound); @@ -337,7 +336,10 @@ export function get_wave(sound: Sound): Wave { * * @param sound given Sound * @returns the duration of the Sound - * @example get_duration(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns 5 + * @example + * ``` + * get_duration(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns 5 + * ``` */ export function get_duration(sound: Sound): number { return tail(sound); @@ -348,13 +350,16 @@ export function get_duration(sound: Sound): number { * * @param x input to be checked * @returns true if x is a Sound, false otherwise - * @example is_sound(make_sound(t => 0, 2)); // Returns true + * @example + * ``` + * is_sound(make_sound(t => 0, 2)); // Returns true + * ``` */ export function is_sound(x: unknown): x is Sound { return ( is_pair(x) - && typeof get_wave(x) === 'function' - && typeof get_duration(x) === 'number' + && isFunctionOfLength(head(x), 1) + && typeof tail(x) === 'number' ); } @@ -364,7 +369,10 @@ export function is_sound(x: unknown): x is Sound { * * @param wave the wave function to play, starting at 0 * @returns the resulting Sound - * @example play_wave(t => math_sin(t * 3000), 5); + * @example + * ``` + * play_wave(t => math_sin(t * 3000), 5); + * ``` */ export function play_wave(wave: Wave, duration: number): Sound { validateDuration(play_wave.name, duration); @@ -379,22 +387,23 @@ export function play_wave(wave: Wave, duration: number): Sound { * * @param sound the Sound to play * @returns the given Sound - * @example play(sine_sound(440, 5)); + * @example + * ``` + * play(sine_sound(440, 5)); + * ``` */ export function play(sound: Sound): Sound { // Type-check sound if (!is_sound(sound)) { - throw new Error(`${play.name} is expecting sound, but encountered ${sound}`); + throw new InvalidParameterTypeError('sound', sound, play.name); } else if (globalVars.isPlaying) { throw new Error(`${play.name}: Previous sound still playing!`); } const duration = get_duration(sound); - if (duration < 0) { - throw new Error(`${play.name}: duration of sound is negative`); - } else if (duration === 0) { - return sound; - } + validateDuration(play.name, duration); + + if (duration === 0) return sound; const audioplayer = getAudioContext(); @@ -407,12 +416,16 @@ export function play(sound: Sound): Sound { const channel = theBuffer.getChannelData(0); - let temp: number; let prev_value = 0; const wave = get_wave(sound); - for (let i = 0; i < channel.length; i += 1) { - temp = wave(i / FS); + for (let i = 0; i < channel.length; i++) { + const temp = wave(i / FS); + + if (typeof temp !== 'number') { + throw new Error(`${play.name}: Provided Sound returned a non-numeric value ${stringify(temp)}.`); + } + // clip amplitude if (temp > 1) { channel[i] = 1; @@ -460,7 +473,11 @@ export function stop(): void { * * @param duration the duration of the noise sound * @returns resulting noise Sound - * @example noise_sound(5); + * @example + * ``` + * noise_sound(5); + * ``` + * * @category Primitive */ export function noise_sound(duration: number): Sound { @@ -469,11 +486,15 @@ export function noise_sound(duration: number): Sound { } /** - * Makes a silence Sound with given duration + * Makes a silent Sound with given duration * * @param duration the duration of the silence Sound * @returns resulting silence Sound - * @example silence_sound(5); + * @example + * ``` + * silence_sound(5); + * ``` + * * @category Primitive */ export function silence_sound(duration: number): Sound { @@ -487,7 +508,11 @@ export function silence_sound(duration: number): Sound { * @param freq the frequency of the sine wave Sound * @param duration the duration of the sine wave Sound * @returns resulting sine wave Sound - * @example sine_sound(440, 5); + * @example + * ``` + * sine_sound(440, 5); + * ``` + * * @category Primitive */ export function sine_sound(freq: number, duration: number): Sound { @@ -501,7 +526,11 @@ export function sine_sound(freq: number, duration: number): Sound { * @param f the frequency of the square wave Sound * @param duration the duration of the square wave Sound * @returns resulting square wave Sound - * @example square_sound(440, 5); + * @example + * ``` + * square_sound(440, 5); + * ``` + * * @category Primitive */ export function square_sound(f: number, duration: number): Sound { @@ -525,7 +554,11 @@ export function square_sound(f: number, duration: number): Sound { * @param freq the frequency of the triangle wave Sound * @param duration the duration of the triangle wave Sound * @returns resulting triangle wave Sound - * @example triangle_sound(440, 5); + * @example + * ``` + * triangle_sound(440, 5); + * ``` + * * @category Primitive */ export function triangle_sound(freq: number, duration: number): Sound { @@ -551,7 +584,11 @@ export function triangle_sound(freq: number, duration: number): Sound { * @param freq the frequency of the sawtooth wave Sound * @param duration the duration of the sawtooth wave Sound * @returns resulting sawtooth wave Sound - * @example sawtooth_sound(440, 5); + * @example + * ``` + * sawtooth_sound(440, 5); + * ``` + * * @category Primitive */ export function sawtooth_sound(freq: number, duration: number): Sound { @@ -579,9 +616,13 @@ export function sawtooth_sound(freq: number, duration: number): Sound { * * @param list_of_sounds given list of Sounds * @returns the combined Sound - * @example consecutively(list(sine_sound(200, 2), sine_sound(400, 3))); + * @example + * ``` + * const sound = consecutively(list(sine_sound(200, 2), sine_sound(400, 3))); + * play(sound); + * ``` */ -export function consecutively(list_of_sounds: List): Sound { +export function consecutively(list_of_sounds: List): Sound { function consec_two(ss1: Sound, ss2: Sound) { const wave1 = get_wave(ss1); const wave2 = get_wave(ss2); @@ -602,9 +643,13 @@ export function consecutively(list_of_sounds: List): Sound { * * @param list_of_sounds given list of Sounds * @returns the combined Sound - * @example simultaneously(list(sine_sound(200, 2), sine_sound(400, 3))) + * @example + * ``` + * const new_sound = simultaneously(list(sine_sound(200, 2), sine_sound(400, 3))); + * play(new_sound); + * ``` */ -export function simultaneously(list_of_sounds: List): Sound { +export function simultaneously(list_of_sounds: List): Sound { function simul_two(ss1: Sound, ss2: Sound) { const wave1 = get_wave(ss1); const wave2 = get_wave(ss2); @@ -636,6 +681,23 @@ export function simultaneously(list_of_sounds: List): Sound { return make_sound(normalised_wave, highest_duration); } +/** + * Utility function for wrapping Sound transformers. Adds the toReplString representation + * and adds check for verifying that the given input is a Sound. + */ +function wrapSoundTransformer(transformer: SoundTransformer): SoundTransformer { + function wrapped(sound: Sound) { + if (!is_sound(sound)) { + throw new InvalidParameterTypeError('Sound', sound, 'SoundTransformer'); + } + + return transformer(sound); + } + + wrapped.toReplString = () => ''; + return wrapped; +} + /** * Returns an envelope: a function from Sound to Sound. * When the adsr envelope is applied to a Sound, it returns @@ -648,8 +710,11 @@ export function simultaneously(list_of_sounds: List): Sound { * @param decay_ratio proportion of Sound decay phase * @param sustain_level sustain level between 0 and 1 * @param release_ratio proportion of Sound in release phase - * @returns Envelope a function from Sound to Sound - * @example adsr(0.2, 0.3, 0.3, 0.1)(sound); + * @function + * @example + * ``` + * adsr(0.2, 0.3, 0.3, 0.1)(sound); + * ``` */ export function adsr( attack_ratio: number, @@ -657,12 +722,19 @@ export function adsr( sustain_level: number, release_ratio: number ): SoundTransformer { - return sound => { + assertNumberWithinRange(attack_ratio, adsr.name, undefined, undefined, false, 'attack_ratio'); + assertNumberWithinRange(decay_ratio, adsr.name, undefined, undefined, false, 'decay_ratio'); + assertNumberWithinRange(sustain_level, adsr.name, 0, undefined, false, 'sustain_level'); + assertNumberWithinRange(release_ratio, adsr.name, undefined, undefined, false, 'release_ratio'); + + return wrapSoundTransformer(sound => { const wave = get_wave(sound); const duration = get_duration(sound); + const attack_time = duration * attack_ratio; const decay_time = duration * decay_ratio; const release_time = duration * release_ratio; + return make_sound((x) => { if (x < attack_time) { return wave(x) * (x / attack_time); @@ -683,7 +755,7 @@ export function adsr( * linear_decay(release_time)(x - (duration - release_time)) ); }, duration); - }; + }); } /** @@ -699,27 +771,37 @@ export function adsr( * @param base_frequency frequency of the first harmonic * @param duration duration of the produced Sound, in seconds * @param envelopes – list of envelopes, which are functions from Sound to Sound - * @returns Sound resulting Sound - * @example stacking_adsr(sine_sound, 300, 5, list(adsr(0.1, 0.3, 0.2, 0.5), adsr(0.2, 0.5, 0.6, 0.1), adsr(0.3, 0.1, 0.7, 0.3))); + * @returns resulting Sound + * @example + * ``` + * const sound = stacking_adsr( + * sine_sound, + * 300, + * 5, + * list( + * adsr(0.1, 0.3, 0.2, 0.5), + * adsr(0.2, 0.5, 0.6, 0.1), + * adsr(0.3, 0.1, 0.7, 0.3) + * ) + * ); + * play(sound); + * ``` */ export function stacking_adsr( waveform: SoundProducer, base_frequency: number, duration: number, - envelopes: List + envelopes: List ): Sound { - function zip(lst: List, n: number) { + function zip(lst: List, n: number): List> { if (is_null(lst)) { return lst; } return pair(pair(n, head(lst)), zip(tail(lst), n + 1)); } - return simultaneously(accumulate( - (x: any, y: any) => pair(tail(x)(waveform(base_frequency * head(x), duration)), y), - null, - zip(envelopes, 1) - )); + const new_list = map(x => tail(x)(waveform(base_frequency * head(x), duration)), zip(envelopes, 1)); + return simultaneously(new_list); } /** @@ -733,21 +815,27 @@ export function stacking_adsr( * @param freq the frequency of the sine wave to be modulated * @param duration the duration of the output Sound * @param amount the amount of modulation to apply to the carrier sine wave - * @returns function which takes in a Sound and returns a Sound - * @example phase_mod(440, 5, 1)(sine_sound(220, 5)); + * @example + * ``` + * phase_mod(440, 5, 1)(sine_sound(220, 5)); + * ``` */ export function phase_mod( freq: number, duration: number, amount: number ): SoundTransformer { - return modulator => { + assertNumberWithinRange(freq, phase_mod.name, 0, undefined, false); + validateDuration(phase_mod.name, duration); + assertNumberWithinRange(amount, phase_mod.name, undefined, undefined, false); + + return wrapSoundTransformer(modulator => { const wave = get_wave(modulator); return make_sound( t => Math.sin(2 * Math.PI * t * freq + amount * wave(t)), duration ); - }; + }); } // Instruments @@ -758,10 +846,16 @@ export function phase_mod( * @param note MIDI note * @param duration duration in seconds * @returns Sound resulting bell Sound with given pitch and duration - * @example bell(40, 1); + * @example + * ``` + * bell(40, 1); + * ``` + * * @category Instrument */ -export function bell(note: number, duration: number): Sound { +export function bell(note: MIDINote, duration: number): Sound { + validateDuration(bell.name, duration); + return stacking_adsr( square_sound, midi_note_to_frequency(note), @@ -781,10 +875,15 @@ export function bell(note: number, duration: number): Sound { * @param note MIDI note * @param duration duration in seconds * @returns Sound resulting cello Sound with given pitch and duration - * @example cello(36, 5); + * @example + * ``` + * cello(36, 5); + * ``` * @category Instrument */ -export function cello(note: number, duration: number): Sound { +export function cello(note: MIDINote, duration: number): Sound { + validateDuration(cello.name, duration); + return stacking_adsr( square_sound, midi_note_to_frequency(note), @@ -799,11 +898,16 @@ export function cello(note: number, duration: number): Sound { * @param note MIDI note * @param duration duration in seconds * @returns Sound resulting piano Sound with given pitch and duration - * @example piano(48, 5); + * @example + * ``` + * piano(48, 5); + * ``` * @category Instrument * */ -export function piano(note: number, duration: number): Sound { +export function piano(note: MIDINote, duration: number): Sound { + validateDuration(piano.name, duration); + return stacking_adsr( triangle_sound, midi_note_to_frequency(note), @@ -818,10 +922,15 @@ export function piano(note: number, duration: number): Sound { * @param note MIDI note * @param duration duration in seconds * @returns Sound resulting trombone Sound with given pitch and duration - * @example trombone(60, 2); + * @example + * ``` + * trombone(60, 2); + * ``` * @category Instrument */ -export function trombone(note: number, duration: number): Sound { +export function trombone(note: MIDINote, duration: number): Sound { + validateDuration(trombone.name, duration); + return stacking_adsr( square_sound, midi_note_to_frequency(note), @@ -836,10 +945,15 @@ export function trombone(note: number, duration: number): Sound { * @param note MIDI note * @param duration duration in seconds * @returns Sound resulting violin Sound with given pitch and duration - * @example violin(53, 4); + * @example + * ``` + * violin(53, 4); + * ``` * @category Instrument */ -export function violin(note: number, duration: number): Sound { +export function violin(note: MIDINote, duration: number): Sound { + validateDuration(violin.name, duration); + return stacking_adsr( sawtooth_sound, midi_note_to_frequency(note), diff --git a/src/bundles/sound/src/index.ts b/src/bundles/sound/src/index.ts index 6ecf04d96b..6e99609502 100644 --- a/src/bundles/sound/src/index.ts +++ b/src/bundles/sound/src/index.ts @@ -13,7 +13,7 @@ * `(get_wave(sound))(get_duration(sound) + x) === 0` for any x >= 0. * * Two functions which combine Sounds, `consecutively` and `simultaneously` are given. - * Additionally, we provide sound transformation functions `adsr` and `phase_mod` + * Additionally, we provide sound transformation functions like `adsr` and `phase_mod` * which take in a Sound and return a Sound. * * Finally, the provided `play` function takes in a Sound and plays it using your diff --git a/src/bundles/sound/src/play_in_tab.ts b/src/bundles/sound/src/play_in_tab.ts index a55aa04fb1..66e8b2cdc5 100644 --- a/src/bundles/sound/src/play_in_tab.ts +++ b/src/bundles/sound/src/play_in_tab.ts @@ -1,5 +1,7 @@ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import context from 'js-slang/context'; -import { FS, get_duration, get_wave, is_sound } from './functions'; +import { stringify } from 'js-slang/dist/utils/stringify'; +import { FS, get_duration, get_wave, is_sound, validateDuration } from './functions'; import { RIFFWAVE } from './riffwave'; import type { AudioPlayed, Sound } from './types'; @@ -12,34 +14,37 @@ context.moduleContexts.sound.state = { audioPlayed }; * * @param sound the Sound to play * @returns the given Sound - * @example play_in_tab(sine_sound(440, 5)); + * @example + * ``` + * play_in_tab(sine_sound(440, 5)); + * ``` */ export function play_in_tab(sound: Sound): Sound { // Type-check sound if (!is_sound(sound)) { - throw new Error(`${play_in_tab.name} is expecting sound, but encountered ${sound}`); + throw new InvalidParameterTypeError('Sound', sound, play_in_tab.name); } const duration = get_duration(sound); - if (duration < 0) { - throw new Error(`${play_in_tab.name}: duration of sound is negative`); - } else if (duration === 0) { - return sound; - } + validateDuration(play_in_tab.name, duration); + if (duration === 0) return sound; // Create mono buffer const channel: number[] = []; const len = Math.ceil(FS * duration); - let temp: number; let prev_value = 0; const wave = get_wave(sound); for (let i = 0; i < len; i += 1) { - temp = wave(i / FS); + const temp = wave(i / FS); + + if (typeof temp !== 'number') { + throw new Error(`${play_in_tab.name}: Provided Sound returned a non-numeric value ${stringify(temp)}.`); + } + // clip amplitude - // channel[i] = temp > 1 ? 1 : temp < -1 ? -1 : temp; if (temp > 1) { channel[i] = 1; } else if (temp < -1) { @@ -67,7 +72,7 @@ export function play_in_tab(sound: Sound): Sound { riffwave.header.bitsPerSample = 16; riffwave.Make(channel); - const soundToPlay = { + const soundToPlay: AudioPlayed = { toReplString: () => '', dataUri: riffwave.dataURI }; diff --git a/src/bundles/sound_matrix/package.json b/src/bundles/sound_matrix/package.json index 7161042b62..fabb83b6de 100644 --- a/src/bundles/sound_matrix/package.json +++ b/src/bundles/sound_matrix/package.json @@ -19,8 +19,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/stereo_sound/package.json b/src/bundles/stereo_sound/package.json index b8c3a00a93..719a11437d 100644 --- a/src/bundles/stereo_sound/package.json +++ b/src/bundles/stereo_sound/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@sourceacademy/bundle-midi": "workspace:^", + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85" }, "type": "module", @@ -20,8 +21,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/stereo_sound/src/functions.ts b/src/bundles/stereo_sound/src/functions.ts index c8c193c3dd..50c1f80f76 100644 --- a/src/bundles/stereo_sound/src/functions.ts +++ b/src/bundles/stereo_sound/src/functions.ts @@ -1,4 +1,5 @@ import { midi_note_to_frequency } from '@sourceacademy/bundle-midi'; +import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import context from 'js-slang/context'; import { accumulate, @@ -7,9 +8,11 @@ import { is_pair, length, list, + map, pair, tail, - type List + type List, + type Pair } from 'js-slang/dist/stdlib/list'; import { RIFFWAVE } from './riffwave'; import type { @@ -339,12 +342,19 @@ export function get_duration(sound: Sound): number { * @example is_sound(make_sound(t => 0, 2)); // Returns true */ export function is_sound(x: unknown): x is Sound { - return ( - is_pair(x) - && typeof get_left_wave(x) === 'function' - && typeof get_right_wave(x) === 'function' - && typeof get_duration(x) === 'number' - ); + if (!is_pair(x)) return false; + + const waves = head(x); + if (!is_pair(waves)) return false; + + const left_wave = head(waves); + if (!isFunctionOfLength(left_wave, 1)) return false; + + const right_wave = head(waves); + if (!isFunctionOfLength(right_wave, 1)) return false; + + const duration = tail(x); + return typeof duration === 'number'; } /** @@ -789,7 +799,7 @@ export function sawtooth_sound(freq: number, duration: number): Sound { * @returns the combined Sound * @example consecutively(list(sine_sound(200, 2), sine_sound(400, 3))); */ -export function consecutively(list_of_sounds: List): Sound { +export function consecutively(list_of_sounds: List): Sound { function stereo_cons_two(sound1: Sound, sound2: Sound) { const Lwave1 = get_left_wave(sound1); const Rwave1 = get_right_wave(sound1); @@ -815,7 +825,7 @@ export function consecutively(list_of_sounds: List): Sound { * @returns the combined Sound * @example simultaneously(list(sine_sound(200, 2), sine_sound(400, 3))) */ -export function simultaneously(list_of_sounds: List): Sound { +export function simultaneously(list_of_sounds: List): Sound { function stereo_simul_two(sound1: Sound, sound2: Sound) { const Lwave1 = get_left_wave(sound1); const Rwave1 = get_right_wave(sound1); @@ -916,20 +926,17 @@ export function stacking_adsr( waveform: SoundProducer, base_frequency: number, duration: number, - envelopes: List + envelopes: List ): Sound { - function zip(lst: List, n: number) { + function zip(lst: List, n: number): List> { if (is_null(lst)) { return lst; } return pair(pair(n, head(lst)), zip(tail(lst), n + 1)); } - return simultaneously(accumulate( - (x: any, y: any) => pair(tail(x)(waveform(base_frequency * head(x), duration)), y), - null, - zip(envelopes, 1) - )); + const new_list = map(x => tail(x)(waveform(base_frequency * head(x), duration)), zip(envelopes, 1)); + return simultaneously(new_list); } /** diff --git a/src/bundles/unittest/package.json b/src/bundles/unittest/package.json index 9e380e5ea8..f6f5a83ee8 100644 --- a/src/bundles/unittest/package.json +++ b/src/bundles/unittest/package.json @@ -20,8 +20,9 @@ "test": "buildtools test --project .", "tsc": "buildtools tsc .", "lint": "buildtools lint .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/unittest/src/__tests__/index.test.ts b/src/bundles/unittest/src/__tests__/index.test.ts index 4890a8332f..085f07a34f 100644 --- a/src/bundles/unittest/src/__tests__/index.test.ts +++ b/src/bundles/unittest/src/__tests__/index.test.ts @@ -10,10 +10,10 @@ vi.spyOn(performance, 'now').mockReturnValue(0); describe('Test \'it\' and \'describe\'', () => { beforeEach(() => { - testing.suiteResults.splice(0); + testing.topLevelSuiteResults.splice(0); }); - test('it and describe correctly set and resets the value of current test and suite', () => { + test('it() and describe() correctly set and resets the value of current test and suite', () => { expect(testing.currentTest).toBeNull(); expect(testing.currentSuite).toBeNull(); testing.describe('suite', () => { @@ -28,12 +28,12 @@ describe('Test \'it\' and \'describe\'', () => { }); test('it() throws an error when called without describe', () => { - expect(() => testing.it('desc', () => {})).toThrowError('it must be called from within a test suite!'); + expect(() => testing.it('desc', () => {})).toThrow('it must be called from within a test suite!'); }); test('it() throws an error even if it is called after describe', () => { testing.describe('a test', () => {}); - expect(() => testing.it('desc', () => {})).toThrowError('it must be called from within a test suite!'); + expect(() => testing.it('desc', () => {})).toThrow('it must be called from within a test suite!'); }); test('it() works fine from within a describe block', () => { @@ -53,8 +53,8 @@ describe('Test \'it\' and \'describe\'', () => { testing.it('test2', () => {}); }); - expect(testing.suiteResults.length).toEqual(2); - const [result1, result2] = testing.suiteResults; + expect(testing.topLevelSuiteResults.length).toEqual(2); + const [result1, result2] = testing.topLevelSuiteResults; expect(result1).toMatchObject({ name: 'block1', results: [{ name: 'test1', passed: true }], @@ -84,8 +84,8 @@ describe('Test \'it\' and \'describe\'', () => { testing.it('test2', () => {}); }); - expect(testing.suiteResults.length).toEqual(2); - const [result1, result2] = testing.suiteResults; + expect(testing.topLevelSuiteResults.length).toEqual(2); + const [result1, result2] = testing.topLevelSuiteResults; // Verify Result 1 first expect(result1.results.length).toEqual(2); const [subResult1, subResult2] = result1.results; @@ -122,14 +122,37 @@ describe('Test \'it\' and \'describe\'', () => { }); }); - expect(f).toThrowError('it cannot be called from within another test!'); + expect(f).toThrow('it cannot be called from within another test!'); + }); + + test('it() and describe() throw when provided a non-nullary function', () => { + expect(() => testing.it('test name', 0 as any)).toThrow( + 'it: A test or test suite must be a nullary function!' + ); + + expect(() => testing.describe('test name', 0 as any)).toThrow( + 'describe: A test or test suite must be a nullary function!' + ); + }); + + test('internal errors are not handled', () => { + expect(() => testing.describe('suite', () => { + testing.test('test', () => { throw new UnitestBundleInternalError(); }); + })).toThrow(UnitestBundleInternalError); }); }); describe('Test assertion functions', () => { - test('assert', () => { - expect(() => asserts.assert(() => true)).not.toThrow(); - expect(() => asserts.assert(() => false)).toThrow('Assert failed'); + describe(asserts.assert, () => { + it('works', () => { + expect(() => asserts.assert(() => true)).not.toThrow(); + expect(() => asserts.assert(() => false)).toThrow('Assert failed'); + }); + + it('will throw an error if not provided a nullary function', () => { + expect(() => asserts.assert(0 as any)).toThrow(`${asserts.assert.name} expects a nullary function that returns a boolean!`); + expect(() => asserts.assert((x => x === true) as any)).toThrow(`${asserts.assert.name} expects a nullary function that returns a boolean!`); + }); }); describe(asserts.assert_equals, () => { @@ -168,8 +191,8 @@ describe('Test assertion functions', () => { }); test('deep equality', () => { - const list0 = list(1, pair(2, 3), 4); - const list1 = list(1, pair(2, 3), 4); + const list0 = list(1, pair(2, 3), 4); + const list1 = list(1, pair(2, 3), 4); expect(() => asserts.assert_equals(list0, list1)).not.toThrow(); }); @@ -373,21 +396,27 @@ describe('Mocking functions', () => { expect(mocks.get_ret_vals(fn)).toEqual(null); }); + describe(mocks.mock_function, () => { + it('throws when passed not a function', () => { + expect(() => mocks.mock_function(0 as any)).toThrow('mock_function: Expected function, got 0.'); + }); + }); + describe(mocks.get_arg_list, () => { it('throws when function isn\'t a mocked function', () => { - expect(() => mocks.get_arg_list((() => 0) as any)).toThrowError('get_arg_list expects a mocked function as argument'); + expect(() => mocks.get_arg_list((() => 0) as any)).toThrow('get_arg_list: Expected mocked function, got () => 0.'); }); }); describe(mocks.get_ret_vals, () => { it('throws when function isn\'t a mocked function', () => { - expect(() => mocks.get_ret_vals((() => 0) as any)).toThrowError('get_ret_vals expects a mocked function as argument'); + expect(() => mocks.get_ret_vals((() => 0) as any)).toThrowError('get_ret_vals: Expected mocked function, got () => 0.'); }); }); describe(mocks.clear_mock, () => { it('throws when function isn\'t a mocked function', () => { - expect(() => mocks.clear_mock((() => 0) as any)).toThrowError('clear_mock expects a mocked function as argument'); + expect(() => mocks.clear_mock((() => 0) as any)).toThrowError('clear_mock: Expected mocked function, got () => 0.'); }); it('works', () => { @@ -399,16 +428,4 @@ describe('Mocking functions', () => { expect(mocks.get_num_calls(fn)).toEqual(0); }); }); - - describe(mocks.mock_function, () => { - it('throws when passed not a function', () => { - expect(() => mocks.mock_function(0 as any)).toThrowError('mock_function expects a function as argument'); - }); - }); -}); - -test('internal errors are not handled', () => { - expect(() => { - throw new UnitestBundleInternalError(); - }).toThrow(); }); diff --git a/src/bundles/unittest/src/asserts.ts b/src/bundles/unittest/src/asserts.ts index a1d337271e..7666eaba51 100644 --- a/src/bundles/unittest/src/asserts.ts +++ b/src/bundles/unittest/src/asserts.ts @@ -1,6 +1,8 @@ +import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import { isEqualWith } from 'es-toolkit'; import * as list from 'js-slang/dist/stdlib/list'; import { stringify } from 'js-slang/dist/utils/stringify'; +import { UnitestBundleInternalError } from './types'; /** * Asserts that a predicate returns true. @@ -8,16 +10,22 @@ import { stringify } from 'js-slang/dist/utils/stringify'; * @returns */ export function assert(pred: () => boolean) { + if (!isFunctionOfLength(pred, 0)) { + throw new UnitestBundleInternalError(`${assert.name} expects a nullary function that returns a boolean!`); + } + if (!pred()) { throw new Error('Assert failed!'); } } -function equalityComparer(expected: any, received: any): boolean | undefined { +function equalityComparer(expected: unknown, received: unknown): boolean | undefined { if (typeof expected === 'number') { + if (typeof received !== 'number') return false; + // if either is a float, use approximate checking if (!Number.isInteger(expected) || !Number.isInteger(received)) { - return Math.abs(expected - received) <= 0.001; + return Math.abs(expected - received) <= 0.0001; } return expected === received; @@ -39,6 +47,7 @@ function equalityComparer(expected: any, received: any): boolean | undefined { return true; } + // TODO: Need to account for circular lists if (!isEqualWith(list.head(list0), list.head(list1), equalityComparer)) { return false; } @@ -56,7 +65,19 @@ function equalityComparer(expected: any, received: any): boolean | undefined { return true; } - // TODO: A comparison for streams/arrays? + if (Array.isArray(expected)) { + if (!Array.isArray(received) || received.length !== expected.length) return false; + + for (let i = 0; i < expected.length; i++) { + const expectedItem = expected[i]; + const receivedItem = received[i]; + + if (!isEqualWith(expectedItem, receivedItem, equalityComparer)) return false; + } + return true; + } + + // TODO: A comparison for streams? return undefined; } @@ -109,11 +130,12 @@ export function assert_contains(xs: any, toContain: any) { isEqualWith(list.tail(xs), item, equalityComparer) ) return true; - if (list.is_pair(list.head(xs)) && member(list.head(xs), item)) { - return true; - } + const head_element = list.head(xs); + if (list.is_pair(head_element) && member(head_element, item)) return true; + + const tail_element = list.tail(xs); - return list.is_pair(list.tail(xs)) && member(list.tail(xs), item); + return list.is_pair(tail_element) && member(tail_element, item); } throw new Error(`First argument to ${assert_contains.name} must be a list or a pair, got \`${stringify(xs)}\`.`); diff --git a/src/bundles/unittest/src/functions.ts b/src/bundles/unittest/src/functions.ts index 76c54e4b91..d7ce5fcece 100644 --- a/src/bundles/unittest/src/functions.ts +++ b/src/bundles/unittest/src/functions.ts @@ -1,3 +1,4 @@ +import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import context from 'js-slang/context'; import { @@ -20,7 +21,7 @@ function getNewSuite(name?: string): Suite { * If describe was called multiple times from the root level, we need somewhere * to collect those Suite Results since none of them will have a parent suite */ -export const suiteResults: SuiteResult[] = []; +export const topLevelSuiteResults: SuiteResult[] = []; export let currentSuite: Suite | null = null; export let currentTest: string | null = null; @@ -36,6 +37,10 @@ function handleErr(err: any) { } function runTest(name: string, funcName: string, func: Test) { + if (!isFunctionOfLength(func, 0)) { + throw new UnitestBundleInternalError(`${funcName}: A test must be a nullary function!`); + } + if (currentSuite === null) { throw new UnitestBundleInternalError(`${funcName} must be called from within a test suite!`); } @@ -104,13 +109,21 @@ function determinePassCount(results: (TestResult | SuiteResult)[]): number { * @param func Function containing tests. */ export function describe(msg: string, func: TestSuite): void { - const oldSuite = currentSuite; + if (!isFunctionOfLength(func, 0)) { + throw new UnitestBundleInternalError(`${describe.name}: A test suite must be a nullary function!`); + } + + const parentSuite = currentSuite; const newSuite = getNewSuite(msg); currentSuite = newSuite; newSuite.startTime = performance.now(); - func(); - currentSuite = oldSuite; + + try { + func(); + } finally { + currentSuite = parentSuite; + } const passCount = determinePassCount(newSuite.results); const suiteResult: SuiteResult = { @@ -121,13 +134,13 @@ export function describe(msg: string, func: TestSuite): void { runtime: performance.now() - newSuite.startTime }; - if (oldSuite !== null) { - oldSuite.results.push(suiteResult); + if (parentSuite !== null) { + parentSuite.results.push(suiteResult); } else { - suiteResults.push(suiteResult); + topLevelSuiteResults.push(suiteResult); } } context.moduleContexts.unittest.state = { - suiteResults + suiteResults: topLevelSuiteResults }; diff --git a/src/bundles/unittest/src/mocks.ts b/src/bundles/unittest/src/mocks.ts index 7e927256f2..49d4010637 100644 --- a/src/bundles/unittest/src/mocks.ts +++ b/src/bundles/unittest/src/mocks.ts @@ -1,4 +1,5 @@ -import { pair, vector_to_list, type List } from 'js-slang/dist/stdlib/list'; +import { InvalidCallbackError, InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { vector_to_list, type List } from 'js-slang/dist/stdlib/list'; /** * Symbol for identifying the mock properties. Should not be exposed to cadets. @@ -13,9 +14,9 @@ interface MockedFunction { }; } -function throwIfNotMockedFunction(obj: (...args: any[]) => any, func_name: string): asserts obj is MockedFunction { - if (!(mockSymbol in obj)) { - throw new Error(`${func_name} expects a mocked function as argument`); +function throwIfNotMockedFunction(obj: unknown, func_name: string, param_name?: string): asserts obj is MockedFunction { + if (typeof obj !== 'function' || !(mockSymbol in obj)) { + throw new InvalidCallbackError('mocked function', obj, func_name, param_name); } } @@ -25,19 +26,26 @@ function throwIfNotMockedFunction(obj: (...args: any[]) => any, func_name: strin * original value you passed in if you want the mocked function to be properly tracked. * @param fn Function to mock * @returns A mocked version of the given function. + * @example + * ``` + * const fn = mock_function(x => x + 1); + * fn(1); + * head(get_arg_list(fn)) === 1; // is true + * head(get_ret_vals(fn)) === 2; // is true + * ``` */ export function mock_function(fn: (...args: any[]) => any): MockedFunction { if (typeof fn !== 'function') { - throw new Error(`${mock_function.name} expects a function as argument`); + throw new InvalidParameterTypeError('function', fn, mock_function.name); } - const arglist: any[] = []; + const arglist: List[] = []; const retVals: any[] = []; // TODO: Check if some kind of function copying is required // js-slang has its own set of utils for doing this function func(...args: any[]) { - arglist.push(args); + arglist.push(vector_to_list(args)); const retVal = fn.apply(fn, args); if (retVal !== undefined) { retVals.push(retVal); @@ -47,7 +55,9 @@ export function mock_function(fn: (...args: any[]) => any): MockedFunction { } func[mockSymbol] = { arglist, retVals }; - func.toString = () => fn.toString(); + if (typeof func.toReplString === 'function') { + func.toReplString = () => (fn as any).toReplString(); + } return func; } @@ -70,11 +80,7 @@ export function get_num_calls(fn: MockedFunction) { export function get_arg_list(fn: MockedFunction) { throwIfNotMockedFunction(fn, get_arg_list.name); const { arglist } = fn[mockSymbol]; - - return arglist.reduceRight((res, args) => { - const argsAsList = vector_to_list(args); - return pair(argsAsList, res); - }, null); + return vector_to_list(arglist); } /** diff --git a/src/bundles/unity_academy/package.json b/src/bundles/unity_academy/package.json index 7a398055ee..b4238965d3 100644 --- a/src/bundles/unity_academy/package.json +++ b/src/bundles/unity_academy/package.json @@ -24,8 +24,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/bundles/wasm/package.json b/src/bundles/wasm/package.json index 9410572f1f..54ff2e9287 100644 --- a/src/bundles/wasm/package.json +++ b/src/bundles/wasm/package.json @@ -20,8 +20,9 @@ "build": "buildtools build bundle .", "lint": "buildtools lint .", "test": "buildtools test --project .", - "postinstall": "buildtools compile", - "serve": "yarn buildtools serve" + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" }, "scripts-info": { "build": "Compiles the given bundle to the output directory", diff --git a/src/tabs/ArcadeTwod/index.tsx b/src/tabs/ArcadeTwod/index.tsx index 5396ae2d7c..477c57a846 100644 --- a/src/tabs/ArcadeTwod/index.tsx +++ b/src/tabs/ArcadeTwod/index.tsx @@ -1,5 +1,5 @@ -import { Button, ButtonGroup } from '@blueprintjs/core'; -import { IconNames, Pause, Play } from '@blueprintjs/icons'; +import { ButtonGroup } from '@blueprintjs/core'; +import PlayButton from '@sourceacademy/modules-lib/tabs/PlayButton'; import { defineTab } from '@sourceacademy/modules-lib/tabs/utils'; import Phaser from 'phaser'; import React from 'react'; @@ -65,12 +65,14 @@ class A2dUiButtons extends React.Component { public render() { return ( - - +
{