Integrate timeline draft with JsPsych class

This commit is contained in:
bjoluc 2022-01-27 23:33:33 +01:00
parent 76a02685d8
commit 9ab889f38e
11 changed files with 508 additions and 706 deletions

View File

@ -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);
}
}

View File

@ -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 = {};
}
}

View File

@ -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;
}

View File

@ -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))

View File

@ -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)) {

View File

@ -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
});
});
});

View File

@ -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;
}
}

View File

@ -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();
});
});
});

View File

@ -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);
}
}

View File

@ -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.
*/

View 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));
}