diff --git a/devserver/src/components/Playground.tsx b/devserver/src/components/Playground.tsx index 783e7df7eb..6a462d85a4 100644 --- a/devserver/src/components/Playground.tsx +++ b/devserver/src/components/Playground.tsx @@ -1,9 +1,9 @@ import { Button, Classes, Intent, OverlayToaster, Popover, Tooltip, type ToastProps } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; import classNames from 'classnames'; 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 { 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'; @@ -159,6 +159,12 @@ const Playground: React.FC = () => { value: stringify(result.value) }); } else if (result.status === 'error') { + codeContext.errors.forEach(error => { + if (error instanceof ModuleInternalError) { + console.error(error); + } + }); + setReplOutput({ type: 'errors', errors: codeContext.errors, @@ -210,7 +216,7 @@ const Playground: React.FC = () => { -

+

{ changeStep(currentStep + 1); diff --git a/lib/modules-lib/src/tabs/NumberSelector.tsx b/lib/modules-lib/src/tabs/NumberSelector.tsx index f3e3f91cae..0d9d2f97c5 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 769d8f1bb6..9a948df277 100644 --- a/lib/modules-lib/src/tabs/PlayButton.tsx +++ b/lib/modules-lib/src/tabs/PlayButton.tsx @@ -1,24 +1,49 @@ -/* [Imports] */ -import { Icon, Tooltip } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; +import { Icon, Tooltip, type IconProps } from '@blueprintjs/core'; import ButtonComponent, { type ButtonComponentProps } from './ButtonComponent'; -/* [Exports] */ export type PlayButtonProps = ButtonComponentProps & { isPlaying: boolean; - // onClickCallback: () => void, + + /** + * 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..8e02e6ef20 100644 --- a/lib/modules-lib/src/utilities.ts +++ b/lib/modules-lib/src/utilities.ts @@ -30,6 +30,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 +77,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; 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 1fb0389d8a..8001c8de76 100644 --- a/lib/modules-lib/vitest.config.ts +++ b/lib/modules-lib/vitest.config.ts @@ -13,7 +13,10 @@ export default mergeConfig( '@blueprintjs/core', '@blueprintjs/icons', 'lodash', + 'lodash/clamp', '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/binary_tree/package.json b/src/bundles/binary_tree/package.json index 9e68b5c26a..ab65f87875 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": { diff --git a/src/bundles/binary_tree/src/__tests__/index.test.ts b/src/bundles/binary_tree/src/__tests__/index.test.ts index 4b7562ca24..07600993bb 100644 --- a/src/bundles/binary_tree/src/__tests__/index.test.ts +++ b/src/bundles/binary_tree/src/__tests__/index.test.ts @@ -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)).toThrowError('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)).toThrowError('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)).toThrowError('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)).toThrowError('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)).toThrowError('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)).toThrowError('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)).toThrowError('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)).toThrowError('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..0a77e263cf 100644 --- a/src/bundles/binary_tree/src/functions.ts +++ b/src/bundles/binary_tree/src/functions.ts @@ -1,3 +1,4 @@ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import { head, is_list, is_pair, list, tail } from 'js-slang/dist/stdlib/list'; import type { BinaryTree, EmptyBinaryTree, NonEmptyBinaryTree } from './types'; @@ -26,6 +27,14 @@ export function make_empty_tree(): BinaryTree { * @returns A binary tree */ export function make_tree(value: any, left: BinaryTree, right: BinaryTree): BinaryTree { + 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 list(value, left, right); } @@ -65,17 +74,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); } } diff --git a/src/bundles/curve/src/__tests__/curve.test.ts b/src/bundles/curve/src/__tests__/curve.test.ts index ae0ca283b9..b78e5d3c68 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', () => { @@ -120,7 +117,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 +129,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 +141,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'); }); }); }); diff --git a/src/bundles/curve/src/drawers.ts b/src/bundles/curve/src/drawers.ts index 1aa95b4593..34da58b0d2 100644 --- a/src/bundles/curve/src/drawers.ts +++ b/src/bundles/curve/src/drawers.ts @@ -1,3 +1,4 @@ +import { InvalidCallbackError } from '@sourceacademy/modules-lib/errors'; import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import context from 'js-slang/context'; @@ -35,11 +36,7 @@ function createDrawFunction( 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.' - ); + throw new InvalidCallbackError('Curve', curve, name); } const curveDrawn = generateCurve( @@ -396,6 +393,10 @@ class CurveAnimators { throw new Error(`${animate_curve.name} cannot be used with 3D draw function!`); } + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError('CurveAnimation', func, animate_curve.name); + } + const anim = new AnimatedCurve(duration, fps, func, drawer, false); drawnCurves.push(anim); return anim; @@ -412,6 +413,10 @@ class CurveAnimators { throw new Error(`${animate_3D_curve.name} cannot be used with 2D draw function!`); } + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError('CurveAnimation', func, animate_3D_curve.name); + } + const anim = new AnimatedCurve(duration, fps, func, drawer, true); drawnCurves.push(anim); return anim; diff --git a/src/bundles/curve/src/functions.ts b/src/bundles/curve/src/functions.ts index c2fce8b1b4..3e33e68637 100644 --- a/src/bundles/curve/src/functions.ts +++ b/src/bundles/curve/src/functions.ts @@ -1,3 +1,4 @@ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import clamp from 'lodash/clamp'; import { Point, type Curve } from './curves_webgl'; import { functionDeclaration } from './type_interface'; @@ -5,7 +6,7 @@ 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); } } diff --git a/src/bundles/curve/src/index.ts b/src/bundles/curve/src/index.ts index 001782b89f..27b111f138 100644 --- a/src/bundles/curve/src/index.ts +++ b/src/bundles/curve/src/index.ts @@ -14,11 +14,11 @@ * A *curve transformation* is a function that takes a curve as argument and * returns a curve. Examples of curve transformations are `scale` and `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 `draw_connected_full_view` to display a curve called * `unit_circle`. * ``` * import { make_point, draw_connected_full_view } from "curve"; @@ -34,6 +34,7 @@ * @author Lee Zheng Han * @author Ng Yong Xiang */ + export { arc, b_of, diff --git a/src/bundles/midi/package.json b/src/bundles/midi/package.json index ecda58a0c2..1a1f51f885 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", diff --git a/src/bundles/midi/src/__tests__/index.test.ts b/src/bundles/midi/src/__tests__/index.test.ts index 1ef04b31f6..6bcca320e1 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: Octave must be an integer greater than 0'); + expect(() => funcs.add_octave_to_note('C', 2.5)).toThrowError('add_octave_to_note: Octave must be an integer greater than 0'); + }); +}); + +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: Number of accidentals must be a number between 0 and 6'); + expect(() => funcs.key_signature_to_keys(Accidental.SHARP, 7)).toThrowError('key_signature_to_keys: Number of accidentals must be a number between 0 and 6'); + expect(() => funcs.key_signature_to_keys(Accidental.SHARP, 2.5)).toThrowError('key_signature_to_keys: Number of accidentals must be a number between 0 and 6'); + }); + + test('Invalid accidental should throw an error', () => { + expect(() => funcs.key_signature_to_keys('invalid' as any, 2)).toThrowError('key_signature_to_keys: Expected 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..84296f31cb 100644 --- a/src/bundles/midi/src/index.ts +++ b/src/bundles/midi/src/index.ts @@ -6,8 +6,17 @@ * @author leeyi45 */ -import { Accidental, type MIDINote, type NoteWithOctave } from './types'; -import { midiNoteToNoteName, noteToValues } from './utils'; +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +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,19 +85,30 @@ 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 @@ -101,16 +121,87 @@ export function midi_note_to_frequency(note: MIDINote): number { } /** - * 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 + * @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 { + if (!Number.isInteger(octave) || octave < 0) { + throw new Error(`${add_octave_to_note.name}: Octave must be an integer greater than 0`); + } + + 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 { + if (!Number.isInteger(numAccidentals) || numAccidentals < 0 || numAccidentals > 6) { + throw new Error(`${key_signature_to_keys.name}: Number of accidentals must be a number between 0 and 6`); + } + + 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('accidental', accidental, key_signature_to_keys.name); + } +} + export * from './scales'; /** diff --git a/src/bundles/midi/src/types.ts b/src/bundles/midi/src/types.ts index 6291c6790a..df28275a29 100644 --- a/src/bundles/midi/src/types.ts +++ b/src/bundles/midi/src/types.ts @@ -11,7 +11,7 @@ type NotesWithFlats = 'A' | 'B' | 'D' | 'E' | 'G'; // & {} is a weird trick with typescript that causes intellisense to evaluate every single option // so you see all the valid notes instead of just the type definition below export type Note = {} & (NoteName | `${NoteName}${Accidental.NATURAL}` | `${NotesWithFlats}${Accidental.FLAT}` | `${NotesWithSharps}${Accidental.SHARP}`); -export type NoteWithOctave = (Note | `${Note}${number}`); +export type NoteWithOctave = Note | `${Note}${number}`; /** * An integer representing a MIDI note value. Refer to {@link https://i.imgur.com/qGQgmYr.png|this} mapping from 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/repeat/package.json b/src/bundles/repeat/package.json index f65d342dab..39fd83a816 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", diff --git a/src/bundles/repeat/src/__tests__/index.test.ts b/src/bundles/repeat/src/__tests__/index.test.ts index 3a3cff9e81..44d91aec3a 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 integers', () => { + expect(() => funcs.repeat((x: number) => x, -1)) + .toThrowError('repeat: Expected non-negative integer, got -1.'); + + expect(() => funcs.repeat((x: number) => x, 1.5)) + .toThrowError('repeat: Expected non-negative integer, 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..6606496841 100644 --- a/src/bundles/repeat/src/functions.ts +++ b/src/bundles/repeat/src/functions.ts @@ -3,6 +3,23 @@ * @module repeat */ +import { InvalidCallbackError, InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { isFunctionOfLength } 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 +33,15 @@ * @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)); + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError(1, func, repeat.name); + } + + if (!Number.isInteger(n) || n < 0) { + throw new InvalidParameterTypeError('non-negative integer', n, repeat.name); + } + + return repeat_internal(func, n); } /** @@ -31,7 +56,11 @@ 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); + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError(1, func, twice.name); + } + + return repeat_internal(func, 2); } /** @@ -46,5 +75,9 @@ 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); + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError(1, func, thrice.name); + } + + return repeat_internal(func, 3); } diff --git a/src/bundles/repl/src/programmable_repl.ts b/src/bundles/repl/src/programmable_repl.ts index 7bb7678417..1d9bfc9720 100644 --- a/src/bundles/repl/src/programmable_repl.ts +++ b/src/bundles/repl/src/programmable_repl.ts @@ -10,7 +10,7 @@ import { COLOR_ERROR_MESSAGE, COLOR_RUN_CODE_RESULT, DEFAULT_EDITOR_HEIGHT } fro import { evaluatorSymbol } from './functions'; export class ProgrammableRepl { - public evalFunction: ((code: string) => any); + public evalFunction: (code: string) => any; public userCodeInEditor: string; public outputStrings: any[]; private _editorInstance; diff --git a/src/bundles/rune/src/__tests__/index.test.ts b/src/bundles/rune/src/__tests__/index.test.ts index 826a57cb22..0ca084916e 100644 --- a/src/bundles/rune/src/__tests__/index.test.ts +++ b/src/bundles/rune/src/__tests__/index.test.ts @@ -6,7 +6,7 @@ import type { Rune } from '../rune'; describe(display.anaglyph, () => { 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,7 +55,7 @@ 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', () => { @@ -67,8 +67,8 @@ describe(funcs.color, () => { 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', () => { @@ -88,8 +88,8 @@ 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', () => { @@ -102,7 +102,7 @@ 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', () => { @@ -131,8 +131,8 @@ describe(funcs.repeat_pattern, () => { 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, got 0.'); + expect(() => funcs.overlay_frac(0, funcs.heart, 0 as any)).toThrowError('overlay_frac: Expected Rune, got 0.'); }); it('throws when frac is out of range', () => { @@ -150,7 +150,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..2b4e99226d 100644 --- a/src/bundles/rune/src/display.ts +++ b/src/bundles/rune/src/display.ts @@ -1,3 +1,5 @@ +import { InvalidCallbackError } from '@sourceacademy/modules-lib/errors'; +import { isFunctionOfLength } 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,6 +45,10 @@ class RuneDisplay { @functionDeclaration('duration: number, fps: number, func: RuneAnimation', 'AnimatedRune') static animate_rune(duration: number, fps: number, func: RuneAnimation) { + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError('RuneAnimation', func, RuneDisplay.animate_rune.name); + } + const anim = new AnimatedRune(duration, fps, (n) => { const rune = func(n); throwIfNotRune(RuneDisplay.animate_rune.name, rune); @@ -54,6 +60,10 @@ class RuneDisplay { @functionDeclaration('duration: number, fps: number, func: RuneAnimation', 'AnimatedRune') static animate_anaglyph(duration: number, fps: number, func: RuneAnimation) { + if (!isFunctionOfLength(func, 1)) { + throw new InvalidCallbackError('RuneAnimation', func, RuneDisplay.animate_anaglyph.name); + } + const anim = new AnimatedRune(duration, fps, (n) => { const rune = func(n); throwIfNotRune(RuneDisplay.animate_anaglyph.name, rune); diff --git a/src/bundles/rune/src/functions.ts b/src/bundles/rune/src/functions.ts index 84731063b2..91f7d26de9 100644 --- a/src/bundles/rune/src/functions.ts +++ b/src/bundles/rune/src/functions.ts @@ -35,7 +35,9 @@ 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 (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!`); diff --git a/src/bundles/rune/src/runes_ops.ts b/src/bundles/rune/src/runes_ops.ts index 2f327ceb4a..029585f9fa 100644 --- a/src/bundles/rune/src/runes_ops.ts +++ b/src/bundles/rune/src/runes_ops.ts @@ -1,6 +1,7 @@ /** * 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'; @@ -8,7 +9,9 @@ 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.`); + if (!(rune instanceof Rune)) { + throw new InvalidParameterTypeError('Rune', rune, name); + } } // ============================================================================= diff --git a/src/bundles/sound/package.json b/src/bundles/sound/package.json index 720c5f43e7..c2041d2148 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", diff --git a/src/bundles/sound/src/__tests__/recording.test.ts b/src/bundles/sound/src/__tests__/recording.test.ts index b90d0fe763..f463db90d9 100644 --- a/src/bundles/sound/src/__tests__/recording.test.ts +++ b/src/bundles/sound/src/__tests__/recording.test.ts @@ -67,7 +67,7 @@ describe('Recording functions', () => { }); test('throws error if called concurrently with another sound', () => { - funcs.play_wave(() => 0, 10); + funcs.play_wave(_t => 0, 10); expect(() => funcs.record(1)).toThrowError('record: Cannot record while another sound is playing!'); }); @@ -105,7 +105,7 @@ describe('Recording functions', () => { }); test('throws error if called concurrently with another sound', () => { - funcs.play_wave(() => 0, 10); + funcs.play_wave(_t => 0, 10); expect(() => funcs.record_for(1, 1)).toThrowError('record_for: Cannot record while another sound is playing!'); }); diff --git a/src/bundles/sound/src/__tests__/sound.test.ts b/src/bundles/sound/src/__tests__/sound.test.ts index ea9af73b35..8e392070c7 100644 --- a/src/bundles/sound/src/__tests__/sound.test.ts +++ b/src/bundles/sound/src/__tests__/sound.test.ts @@ -1,3 +1,4 @@ +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'; @@ -13,12 +14,17 @@ describe(funcs.make_sound, () => { }); 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.'); }); }); @@ -39,7 +45,7 @@ describe('Concurrent playback functions', () => { }); 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(); }); @@ -56,22 +62,22 @@ describe('Concurrent playback functions', () => { describe(funcs.play_wave, () => { it('Should error gracefully when duration is negative', () => { - expect(() => funcs.play_wave(() => 0, -1)) + expect(() => funcs.play_wave(_t => 0, -1)) .toThrow('play_wave: Sound duration must be greater than or equal to 0'); }); 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 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'); }); @@ -92,18 +98,18 @@ describe('Concurrent playback functions', () => { describe(play_in_tab.play_in_tab, () => { it('Should error gracefully when duration is negative', () => { - const sound = [() => 0, -1]; + const sound = [_t => 0, -1]; expect(() => play_in_tab.play_in_tab(sound as any)) .toThrow('play_in_tab: duration of sound is negative'); }); it('Should not error when duration is zero', () => { - const sound = funcs.make_sound(() => 0, 0); + const sound = funcs.make_sound(_t => 0, 0); expect(() => play_in_tab.play_in_tab(sound)).not.toThrow(); }); 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.play_in_tab(0 as any)).toThrow('play_in_tab: Expected Sound, got 0'); }); test('Multiple calls does not cause an error', () => { @@ -127,8 +133,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 +147,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 +167,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 +181,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)).toThrowError('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..4bfd140dc8 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 { InvalidCallbackError, InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; +import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities'; import { accumulate, head, @@ -286,7 +289,7 @@ export function record_for(duration: number, buffer: number): SoundPromise { */ 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}`); + throw new InvalidParameterTypeError('number', duration, func_name, 'duration'); } if (duration < 0) { @@ -298,8 +301,8 @@ function validateDuration(func_name: string, duration: unknown): asserts duratio * 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}`); + if (!isFunctionOfLength(wave, 1)) { + throw new InvalidCallbackError('Wave', wave, func_name); } } @@ -636,6 +639,25 @@ 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 = () => ''; + // TODO: Remove when ReplResult is properly implemented + wrapped.toString = () => ''; + return wrapped; +} + /** * Returns an envelope: a function from Sound to Sound. * When the adsr envelope is applied to a Sound, it returns @@ -657,12 +679,14 @@ export function adsr( sustain_level: number, release_ratio: number ): SoundTransformer { - return sound => { + 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 +707,7 @@ export function adsr( * linear_decay(release_time)(x - (duration - release_time)) ); }, duration); - }; + }); } /** @@ -741,13 +765,13 @@ export function phase_mod( duration: number, amount: number ): SoundTransformer { - return modulator => { + return wrapSoundTransformer(modulator => { const wave = get_wave(modulator); return make_sound( t => Math.sin(2 * Math.PI * t * freq + amount * wave(t)), duration ); - }; + }); } // Instruments @@ -761,7 +785,7 @@ export function phase_mod( * @example bell(40, 1); * @category Instrument */ -export function bell(note: number, duration: number): Sound { +export function bell(note: MIDINote, duration: number): Sound { return stacking_adsr( square_sound, midi_note_to_frequency(note), @@ -784,7 +808,7 @@ export function bell(note: number, duration: number): Sound { * @example cello(36, 5); * @category Instrument */ -export function cello(note: number, duration: number): Sound { +export function cello(note: MIDINote, duration: number): Sound { return stacking_adsr( square_sound, midi_note_to_frequency(note), @@ -803,7 +827,7 @@ export function cello(note: number, duration: number): Sound { * @category Instrument * */ -export function piano(note: number, duration: number): Sound { +export function piano(note: MIDINote, duration: number): Sound { return stacking_adsr( triangle_sound, midi_note_to_frequency(note), @@ -821,7 +845,7 @@ export function piano(note: number, duration: number): Sound { * @example trombone(60, 2); * @category Instrument */ -export function trombone(note: number, duration: number): Sound { +export function trombone(note: MIDINote, duration: number): Sound { return stacking_adsr( square_sound, midi_note_to_frequency(note), @@ -839,7 +863,7 @@ export function trombone(note: number, duration: number): Sound { * @example violin(53, 4); * @category Instrument */ -export function violin(note: number, duration: number): Sound { +export function violin(note: MIDINote, duration: number): Sound { return stacking_adsr( sawtooth_sound, midi_note_to_frequency(note), diff --git a/src/bundles/sound/src/play_in_tab.ts b/src/bundles/sound/src/play_in_tab.ts index a55aa04fb1..9333f64c75 100644 --- a/src/bundles/sound/src/play_in_tab.ts +++ b/src/bundles/sound/src/play_in_tab.ts @@ -1,3 +1,4 @@ +import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import context from 'js-slang/context'; import { FS, get_duration, get_wave, is_sound } from './functions'; import { RIFFWAVE } from './riffwave'; @@ -18,7 +19,7 @@ context.moduleContexts.sound.state = { audioPlayed }; 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); diff --git a/src/bundles/unittest/package.json b/src/bundles/unittest/package.json index cd0e741960..0a76490156 100644 --- a/src/bundles/unittest/package.json +++ b/src/bundles/unittest/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@sourceacademy/modules-lib": "workspace:^", "js-slang": "^1.0.85", "lodash": "^4.17.23" }, diff --git a/src/bundles/unittest/src/__tests__/index.test.ts b/src/bundles/unittest/src/__tests__/index.test.ts index 4890a8332f..7b0316cab6 100644 --- a/src/bundles/unittest/src/__tests__/index.test.ts +++ b/src/bundles/unittest/src/__tests__/index.test.ts @@ -13,7 +13,7 @@ describe('Test \'it\' and \'describe\'', () => { testing.suiteResults.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', () => { @@ -124,12 +124,29 @@ describe('Test \'it\' and \'describe\'', () => { expect(f).toThrowError('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!' + ); + }); }); 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, () => { @@ -375,19 +392,19 @@ describe('Mocking functions', () => { 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)).toThrowError('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', () => { @@ -402,7 +419,7 @@ describe('Mocking functions', () => { 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'); + expect(() => mocks.mock_function(0 as any)).toThrowError('mock_function: Expected function, got 0.'); }); }); }); diff --git a/src/bundles/unittest/src/asserts.ts b/src/bundles/unittest/src/asserts.ts index abba0c02ed..1f6977c2f1 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 * as list from 'js-slang/dist/stdlib/list'; import { stringify } from 'js-slang/dist/utils/stringify'; import isEqualWith from 'lodash/isEqualWith'; +import { UnitestBundleInternalError } from './types'; /** * Asserts that a predicate returns true. @@ -8,6 +10,10 @@ import isEqualWith from 'lodash/isEqualWith'; * @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!'); } diff --git a/src/bundles/unittest/src/functions.ts b/src/bundles/unittest/src/functions.ts index 76c54e4b91..98f5d87b48 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 { @@ -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 or test suite must be a nullary function!`); + } + if (currentSuite === null) { throw new UnitestBundleInternalError(`${funcName} must be called from within a test suite!`); } @@ -104,6 +109,10 @@ function determinePassCount(results: (TestResult | SuiteResult)[]): number { * @param func Function containing tests. */ export function describe(msg: string, func: TestSuite): void { + if (!isFunctionOfLength(func, 0)) { + throw new UnitestBundleInternalError(`${describe.name}: A test or test suite must be a nullary function!`); + } + const oldSuite = currentSuite; const newSuite = getNewSuite(msg); diff --git a/src/bundles/unittest/src/mocks.ts b/src/bundles/unittest/src/mocks.ts index 7e927256f2..386770db45 100644 --- a/src/bundles/unittest/src/mocks.ts +++ b/src/bundles/unittest/src/mocks.ts @@ -1,3 +1,4 @@ +import { InvalidCallbackError, InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; import { pair, vector_to_list, type List } from 'js-slang/dist/stdlib/list'; /** @@ -13,9 +14,9 @@ interface MockedFunction { }; } -function throwIfNotMockedFunction(obj: (...args: any[]) => any, func_name: string): asserts obj is MockedFunction { +function throwIfNotMockedFunction(obj: (...args: any[]) => any, func_name: string, param_name?: string): asserts obj is MockedFunction { if (!(mockSymbol in obj)) { - throw new Error(`${func_name} expects a mocked function as argument`); + throw new InvalidCallbackError('mocked function', obj, func_name, param_name); } } @@ -28,7 +29,7 @@ function throwIfNotMockedFunction(obj: (...args: any[]) => any, func_name: strin */ 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[] = []; diff --git a/src/tabs/ArcadeTwod/index.tsx b/src/tabs/ArcadeTwod/index.tsx index 68e35ed218..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 } 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 ( -
@@ -135,7 +134,7 @@ export default class CanvasHolder extends React.Component<
- +
; }, label: 'Curves Tab', - iconName: IconNames.MEDIA // See https://blueprintjs.com/docs/#icons for more options + iconName: 'media' }); diff --git a/src/tabs/Nbody/index.tsx b/src/tabs/Nbody/index.tsx index 7d161df11d..dd768a1d21 100644 --- a/src/tabs/Nbody/index.tsx +++ b/src/tabs/Nbody/index.tsx @@ -1,5 +1,5 @@ import { Button, ButtonGroup, NumericInput } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; +import PlayButton from '@sourceacademy/modules-lib/tabs/PlayButton'; import { defineTab } from '@sourceacademy/modules-lib/tabs/utils'; import type { DebuggerContext } from '@sourceacademy/modules-lib/types/index'; import type { Simulation } from 'nbody'; @@ -86,18 +86,15 @@ class SimulationControl extends React.Component -