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 = () => {
);
diff --git a/devserver/src/components/controlBar/ControlBarClearButton.tsx b/devserver/src/components/controlBar/ControlBarClearButton.tsx
index 901cca1483..9aa82b5a06 100644
--- a/devserver/src/components/controlBar/ControlBarClearButton.tsx
+++ b/devserver/src/components/controlBar/ControlBarClearButton.tsx
@@ -1,5 +1,4 @@
import { Tooltip } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
import ControlButton from '../ControlButton';
type Props = {
@@ -9,7 +8,7 @@ type Props = {
export const ControlBarClearButton = (props: Props) => ;
diff --git a/devserver/src/components/controlBar/ControlBarRefreshButton.tsx b/devserver/src/components/controlBar/ControlBarRefreshButton.tsx
index 1d9a797761..55081bc4d2 100644
--- a/devserver/src/components/controlBar/ControlBarRefreshButton.tsx
+++ b/devserver/src/components/controlBar/ControlBarRefreshButton.tsx
@@ -1,5 +1,4 @@
import { Tooltip } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
import ControlButton from '../ControlButton';
type Props = {
@@ -9,7 +8,7 @@ type Props = {
export const ControlBarRefreshButton = (props: Props) => ;
diff --git a/devserver/src/components/controlBar/ControlBarRunButton.tsx b/devserver/src/components/controlBar/ControlBarRunButton.tsx
index e07163fd1b..1ddca030a1 100644
--- a/devserver/src/components/controlBar/ControlBarRunButton.tsx
+++ b/devserver/src/components/controlBar/ControlBarRunButton.tsx
@@ -1,5 +1,4 @@
import { Position, Tooltip } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
import React from 'react';
import ControlButton from '../ControlButton';
@@ -22,7 +21,7 @@ export const ControlBarRunButton: React.FC = (props
''
+ };
+}
export function change_text_options(options: TextOptions): void;
// Used like this:
diff --git a/docs/src/modules/2-bundle/4-conventions/3-errors.md b/docs/src/modules/2-bundle/4-conventions/3-errors.md
index 7a60c5bbc8..b77e5df8b3 100644
--- a/docs/src/modules/2-bundle/4-conventions/3-errors.md
+++ b/docs/src/modules/2-bundle/4-conventions/3-errors.md
@@ -53,4 +53,45 @@ Then, the error can be thrown with the correct function name. Otherwise, cadets
that is visible to them. Many other functions might rely on `throwIfNotRune`. If they were all called in the same program, it doesn't tell the cadet which function the error was thrown from
(was it `show`? or `anaglyph`? or something else?)
-An important use for error handling is when it comes to validating types. More information about type checking can be found in the next section.
+An important use for error handling is when it comes to validating types. More information about type checking can be found in the [next](./4-types) section.
+
+> [!WARNING] Undefined Name Property
+>
+> It's possible to create functions without names using anonymous expressions:
+>
+> ```ts
+> function getFunc(value: string) {
+> return () => value;
+> }
+>
+> export const getFoo = getFunc('foo');
+>
+> // getFoo.name is undefined!
+> console.log(getFoo.name);
+> ```
+>
+> A common case (especially if you are using type maps) is using an expression when defining a class function. This causes an anonymous function to be assigned to that property:
+>
+> ```ts
+> class Functions {
+> static bar = () => 'bar';
+> }
+>
+> // the name is also undefined!
+> console.log(Functions.bar.name);
+> ```
+>
+> In such a case, you should take care to define the `name` property manually (at least on the exported version):
+>
+> ```ts
+> function getFunc(value: string, func_name: string) {
+> const func = () => value;
+> Object.defineProperty(func, 'name', { value: func_name });
+> return func;
+> }
+>
+> export const getFoo = getFunc('foo', 'foo');
+>
+> // Now correctly prints foo!
+> console.log(getFoo.name);
+> ```
diff --git a/docs/src/modules/2-bundle/4-conventions/4-types.md b/docs/src/modules/2-bundle/4-conventions/4-types.md
index 46732b7e9d..4257361fc1 100644
--- a/docs/src/modules/2-bundle/4-conventions/4-types.md
+++ b/docs/src/modules/2-bundle/4-conventions/4-types.md
@@ -48,13 +48,52 @@ Currently, bundle documentation for cadets relies on these type annotations bein
all cadets would see.
As the typing system is improved, we may be able to use one set of typing for cadets and another for internal implementation.
+
+In effect, all bundle functions really look like this:
+
+```ts
+// Typescript style function overloads
+export function show(rune: Rune): Rune;
+export function show(rune: unknown) {
+ throwIfNotRune(show.name, rune);
+ drawnRunes.push(new NormalRune(rune));
+ return rune;
+}
+```
:::
-As part of ensuring type safety, there are several conventions bundle code should abide by:
+## `InvalidTypeParameterError`
+
+When throwing errors related to type checking, you should throw an `InvalidParameterTypeError`, which can be imported from the `modules-lib`:
+
+```ts
+import { InvalidTypeParameterError } from '@sourceacademy/modules-lib/errors';
-## 1. Cadet facing functions should not have default or rest parameters
+export function play(value: unknown): asserts value is Sound {
+ if (!is_sound(value)) {
+ throw new InvalidParameterTypeError('Sound', value, play.name);
-The function signature below takes in two booleans, the second of which is optional. This is not supported for Module functions in Source, but is fine if your function
+ // you can provide the name of the parameter too!
+ throw new InvalidParameterTypeError('Sound', value, play.name, 'value');
+ }
+
+ // ...implementation
+}
+```
+
+The parameters of the constructor for `InvalidParameterTypeError` are as follows:
+1. String representation of the expected type
+2. Actual value passed in by the user
+3. _Optional_: The name of the function that the error was thrown from (see [here](./3-errors) for more information)
+4. _Optional_: The name of the parameter that is being validated
+
+## Type Safety Conventions
+
+As part of ensuring type safety, there are several function related conventions bundle code should abide by:
+
+### 1. Cadet facing functions should not have default, optional or rest parameters
+
+The functions make use of default, optional and rest parameters. This is not supported for Module functions in Source, but is fine if your function
isn't being exposed to cadets.
```ts
@@ -68,6 +107,11 @@ function concat_strings(...args: string[]) {
return args.join(',');
}
+// or this
+function configure_other_option(other_option?: boolean) {
+ // ...implementation
+}
+
// But default and rest parameters are okay for internal use
export function exposed_function() {
configure_options(true);
@@ -79,7 +123,19 @@ export function exposed_function() {
Neither default nor rest parameters are currently supported due to an [issue](https://github.com/source-academy/js-slang/issues/1238) on the `js-slang` side.
:::
-## 2. Cadet facing functions should not use destructuring for parameters
+Instead, if you require such functionality, they should be defined as separate functions:
+
+```ts
+// Equivalent implementation of configure_options from the above example
+
+export function configure_options_1_and_2(option_1: boolean, option_2: boolean) {}
+
+export function configure_option_1_only(option_1: boolean) {
+ configure_options_1_and_2(option_1, false);
+}
+```
+
+### 2. Cadet facing functions should not use destructuring for parameters
Javascript allows us to destruct iterables directly within a function's parameters:
@@ -113,7 +169,7 @@ function bar({ x, y }: BarParams) {
}
```
-However, Javascript doesn't actually throw an error if you pass an invalid object into the function:
+In this case, Javascript doesn't actually throw an error if you pass an invalid object into the function:
```ts
function bar({ x, y }: BarParams) {
@@ -152,11 +208,13 @@ Uncaught TypeError: Cannot read properties of undefined (reading 'a')
because of course, when `bar2` is called with `0`, `x` becomes `undefined` and trying to destructure `undefined` causes the `TypeError`.
-If instead the parameter isn't destructured, it gives you the chance to perform type checking:
+In both cases, if instead the parameter isn't destructured, you have the chance to perform type checking:
```ts
export function foo(arr: [string, string]) {
- if (!Array.isArray(arr)) throw new Error();
+ if (!Array.isArray(arr) && arr.length !== 2) {
+ throw new InvalidParameterTypeError('Tuple of length 2', arr, foo.name, 'arr');
+ }
return arr[0];
}
@@ -169,7 +227,7 @@ export function bar2(obj: Bar2Params) {
}
```
-## 3. If a callback is passed as a parameter, its number of parameters should be validated
+### 3. If a callback is passed as a parameter, its number of parameters should be validated
By default, Javascript doesn't really mind if you call a function with fewer arguments than it was defined with:
@@ -206,11 +264,7 @@ import { isFunctionOfLength } from '@sourceacademy/modules-lib/utilities';
function draw_connected(pts: number): (c: Curve) => void {
function renderFunction(c: Curve) {
if (!isFunctionOfLength(curve, 1)) {
- throw new Error(
- `${renderFunction.name}: 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, draw_connected.name);
}
// ...implementation details
@@ -228,6 +282,18 @@ import { draw_connected, make_point } from 'curve';
draw_connected(200)((a, b) => make_point(a, 0)); // error: The provided curve is not a valid Curve function.
```
+> [!INFO] `InvalidCallbackError`
+>
+> The `InvalidCallbackError` is a subclass of the `InvalidParameterTypeError`, specifically to be used for the error to be thrown
+> for invalid callbacks. The parameters for its constructor are as follows:
+>
+> 1. Number of Parameters/String representation of expected function:
+> - If a number is given, it is assumed that a function with that number of parameters is expected
+> - If a string is given, that will be used as a name for the function type.
+> 2. The actual value passed in by the user
+> 3. _Optional_: The name of the function that the error was thrown from (see [here](./3-errors) for more information)
+> 4. _Optional_: The name of the parameter that is being validated
+
The `isFunctionOfLength` function is a type guard that also checks if the given input is a function at all, so it is
not necessary to check for that separately:
@@ -238,49 +304,34 @@ function isFunctionOfLength(f: unknown, l: number): f is Function {
}
```
-### Limitations of `isFunctionOfLength`
-
-Of course, `isFunctionOfLength` can only guarantee that the object passed to it is indeed a function and that it takes
-the specified number of parameters. It won't actually guarantee at runtime that the provided parameters are of the defined types. For example:
-
-```ts
-export function call_callback(f: (a: string, b: string) => void) {
- if (!isFunctionOfLength(f, 2)) {
- throw new Error();
- }
-
- return f('a', 'b');
-}
-
-// isFunctionOfLength won't be able to guarantee that x and y are strings
-call_callback((x, y) => x * y);
-```
-
-In fact, if you give it something of type `unknown`, the best it can do is narrow it down to a function that takes parameters of type `unknown`
-and returns a value with type `unknown`:
-
-```ts
-export function call_callback(f: unknown) {
- if (!isFunctionOfLength(f, 2)) {
- throw new Error();
- }
-
- // Then f here gets narrowed to (a: unknown, b: unknown) => unknown
- return f('a', 'b');
-}
-```
-
-For such a case, you can provide the type you want to narrow to as a generic type parameter:
-
-```ts
-type Callback = (a: string, b: string) => void;
-
-export function call_callback(f: unknown) {
- if (!isFunctionOfLength(f, 2)) {
- throw new Error();
- }
-
- // Then f here gets narrowed to Callback
- return f('a', 'b');
-}
-```
+> [!WARN] Limitations of `isFunctionOfLength`
+>
+> Of course, `isFunctionOfLength` can only guarantee that the object passed to it is indeed a function and that it takes
+> the specified number of parameters. It won't actually guarantee at runtime that the provided parameters are of the defined types. For example:
+>
+> ```ts
+> export function call_callback(f: (a: string, b: string) => void) {
+> if (!isFunctionOfLength(f, 2)) {
+> throw new InvalidCallbackError(2, f, call_callback.name);
+> }
+>
+> return f('a', 'b');
+> }
+>
+> // isFunctionOfLength won't be able to guarantee that x and y are strings
+> call_callback((x, y) => x * y);
+> ```
+>
+> In fact, if you give it something of type `unknown`, the best it can do is narrow it down to a function that takes parameters of type `unknown`
+> and returns a value with type `unknown`:
+>
+> ```ts
+> export function call_callback(f: unknown) {
+> if (!isFunctionOfLength(f, 2)) {
+> throw new InvalidCallbackError(2, f, call_callback.name);
+> }
+>
+> // Then f here gets narrowed to (a: unknown, b: unknown) => unknown
+> return f('a', 'b');
+> }
+> ```
diff --git a/docs/src/modules/2-bundle/5-documentation/2-dev.md b/docs/src/modules/2-bundle/5-documentation/2-dev.md
index 30886280c4..84ce51aba6 100644
--- a/docs/src/modules/2-bundle/5-documentation/2-dev.md
+++ b/docs/src/modules/2-bundle/5-documentation/2-dev.md
@@ -95,6 +95,8 @@ You should document:
- Information flows (like what functions write to the module context etc...)
- Any interesting quirks or configurations required to get your bundle working
- How the underlying implementation of your bundle actually works
+- Some way for other developers to contact you about your code
+- A list of places where your bundle is deployed or used (e.g. frontend assessments)
::: details The `pix_n_flix` bundle
A good example for the last point is the `pix_n_flix` bundle, where many of the cadet facing functions, when called,
diff --git a/docs/src/modules/3-tabs/1-overview.md b/docs/src/modules/3-tabs/1-overview.md
index 3cae201d3b..d5143517df 100644
--- a/docs/src/modules/3-tabs/1-overview.md
+++ b/docs/src/modules/3-tabs/1-overview.md
@@ -40,9 +40,9 @@ children:
children:
- name: __tests__
children:
- - name: test.tsx
+ - name: index.test.tsx
comment: You can use a tsx file
- - name: test2.ts
+ - name: index.test.ts
comment: Or a regular ts file
- index.tsx
- component.tsx
@@ -65,11 +65,13 @@ The entry point for a tab is either `index.tsx` or `src/index.tsx`. No other ent
The Frontend expects each tab's entry point to provide a default export of an object conforming to the following interface:
```ts
+import type { IconName } from '@blueprintjs/core';
+
interface ModuleSideContent {
toSpawn: ((context: DebuggerContext) => boolean) | undefined;
body: (context: DebuggerContext) => JSX.Element;
label: string;
- iconName: string;
+ iconName: IconName;
}
```
@@ -106,7 +108,7 @@ export default defineTab({
return ;
},
label: 'Curves Tab',
- iconName: 'media',
+ iconName: 'media'
});
```
@@ -142,4 +144,6 @@ A string containing the text for the tooltip to display when the user hovers ove
### `iconName`
-The name of the BlueprintJS icon to use for the tab icon. You can refer to the list of icon names [here](https://blueprintjs.com/docs/#icons)
+The name of the BlueprintJS icon to use for the tab icon. You can refer to the list of icon names [here](https://blueprintjs.com/docs/#icons).
+
+Note that `IconName`s are defined in `@blueprintjs/core`, but it is not necessary to import that type.
diff --git a/docs/src/modules/3-tabs/3-editing.md b/docs/src/modules/3-tabs/3-editing.md
index 465f410b41..5b74c5e65e 100644
--- a/docs/src/modules/3-tabs/3-editing.md
+++ b/docs/src/modules/3-tabs/3-editing.md
@@ -104,6 +104,20 @@ There are also several React components defined under `@sourceacademy/modules-li
You can see the documentation for these components [here](/lib/modules-lib/)
+### Styles from `@blueprintjs`
+
+Besides providing components, Blueprint also provides CSS Styles that can be attached to your own components:
+
+```jsx
+const component =
+
Hello World!
+
;
+```
+You can use these styles directly instead of having to come up with your own.
+
+Often, these styles will be prefixed with `bp` and the version number (i.e `bp6`). Take care to ensure that this prefix matches the version of Blueprint that
+is in use.
+
## Export Interface
As mentioned in the [overview](./1-overview), all tabs should export a single default export from their entry point using the `defineTab` helper from `@sourceacademy/modules-lib/tabs`:
@@ -169,7 +183,6 @@ test('Matches snapshot', () => {
expect().toMatchSnapshot();
});
```
-
:::
## Working with Module Contexts from within Tabs
diff --git a/docs/src/repotools/5-yarn.md b/docs/src/repotools/5-yarn.md
index 2abbde97ac..0720f848c2 100644
--- a/docs/src/repotools/5-yarn.md
+++ b/docs/src/repotools/5-yarn.md
@@ -54,6 +54,11 @@ other package within this repository.
Aside from `vitest-browser-react` and `@vitest/eslint-plugin`, all Vitest packages should have the same version range
as Vitest throughout the repository.
+### 6. `@types` dependencies are specified as Dev Dependencies
+
+The `@types` packages provide Typescript types for packages that don't come with them bundled. Typescript types should not ever be required at runtime,
+so they should be specified as a `devDependency`.
+
## Parallel Execution of Scripts
Using the various options of the `yarn workspaces foreach` command, you can execute multiple tasks in parallel, which is how many of the commands in the root repository have been set up.
diff --git a/eslint.config.js b/eslint.config.js
index 3e73a8e0e6..0fd4ed80c1 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -153,6 +153,7 @@ export default defineConfig(
'@stylistic/brace-style': ['warn', '1tbs', { allowSingleLine: true }],
'@stylistic/function-call-spacing': ['warn', 'never'],
'@stylistic/function-paren-newline': ['warn', 'multiline-arguments'],
+ '@stylistic/implicit-arrow-linebreak': ['error', 'beside'],
'@stylistic/keyword-spacing': 'warn',
'@stylistic/member-delimiter-style': [
'warn',
@@ -168,7 +169,9 @@ export default defineConfig(
}
],
'@stylistic/no-extra-parens': ['warn', 'all', {
- enforceForArrowConditionals: false,
+ ignoredNodes: [
+ 'ArrowFunctionExpression[body.type=ConditionalExpression]'
+ ],
ignoreJSX: 'all',
nestedBinaryExpressions: false,
}],
@@ -184,7 +187,8 @@ export default defineConfig(
anonymous: 'always',
asyncArrow: 'always',
named: 'never'
- }]
+ }],
+ '@stylistic/template-curly-spacing': 'warn'
}
},
{
@@ -209,10 +213,11 @@ export default defineConfig(
'no-dupe-keys': 'off',
'no-redeclare': 'off',
'no-undef': 'off',
+ 'no-unreachable': 'off',
'no-unused-expressions': 'off',
- 'react/jsx-no-undef': 'off',
'no-unused-vars': 'off',
'padded-blocks': 'off',
+ 'react/jsx-no-undef': 'off',
// Adding a "use strict" directive at the top of every
// code block is tedious and distracting. The config
@@ -336,7 +341,18 @@ export default defineConfig(
// This rule doesn't seem to fail locally but fails on the CI
// '@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], // Was 'error'
- '@typescript-eslint/only-throw-error': 'error'
+ '@typescript-eslint/only-throw-error': ['error', {
+ allowRethrowing: true,
+ allow: [
+ // TODO: Remove these exceptions when js-slang errors inherit from Error
+ 'InvalidParameterTypeError', 'InvalidCallbackError',
+ // {
+ // from: 'package',
+ // name: ['InvalidParameterTypeError', 'InvalidCallbackError'],
+ // package: '@sourceacademy/modules-lib',
+ // }
+ ]
+ }]
},
settings: {
'import/resolver': {
@@ -371,7 +387,6 @@ export default defineConfig(
'@stylistic/jsx-equals-spacing': ['warn', 'never'],
'@stylistic/jsx-indent-props': ['warn', 2],
- '@stylistic/jsx-props-no-multi-spaces': 'warn',
'@stylistic/jsx-self-closing-comp': 'warn',
},
settings: {
diff --git a/lib/markdown-tree/src/tree.ts b/lib/markdown-tree/src/tree.ts
index 7c6a728609..1cce7ae5f4 100644
--- a/lib/markdown-tree/src/tree.ts
+++ b/lib/markdown-tree/src/tree.ts
@@ -174,5 +174,4 @@ const getName = (
* is the last child of its parent
* @param structure The file or folder to test
*/
-const isLastChild = (structure: FileStructure): boolean =>
- Boolean(structure.parent && last(structure.parent.children) === structure);
+const isLastChild = (structure: FileStructure) => !!structure.parent && last(structure.parent.children) === structure;
diff --git a/lib/modules-lib/src/__tests__/errors.test.ts b/lib/modules-lib/src/__tests__/errors.test.ts
new file mode 100644
index 0000000000..4ce189b613
--- /dev/null
+++ b/lib/modules-lib/src/__tests__/errors.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from 'vitest';
+import { InvalidCallbackError, InvalidParameterTypeError } from '../errors';
+
+describe(InvalidParameterTypeError, () => {
+ test('constructing without parameter name', () => {
+ const error = new InvalidParameterTypeError('number', 'abc', 'foo');
+ expect(error.explain()).toEqual('foo: Expected number, got "abc".');
+ });
+
+ test('constructing with parameter name', () => {
+ const error = new InvalidParameterTypeError('number', 'abc', 'foo', 'x');
+ expect(error.explain()).toEqual('foo: Expected number for x, got "abc".');
+ });
+});
+
+describe(InvalidCallbackError, () => {
+ test('constructing with expected number of parameters being greater than 0', () => {
+ const error = new InvalidCallbackError(2, 'abc', 'foo');
+ expect(error.explain()).toEqual('foo: Expected function with 2 parameters, got "abc".');
+ });
+
+ test('constructing with expected number of parameters being 0', () => {
+ const error = new InvalidCallbackError(0, 'abc', 'foo');
+ expect(error.explain()).toEqual('foo: Expected function with 0 parameters, got "abc".');
+ });
+
+ test('constructing with expected callback type string', () => {
+ const error = new InvalidCallbackError('Curve', 'abc', 'foo', 'callback');
+ expect(error.explain()).toEqual('foo: Expected Curve for callback, got "abc".');
+ });
+});
diff --git a/lib/modules-lib/src/__tests__/utilities.test.ts b/lib/modules-lib/src/__tests__/utilities.test.ts
index c88833ca99..5fb17d9aef 100644
--- a/lib/modules-lib/src/__tests__/utilities.test.ts
+++ b/lib/modules-lib/src/__tests__/utilities.test.ts
@@ -18,6 +18,10 @@ describe(hexToColor, () => {
it('throws an error when an invalid hex string is passed', () => {
expect(() => hexToColor('GGGGGG')).toThrowErrorMatchingInlineSnapshot('[Error: hexToColor: Invalid color hex string: GGGGGG]');
});
+
+ it('throws an error when a non-string is passed', () => {
+ expect(() => hexToColor(123 as any)).toThrowErrorMatchingInlineSnapshot('[Error: hexToColor: Expected a string, got number]');
+ });
});
describe(isFunctionOfLength, () => {
diff --git a/lib/modules-lib/src/errors.ts b/lib/modules-lib/src/errors.ts
new file mode 100644
index 0000000000..3a01846001
--- /dev/null
+++ b/lib/modules-lib/src/errors.ts
@@ -0,0 +1,92 @@
+/**
+ * This module defines custom error classes for handling errors that occur in Source bundles.
+ * @module Errors
+ * @title Errors
+ */
+
+import { RuntimeSourceError } from 'js-slang/dist/errors/runtimeSourceError';
+import { stringify } from 'js-slang/dist/utils/stringify';
+
+/**
+ * A specific {@link RuntimeSourceError|RuntimeSourceError} that is thrown when a function receives a parameter of the wrong type.
+ *
+ * @example
+ * ```
+ * function play_sound(sound: unknown): asserts sound is Sound {
+ * if (!is_sound(sound)) {
+ * throw new InvalidParameterTypeError('Sound', sound, play_sound.name, 'sound');
+ * }
+ * }
+ * ```
+ */
+export class InvalidParameterTypeError extends RuntimeSourceError {
+ constructor(
+ /**
+ * String representation of the expected type. Examples include "number", "string", or "Point".
+ */
+ public readonly expectedType: string,
+
+ /**
+ * The actual value that was received.
+ */
+ public readonly actualValue: unknown,
+
+ /**
+ * The name of the function that received the invalid parameter.
+ */
+ public readonly func_name: string,
+
+ /**
+ * The name of the parameter that received the invalid value, if available.
+ */
+ public readonly param_name?: string
+ ) {
+ super();
+ }
+
+ public override explain(): string {
+ const paramString = this.param_name ? ` for ${this.param_name}` : '';
+ return `${this.func_name}: Expected ${this.expectedType}${paramString}, got ${stringify(this.actualValue)}.`;
+ }
+
+ public get message() {
+ return this.explain();
+ }
+
+ public override toString() {
+ return this.explain();
+ }
+}
+
+/**
+ * A subclass of the {@link InvalidParameterTypeError|InvalidParameterTypeError} that is thrown when a function receives a callback parameter
+ * that is not a function or does not have the expected number of parameters.
+ *
+ * @example
+ * ```
+ * function call_callback(callback: (x: number, y: number) => number) {
+ * if (!isFunctionOfLength(callback, 2)) {
+ * throw new InvalidCallbackError(2, callback, call_callback.name, 'callback');
+ * }
+ * }
+ * ```
+ */
+export class InvalidCallbackError extends InvalidParameterTypeError {
+ constructor(
+ /**
+ * Either the expected number of parameters of the callback function, or a string describing the expected callback type.
+ */
+ expected: number | string,
+ actualValue: unknown,
+ func_name: string,
+ param_name?: string
+ ) {
+ const expectedStr = typeof expected === 'number' ? `function with ${expected} parameter${expected !== 1 ? 's' : ''}` : expected;
+ super(
+ expectedStr,
+ actualValue,
+ func_name,
+ param_name
+ );
+ }
+}
diff --git a/lib/modules-lib/src/tabs/AnimationCanvas.tsx b/lib/modules-lib/src/tabs/AnimationCanvas.tsx
index 97c6f4731a..19f9cf44e0 100644
--- a/lib/modules-lib/src/tabs/AnimationCanvas.tsx
+++ b/lib/modules-lib/src/tabs/AnimationCanvas.tsx
@@ -1,5 +1,4 @@
import { Icon, Slider, Tooltip } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
import { useMemo, useState } from 'react';
import type { glAnimation } from '../types';
import AnimationError from './AnimationError';
@@ -10,14 +9,15 @@ import WebGLCanvas from './WebGLCanvas';
import { BP_TAB_BUTTON_MARGIN, BP_TEXT_MARGIN, CANVAS_MAX_WIDTH } from './css_constants';
import { useAnimation } from './useAnimation';
-export type AnimCanvasProps = {
+export interface AnimCanvasProps {
animation: glAnimation;
};
/**
* React Component for displaying {@link glAnimation|glAnimations}.
- *
* Uses {@link WebGLCanvas} internally.
+ *
+ * @category Components
*/
export default function AnimationCanvas(props: AnimCanvasProps) {
const [isAutoLooping, setIsAutoLooping] = useState(true);
@@ -71,7 +71,7 @@ export default function AnimationCanvas(props: AnimCanvasProps) {
disabled={Boolean(errored)}
onClick={reset}
>
-
+
-
+
& {
isAutoLooping: boolean;
};
-/* [Main] */
+/**
+ * A {@link Switch|Switch} component designed to be used as a toggle for auto playing
+ * @category Components
+ */
export default function AutoLoopSwitch({ isAutoLooping, ...props }: AutoLoopSwitchProps) {
return (
<>
{open && (
diff --git a/lib/modules-lib/src/tabs/MultiItemDisplay/index.tsx b/lib/modules-lib/src/tabs/MultiItemDisplay/index.tsx
index 375d6658d8..0dd16faa86 100644
--- a/lib/modules-lib/src/tabs/MultiItemDisplay/index.tsx
+++ b/lib/modules-lib/src/tabs/MultiItemDisplay/index.tsx
@@ -1,5 +1,4 @@
import { Button, EditableText } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
import clamp from 'lodash/clamp';
import { useState } from 'react';
@@ -11,6 +10,8 @@ export interface MultiItemDisplayProps {
/**
* React Component for displaying multiple items
* 
+ *
+ * @category Components
*/
export default function MultiItemDisplay(props: MultiItemDisplayProps) {
// The actual index of the currently selected element
@@ -60,7 +61,7 @@ export default function MultiItemDisplay(props: MultiItemDisplayProps) {
tabIndex={0}
large
outlined
- icon={IconNames.ARROW_LEFT}
+ icon='arrow-left'
onClick={() => {
changeStep(currentStep - 1);
setStepEditorValue(currentStep.toString());
@@ -69,7 +70,7 @@ export default function MultiItemDisplay(props: MultiItemDisplayProps) {
>
Previous
-
+
{
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 (
-
);
@@ -152,5 +154,5 @@ export default defineTab({
},
body: context => ,
label: 'Arcade2D Tab',
- iconName: IconNames.SHAPES
+ iconName: 'shapes'
});
diff --git a/src/tabs/ArcadeTwod/package.json b/src/tabs/ArcadeTwod/package.json
index 2a2e7431c6..6e65cc6dc4 100644
--- a/src/tabs/ArcadeTwod/package.json
+++ b/src/tabs/ArcadeTwod/package.json
@@ -4,7 +4,6 @@
"private": true,
"dependencies": {
"@blueprintjs/core": "^6.0.0",
- "@blueprintjs/icons": "^6.0.0",
"@sourceacademy/modules-lib": "workspace:^",
"phaser": "^3.54.0",
"react": "^18.3.1",
diff --git a/src/tabs/Csg/package.json b/src/tabs/Csg/package.json
index ca9a861b0f..5a7e503834 100644
--- a/src/tabs/Csg/package.json
+++ b/src/tabs/Csg/package.json
@@ -4,7 +4,6 @@
"private": true,
"dependencies": {
"@blueprintjs/core": "^6.0.0",
- "@blueprintjs/icons": "^6.0.0",
"@sourceacademy/bundle-csg": "workspace:^",
"@sourceacademy/modules-lib": "workspace:^",
"react": "^18.3.1",
diff --git a/src/tabs/Csg/src/canvas_holder.tsx b/src/tabs/Csg/src/canvas_holder.tsx
index 3b681a78d8..f602af8b4a 100644
--- a/src/tabs/Csg/src/canvas_holder.tsx
+++ b/src/tabs/Csg/src/canvas_holder.tsx
@@ -1,6 +1,5 @@
/* [Imports] */
import { Spinner, SpinnerSize } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
import { Core } from '@sourceacademy/bundle-csg/core';
import StatefulRenderer from '@sourceacademy/bundle-csg/stateful_renderer';
import type { RenderGroup } from '@sourceacademy/bundle-csg/utilities';
@@ -78,23 +77,23 @@ export default class CanvasHolder extends React.Component<
>
@@ -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
-