mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-11 16:18:11 +00:00
Integrate timeline draft with JsPsych class
This commit is contained in:
parent
76a02685d8
commit
9ab889f38e
@ -1,18 +1,14 @@
|
||||
import autoBind from "auto-bind";
|
||||
|
||||
import { version } from "../package.json";
|
||||
import { MigrationError } from "./migration";
|
||||
import { JsPsychData } from "./modules/data";
|
||||
import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api";
|
||||
import { ParameterType, universalPluginParameters } from "./modules/plugins";
|
||||
import * as randomization from "./modules/randomization";
|
||||
import * as turk from "./modules/turk";
|
||||
import * as utils from "./modules/utils";
|
||||
import { TimelineNode } from "./TimelineNode";
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
import { TimelineArray, TimelineDescription, TimelineVariable, TrialResult } from "./timeline";
|
||||
import { Timeline } from "./timeline/Timeline";
|
||||
import { PromiseWrapper } from "./timeline/util";
|
||||
|
||||
export class JsPsych {
|
||||
extensions = <any>{};
|
||||
@ -38,13 +34,11 @@ export class JsPsych {
|
||||
/**
|
||||
* experiment timeline
|
||||
*/
|
||||
private timeline: TimelineNode;
|
||||
private timelineDescription: any[];
|
||||
private timeline: Timeline;
|
||||
|
||||
// flow control
|
||||
private global_trial_index = 0;
|
||||
private current_trial: any = {};
|
||||
private current_trial_finished = false;
|
||||
|
||||
// target DOM element
|
||||
private DOM_container: HTMLElement;
|
||||
@ -55,23 +49,11 @@ export class JsPsych {
|
||||
*/
|
||||
private exp_start_time;
|
||||
|
||||
/**
|
||||
* is the experiment paused?
|
||||
*/
|
||||
private paused = false;
|
||||
private waiting = false;
|
||||
|
||||
/**
|
||||
* is the page retrieved directly via file:// protocol (true) or hosted on a server (false)?
|
||||
*/
|
||||
private file_protocol = false;
|
||||
|
||||
/**
|
||||
* Promise that is resolved when `finishExperiment()` is called
|
||||
*/
|
||||
private finished: Promise<void>;
|
||||
private resolveFinishedPromise: () => void;
|
||||
|
||||
/**
|
||||
* is the experiment running in `simulate()` mode
|
||||
*/
|
||||
@ -82,10 +64,6 @@ export class JsPsych {
|
||||
*/
|
||||
private simulation_options;
|
||||
|
||||
// storing a single webaudio context to prevent problems with multiple inits
|
||||
// of jsPsych
|
||||
webaudio_context: AudioContext = null;
|
||||
|
||||
internal = {
|
||||
/**
|
||||
* this flag is used to determine whether we are in a scope where
|
||||
@ -123,13 +101,6 @@ export class JsPsych {
|
||||
|
||||
autoBind(this); // so we can pass JsPsych methods as callbacks and `this` remains the JsPsych instance
|
||||
|
||||
this._resetTrialPromise();
|
||||
|
||||
this.webaudio_context =
|
||||
typeof window !== "undefined" && typeof window.AudioContext !== "undefined"
|
||||
? new AudioContext()
|
||||
: null;
|
||||
|
||||
// detect whether page is running in browser as a local file, and if so, disable web audio and video preloading to prevent CORS issues
|
||||
if (
|
||||
window.location.protocol == "file:" &&
|
||||
@ -153,9 +124,6 @@ export class JsPsych {
|
||||
for (const extension of options.extensions) {
|
||||
this.extensions[extension.type.info.name] = new extension.type(this);
|
||||
}
|
||||
|
||||
// initialize audio context based on options and browser capabilities
|
||||
this.pluginAPI.initAudio();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,7 +132,7 @@ export class JsPsych {
|
||||
*
|
||||
* @param timeline The timeline to be run
|
||||
*/
|
||||
async run(timeline: any[]) {
|
||||
async run(timeline: TimelineDescription | TimelineArray) {
|
||||
if (typeof timeline === "undefined") {
|
||||
console.error("No timeline declared in jsPsych.run. Cannot start experiment.");
|
||||
}
|
||||
@ -176,17 +144,14 @@ export class JsPsych {
|
||||
}
|
||||
|
||||
// create experiment timeline
|
||||
this.timelineDescription = timeline;
|
||||
this.timeline = new TimelineNode(this, { timeline });
|
||||
this.timeline = new Timeline(this, timeline);
|
||||
|
||||
await this.prepareDom();
|
||||
await this.checkExclusions(this.opts.exclusions);
|
||||
await this.loadExtensions(this.opts.extensions);
|
||||
|
||||
document.documentElement.setAttribute("jspsych", "present");
|
||||
|
||||
this.startExperiment();
|
||||
await this.finished;
|
||||
await this.timeline.run();
|
||||
}
|
||||
|
||||
async simulate(
|
||||
@ -201,9 +166,9 @@ export class JsPsych {
|
||||
|
||||
getProgress() {
|
||||
return {
|
||||
total_trials: typeof this.timeline === "undefined" ? undefined : this.timeline.length(),
|
||||
total_trials: this.timeline?.getNaiveTrialCount(),
|
||||
current_trial_global: this.global_trial_index,
|
||||
percent_complete: typeof this.timeline === "undefined" ? 0 : this.timeline.percentComplete(),
|
||||
percent_complete: this.timeline?.getProgress() * 100,
|
||||
};
|
||||
}
|
||||
|
||||
@ -226,112 +191,16 @@ export class JsPsych {
|
||||
return this.DOM_container;
|
||||
}
|
||||
|
||||
// finishTrial(data = {}) {
|
||||
// if (this.current_trial_finished) {
|
||||
// return;
|
||||
// }
|
||||
// this.current_trial_finished = true;
|
||||
|
||||
// // remove any CSS classes that were added to the DOM via css_classes parameter
|
||||
// if (
|
||||
// typeof this.current_trial.css_classes !== "undefined" &&
|
||||
// Array.isArray(this.current_trial.css_classes)
|
||||
// ) {
|
||||
// this.DOM_target.classList.remove(...this.current_trial.css_classes);
|
||||
// }
|
||||
|
||||
// // write the data from the trial
|
||||
// this.data.write(data);
|
||||
|
||||
// // get back the data with all of the defaults in
|
||||
// const trial_data = this.data.get().filter({ trial_index: this.global_trial_index });
|
||||
|
||||
// // for trial-level callbacks, we just want to pass in a reference to the values
|
||||
// // of the DataCollection, for easy access and editing.
|
||||
// const trial_data_values = trial_data.values()[0];
|
||||
|
||||
// const current_trial = this.current_trial;
|
||||
|
||||
// if (typeof current_trial.save_trial_parameters === "object") {
|
||||
// for (const key of Object.keys(current_trial.save_trial_parameters)) {
|
||||
// const key_val = current_trial.save_trial_parameters[key];
|
||||
// if (key_val === true) {
|
||||
// if (typeof current_trial[key] === "undefined") {
|
||||
// console.warn(
|
||||
// `Invalid parameter specified in save_trial_parameters. Trial has no property called "${key}".`
|
||||
// );
|
||||
// } else if (typeof current_trial[key] === "function") {
|
||||
// trial_data_values[key] = current_trial[key].toString();
|
||||
// } else {
|
||||
// trial_data_values[key] = current_trial[key];
|
||||
// }
|
||||
// }
|
||||
// if (key_val === false) {
|
||||
// // we don't allow internal_node_id or trial_index to be deleted because it would break other things
|
||||
// if (key !== "internal_node_id" && key !== "trial_index") {
|
||||
// delete trial_data_values[key];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// // handle extension callbacks
|
||||
// if (Array.isArray(current_trial.extensions)) {
|
||||
// for (const extension of current_trial.extensions) {
|
||||
// const ext_data_values = this.extensions[extension.type.info.name].on_finish(
|
||||
// extension.params
|
||||
// );
|
||||
// Object.assign(trial_data_values, ext_data_values);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // about to execute lots of callbacks, so switch context.
|
||||
// this.internal.call_immediate = true;
|
||||
|
||||
// // handle callback at plugin level
|
||||
// if (typeof current_trial.on_finish === "function") {
|
||||
// current_trial.on_finish(trial_data_values);
|
||||
// }
|
||||
|
||||
// // handle callback at whole-experiment level
|
||||
// this.opts.on_trial_finish(trial_data_values);
|
||||
|
||||
// // after the above callbacks are complete, then the data should be finalized
|
||||
// // for this trial. call the on_data_update handler, passing in the same
|
||||
// // data object that just went through the trial's finish handlers.
|
||||
// this.opts.on_data_update(trial_data_values);
|
||||
|
||||
// // done with callbacks
|
||||
// this.internal.call_immediate = false;
|
||||
|
||||
// // wait for iti
|
||||
// if (
|
||||
// typeof current_trial.post_trial_gap === null ||
|
||||
// typeof current_trial.post_trial_gap === "undefined"
|
||||
// ) {
|
||||
// if (this.opts.default_iti > 0) {
|
||||
// setTimeout(this.nextTrial, this.opts.default_iti);
|
||||
// } else {
|
||||
// this.nextTrial();
|
||||
// }
|
||||
// } else {
|
||||
// if (current_trial.post_trial_gap > 0) {
|
||||
// setTimeout(this.nextTrial, current_trial.post_trial_gap);
|
||||
// } else {
|
||||
// this.nextTrial();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
endExperiment(end_message = "", data = {}) {
|
||||
this.timeline.end_message = end_message;
|
||||
this.timeline.end();
|
||||
// this.timeline.end_message = end_message;
|
||||
// this.timeline.end();
|
||||
this.pluginAPI.cancelAllKeyboardResponses();
|
||||
this.pluginAPI.clearAllTimeouts();
|
||||
this.finishTrial(data);
|
||||
}
|
||||
|
||||
endCurrentTimeline() {
|
||||
this.timeline.endActiveNode();
|
||||
// this.timeline.endActiveNode();
|
||||
}
|
||||
|
||||
getCurrentTrial() {
|
||||
@ -342,42 +211,23 @@ export class JsPsych {
|
||||
return this.opts;
|
||||
}
|
||||
|
||||
getCurrentTimelineNodeID() {
|
||||
return this.timeline.activeID();
|
||||
}
|
||||
|
||||
timelineVariable(varname: string, immediate = false) {
|
||||
if (this.internal.call_immediate || immediate === true) {
|
||||
return this.timeline.timelineVariable(varname);
|
||||
timelineVariable(varname: string) {
|
||||
if (this.internal.call_immediate) {
|
||||
return undefined;
|
||||
} else {
|
||||
return {
|
||||
timelineVariablePlaceholder: true,
|
||||
timelineVariableFunction: () => this.timeline.timelineVariable(varname),
|
||||
};
|
||||
return new TimelineVariable(varname);
|
||||
}
|
||||
}
|
||||
|
||||
getAllTimelineVariables() {
|
||||
return this.timeline.allTimelineVariables();
|
||||
}
|
||||
|
||||
addNodeToEndOfTimeline(new_timeline, preload_callback?) {
|
||||
this.timeline.insert(new_timeline);
|
||||
}
|
||||
|
||||
pauseExperiment() {
|
||||
this.paused = true;
|
||||
this.timeline.pause();
|
||||
}
|
||||
|
||||
resumeExperiment() {
|
||||
this.paused = false;
|
||||
if (this.waiting) {
|
||||
this.waiting = false;
|
||||
this.nextTrial();
|
||||
}
|
||||
this.timeline.resume();
|
||||
}
|
||||
|
||||
loadFail(message) {
|
||||
private loadFail(message) {
|
||||
message = message || "<p>The experiment failed to load.</p>";
|
||||
this.DOM_target.innerHTML = message;
|
||||
}
|
||||
@ -387,7 +237,7 @@ export class JsPsych {
|
||||
}
|
||||
|
||||
getTimeline() {
|
||||
return this.timelineDescription;
|
||||
return this.timeline?.description;
|
||||
}
|
||||
|
||||
private async prepareDom() {
|
||||
@ -462,396 +312,14 @@ export class JsPsych {
|
||||
try {
|
||||
await Promise.all(
|
||||
extensions.map((extension) =>
|
||||
this.extensions[extension.type.info.name].initialize(extension.params || {})
|
||||
this.extensions[extension.type.info.name].initialize(extension.params ?? {})
|
||||
)
|
||||
);
|
||||
} catch (error_message) {
|
||||
console.error(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
}
|
||||
|
||||
private startExperiment() {
|
||||
this.finished = new Promise((resolve) => {
|
||||
this.resolveFinishedPromise = resolve;
|
||||
});
|
||||
|
||||
// show progress bar if requested
|
||||
if (this.opts.show_progress_bar === true) {
|
||||
this.drawProgressBar(this.opts.message_progress_bar);
|
||||
}
|
||||
|
||||
// record the start time
|
||||
this.exp_start_time = new Date();
|
||||
|
||||
// begin!
|
||||
this.timeline.advance();
|
||||
this.doTrial(this.timeline.trial());
|
||||
}
|
||||
|
||||
private finishExperiment() {
|
||||
const finish_result = this.opts.on_finish(this.data.get());
|
||||
|
||||
const done_handler = () => {
|
||||
if (typeof this.timeline.end_message !== "undefined") {
|
||||
this.DOM_target.innerHTML = this.timeline.end_message;
|
||||
}
|
||||
this.resolveFinishedPromise();
|
||||
};
|
||||
|
||||
if (finish_result) {
|
||||
Promise.resolve(finish_result).then(done_handler);
|
||||
} else {
|
||||
done_handler();
|
||||
}
|
||||
}
|
||||
|
||||
private nextTrial() {
|
||||
// if experiment is paused, don't do anything.
|
||||
if (this.paused) {
|
||||
this.waiting = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.global_trial_index++;
|
||||
|
||||
// advance timeline
|
||||
this.timeline.markCurrentTrialComplete();
|
||||
const complete = this.timeline.advance();
|
||||
|
||||
// update progress bar if shown
|
||||
if (this.opts.show_progress_bar === true && this.opts.auto_update_progress_bar === true) {
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
// check if experiment is over
|
||||
if (complete) {
|
||||
this.finishExperiment();
|
||||
return;
|
||||
}
|
||||
|
||||
this.doTrial(this.timeline.trial());
|
||||
}
|
||||
|
||||
private doTrial(trial) {
|
||||
this.current_trial = trial;
|
||||
this.current_trial_finished = false;
|
||||
|
||||
// process all timeline variables for this trial
|
||||
this.evaluateTimelineVariables(trial);
|
||||
|
||||
if (typeof trial.type === "string") {
|
||||
throw new MigrationError(
|
||||
"A string was provided as the trial's `type` parameter. Since jsPsych v7, the `type` parameter needs to be a plugin object."
|
||||
);
|
||||
}
|
||||
|
||||
// instantiate the plugin for this trial
|
||||
trial.type = {
|
||||
// this is a hack to internally keep the old plugin object structure and prevent touching more
|
||||
// of the core jspsych code
|
||||
...autoBind(new trial.type(this)),
|
||||
info: trial.type.info,
|
||||
};
|
||||
|
||||
// evaluate variables that are functions
|
||||
this.evaluateFunctionParameters(trial);
|
||||
|
||||
// get default values for parameters
|
||||
this.setDefaultValues(trial);
|
||||
|
||||
// about to execute callbacks
|
||||
this.internal.call_immediate = true;
|
||||
|
||||
// call experiment wide callback
|
||||
this.opts.on_trial_start(trial);
|
||||
|
||||
// call trial specific callback if it exists
|
||||
if (typeof trial.on_start === "function") {
|
||||
trial.on_start(trial);
|
||||
}
|
||||
|
||||
// call any on_start functions for extensions
|
||||
if (Array.isArray(trial.extensions)) {
|
||||
for (const extension of trial.extensions) {
|
||||
this.extensions[extension.type.info.name].on_start(extension.params);
|
||||
}
|
||||
}
|
||||
|
||||
// apply the focus to the element containing the experiment.
|
||||
this.DOM_container.focus();
|
||||
|
||||
// reset the scroll on the DOM target
|
||||
this.DOM_target.scrollTop = 0;
|
||||
|
||||
// add CSS classes to the DOM_target if they exist in trial.css_classes
|
||||
if (typeof trial.css_classes !== "undefined") {
|
||||
if (!Array.isArray(trial.css_classes) && typeof trial.css_classes === "string") {
|
||||
trial.css_classes = [trial.css_classes];
|
||||
}
|
||||
if (Array.isArray(trial.css_classes)) {
|
||||
this.DOM_target.classList.add(...trial.css_classes);
|
||||
}
|
||||
}
|
||||
|
||||
// setup on_load event callback
|
||||
const load_callback = () => {
|
||||
if (typeof trial.on_load === "function") {
|
||||
trial.on_load();
|
||||
}
|
||||
|
||||
// call any on_load functions for extensions
|
||||
if (Array.isArray(trial.extensions)) {
|
||||
for (const extension of trial.extensions) {
|
||||
this.extensions[extension.type.info.name].on_load(extension.params);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let trial_complete;
|
||||
if (!this.simulation_mode) {
|
||||
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
|
||||
}
|
||||
if (this.simulation_mode) {
|
||||
// check if the trial supports simulation
|
||||
if (trial.type.simulate) {
|
||||
let trial_sim_opts;
|
||||
if (!trial.simulation_options) {
|
||||
trial_sim_opts = this.simulation_options.default;
|
||||
}
|
||||
if (trial.simulation_options) {
|
||||
if (typeof trial.simulation_options == "string") {
|
||||
if (this.simulation_options[trial.simulation_options]) {
|
||||
trial_sim_opts = this.simulation_options[trial.simulation_options];
|
||||
} else if (this.simulation_options.default) {
|
||||
console.log(
|
||||
`No matching simulation options found for "${trial.simulation_options}". Using "default" options.`
|
||||
);
|
||||
trial_sim_opts = this.simulation_options.default;
|
||||
} else {
|
||||
console.log(
|
||||
`No matching simulation options found for "${trial.simulation_options}" and no "default" options provided. Using the default values provided by the plugin.`
|
||||
);
|
||||
trial_sim_opts = {};
|
||||
}
|
||||
} else {
|
||||
trial_sim_opts = trial.simulation_options;
|
||||
}
|
||||
}
|
||||
trial_sim_opts = this.utils.deepCopy(trial_sim_opts);
|
||||
trial_sim_opts = this.replaceFunctionsWithValues(trial_sim_opts, null);
|
||||
|
||||
if (trial_sim_opts?.simulate === false) {
|
||||
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
|
||||
} else {
|
||||
trial_complete = trial.type.simulate(
|
||||
trial,
|
||||
trial_sim_opts?.mode || this.simulation_mode,
|
||||
trial_sim_opts,
|
||||
load_callback
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// trial doesn't have a simulate method, so just run as usual
|
||||
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
|
||||
}
|
||||
}
|
||||
|
||||
// see if trial_complete is a Promise by looking for .then() function
|
||||
const is_promise = trial_complete && typeof trial_complete.then == "function";
|
||||
|
||||
// in simulation mode we let the simulate function call the load_callback always.
|
||||
if (!is_promise && !this.simulation_mode) {
|
||||
load_callback();
|
||||
}
|
||||
|
||||
// done with callbacks
|
||||
this.internal.call_immediate = false;
|
||||
}
|
||||
|
||||
private evaluateTimelineVariables(trial) {
|
||||
for (const key of Object.keys(trial)) {
|
||||
if (key === "type") {
|
||||
// skip the `type` parameter as it contains a plugin
|
||||
//continue;
|
||||
}
|
||||
// timeline variables on the root level
|
||||
if (
|
||||
typeof trial[key] === "object" &&
|
||||
trial[key] !== null &&
|
||||
typeof trial[key].timelineVariablePlaceholder !== "undefined"
|
||||
) {
|
||||
/*trial[key].toString().replace(/\s/g, "") ==
|
||||
"function(){returntimeline.timelineVariable(varname);}"
|
||||
)*/ trial[key] = trial[key].timelineVariableFunction();
|
||||
}
|
||||
// timeline variables that are nested in objects
|
||||
if (typeof trial[key] === "object" && trial[key] !== null) {
|
||||
this.evaluateTimelineVariables(trial[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateFunctionParameters(trial) {
|
||||
// set a flag so that jsPsych.timelineVariable() is immediately executed in this context
|
||||
this.internal.call_immediate = true;
|
||||
|
||||
// iterate over each parameter
|
||||
for (const key of Object.keys(trial)) {
|
||||
// check to make sure parameter is not "type", since that was eval'd above.
|
||||
if (key !== "type") {
|
||||
// this if statement is checking to see if the parameter type is expected to be a function, in which case we should NOT evaluate it.
|
||||
// the first line checks if the parameter is defined in the universalPluginParameters set
|
||||
// the second line checks the plugin-specific parameters
|
||||
if (
|
||||
typeof universalPluginParameters[key] !== "undefined" &&
|
||||
universalPluginParameters[key].type !== ParameterType.FUNCTION
|
||||
) {
|
||||
trial[key] = this.replaceFunctionsWithValues(trial[key], null);
|
||||
}
|
||||
if (
|
||||
typeof trial.type.info.parameters[key] !== "undefined" &&
|
||||
trial.type.info.parameters[key].type !== ParameterType.FUNCTION
|
||||
) {
|
||||
trial[key] = this.replaceFunctionsWithValues(trial[key], trial.type.info.parameters[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// reset so jsPsych.timelineVariable() is no longer immediately executed
|
||||
this.internal.call_immediate = false;
|
||||
}
|
||||
|
||||
private replaceFunctionsWithValues(obj, info) {
|
||||
// null typeof is 'object' (?!?!), so need to run this first!
|
||||
if (obj === null) {
|
||||
return obj;
|
||||
}
|
||||
// arrays
|
||||
else if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
obj[i] = this.replaceFunctionsWithValues(obj[i], info);
|
||||
}
|
||||
}
|
||||
// objects
|
||||
else if (typeof obj === "object") {
|
||||
if (info === null || !info.nested) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === "type") {
|
||||
// Ignore the object's `type` field because it contains a plugin and we do not want to
|
||||
// call plugin functions
|
||||
continue;
|
||||
}
|
||||
obj[key] = this.replaceFunctionsWithValues(obj[key], null);
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (
|
||||
typeof info.nested[key] === "object" &&
|
||||
info.nested[key].type !== ParameterType.FUNCTION
|
||||
) {
|
||||
obj[key] = this.replaceFunctionsWithValues(obj[key], info.nested[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof obj === "function") {
|
||||
return obj();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private setDefaultValues(trial) {
|
||||
for (const param in trial.type.info.parameters) {
|
||||
// check if parameter is complex with nested defaults
|
||||
if (trial.type.info.parameters[param].type === ParameterType.COMPLEX) {
|
||||
if (trial.type.info.parameters[param].array === true) {
|
||||
// iterate over each entry in the array
|
||||
trial[param].forEach(function (ip, i) {
|
||||
// check each parameter in the plugin description
|
||||
for (const p in trial.type.info.parameters[param].nested) {
|
||||
if (typeof trial[param][i][p] === "undefined" || trial[param][i][p] === null) {
|
||||
if (typeof trial.type.info.parameters[param].nested[p].default === "undefined") {
|
||||
console.error(
|
||||
"You must specify a value for the " +
|
||||
p +
|
||||
" parameter (nested in the " +
|
||||
param +
|
||||
" parameter) in the " +
|
||||
trial.type +
|
||||
" plugin."
|
||||
);
|
||||
} else {
|
||||
trial[param][i][p] = trial.type.info.parameters[param].nested[p].default;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// if it's not nested, checking is much easier and do that here:
|
||||
else if (typeof trial[param] === "undefined" || trial[param] === null) {
|
||||
if (typeof trial.type.info.parameters[param].default === "undefined") {
|
||||
console.error(
|
||||
"You must specify a value for the " +
|
||||
param +
|
||||
" parameter in the " +
|
||||
trial.type.info.name +
|
||||
" plugin."
|
||||
);
|
||||
} else {
|
||||
trial[param] = trial.type.info.parameters[param].default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkExclusions(exclusions) {
|
||||
if (exclusions.min_width || exclusions.min_height || exclusions.audio) {
|
||||
console.warn(
|
||||
"The exclusions option in `initJsPsych()` is deprecated and will be removed in a future version. We recommend using the browser-check plugin instead. See https://www.jspsych.org/latest/plugins/browser-check/."
|
||||
);
|
||||
}
|
||||
// MINIMUM SIZE
|
||||
if (exclusions.min_width || exclusions.min_height) {
|
||||
const mw = exclusions.min_width || 0;
|
||||
const mh = exclusions.min_height || 0;
|
||||
|
||||
if (window.innerWidth < mw || window.innerHeight < mh) {
|
||||
this.getDisplayElement().innerHTML =
|
||||
"<p>Your browser window is too small to complete this experiment. " +
|
||||
"Please maximize the size of your browser window. If your browser window is already maximized, " +
|
||||
"you will not be able to complete this experiment.</p>" +
|
||||
"<p>The minimum width is " +
|
||||
mw +
|
||||
"px. Your current width is " +
|
||||
window.innerWidth +
|
||||
"px.</p>" +
|
||||
"<p>The minimum height is " +
|
||||
mh +
|
||||
"px. Your current height is " +
|
||||
window.innerHeight +
|
||||
"px.</p>";
|
||||
|
||||
// Wait for window size to increase
|
||||
while (window.innerWidth < mw || window.innerHeight < mh) {
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
this.getDisplayElement().innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
// WEB AUDIO API
|
||||
if (typeof exclusions.audio !== "undefined" && exclusions.audio) {
|
||||
if (!window.hasOwnProperty("AudioContext") && !window.hasOwnProperty("webkitAudioContext")) {
|
||||
this.getDisplayElement().innerHTML =
|
||||
"<p>Your browser does not support the WebAudio API, which means that you will not " +
|
||||
"be able to complete the experiment.</p><p>Browsers that support the WebAudio API include " +
|
||||
"Chrome, Firefox, Safari, and Edge.</p>";
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawProgressBar(msg) {
|
||||
document
|
||||
.querySelector(".jspsych-display-element")
|
||||
@ -886,20 +354,8 @@ export class JsPsych {
|
||||
|
||||
// New stuff as replacements for old methods:
|
||||
|
||||
/**
|
||||
* resolved when `jsPsych.finishTrial()` is called
|
||||
*/
|
||||
_trialPromise: Promise<Record<string, any>>;
|
||||
|
||||
private _resolveTrialPromise: (data: Record<string, any>) => void;
|
||||
private _resetTrialPromise = () => {
|
||||
this._trialPromise = new Promise((resolve) => {
|
||||
this._resolveTrialPromise = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
finishTrial(data?: Record<string, any>) {
|
||||
this._resolveTrialPromise(data);
|
||||
this._resetTrialPromise();
|
||||
finishTrialPromise = new PromiseWrapper<TrialResult>();
|
||||
finishTrial(data?: TrialResult) {
|
||||
this.finishTrialPromise.resolve(data);
|
||||
}
|
||||
}
|
||||
|
@ -33,24 +33,13 @@ export class JsPsychData {
|
||||
}
|
||||
|
||||
write(data_object) {
|
||||
const progress = this.jsPsych.getProgress();
|
||||
const trial = this.jsPsych.getCurrentTrial();
|
||||
|
||||
//var trial_opt_data = typeof trial.data == 'function' ? trial.data() : trial.data;
|
||||
|
||||
const default_data = {
|
||||
trial_type: trial.type.info.name,
|
||||
trial_index: progress.current_trial_global,
|
||||
time_elapsed: this.jsPsych.getTotalTime(),
|
||||
internal_node_id: this.jsPsych.getCurrentTimelineNodeID(),
|
||||
};
|
||||
|
||||
this.allData.push({
|
||||
const newObject = {
|
||||
...data_object,
|
||||
...trial.data,
|
||||
...default_data,
|
||||
time_elapsed: this.jsPsych.getTotalTime(),
|
||||
...this.dataProperties,
|
||||
});
|
||||
};
|
||||
this.allData.push(newObject);
|
||||
return newObject;
|
||||
}
|
||||
|
||||
addProperties(properties) {
|
||||
@ -161,14 +150,4 @@ export class JsPsychData {
|
||||
document.addEventListener("mozfullscreenchange", fullscreenchange);
|
||||
document.addEventListener("webkitfullscreenchange", fullscreenchange);
|
||||
}
|
||||
|
||||
// public methods for testing purposes. not recommended for use.
|
||||
_customInsert(data) {
|
||||
this.allData = new DataCollection(data);
|
||||
}
|
||||
|
||||
_fullreset() {
|
||||
this.reset();
|
||||
this.dataProperties = {};
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,15 @@ const preloadParameterTypes = <const>[
|
||||
type PreloadType = typeof preloadParameterTypes[number];
|
||||
|
||||
export class MediaAPI {
|
||||
constructor(private useWebaudio: boolean, private webaudioContext?: AudioContext) {}
|
||||
constructor(private useWebaudio: boolean) {
|
||||
if (
|
||||
this.useWebaudio &&
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.AudioContext !== "undefined"
|
||||
) {
|
||||
this.context = new AudioContext();
|
||||
}
|
||||
}
|
||||
|
||||
// video //
|
||||
private video_buffers = {};
|
||||
@ -18,18 +26,12 @@ export class MediaAPI {
|
||||
}
|
||||
|
||||
// audio //
|
||||
private context = null;
|
||||
private context: AudioContext = null;
|
||||
private audio_buffers = [];
|
||||
|
||||
initAudio() {
|
||||
this.context = this.useWebaudio ? this.webaudioContext : null;
|
||||
}
|
||||
|
||||
audioContext() {
|
||||
if (this.context !== null) {
|
||||
if (this.context.state !== "running") {
|
||||
this.context.resume();
|
||||
}
|
||||
if (this.context && this.context.state !== "running") {
|
||||
this.context.resume();
|
||||
}
|
||||
return this.context;
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export function createJointPluginAPIObject(jsPsych: JsPsych) {
|
||||
settings.minimum_valid_rt
|
||||
),
|
||||
new TimeoutAPI(),
|
||||
new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context),
|
||||
new MediaAPI(settings.use_webaudio),
|
||||
new HardwareAPI(),
|
||||
new SimulationAPI(),
|
||||
].map((object) => autoBind(object))
|
||||
|
@ -7,21 +7,30 @@ import {
|
||||
GetParameterValueOptions,
|
||||
TimelineDescription,
|
||||
TimelineNode,
|
||||
TimelineNodeStatus,
|
||||
TimelineVariable,
|
||||
TrialDescription,
|
||||
} from ".";
|
||||
|
||||
export abstract class BaseTimelineNode implements TimelineNode {
|
||||
abstract readonly description: TimelineDescription | TrialDescription;
|
||||
protected abstract readonly parent?: Timeline;
|
||||
abstract readonly index: number;
|
||||
|
||||
constructor(protected readonly jsPsych: JsPsych) {}
|
||||
protected abstract readonly parent?: Timeline;
|
||||
|
||||
abstract run(): Promise<void>;
|
||||
abstract evaluateTimelineVariable(variable: TimelineVariable): any;
|
||||
|
||||
protected status = TimelineNodeStatus.PENDING;
|
||||
|
||||
constructor(protected readonly jsPsych: JsPsych) {}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getParameterValue(parameterName: string, options: GetParameterValueOptions = {}) {
|
||||
const { evaluateFunctions = false, recursive = true } = options;
|
||||
const { evaluateFunctions = true, recursive = true } = options;
|
||||
|
||||
let result: any;
|
||||
if (has(this.description, parameterName)) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { flushPromises } from "@jspsych/test-utils";
|
||||
import { JsPsych, initJsPsych } from "jspsych";
|
||||
import { mocked } from "ts-jest/utils";
|
||||
|
||||
@ -11,7 +12,10 @@ import {
|
||||
} from "../modules/randomization";
|
||||
import { Timeline } from "./Timeline";
|
||||
import { Trial } from "./Trial";
|
||||
import { SampleOptions, TimelineDescription, TimelineVariable, trialDescriptionKeys } from ".";
|
||||
import { PromiseWrapper } from "./util";
|
||||
import { SampleOptions, TimelineDescription, TimelineNodeStatus, TimelineVariable } from ".";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
jest.mock("../../tests/TestPlugin");
|
||||
jest.mock("../modules/randomization");
|
||||
@ -24,12 +28,26 @@ const exampleTimeline: TimelineDescription = {
|
||||
describe("Timeline", () => {
|
||||
let jsPsych: JsPsych;
|
||||
|
||||
/**
|
||||
* Allows to run
|
||||
* ```js
|
||||
* TestPluginMock.prototype.trial.mockImplementation(() => trialPromise.get());
|
||||
* ```
|
||||
* and move through trials via `proceedWithTrial()`
|
||||
*/
|
||||
const trialPromise = new PromiseWrapper();
|
||||
const proceedWithTrial = () => {
|
||||
trialPromise.resolve();
|
||||
return flushPromises();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsPsych = initJsPsych();
|
||||
TestPluginMock.mockReset();
|
||||
TestPluginMock.prototype.trial.mockImplementation(() => {
|
||||
jsPsych.finishTrial({ my: "result" });
|
||||
});
|
||||
trialPromise.reset();
|
||||
});
|
||||
|
||||
describe("run()", () => {
|
||||
@ -41,6 +59,71 @@ describe("Timeline", () => {
|
||||
const children = timeline.children;
|
||||
expect(children).toEqual([expect.any(Trial), expect.any(Trial), expect.any(Timeline)]);
|
||||
expect((children[2] as Timeline).children).toEqual([expect.any(Trial)]);
|
||||
|
||||
expect(children.map((child) => child.index)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
describe("with `pause()` and `resume()` calls`", () => {
|
||||
it("pauses, resumes, and updates the results of getStatus()", async () => {
|
||||
TestPluginMock.prototype.trial.mockImplementation(() => trialPromise.get());
|
||||
|
||||
const timeline = new Timeline(jsPsych, exampleTimeline);
|
||||
const runPromise = timeline.run();
|
||||
|
||||
expect(timeline.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
expect(timeline.children[0].getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
await proceedWithTrial();
|
||||
|
||||
expect(timeline.children[0].getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
expect(timeline.children[1].getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
timeline.pause();
|
||||
expect(timeline.getStatus()).toBe(TimelineNodeStatus.PAUSED);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(timeline.children[1].getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PENDING);
|
||||
|
||||
// Resolving the next trial promise shouldn't continue the experiment since no trial should be running.
|
||||
await proceedWithTrial();
|
||||
|
||||
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PENDING);
|
||||
|
||||
timeline.resume();
|
||||
await flushPromises();
|
||||
expect(timeline.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
expect(timeline.getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
|
||||
await runPromise;
|
||||
});
|
||||
|
||||
// https://www.jspsych.org/7.1/reference/jspsych/#description_15
|
||||
it("doesn't affect `post_trial_gap`", async () => {
|
||||
TestPluginMock.prototype.trial.mockImplementation(() => trialPromise.get());
|
||||
|
||||
const timeline = new Timeline(jsPsych, [{ type: TestPlugin, post_trial_gap: 200 }]);
|
||||
const runPromise = timeline.run();
|
||||
const child = timeline.children[0];
|
||||
|
||||
expect(child.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
await proceedWithTrial();
|
||||
expect(child.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
timeline.pause();
|
||||
jest.advanceTimersByTime(100);
|
||||
timeline.resume();
|
||||
await flushPromises();
|
||||
expect(timeline.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
await flushPromises();
|
||||
expect(timeline.getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
|
||||
await runPromise;
|
||||
});
|
||||
});
|
||||
|
||||
it("repeats a timeline according to `repetitions`", async () => {
|
||||
@ -60,8 +143,14 @@ describe("Timeline", () => {
|
||||
|
||||
await timeline.run();
|
||||
expect(loopFunction).toHaveBeenCalledTimes(2);
|
||||
expect(loopFunction).toHaveBeenNthCalledWith(1, Array(3).fill({ my: "result" }));
|
||||
expect(loopFunction).toHaveBeenNthCalledWith(2, Array(6).fill({ my: "result" }));
|
||||
expect(loopFunction).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
Array(3).fill(expect.objectContaining({ my: "result" }))
|
||||
);
|
||||
expect(loopFunction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
Array(6).fill(expect.objectContaining({ my: "result" }))
|
||||
);
|
||||
|
||||
expect(timeline.children.length).toBe(6);
|
||||
});
|
||||
@ -299,19 +388,19 @@ describe("Timeline", () => {
|
||||
expect(timeline.children[0].getParameterValue("parent_parameter")).toBe(0);
|
||||
});
|
||||
|
||||
it("evaluates functions if `evaluateFunctions` is set to `true`", async () => {
|
||||
it("evaluates functions unless `evaluateFunctions` is set to `false`", async () => {
|
||||
const timeline = new Timeline(jsPsych, {
|
||||
timeline: [],
|
||||
function_parameter: jest.fn(() => "result"),
|
||||
});
|
||||
|
||||
expect(typeof timeline.getParameterValue("function_parameter")).toBe("function");
|
||||
expect(
|
||||
typeof timeline.getParameterValue("function_parameter", { evaluateFunctions: false })
|
||||
).toBe("function");
|
||||
expect(timeline.getParameterValue("function_parameter")).toBe("result");
|
||||
expect(timeline.getParameterValue("function_parameter", { evaluateFunctions: true })).toBe(
|
||||
"result"
|
||||
);
|
||||
expect(
|
||||
typeof timeline.getParameterValue("function_parameter", { evaluateFunctions: false })
|
||||
).toBe("function");
|
||||
});
|
||||
|
||||
it("considers nested properties if `parameterName` contains dots", async () => {
|
||||
@ -335,7 +424,9 @@ describe("Timeline", () => {
|
||||
it("recursively returns all results", async () => {
|
||||
const timeline = new Timeline(jsPsych, exampleTimeline);
|
||||
await timeline.run();
|
||||
expect(timeline.getResults()).toEqual(Array(3).fill({ my: "result" }));
|
||||
expect(timeline.getResults()).toEqual(
|
||||
Array(3).fill(expect.objectContaining({ my: "result" }))
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include `undefined` results", async () => {
|
||||
@ -343,7 +434,59 @@ describe("Timeline", () => {
|
||||
await timeline.run();
|
||||
|
||||
jest.spyOn(timeline.children[0] as Trial, "getResult").mockReturnValue(undefined);
|
||||
expect(timeline.getResults()).toEqual(Array(2).fill({ my: "result" }));
|
||||
expect(timeline.getResults()).toEqual(
|
||||
Array(2).fill(expect.objectContaining({ my: "result" }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProgress()", () => {
|
||||
it("always returns the current progress of a simple timeline", async () => {
|
||||
TestPluginMock.prototype.trial.mockImplementation(() => trialPromise.get());
|
||||
|
||||
const timeline = new Timeline(jsPsych, Array(4).fill({ type: TestPlugin }));
|
||||
expect(timeline.getProgress()).toBe(0);
|
||||
|
||||
const runPromise = timeline.run();
|
||||
expect(timeline.getProgress()).toBe(0);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(timeline.getProgress()).toBe(0.25);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(timeline.getProgress()).toBe(0.5);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(timeline.getProgress()).toBe(0.75);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(timeline.getProgress()).toBe(1);
|
||||
|
||||
await runPromise;
|
||||
expect(timeline.getProgress()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNaiveTrialCount()", () => {
|
||||
it("correctly estimates the length of a timeline (including nested timelines)", async () => {
|
||||
const timeline = new Timeline(jsPsych, {
|
||||
timeline: [
|
||||
{ type: TestPlugin },
|
||||
{ timeline: [{ type: TestPlugin }], repetitions: 2, timeline_variables: [] },
|
||||
{ timeline: [{ type: TestPlugin }], repetitions: 5 },
|
||||
],
|
||||
repetitions: 3,
|
||||
timeline_variables: [{ x: 1 }, { x: 2 }],
|
||||
});
|
||||
|
||||
const estimate = (1 + 1 * 2 + 1 * 5) * 3 * 2;
|
||||
expect(timeline.getNaiveTrialCount()).toBe(estimate);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveNode()", () => {
|
||||
it("", async () => {
|
||||
// TODO
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -8,57 +8,90 @@ import {
|
||||
} from "../modules/randomization";
|
||||
import { BaseTimelineNode } from "./BaseTimelineNode";
|
||||
import { Trial } from "./Trial";
|
||||
import { PromiseWrapper } from "./util";
|
||||
import {
|
||||
GetParameterValueOptions,
|
||||
TimelineArray,
|
||||
TimelineDescription,
|
||||
TimelineNode,
|
||||
TimelineNodeStatus,
|
||||
TimelineVariable,
|
||||
TrialDescription,
|
||||
isTimelineDescription,
|
||||
isTrialDescription,
|
||||
timelineDescriptionKeys,
|
||||
} from ".";
|
||||
|
||||
export class Timeline extends BaseTimelineNode {
|
||||
public readonly children: TimelineNode[] = [];
|
||||
public readonly description: TimelineDescription;
|
||||
|
||||
constructor(
|
||||
jsPsych: JsPsych,
|
||||
public readonly description: TimelineDescription,
|
||||
protected readonly parent?: Timeline
|
||||
description: TimelineDescription | TimelineArray,
|
||||
protected readonly parent?: Timeline,
|
||||
public readonly index = 0
|
||||
) {
|
||||
super(jsPsych);
|
||||
this.description = Array.isArray(description) ? { timeline: description } : description;
|
||||
this.nextChildNodeIndex = index;
|
||||
}
|
||||
|
||||
private activeChild?: TimelineNode;
|
||||
|
||||
public async run() {
|
||||
this.status = TimelineNodeStatus.RUNNING;
|
||||
const description = this.description;
|
||||
|
||||
for (let repetition = 0; repetition < (description.repetitions ?? 1); repetition++) {
|
||||
if (!description.conditional_function || description.conditional_function()) {
|
||||
if (!description.conditional_function || description.conditional_function()) {
|
||||
for (let repetition = 0; repetition < (this.description.repetitions ?? 1); repetition++) {
|
||||
do {
|
||||
for (const timelineVariableIndex of this.generateTimelineVariableOrder()) {
|
||||
this.setCurrentTimelineVariablesByIndex(timelineVariableIndex);
|
||||
|
||||
const newChildren = this.instantiateChildNodes();
|
||||
this.children.push(...newChildren);
|
||||
|
||||
for (const childNode of newChildren) {
|
||||
this.activeChild = childNode;
|
||||
await childNode.run();
|
||||
// @ts-expect-error TS thinks `this.status` must be `RUNNING` now, but it might have changed while `await`ing
|
||||
if (this.status === TimelineNodeStatus.PAUSED) {
|
||||
await this.resumePromise.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (description.loop_function && description.loop_function(this.getResults()));
|
||||
}
|
||||
}
|
||||
|
||||
this.status = TimelineNodeStatus.COMPLETED;
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.status = TimelineNodeStatus.PAUSED;
|
||||
}
|
||||
|
||||
private resumePromise = new PromiseWrapper();
|
||||
resume() {
|
||||
if (this.status == TimelineNodeStatus.PAUSED) {
|
||||
this.status = TimelineNodeStatus.RUNNING;
|
||||
this.resumePromise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private nextChildNodeIndex: number;
|
||||
private instantiateChildNodes() {
|
||||
return this.description.timeline.map((childDescription) =>
|
||||
isTimelineDescription(childDescription)
|
||||
? new Timeline(this.jsPsych, childDescription, this)
|
||||
: new Trial(this.jsPsych, childDescription, this)
|
||||
);
|
||||
const newChildNodes = this.description.timeline.map((childDescription) => {
|
||||
const childNodeIndex = this.nextChildNodeIndex++;
|
||||
return isTimelineDescription(childDescription)
|
||||
? new Timeline(this.jsPsych, childDescription, this, childNodeIndex)
|
||||
: new Trial(this.jsPsych, childDescription, this, childNodeIndex);
|
||||
});
|
||||
this.children.push(...newChildNodes);
|
||||
return newChildNodes;
|
||||
}
|
||||
|
||||
private currentTimelineVariables: Record<string, any>;
|
||||
|
||||
private setCurrentTimelineVariablesByIndex(index: number | null) {
|
||||
this.currentTimelineVariables =
|
||||
index === null ? {} : this.description.timeline_variables[index];
|
||||
@ -152,4 +185,71 @@ export class Timeline extends BaseTimelineNode {
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the naive progress of the timeline (as a fraction), i.e. only considering the current
|
||||
* position within the description's `timeline` array. This certainly breaks for anything beyond
|
||||
* basic timelines (timeline variables, repetitions, loop functions, conditional functions, ...)!
|
||||
* See https://www.jspsych.org/latest/overview/progress-bar/#automatic-progress-bar for the
|
||||
* motivation.
|
||||
*/
|
||||
public getProgress() {
|
||||
if (this.status === TimelineNodeStatus.PENDING) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
[TimelineNodeStatus.COMPLETED, TimelineNodeStatus.ABORTED].includes(this.status) ||
|
||||
this.children.length === 0
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return this.children.indexOf(this.activeChild) / this.children.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively computes the naive number of trials in the timeline, without considering
|
||||
* conditional or loop functions.
|
||||
*/
|
||||
public getNaiveTrialCount() {
|
||||
// Since child timeline nodes are instantiated lazily, we cannot rely on them but instead have
|
||||
// to recurse the description programmatically.
|
||||
|
||||
const getTrialCount = (description: TimelineArray | TimelineDescription | TrialDescription) => {
|
||||
const getTimelineArrayTrialCount = (description: TimelineArray) =>
|
||||
description
|
||||
.map((childDescription) => getTrialCount(childDescription))
|
||||
.reduce((a, b) => a + b);
|
||||
|
||||
if (Array.isArray(description)) {
|
||||
return getTimelineArrayTrialCount(description);
|
||||
}
|
||||
|
||||
if (isTrialDescription(description)) {
|
||||
return 1;
|
||||
}
|
||||
if (isTimelineDescription(description)) {
|
||||
return (
|
||||
getTimelineArrayTrialCount(description.timeline) *
|
||||
(description.repetitions ?? 1) *
|
||||
(description.timeline_variables?.length || 1)
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return getTrialCount(this.description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently active TimelineNode or `undefined`, if the timeline is not running.
|
||||
*
|
||||
* Note: This is a Trial object most of the time, but it may also be a Timeline object when a
|
||||
* timeline is running but hasn't yet instantiated its children (e.g. during timeline callback
|
||||
* functions).
|
||||
*/
|
||||
public getActiveNode(): TimelineNode {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { flushPromises } from "@jspsych/test-utils";
|
||||
import { JsPsych, initJsPsych } from "jspsych";
|
||||
import { mocked } from "ts-jest/utils";
|
||||
|
||||
@ -5,7 +6,10 @@ import TestPlugin from "../../tests/TestPlugin";
|
||||
import { ParameterInfos, ParameterType } from "../modules/plugins";
|
||||
import { Timeline } from "./Timeline";
|
||||
import { Trial } from "./Trial";
|
||||
import { TimelineVariable, TrialDescription } from ".";
|
||||
import { PromiseWrapper } from "./util";
|
||||
import { TimelineNodeStatus, TimelineVariable, TrialDescription } from ".";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
jest.mock("../../tests/TestPlugin");
|
||||
jest.mock("./Timeline");
|
||||
@ -20,6 +24,19 @@ describe("Trial", () => {
|
||||
let jsPsych: JsPsych;
|
||||
let timeline: Timeline;
|
||||
|
||||
/**
|
||||
* Allows to run
|
||||
* ```js
|
||||
* TestPluginMock.prototype.trial.mockImplementation(() => trialPromise.get());
|
||||
* ```
|
||||
* and move through trials via `proceedWithTrial()`
|
||||
*/
|
||||
const trialPromise = new PromiseWrapper();
|
||||
const proceedWithTrial = () => {
|
||||
trialPromise.resolve();
|
||||
return flushPromises();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsPsych = initJsPsych();
|
||||
TestPluginMock.mockReset();
|
||||
@ -27,15 +44,17 @@ describe("Trial", () => {
|
||||
jsPsych.finishTrial({ my: "result" });
|
||||
});
|
||||
setTestPluginParameters({});
|
||||
trialPromise.reset();
|
||||
|
||||
timeline = new Timeline(jsPsych, { timeline: [] });
|
||||
});
|
||||
|
||||
const createTrial = (description: TrialDescription) => new Trial(jsPsych, description, timeline);
|
||||
const createTrial = (description: TrialDescription) =>
|
||||
new Trial(jsPsych, description, timeline, 0);
|
||||
|
||||
describe("run()", () => {
|
||||
it("instantiates the corresponding plugin", async () => {
|
||||
const trial = new Trial(jsPsych, { type: TestPlugin }, timeline);
|
||||
const trial = new Trial(jsPsych, { type: TestPlugin }, timeline, 0);
|
||||
|
||||
await trial.run();
|
||||
|
||||
@ -106,7 +125,7 @@ describe("Trial", () => {
|
||||
it("picks up the result data from the promise or the `finishTrial()` function (where the latter one takes precedence)", async () => {
|
||||
const trial1 = createTrial({ type: TestPlugin });
|
||||
await trial1.run();
|
||||
expect(trial1.getResult()).toEqual({ promised: "result" });
|
||||
expect(trial1.getResult()).toEqual(expect.objectContaining({ promised: "result" }));
|
||||
|
||||
TestPluginMock.prototype.trial.mockImplementation(
|
||||
async (display_element, trial, on_load) => {
|
||||
@ -118,7 +137,7 @@ describe("Trial", () => {
|
||||
|
||||
const trial2 = createTrial({ type: TestPlugin });
|
||||
await trial2.run();
|
||||
expect(trial2.getResult()).toEqual({ my: "result" });
|
||||
expect(trial2.getResult()).toEqual(expect.objectContaining({ my: "result" }));
|
||||
});
|
||||
});
|
||||
|
||||
@ -135,7 +154,7 @@ describe("Trial", () => {
|
||||
const trial = createTrial({ type: TestPlugin });
|
||||
|
||||
await trial.run();
|
||||
expect(trial.getResult()).toEqual({ my: "result" });
|
||||
expect(trial.getResult()).toEqual(expect.objectContaining({ my: "result" }));
|
||||
});
|
||||
});
|
||||
|
||||
@ -145,13 +164,25 @@ describe("Trial", () => {
|
||||
await trial.run();
|
||||
|
||||
expect(onFinishCallback).toHaveBeenCalledTimes(1);
|
||||
expect(onFinishCallback).toHaveBeenCalledWith({ my: "result" });
|
||||
expect(onFinishCallback).toHaveBeenCalledWith(expect.objectContaining({ my: "result" }));
|
||||
});
|
||||
|
||||
it("includes result data from the `data` property", async () => {
|
||||
const trial = createTrial({ type: TestPlugin, data: { custom: "value" } });
|
||||
await trial.run();
|
||||
expect(trial.getResult()).toEqual({ my: "result", custom: "value" });
|
||||
expect(trial.getResult()).toEqual(expect.objectContaining({ my: "result", custom: "value" }));
|
||||
});
|
||||
|
||||
it("includes a set of common result properties", async () => {
|
||||
const trial = createTrial({ type: TestPlugin });
|
||||
await trial.run();
|
||||
expect(trial.getResult()).toEqual(
|
||||
expect.objectContaining({
|
||||
trial_type: "test",
|
||||
trial_index: 0,
|
||||
time_elapsed: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("with a plugin parameter specification", () => {
|
||||
@ -270,6 +301,45 @@ describe("Trial", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects `default_iti` and `post_trial_gap``", async () => {
|
||||
jest.spyOn(jsPsych, "getInitSettings").mockReturnValue({ default_iti: 100 });
|
||||
TestPluginMock.prototype.trial.mockImplementation(() => trialPromise.get());
|
||||
|
||||
const trial1 = createTrial({ type: TestPlugin });
|
||||
|
||||
const runPromise1 = trial1.run();
|
||||
expect(trial1.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(trial1.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
await flushPromises();
|
||||
expect(trial1.getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
|
||||
await runPromise1;
|
||||
|
||||
// @ts-expect-error function parameters and timeline variables are not yet included in the
|
||||
// trial type
|
||||
const trial2 = createTrial({ type: TestPlugin, post_trial_gap: () => 200 });
|
||||
|
||||
const runPromise2 = trial2.run();
|
||||
expect(trial2.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
await proceedWithTrial();
|
||||
expect(trial2.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
await flushPromises();
|
||||
expect(trial2.getStatus()).toBe(TimelineNodeStatus.RUNNING);
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
await flushPromises();
|
||||
expect(trial2.getStatus()).toBe(TimelineNodeStatus.COMPLETED);
|
||||
|
||||
await runPromise2;
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateTimelineVariable()", () => {
|
||||
@ -277,40 +347,11 @@ describe("Trial", () => {
|
||||
const timeline = new Timeline(jsPsych, { timeline: [] });
|
||||
mocked(timeline).evaluateTimelineVariable.mockReturnValue(1);
|
||||
|
||||
const trial = new Trial(jsPsych, { type: TestPlugin }, timeline);
|
||||
const trial = new Trial(jsPsych, { type: TestPlugin }, timeline, 0);
|
||||
|
||||
const variable = new TimelineVariable("x");
|
||||
expect(trial.evaluateTimelineVariable(variable)).toBe(1);
|
||||
expect(timeline.evaluateTimelineVariable).toHaveBeenCalledWith(variable);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParameterValue()", () => {
|
||||
// Note: The BaseTimelineNode `getParameterValue()` implementation is tested in the unit tests
|
||||
// of the `Timeline` class
|
||||
|
||||
it("ignores builtin trial parameters", async () => {
|
||||
const trial = new Trial(
|
||||
jsPsych,
|
||||
{
|
||||
type: TestPlugin,
|
||||
post_trial_gap: 0,
|
||||
css_classes: "",
|
||||
simulation_options: {},
|
||||
on_start: jest.fn(),
|
||||
on_load: jest.fn(),
|
||||
on_finish: jest.fn(),
|
||||
},
|
||||
timeline
|
||||
);
|
||||
|
||||
expect(trial.getParameterValue("type")).toBeUndefined();
|
||||
expect(trial.getParameterValue("post_trial_gap")).toBeUndefined();
|
||||
expect(trial.getParameterValue("css_classes")).toBeUndefined();
|
||||
expect(trial.getParameterValue("simulation_options")).toBeUndefined();
|
||||
expect(trial.getParameterValue("on_start")).toBeUndefined();
|
||||
expect(trial.getParameterValue("on_load")).toBeUndefined();
|
||||
expect(trial.getParameterValue("on_finish")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -4,37 +4,61 @@ import { ParameterInfos } from "src/modules/plugins";
|
||||
import { deepCopy } from "../modules/utils";
|
||||
import { BaseTimelineNode } from "./BaseTimelineNode";
|
||||
import { Timeline } from "./Timeline";
|
||||
import {
|
||||
GetParameterValueOptions,
|
||||
TimelineVariable,
|
||||
TrialDescription,
|
||||
TrialResult,
|
||||
isPromise,
|
||||
trialDescriptionKeys,
|
||||
} from ".";
|
||||
import { delay } from "./util";
|
||||
import { TimelineNodeStatus, TimelineVariable, TrialDescription, TrialResult, isPromise } from ".";
|
||||
|
||||
export class Trial extends BaseTimelineNode {
|
||||
private result: TrialResult;
|
||||
|
||||
public pluginInstance: JsPsychPlugin<any>;
|
||||
public readonly trialObject: TrialDescription;
|
||||
|
||||
private result: TrialResult;
|
||||
private readonly pluginInfo: PluginInfo;
|
||||
|
||||
constructor(
|
||||
jsPsych: JsPsych,
|
||||
public readonly description: TrialDescription,
|
||||
protected readonly parent: Timeline
|
||||
protected readonly parent: Timeline,
|
||||
public readonly index: number
|
||||
) {
|
||||
super(jsPsych);
|
||||
this.trialObject = deepCopy(description);
|
||||
this.pluginInfo = this.description.type["info"];
|
||||
}
|
||||
|
||||
public async run() {
|
||||
this.status = TimelineNodeStatus.RUNNING;
|
||||
this.processParameters();
|
||||
|
||||
this.focusContainerElement();
|
||||
this.addCssClasses();
|
||||
|
||||
this.onStart();
|
||||
|
||||
this.pluginInstance = new this.description.type(this.jsPsych);
|
||||
|
||||
let trialPromise = this.jsPsych._trialPromise;
|
||||
const result = await this.executeTrial();
|
||||
|
||||
this.result = this.jsPsych.data.write({
|
||||
...this.trialObject.data,
|
||||
...result,
|
||||
trial_type: this.pluginInfo.name,
|
||||
trial_index: this.index,
|
||||
});
|
||||
|
||||
this.onFinish();
|
||||
|
||||
const gap =
|
||||
this.getParameterValue("post_trial_gap") ?? this.jsPsych.getInitSettings().default_iti;
|
||||
if (gap !== 0) {
|
||||
await delay(gap);
|
||||
}
|
||||
|
||||
this.removeCssClasses();
|
||||
this.status = TimelineNodeStatus.COMPLETED;
|
||||
}
|
||||
|
||||
private async executeTrial() {
|
||||
let trialPromise = this.jsPsych.finishTrialPromise.get();
|
||||
|
||||
/** Used as a way to figure out if `finishTrial()` has ben called without awaiting `trialPromise` */
|
||||
let hasTrialPromiseBeenResolved = false;
|
||||
@ -63,9 +87,36 @@ export class Trial extends BaseTimelineNode {
|
||||
result = await trialPromise;
|
||||
}
|
||||
|
||||
this.result = { ...this.trialObject.data, ...result };
|
||||
return result;
|
||||
}
|
||||
|
||||
this.onFinish();
|
||||
private focusContainerElement() {
|
||||
// // apply the focus to the element containing the experiment.
|
||||
// this.DOM_container.focus();
|
||||
// // reset the scroll on the DOM target
|
||||
// this.DOM_target.scrollTop = 0;
|
||||
}
|
||||
|
||||
private addCssClasses() {
|
||||
// // add CSS classes to the DOM_target if they exist in trial.css_classes
|
||||
// if (typeof trial.css_classes !== "undefined") {
|
||||
// if (!Array.isArray(trial.css_classes) && typeof trial.css_classes === "string") {
|
||||
// trial.css_classes = [trial.css_classes];
|
||||
// }
|
||||
// if (Array.isArray(trial.css_classes)) {
|
||||
// this.DOM_target.classList.add(...trial.css_classes);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private removeCssClasses() {
|
||||
// // remove any CSS classes that were added to the DOM via css_classes parameter
|
||||
// if (
|
||||
// typeof this.current_trial.css_classes !== "undefined" &&
|
||||
// Array.isArray(this.current_trial.css_classes)
|
||||
// ) {
|
||||
// this.DOM_target.classList.remove(...this.current_trial.css_classes);
|
||||
// }
|
||||
}
|
||||
|
||||
private onStart() {
|
||||
@ -92,13 +143,6 @@ export class Trial extends BaseTimelineNode {
|
||||
return this.parent?.evaluateTimelineVariable(variable);
|
||||
}
|
||||
|
||||
public getParameterValue(parameterName: string, options?: GetParameterValueOptions) {
|
||||
if (trialDescriptionKeys.includes(parameterName)) {
|
||||
return;
|
||||
}
|
||||
return super.getParameterValue(parameterName, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the result object of this trial or `undefined` if the result is not yet known.
|
||||
*/
|
||||
@ -113,9 +157,6 @@ export class Trial extends BaseTimelineNode {
|
||||
* sets default values for optional parameters.
|
||||
*/
|
||||
private processParameters() {
|
||||
const pluginInfo: PluginInfo = this.description.type["info"];
|
||||
|
||||
// Set parameters according to the plugin info object
|
||||
const assignParameterValues = (
|
||||
parameterObject: Record<string, any>,
|
||||
parameterInfos: ParameterInfos,
|
||||
@ -131,7 +172,7 @@ export class Trial extends BaseTimelineNode {
|
||||
if (typeof parameterValue === "undefined") {
|
||||
if (typeof parameterConfig.default === "undefined") {
|
||||
throw new Error(
|
||||
`You must specify a value for the "${parameterPath}" parameter in the "${pluginInfo.name}" plugin.`
|
||||
`You must specify a value for the "${parameterPath}" parameter in the "${this.pluginInfo.name}" plugin.`
|
||||
);
|
||||
} else {
|
||||
parameterValue = parameterConfig.default;
|
||||
@ -146,6 +187,6 @@ export class Trial extends BaseTimelineNode {
|
||||
}
|
||||
};
|
||||
|
||||
assignParameterValues(this.trialObject, pluginInfo.parameters);
|
||||
assignParameterValues(this.trialObject, this.pluginInfo.parameters);
|
||||
}
|
||||
}
|
||||
|
@ -34,16 +34,6 @@ export interface TrialDescription extends Record<string, any> {
|
||||
on_finish?: (data: any) => void;
|
||||
}
|
||||
|
||||
export const trialDescriptionKeys = [
|
||||
"type",
|
||||
"post_trial_gap",
|
||||
"css_classes",
|
||||
"simulation_options",
|
||||
"on_start",
|
||||
"on_load",
|
||||
"on_finish",
|
||||
];
|
||||
|
||||
/** https://www.jspsych.org/latest/overview/timeline/#sampling-methods */
|
||||
export type SampleOptions =
|
||||
| { type: "with-replacement"; size: number; weights?: number[] }
|
||||
@ -52,8 +42,10 @@ export type SampleOptions =
|
||||
| { type: "alternate-groups"; groups: number[][]; randomize_group_order?: boolean }
|
||||
| { type: "custom"; fn: (ids: number[]) => number[] };
|
||||
|
||||
export type TimelineArray = Array<TimelineDescription | TrialDescription>;
|
||||
|
||||
export interface TimelineDescription extends Record<string, any> {
|
||||
timeline: Array<TimelineDescription | TrialDescription>;
|
||||
timeline: TimelineArray;
|
||||
timeline_variables?: Record<string, any>[];
|
||||
|
||||
// Control flow
|
||||
@ -108,12 +100,22 @@ export function isTimelineDescription(
|
||||
return Boolean((description as TimelineDescription).timeline);
|
||||
}
|
||||
|
||||
export enum TimelineNodeStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
PAUSED,
|
||||
COMPLETED,
|
||||
ABORTED,
|
||||
}
|
||||
|
||||
export type GetParameterValueOptions = { evaluateFunctions?: boolean; recursive?: boolean };
|
||||
|
||||
export interface TimelineNode {
|
||||
readonly description: TimelineDescription | TrialDescription;
|
||||
readonly index: number;
|
||||
|
||||
run(): Promise<void>;
|
||||
getStatus(): TimelineNodeStatus;
|
||||
|
||||
/**
|
||||
* Recursively evaluates the given timeline variable, starting at the current timeline node.
|
||||
@ -129,8 +131,8 @@ export interface TimelineNode {
|
||||
*
|
||||
* * is a timeline variable, evaluates the variable and returns the result.
|
||||
* * is not specified, returns `undefined`.
|
||||
* * is a function and `evaluateFunctions` is set to `true`, invokes the function and returns its
|
||||
* return value
|
||||
* * is a function and `evaluateFunctions` is not set to `false`, invokes the function and returns
|
||||
* its return value
|
||||
*
|
||||
* `parameterName` may include dots to signal nested object properties.
|
||||
*/
|
||||
|
29
packages/jspsych/src/timeline/util.ts
Normal file
29
packages/jspsych/src/timeline/util.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Maintains a promise and offers a function to resolve it. Whenever the promise is resolved, it is
|
||||
* replaced with a new one.
|
||||
*/
|
||||
export class PromiseWrapper<ResolveType = void> {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private promise: Promise<ResolveType>;
|
||||
private resolvePromise: (resolveValue: ResolveType) => void;
|
||||
|
||||
reset() {
|
||||
this.promise = new Promise((resolve) => {
|
||||
this.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
get() {
|
||||
return this.promise;
|
||||
}
|
||||
resolve(value: ResolveType) {
|
||||
this.resolvePromise(value);
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
Loading…
Reference in New Issue
Block a user