Merge pull request #2084 from bjoluc/parameter-types

Enhance parameter types
This commit is contained in:
bjoluc 2021-08-17 17:32:35 +02:00 committed by GitHub
commit f54d48911f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 97 additions and 102 deletions

View File

@ -142,6 +142,6 @@ var trial = {
``` ```
## When dynamic parameters can't be used ## When dynamic parameters can't be used
Note that if the plugin *expects* the value of a given parameter to be a function, then this function *will not* be evaluated at the start of the trial. This is because some plugins allow the researcher to specify functions that should be called at some point during the trial. Some examples of this include the `stimulus` parameter in the canvas-* plugins, the `mistake_fn` parameter in the cloze plugin, and the `stim_function` parameter in the reconstruction plugin. If you want to check whether this is the case for a particular plugin and parameter, then the parameter's `type` in the `plugin.info` section of the plugin file. If the parameter type is `jsPsych.plugins.parameterType.FUNCTION`, then this parameter must be a function and it will not be executed before the trial starts. Note that if the plugin *expects* the value of a given parameter to be a function, then this function *will not* be evaluated at the start of the trial. This is because some plugins allow the researcher to specify functions that should be called at some point during the trial. Some examples of this include the `stimulus` parameter in the canvas-* plugins, the `mistake_fn` parameter in the cloze plugin, and the `stim_function` parameter in the reconstruction plugin. If you want to check whether this is the case for a particular plugin and parameter, then the parameter's `type` in the `plugin.info` section of the plugin file. If the parameter type is `ParameterType.FUNCTION`, then this parameter must be a function and it will not be executed before the trial starts.
Even though function evaluation doesn't work the same way with these parameters, the fact that the parameters are functions means that you can get the same dynamic functionality. These functions are typically evaluated at some point during the trial, so you still get updates to values within the function during the trial. Even though function evaluation doesn't work the same way with these parameters, the fact that the parameters are functions means that you can get the same dynamic functionality. These functions are typically evaluated at some point during the trial, so you still get updates to values within the function during the trial.

View File

@ -3,7 +3,7 @@ import autoBind from "auto-bind";
import { version } from "../package.json"; import { version } from "../package.json";
import { JsPsychData } from "./modules/data"; import { JsPsychData } from "./modules/data";
import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api"; import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api";
import * as plugins from "./modules/plugins"; import { ParameterType, universalPluginParameters } from "./modules/plugins";
import * as randomization from "./modules/randomization"; import * as randomization from "./modules/randomization";
import * as turk from "./modules/turk"; import * as turk from "./modules/turk";
import * as utils from "./modules/utils"; import * as utils from "./modules/utils";
@ -15,7 +15,6 @@ function delay(ms: number) {
export class JsPsych { export class JsPsych {
extensions = <any>{}; extensions = <any>{};
plugins = plugins;
turk = turk; turk = turk;
randomization = randomization; randomization = randomization;
utils = utils; utils = utils;
@ -140,10 +139,6 @@ export class JsPsych {
this.pluginAPI.initAudio(); this.pluginAPI.initAudio();
} }
// enumerated variables for special parameter types
readonly ALL_KEYS = "allkeys";
readonly NO_KEYS = "none";
/** /**
* Starts an experiment using the provided timeline and returns a promise that is resolved when * Starts an experiment using the provided timeline and returns a promise that is resolved when
* the experiment is finished. * the experiment is finished.
@ -614,14 +609,14 @@ export class JsPsych {
// the first line checks if the parameter is defined in the universalPluginParameters set // the first line checks if the parameter is defined in the universalPluginParameters set
// the second line checks the plugin-specific parameters // the second line checks the plugin-specific parameters
if ( if (
typeof this.plugins.universalPluginParameters[key] !== "undefined" && typeof universalPluginParameters[key] !== "undefined" &&
this.plugins.universalPluginParameters[key].type !== this.plugins.parameterType.FUNCTION universalPluginParameters[key].type !== ParameterType.FUNCTION
) { ) {
trial[key] = this.replaceFunctionsWithValues(trial[key], null); trial[key] = this.replaceFunctionsWithValues(trial[key], null);
} }
if ( if (
typeof trial.type.info.parameters[key] !== "undefined" && typeof trial.type.info.parameters[key] !== "undefined" &&
trial.type.info.parameters[key].type !== this.plugins.parameterType.FUNCTION trial.type.info.parameters[key].type !== ParameterType.FUNCTION
) { ) {
trial[key] = this.replaceFunctionsWithValues(trial[key], trial.type.info.parameters[key]); trial[key] = this.replaceFunctionsWithValues(trial[key], trial.type.info.parameters[key]);
} }
@ -658,7 +653,7 @@ export class JsPsych {
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
if ( if (
typeof info.nested[keys[i]] == "object" && typeof info.nested[keys[i]] == "object" &&
info.nested[keys[i]].type !== this.plugins.parameterType.FUNCTION info.nested[keys[i]].type !== ParameterType.FUNCTION
) { ) {
obj[keys[i]] = this.replaceFunctionsWithValues(obj[keys[i]], info.nested[keys[i]]); obj[keys[i]] = this.replaceFunctionsWithValues(obj[keys[i]], info.nested[keys[i]]);
} }
@ -673,7 +668,7 @@ export class JsPsych {
private setDefaultValues(trial) { private setDefaultValues(trial) {
for (var param in trial.type.info.parameters) { for (var param in trial.type.info.parameters) {
// check if parameter is complex with nested defaults // check if parameter is complex with nested defaults
if (trial.type.info.parameters[param].type == this.plugins.parameterType.COMPLEX) { if (trial.type.info.parameters[param].type == ParameterType.COMPLEX) {
if (trial.type.info.parameters[param].array == true) { if (trial.type.info.parameters[param].array == true) {
// iterate over each entry in the array // iterate over each entry in the array
trial[param].forEach(function (ip, i) { trial[param].forEach(function (ip, i) {

View File

@ -1,5 +1,5 @@
import { JsPsych } from "./JsPsych"; import { JsPsych } from "./JsPsych";
import { parameterType } from "./modules/plugins"; import { ParameterType } from "./modules/plugins";
import { import {
repeat, repeat,
sampleWithReplacement, sampleWithReplacement,
@ -546,9 +546,9 @@ export class TimelineNode {
/** Maps parameter types to their corresponding preload type */ /** Maps parameter types to their corresponding preload type */
const parameterTypeMap = new Map<number, PreloadType>([ const parameterTypeMap = new Map<number, PreloadType>([
[parameterType.AUDIO, "audio"], [ParameterType.AUDIO, "audio"],
[parameterType.IMAGE, "image"], [ParameterType.IMAGE, "image"],
[parameterType.VIDEO, "video"], [ParameterType.VIDEO, "video"],
]); ]);
function recurseTimeline(node: TimelineNode) { function recurseTimeline(node: TimelineNode) {

View File

@ -24,5 +24,11 @@ export function initJsPsych(options?) {
} }
export { JsPsych } from "./JsPsych"; export { JsPsych } from "./JsPsych";
export { JsPsychPlugin, PluginInfo, TrialType } from "./modules/plugins"; export {
export { parameterType } from "./modules/plugins"; JsPsychPlugin,
PluginInfo,
TrialType,
ParameterType,
universalPluginParameters,
UniversalPluginParameters,
} from "./modules/plugins";

View File

@ -1,10 +1,5 @@
export class KeyboardListenerAPI { export class KeyboardListenerAPI {
constructor( constructor(private areResponsesCaseSensitive: boolean, private minimumValidRt = 0) {}
private areResponsesCaseSensitive: boolean,
private minimumValidRt = 0,
private readonly ALL_KEYS = "allkeys",
private readonly NO_KEYS = "none"
) {}
private keyboard_listeners = []; private keyboard_listeners = [];
@ -83,9 +78,9 @@ export class KeyboardListenerAPI {
var valid_response = false; var valid_response = false;
if (typeof parameters.valid_responses === "undefined") { if (typeof parameters.valid_responses === "undefined") {
valid_response = true; valid_response = true;
} else if (parameters.valid_responses == this.ALL_KEYS) { } else if (parameters.valid_responses == "ALL_KEYS") {
valid_response = true; valid_response = true;
} else if (parameters.valid_responses != this.NO_KEYS) { } else if (parameters.valid_responses != "NO_KEYS") {
if (parameters.valid_responses.includes(e.key)) { if (parameters.valid_responses.includes(e.key)) {
valid_response = true; valid_response = true;
} }

View File

@ -11,12 +11,7 @@ export function createJointPluginAPIObject(jsPsych: JsPsych) {
return Object.assign( return Object.assign(
{}, {},
...[ ...[
new KeyboardListenerAPI( new KeyboardListenerAPI(settings.case_sensitive_responses, settings.minimum_valid_rt),
settings.case_sensitive_responses,
settings.minimum_valid_rt,
jsPsych.ALL_KEYS,
jsPsych.NO_KEYS
),
new TimeoutAPI(), new TimeoutAPI(),
new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context), new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context),
new HardwareAPI(), new HardwareAPI(),

View File

@ -12,43 +12,47 @@ type SetRequired<BaseType, Keys extends keyof BaseType> = Simplify<
Omit<BaseType, Keys> & Required<Pick<BaseType, Keys>> Omit<BaseType, Keys> & Required<Pick<BaseType, Keys>>
>; >;
// enumerate possible parameter types for plugins /**
export const parameterType = <const>{ * Parameter types for plugins
BOOL: 0, */
STRING: 1, export enum ParameterType {
INT: 2, BOOL,
FLOAT: 3, STRING,
FUNCTION: 4, INT,
KEY: 5, FLOAT,
SELECT: 6, FUNCTION,
HTML_STRING: 7, KEY,
IMAGE: 8, KEYS,
AUDIO: 9, SELECT,
VIDEO: 10, HTML_STRING,
OBJECT: 11, IMAGE,
COMPLEX: 12, AUDIO,
TIMELINE: 13, VIDEO,
}; OBJECT,
COMPLEX,
TIMELINE,
}
type ParameterTypeMap = { type ParameterTypeMap = {
0: boolean; // BOOL [ParameterType.BOOL]: boolean;
1: string; // STRING [ParameterType.STRING]: string;
2: number; // INT [ParameterType.INT]: number;
3: number; // FLOAT [ParameterType.FLOAT]: number;
4: (...args: any[]) => any; // FUNCTION [ParameterType.FUNCTION]: (...args: any[]) => any;
5: string; // KEY [ParameterType.KEY]: string;
6: any; // SELECT [ParameterType.KEYS]: string[] | "ALL_KEYS" | "NO_KEYS";
7: string; // HTML_STRING [ParameterType.SELECT]: any;
8: string; // IMAGE [ParameterType.HTML_STRING]: string;
9: string; // AUDIO [ParameterType.IMAGE]: string;
10: string; // VIDEO [ParameterType.AUDIO]: string;
11: object; // OBJECT [ParameterType.VIDEO]: string;
12: any; // COMPLEX [ParameterType.OBJECT]: object;
13: any; // TIMELINE [ParameterType.COMPLEX]: any;
[ParameterType.TIMELINE]: any;
}; };
export interface ParameterInfo { export interface ParameterInfo {
type: keyof ParameterTypeMap; type: ParameterType;
array?: boolean; array?: boolean;
pretty_name?: string; pretty_name?: string;
default?: any; default?: any;
@ -59,7 +63,7 @@ export interface ParameterInfos {
[key: string]: ParameterInfo; [key: string]: ParameterInfo;
} }
type ParameterType<I extends ParameterInfo> = I["array"] extends boolean // Hack to deal with type widening in parameter declarations inferred from JavaScript type InferredParameter<I extends ParameterInfo> = I["array"] extends true
? Array<ParameterTypeMap[I["type"]]> ? Array<ParameterTypeMap[I["type"]]>
: ParameterTypeMap[I["type"]]; : ParameterTypeMap[I["type"]];
@ -69,17 +73,17 @@ type RequiredParameterNames<I extends ParameterInfos> = {
type InferredParameters<I extends ParameterInfos> = SetRequired< type InferredParameters<I extends ParameterInfos> = SetRequired<
{ {
[Property in keyof I]?: ParameterType<I[Property]>; [Property in keyof I]?: InferredParameter<I[Property]>;
}, },
RequiredParameterNames<I> RequiredParameterNames<I>
>; >;
export const universalPluginParameters: ParameterInfos = { export const universalPluginParameters = <const>{
/** /**
* Data to add to this trial (key-value pairs) * Data to add to this trial (key-value pairs)
*/ */
data: { data: {
type: parameterType.OBJECT, type: ParameterType.OBJECT,
pretty_name: "Data", pretty_name: "Data",
default: {}, default: {},
}, },
@ -87,7 +91,7 @@ export const universalPluginParameters: ParameterInfos = {
* Function to execute when trial begins * Function to execute when trial begins
*/ */
on_start: { on_start: {
type: parameterType.FUNCTION, type: ParameterType.FUNCTION,
pretty_name: "On start", pretty_name: "On start",
default: function () { default: function () {
return; return;
@ -97,7 +101,7 @@ export const universalPluginParameters: ParameterInfos = {
* Function to execute when trial is finished * Function to execute when trial is finished
*/ */
on_finish: { on_finish: {
type: parameterType.FUNCTION, type: ParameterType.FUNCTION,
pretty_name: "On finish", pretty_name: "On finish",
default: function () { default: function () {
return; return;
@ -107,7 +111,7 @@ export const universalPluginParameters: ParameterInfos = {
* Function to execute after the trial has loaded * Function to execute after the trial has loaded
*/ */
on_load: { on_load: {
type: parameterType.FUNCTION, type: ParameterType.FUNCTION,
pretty_name: "On load", pretty_name: "On load",
default: function () { default: function () {
return; return;
@ -117,7 +121,7 @@ export const universalPluginParameters: ParameterInfos = {
* Length of gap between the end of this trial and the start of the next trial * Length of gap between the end of this trial and the start of the next trial
*/ */
post_trial_gap: { post_trial_gap: {
type: parameterType.INT, type: ParameterType.INT,
pretty_name: "Post trial gap", pretty_name: "Post trial gap",
default: null, default: null,
}, },
@ -125,7 +129,7 @@ export const universalPluginParameters: ParameterInfos = {
* A list of CSS classes to add to the jsPsych display element for the duration of this trial * A list of CSS classes to add to the jsPsych display element for the duration of this trial
*/ */
css_classes: { css_classes: {
type: parameterType.STRING, type: ParameterType.STRING,
pretty_name: "Custom CSS classes", pretty_name: "Custom CSS classes",
default: null, default: null,
}, },

View File

@ -1,7 +1,7 @@
// import cloze from "@jspsych/plugin-cloze"; // import cloze from "@jspsych/plugin-cloze";
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
import { JsPsych, JsPsychPlugin, TrialType, parameterType } from "../../src"; import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "../../src";
import { clickTarget, pressKey, startTimeline } from "../utils"; import { clickTarget, pressKey, startTimeline } from "../utils";
// import surveyMultiChoice from "@jspsych/plugin-survey-multi-choice"; // import surveyMultiChoice from "@jspsych/plugin-survey-multi-choice";
@ -20,7 +20,7 @@ describe("standard use of function as parameter", () => {
pressKey("a"); pressKey("a");
}); });
test.skip("parameters can be protected from early evaluation using jsPsych.plugins.parameterType.FUNCTION", async () => { test.skip("parameters can be protected from early evaluation using ParameterType.FUNCTION", async () => {
var mock = jest.fn(); var mock = jest.fn();
await startTimeline([ await startTimeline([
@ -142,22 +142,22 @@ describe("nested parameters as functions", () => {
await expectFinished(); await expectFinished();
}); });
test("nested parameters can be protected from early evaluation using jsPsych.plugins.parameterType.FUNCTION", async () => { test("nested parameters can be protected from early evaluation using ParameterType.FUNCTION", async () => {
// currently no plugins that use this feature (Jan. 2021), so here's a simple placeholder plugin. // currently no plugins that use this feature (Jan. 2021), so here's a simple placeholder plugin.
const info = <const>{ const info = <const>{
name: "function-test-plugin", name: "function-test-plugin",
parameters: { parameters: {
foo: { foo: {
type: parameterType.COMPLEX, type: ParameterType.COMPLEX,
default: null, default: null,
nested: { nested: {
not_protected: { not_protected: {
type: parameterType.STRING, type: ParameterType.STRING,
default: null, default: null,
}, },
protected: { protected: {
type: parameterType.FUNCTION, type: ParameterType.FUNCTION,
default: null, default: null,
}, },
}, },

View File

@ -1,6 +1,6 @@
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
import { initJsPsych, parameterType } from "../../src"; import { ParameterType, initJsPsych } from "../../src";
import { TimelineNode } from "../../src/TimelineNode"; import { TimelineNode } from "../../src/TimelineNode";
import { pressKey, startTimeline } from "../utils"; import { pressKey, startTimeline } from "../utils";
@ -520,13 +520,13 @@ describe("TimelineNode", () => {
info: { info: {
name: "my-plugin", name: "my-plugin",
parameters: { parameters: {
one: { type: parameterType.IMAGE }, one: { type: ParameterType.IMAGE },
two: { type: parameterType.VIDEO }, two: { type: ParameterType.VIDEO },
three: { type: parameterType.AUDIO }, three: { type: ParameterType.AUDIO },
four: { type: parameterType.IMAGE, preload: true }, four: { type: ParameterType.IMAGE, preload: true },
five: { type: parameterType.IMAGE, preload: false }, five: { type: ParameterType.IMAGE, preload: false },
six: { six: {
type: parameterType.STRING, type: ParameterType.STRING,
// This is illegal! But it should still not be added // This is illegal! But it should still not be added
preload: true, preload: true,
}, },
@ -551,7 +551,7 @@ describe("TimelineNode", () => {
type: { type: {
info: { info: {
name: "plugin1", name: "plugin1",
parameters: { one: { type: parameterType.STRING } }, parameters: { one: { type: ParameterType.STRING } },
}, },
}, },
}, },
@ -559,7 +559,7 @@ describe("TimelineNode", () => {
type: { type: {
info: { info: {
name: "plugin2", name: "plugin2",
parameters: { one: { type: parameterType.AUDIO } }, parameters: { one: { type: ParameterType.AUDIO } },
}, },
}, },
}, },
@ -570,8 +570,8 @@ describe("TimelineNode", () => {
info: { info: {
name: "plugin3", name: "plugin3",
parameters: { parameters: {
one: { type: parameterType.VIDEO }, one: { type: ParameterType.VIDEO },
two: { type: parameterType.IMAGE }, two: { type: ParameterType.IMAGE },
}, },
}, },
}, },

View File

@ -51,10 +51,10 @@ describe("#getKeyboardResponse", () => {
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1);
}); });
test("should not respond when jsPsych.NO_KEYS is used", () => { test('should not respond when "NO_KEYS" is used', () => {
jsPsych.pluginAPI.getKeyboardResponse({ jsPsych.pluginAPI.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: jsPsych.NO_KEYS, valid_responses: "NO_KEYS",
}); });
expect(callback).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(0);
@ -69,7 +69,7 @@ describe("#getKeyboardResponse", () => {
jsPsych.pluginAPI.getKeyboardResponse({ jsPsych.pluginAPI.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: jsPsych.ALL_KEYS, valid_responses: "ALL_KEYS",
allow_held_key: false, allow_held_key: false,
}); });
@ -85,7 +85,7 @@ describe("#getKeyboardResponse", () => {
jsPsych.pluginAPI.getKeyboardResponse({ jsPsych.pluginAPI.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: jsPsych.ALL_KEYS, valid_responses: "ALL_KEYS",
allow_held_key: true, allow_held_key: true,
}); });

View File

@ -1,4 +1,4 @@
import { JsPsych, JsPsychPlugin, TrialType, parameterType } from "jspsych"; import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
const info = <const>{ const info = <const>{
name: "html-keyboard-response", name: "html-keyboard-response",
@ -7,7 +7,7 @@ const info = <const>{
* The HTML string to be displayed * The HTML string to be displayed
*/ */
stimulus: { stimulus: {
type: parameterType.HTML_STRING, type: ParameterType.HTML_STRING,
pretty_name: "Stimulus", pretty_name: "Stimulus",
default: undefined, default: undefined,
}, },
@ -15,16 +15,16 @@ const info = <const>{
* The keys the subject is allowed to press to respond to the stimulus. * The keys the subject is allowed to press to respond to the stimulus.
*/ */
choices: { choices: {
type: parameterType.KEY, type: ParameterType.KEY,
array: true, array: true,
pretty_name: "Choices", pretty_name: "Choices",
default: "allkeys", // cannot access jsPsych.ALL_KEYS here ideally, it would be static default: "ALL_KEYS",
}, },
/** /**
* Any content here will be displayed below the stimulus. * Any content here will be displayed below the stimulus.
*/ */
prompt: { prompt: {
type: parameterType.STRING, type: ParameterType.STRING,
pretty_name: "Prompt", pretty_name: "Prompt",
default: null, default: null,
}, },
@ -32,7 +32,7 @@ const info = <const>{
* How long to hide the stimulus. * How long to hide the stimulus.
*/ */
stimulus_duration: { stimulus_duration: {
type: parameterType.INT, type: ParameterType.INT,
pretty_name: "Stimulus duration", pretty_name: "Stimulus duration",
default: null, default: null,
}, },
@ -40,7 +40,7 @@ const info = <const>{
* How long to show trial before it ends. * How long to show trial before it ends.
*/ */
trial_duration: { trial_duration: {
type: parameterType.INT, type: ParameterType.INT,
pretty_name: "Trial duration", pretty_name: "Trial duration",
default: null, default: null,
}, },
@ -48,7 +48,7 @@ const info = <const>{
* If true, trial will end when subject makes a response. * If true, trial will end when subject makes a response.
*/ */
response_ends_trial: { response_ends_trial: {
type: parameterType.BOOL, type: ParameterType.BOOL,
pretty_name: "Response ends trial", pretty_name: "Response ends trial",
default: true, default: true,
}, },