diff --git a/.changeset/eleven-drinks-fail.md b/.changeset/eleven-drinks-fail.md new file mode 100644 index 0000000000..fe9665eccb --- /dev/null +++ b/.changeset/eleven-drinks-fail.md @@ -0,0 +1,5 @@ +--- +"jspsych": patch +--- + +parameter types will properly be checked in case of type mismatch, along with `ParameterType.SELECT` params properly using the `option` field to check if it is a valid parameter diff --git a/packages/jspsych/src/modules/plugins.ts b/packages/jspsych/src/modules/plugins.ts index ec1bbc98f1..428a654be3 100644 --- a/packages/jspsych/src/modules/plugins.ts +++ b/packages/jspsych/src/modules/plugins.ts @@ -51,6 +51,7 @@ export type ParameterInfo = ( array?: boolean; pretty_name?: string; default?: any; + options?: any; }; export type ParameterInfos = Record; diff --git a/packages/jspsych/src/timeline/Trial.spec.ts b/packages/jspsych/src/timeline/Trial.spec.ts index 2a53226257..bcf7a759f3 100644 --- a/packages/jspsych/src/timeline/Trial.spec.ts +++ b/packages/jspsych/src/timeline/Trial.spec.ts @@ -560,6 +560,92 @@ describe("Trial", () => { }); }); + describe("with parameter type mismatches", () => { + it("errors on non-boolean values for boolean parameters", async () => { + TestPlugin.setParameterInfos({ + boolParameter: { type: ParameterType.BOOL }, + }); + + // this should work: + await createTrial({ type: TestPlugin, boolParameter: true }).run(); + + // this shouldn't: + await expect(createTrial({ type: TestPlugin, boolParameter: "foo" }).run()).rejects.toThrow( + "A non-boolean value (`foo`) was provided for the boolean parameter \"boolParameter\" in the \"test\" plugin." + ); + }); + + it("errors on non-string values for string parameters", async () => { + TestPlugin.setParameterInfos({ + stringParameter: { type: ParameterType.STRING }, + }); + + // this should work: + await createTrial({ type: TestPlugin, stringParameter: "foo" }).run(); + + // this shouldn't: + await expect(createTrial({ type: TestPlugin, stringParameter: 1 }).run()).rejects.toThrow( + "A non-string value (`1`) was provided for the parameter \"stringParameter\" in the \"test\" plugin." + ); + }); + + it("errors on non-numeric values for numeric parameters", async () => { + TestPlugin.setParameterInfos({ + intParameter: { type: ParameterType.INT }, + floatParameter: { type: ParameterType.FLOAT }, + }); + + // this should work: + await createTrial({ type: TestPlugin, intParameter: 1, floatParameter: 1.5 }).run(); + + // this shouldn't: + await expect(createTrial({ type: TestPlugin, intParameter: "foo", floatParameter: 1.5 }).run()).rejects.toThrow( + "A non-numeric value (`foo`) was provided for the numeric parameter \"intParameter\" in the \"test\" plugin." + ); + await expect(createTrial({ type: TestPlugin, intParameter: 1, floatParameter: "foo" }).run()).rejects.toThrow( + "A non-numeric value (`foo`) was provided for the numeric parameter \"floatParameter\" in the \"test\" plugin." + ); + + // this should warn but not error: + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + await createTrial({ type: TestPlugin, intParameter: 1.5, floatParameter: 1.5 }).run(); + expect(consoleSpy).toHaveBeenCalledWith( + `A float value (\`1.5\`) was provided for the integer parameter "intParameter" in the "test" plugin. The value will be truncated to an integer.` + ); + }); + + it("errors on non-function values for function parameters", async () => { + TestPlugin.setParameterInfos({ + functionParameter: { type: ParameterType.FUNCTION }, + }); + + // this should work: + await createTrial({ type: TestPlugin, functionParameter: () => {} }).run(); + + // this shouldn't: + await expect(createTrial({ type: TestPlugin, functionParameter: "foo" }).run()).rejects.toThrow( + "A non-function value (`foo`) was provided for the function parameter \"functionParameter\" in the \"test\" plugin." + ); + }); + + it("errors on select parameters with values not in the options", async () => { + TestPlugin.setParameterInfos({ + selectParameter: { + type: ParameterType.SELECT, + options: ["foo", "bar"], + }, + }); + + // this should work: + await createTrial({ type: TestPlugin, selectParameter: "foo" }).run(); + + // this shouldn't: + await expect(createTrial({ type: TestPlugin, selectParameter: "baz" }).run()).rejects.toThrow( + "The value \"baz\" is not a valid option for the parameter \"selectParameter\" in the \"test\" plugin. Valid options are: foo, bar." + ); + }); + }); + it("respects `default_iti` and `post_trial_gap``", async () => { dependencies.getDefaultIti.mockReturnValue(100); TestPlugin.setManualFinishTrialMode(); diff --git a/packages/jspsych/src/timeline/Trial.ts b/packages/jspsych/src/timeline/Trial.ts index b2575240d6..15ee4a549e 100644 --- a/packages/jspsych/src/timeline/Trial.ts +++ b/packages/jspsych/src/timeline/Trial.ts @@ -360,6 +360,7 @@ export class Trial extends TimelineNode { for (const [parameterName, parameterConfig] of Object.entries(parameterInfos)) { const parameterPath = [...parentParameterPath, parameterName]; + // evaluate parameter and validate required parameter let parameterValue = this.getParameterValue(parameterPath, { evaluateFunctions: parameterConfig.type !== ParameterType.FUNCTION, replaceResult: (originalResult) => { @@ -379,6 +380,83 @@ export class Trial extends TimelineNode { }, }); + // major parameter type validation + if (!parameterConfig.array && parameterValue !== null) { + switch (parameterConfig.type) { + case ParameterType.BOOL: + if (typeof parameterValue !== "boolean") { + const parameterPathString = parameterPathArrayToString(parameterPath); + throw new Error( + `A non-boolean value (\`${parameterValue}\`) was provided for the boolean parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.` + ); + } + break; + // @ts-ignore falls through + case ParameterType.KEYS: // "ALL_KEYS", "NO_KEYS", and single key strings are checked here + if (Array.isArray(parameterValue)) + break; + case ParameterType.STRING: + case ParameterType.HTML_STRING: + case ParameterType.KEY: + case ParameterType.AUDIO: + case ParameterType.VIDEO: + case ParameterType.IMAGE: + if (typeof parameterValue !== "string") { + const parameterPathString = parameterPathArrayToString(parameterPath); + throw new Error( + `A non-string value (\`${parameterValue}\`) was provided for the parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.` + ); + } + break; + case ParameterType.FLOAT: + case ParameterType.INT: + if (typeof parameterValue !== "number") { + const parameterPathString = parameterPathArrayToString(parameterPath); + throw new Error( + `A non-numeric value (\`${parameterValue}\`) was provided for the numeric parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.` + ); + } + break; + case ParameterType.FUNCTION: + if (typeof parameterValue !== "function") { + const parameterPathString = parameterPathArrayToString(parameterPath); + throw new Error( + `A non-function value (\`${parameterValue}\`) was provided for the function parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.` + ); + } + break; + case ParameterType.SELECT: + if (!parameterConfig.options) { + const parameterPathString = parameterPathArrayToString(parameterPath); + throw new Error( + `The "options" array is required for the "select" parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.` + ); + } + } + + // truncate floats to integers if the parameter type is INT + if (parameterConfig.type === ParameterType.INT && parameterValue % 1 !== 0) { + const parameterPathString = parameterPathArrayToString(parameterPath); + console.warn( + `A float value (\`${parameterValue}\`) was provided for the integer parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin. The value will be truncated to an integer.` + ); + + parameterValue = Math.trunc(parameterValue); + } + } + + if (parameterConfig.type === ParameterType.SELECT) { + if (!parameterConfig.options.includes(parameterValue)) { + const parameterPathString = parameterPathArrayToString(parameterPath); + throw new Error( + `The value "${parameterValue}" is not a valid option for the parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin. Valid options are: ${parameterConfig.options.join( + ", " + )}.` + ); + } + } + + // array validation if (parameterConfig.array && !Array.isArray(parameterValue)) { const parameterPathString = parameterPathArrayToString(parameterPath); throw new Error(