jsPsych/packages/jspsych/src/timeline/Timeline.spec.ts

699 lines
24 KiB
TypeScript

import { flushPromises } from "@jspsych/test-utils";
import { mocked } from "ts-jest/utils";
import { MockTimelineNodeDependencies } from "../../tests/test-utils";
import TestPlugin from "../../tests/TestPlugin";
import {
repeat,
sampleWithReplacement,
sampleWithoutReplacement,
shuffle,
shuffleAlternateGroups,
} from "../modules/randomization";
import { Timeline } from "./Timeline";
import { Trial } from "./Trial";
import {
SampleOptions,
TimelineArray,
TimelineDescription,
TimelineNode,
TimelineNodeStatus,
TimelineVariable,
} from ".";
jest.useFakeTimers();
jest.mock("../modules/randomization");
const exampleTimeline: TimelineDescription = {
timeline: [{ type: TestPlugin }, { type: TestPlugin }, { timeline: [{ type: TestPlugin }] }],
};
const dependencies = new MockTimelineNodeDependencies();
describe("Timeline", () => {
const createTimeline = (description: TimelineDescription | TimelineArray, parent?: Timeline) =>
new Timeline(dependencies, description, parent);
beforeEach(() => {
dependencies.reset();
TestPlugin.reset();
});
describe("run()", () => {
it("instantiates proper child nodes", async () => {
const timeline = createTimeline(exampleTimeline);
await timeline.run();
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`", () => {
beforeEach(() => {
TestPlugin.setManualFinishTrialMode();
});
it("pauses, resumes, and updates the results of getStatus()", async () => {
const timeline = createTimeline({
timeline: [
{ type: TestPlugin },
{ type: TestPlugin },
{ timeline: [{ type: TestPlugin }, { type: TestPlugin }] },
],
});
const runPromise = timeline.run();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.RUNNING);
expect(timeline.children[0].getStatus()).toBe(TimelineNodeStatus.RUNNING);
await TestPlugin.finishTrial();
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 TestPlugin.finishTrial();
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 TestPlugin.finishTrial();
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);
// The child timeline is running. Let's pause the parent timeline to check whether the child
// gets paused too
timeline.pause();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.PAUSED);
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PAUSED);
await TestPlugin.finishTrial();
timeline.resume();
await flushPromises();
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.RUNNING);
await TestPlugin.finishTrial();
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 () => {
const timeline = createTimeline([{ type: TestPlugin, post_trial_gap: 200 }]);
const runPromise = timeline.run();
const child = timeline.children[0];
expect(child.getStatus()).toBe(TimelineNodeStatus.RUNNING);
await TestPlugin.finishTrial();
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;
});
});
describe("abort()", () => {
beforeEach(() => {
TestPlugin.setManualFinishTrialMode();
});
describe("aborts the timeline after the current trial ends, updating the result of getStatus()", () => {
test("when the timeline is running", async () => {
const timeline = createTimeline(exampleTimeline);
const runPromise = timeline.run();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.RUNNING);
timeline.abort();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.RUNNING);
await TestPlugin.finishTrial();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.ABORTED);
await runPromise;
});
test("when the timeline is paused", async () => {
const timeline = createTimeline(exampleTimeline);
timeline.run();
timeline.pause();
await TestPlugin.finishTrial();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.PAUSED);
timeline.abort();
await flushPromises();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.ABORTED);
});
});
it("aborts child timelines too", async () => {
const timeline = createTimeline({
timeline: [{ timeline: [{ type: TestPlugin }, { type: TestPlugin }] }],
});
const runPromise = timeline.run();
expect(timeline.children[0].getStatus()).toBe(TimelineNodeStatus.RUNNING);
timeline.abort();
await TestPlugin.finishTrial();
expect(timeline.children[0].getStatus()).toBe(TimelineNodeStatus.ABORTED);
expect(timeline.getStatus()).toBe(TimelineNodeStatus.ABORTED);
await runPromise;
});
it("doesn't affect the timeline when it is neither running nor paused", async () => {
const timeline = createTimeline([{ type: TestPlugin }]);
expect(timeline.getStatus()).toBe(TimelineNodeStatus.PENDING);
timeline.abort();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.PENDING);
// Complete the timeline
const runPromise = timeline.run();
await TestPlugin.finishTrial();
await runPromise;
expect(timeline.getStatus()).toBe(TimelineNodeStatus.COMPLETED);
timeline.abort();
expect(timeline.getStatus()).toBe(TimelineNodeStatus.COMPLETED);
});
});
it("repeats a timeline according to `repetitions`", async () => {
const timeline = createTimeline({ ...exampleTimeline, repetitions: 2 });
await timeline.run();
expect(timeline.children.length).toEqual(6);
});
it("repeats a timeline according to `loop_function`", async () => {
const loopFunction = jest.fn();
loopFunction.mockReturnValue(false);
loopFunction.mockReturnValueOnce(true);
const timeline = createTimeline({ ...exampleTimeline, loop_function: loopFunction });
await timeline.run();
expect(loopFunction).toHaveBeenCalledTimes(2);
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).toEqual(6);
});
it("repeats a timeline according to `repetitions` and `loop_function`", async () => {
const loopFunction = jest.fn();
loopFunction.mockReturnValue(false);
loopFunction.mockReturnValueOnce(true);
loopFunction.mockReturnValueOnce(false);
loopFunction.mockReturnValueOnce(true);
const timeline = createTimeline({
...exampleTimeline,
repetitions: 2,
loop_function: loopFunction,
});
await timeline.run();
expect(loopFunction).toHaveBeenCalledTimes(4);
expect(timeline.children.length).toEqual(12);
});
it("skips execution if `conditional_function` returns `false`", async () => {
const timeline = createTimeline({
...exampleTimeline,
conditional_function: jest.fn(() => false),
});
await timeline.run();
expect(timeline.children.length).toEqual(0);
});
it("executes regularly if `conditional_function` returns `true`", async () => {
const timeline = createTimeline({
...exampleTimeline,
conditional_function: jest.fn(() => true),
});
await timeline.run();
expect(timeline.children.length).toEqual(3);
});
describe("`on_timeline_start` and `on_timeline_finished` callbacks are invoked", () => {
const onTimelineStart = jest.fn();
const onTimelineFinish = jest.fn();
beforeEach(() => {
TestPlugin.setManualFinishTrialMode();
});
afterEach(() => {
onTimelineStart.mockReset();
onTimelineFinish.mockReset();
});
test("at the beginning and at the end of a timeline, respectively", async () => {
const timeline = createTimeline({
timeline: [{ type: TestPlugin }],
on_timeline_start: onTimelineStart,
on_timeline_finish: onTimelineFinish,
});
timeline.run();
expect(onTimelineStart).toHaveBeenCalledTimes(1);
expect(onTimelineFinish).toHaveBeenCalledTimes(0);
await TestPlugin.finishTrial();
expect(onTimelineStart).toHaveBeenCalledTimes(1);
expect(onTimelineFinish).toHaveBeenCalledTimes(1);
});
test("in every repetition", async () => {
const timeline = createTimeline({
timeline: [{ type: TestPlugin }],
on_timeline_start: onTimelineStart,
on_timeline_finish: onTimelineFinish,
repetitions: 2,
});
timeline.run();
expect(onTimelineStart).toHaveBeenCalledTimes(1);
expect(onTimelineFinish).toHaveBeenCalledTimes(0);
await TestPlugin.finishTrial();
expect(onTimelineFinish).toHaveBeenCalledTimes(1);
expect(onTimelineStart).toHaveBeenCalledTimes(2);
await TestPlugin.finishTrial();
expect(onTimelineStart).toHaveBeenCalledTimes(2);
expect(onTimelineFinish).toHaveBeenCalledTimes(2);
});
});
describe("with timeline variables", () => {
it("repeats all trials for each set of variables", async () => {
const xValues = [];
TestPlugin.prototype.trial.mockImplementation(async () => {
xValues.push(timeline.evaluateTimelineVariable(new TimelineVariable("x")));
});
const timeline = createTimeline({
timeline: [{ type: TestPlugin }],
timeline_variables: [{ x: 0 }, { x: 1 }, { x: 2 }, { x: 3 }],
});
await timeline.run();
expect(timeline.children.length).toEqual(4);
expect(xValues).toEqual([0, 1, 2, 3]);
});
it("respects the `randomize_order` and `sample` options", async () => {
let xValues: number[];
const createSampleTimeline = (sample: SampleOptions, randomize_order?: boolean) => {
xValues = [];
const timeline = createTimeline({
timeline: [{ type: TestPlugin }],
timeline_variables: [{ x: 0 }, { x: 1 }],
sample,
randomize_order,
});
TestPlugin.prototype.trial.mockImplementation(async () => {
xValues.push(timeline.evaluateTimelineVariable(new TimelineVariable("x")));
});
return timeline;
};
// `randomize_order`
mocked(shuffle).mockReturnValue([1, 0]);
await createSampleTimeline(undefined, true).run();
expect(shuffle).toHaveBeenCalledWith([0, 1]);
expect(xValues).toEqual([1, 0]);
// with-replacement
mocked(sampleWithReplacement).mockReturnValue([0, 0]);
await createSampleTimeline({ type: "with-replacement", size: 2, weights: [1, 1] }).run();
expect(sampleWithReplacement).toHaveBeenCalledWith([0, 1], 2, [1, 1]);
expect(xValues).toEqual([0, 0]);
// without-replacement
mocked(sampleWithoutReplacement).mockReturnValue([1, 0]);
await createSampleTimeline({ type: "without-replacement", size: 2 }).run();
expect(sampleWithoutReplacement).toHaveBeenCalledWith([0, 1], 2);
expect(xValues).toEqual([1, 0]);
// fixed-repetitions
mocked(repeat).mockReturnValue([0, 0, 1, 1]);
await createSampleTimeline({ type: "fixed-repetitions", size: 2 }).run();
expect(repeat).toHaveBeenCalledWith([0, 1], 2);
expect(xValues).toEqual([0, 0, 1, 1]);
// alternate-groups
mocked(shuffleAlternateGroups).mockReturnValue([1, 0]);
await createSampleTimeline({
type: "alternate-groups",
groups: [[0], [1]],
randomize_group_order: true,
}).run();
expect(shuffleAlternateGroups).toHaveBeenCalledWith([[0], [1]], true);
expect(xValues).toEqual([1, 0]);
// custom function
const sampleFunction = jest.fn(() => [0]);
await createSampleTimeline({ type: "custom", fn: sampleFunction }).run();
expect(sampleFunction).toHaveBeenCalledTimes(1);
expect(xValues).toEqual([0]);
await expect(
// @ts-expect-error non-existing type
createSampleTimeline({ type: "invalid" }).run()
).rejects.toThrow('Invalid type "invalid" in timeline sample parameters.');
});
it("samples on each loop iteration (be it via `repetitions` or `loop_function`)", async () => {
const sampleFunction = jest.fn(() => [0]);
await createTimeline({
timeline: [{ type: TestPlugin }],
timeline_variables: [{ x: 0 }],
sample: { type: "custom", fn: sampleFunction },
repetitions: 2,
loop_function: jest.fn().mockReturnValue(false).mockReturnValueOnce(true),
}).run();
// 2 repetitions + 1 loop in the first repitition = 3 sample function calls
expect(sampleFunction).toHaveBeenCalledTimes(3);
});
it("makes variables available to callbacks", async () => {
const variableResults: Record<string, any> = {};
const makeCallback = (resultName: string, callbackReturnValue?: any) => () => {
variableResults[resultName] = timeline.evaluateTimelineVariable(
new TimelineVariable("x")
);
return callbackReturnValue;
};
const timeline = createTimeline({
timeline: [{ type: TestPlugin }],
timeline_variables: [{ x: 0 }],
on_timeline_start: jest.fn().mockImplementation(makeCallback("on_timeline_start")),
on_timeline_finish: jest.fn().mockImplementation(makeCallback("on_timeline_finish")),
conditional_function: jest
.fn()
.mockImplementation(makeCallback("conditional_function", true)),
loop_function: jest.fn().mockImplementation(makeCallback("loop_function", false)),
});
await timeline.run();
expect(variableResults).toEqual({
on_timeline_start: 0,
on_timeline_finish: 0,
conditional_function: 0,
loop_function: 0,
});
});
});
});
describe("evaluateTimelineVariable()", () => {
describe("if a local timeline variable exists", () => {
it("returns the local timeline variable", async () => {
const timeline = createTimeline({
timeline: [{ type: TestPlugin }],
timeline_variables: [{ x: 0 }],
});
await timeline.run();
expect(timeline.evaluateTimelineVariable(new TimelineVariable("x"))).toEqual(0);
});
});
describe("if a timeline variable is not defined locally", () => {
it("recursively falls back to parent timeline variables", async () => {
const timeline = createTimeline({
timeline: [{ timeline: [{ type: TestPlugin }], timeline_variables: [{ x: undefined }] }],
timeline_variables: [{ x: 0, y: 0 }],
});
await timeline.run();
expect(timeline.evaluateTimelineVariable(new TimelineVariable("x"))).toEqual(0);
expect(timeline.evaluateTimelineVariable(new TimelineVariable("y"))).toEqual(0);
const childTimeline = timeline.children[0] as Timeline;
expect(childTimeline.evaluateTimelineVariable(new TimelineVariable("x"))).toBeUndefined();
expect(childTimeline.evaluateTimelineVariable(new TimelineVariable("y"))).toEqual(0);
});
it("returns `undefined` if there are no parents or none of them has a value for the variable", async () => {
const timeline = createTimeline({
timeline: [{ timeline: [{ type: TestPlugin }] }],
});
const variable = new TimelineVariable("x");
await timeline.run();
expect(timeline.evaluateTimelineVariable(variable)).toBeUndefined();
expect(
(timeline.children[0] as Timeline).evaluateTimelineVariable(variable)
).toBeUndefined();
});
});
});
describe("getParameterValue()", () => {
// Note: This includes test cases for the implementation provided by `BaseTimelineNode`.
it("ignores builtin timeline parameters", async () => {
const timeline = createTimeline({
timeline: [],
timeline_variables: [],
repetitions: 1,
loop_function: jest.fn(),
conditional_function: jest.fn(),
randomize_order: false,
sample: { type: "custom", fn: jest.fn() },
on_timeline_start: jest.fn(),
on_timeline_finish: jest.fn(),
});
for (const parameter of [
"timeline",
"timeline_variables",
"repetitions",
"loop_function",
"conditional_function",
"randomize_order",
"sample",
"on_timeline_start",
"on_timeline_finish",
]) {
expect(timeline.getParameterValue(parameter)).toBeUndefined();
}
});
it("returns the local parameter value, if it exists", async () => {
const timeline = createTimeline({ timeline: [], my_parameter: "test" });
expect(timeline.getParameterValue("my_parameter")).toEqual("test");
expect(timeline.getParameterValue("other_parameter")).toBeUndefined();
});
it("falls back to parent parameter values if `recursive` is not `false`", async () => {
const parentTimeline = createTimeline({
timeline: [],
first_parameter: "test",
second_parameter: "test",
});
const childTimeline = createTimeline(
{ timeline: [], first_parameter: undefined },
parentTimeline
);
expect(childTimeline.getParameterValue("second_parameter")).toEqual("test");
expect(
childTimeline.getParameterValue("second_parameter", { recursive: false })
).toBeUndefined();
expect(childTimeline.getParameterValue("first_parameter")).toBeUndefined();
expect(childTimeline.getParameterValue("other_parameter")).toBeUndefined();
});
it("evaluates timeline variables", async () => {
const timeline = createTimeline({
timeline: [{ timeline: [], child_parameter: new TimelineVariable("x") }],
timeline_variables: [{ x: 0 }],
parent_parameter: new TimelineVariable("x"),
});
await timeline.run();
expect(timeline.children[0].getParameterValue("child_parameter")).toEqual(0);
expect(timeline.children[0].getParameterValue("parent_parameter")).toEqual(0);
});
it("evaluates functions unless `evaluateFunctions` is set to `false`", async () => {
const timeline = createTimeline({
timeline: [],
function_parameter: jest.fn(() => "result"),
});
expect(timeline.getParameterValue("function_parameter")).toEqual("result");
expect(timeline.getParameterValue("function_parameter", { evaluateFunctions: true })).toEqual(
"result"
);
expect(
typeof timeline.getParameterValue("function_parameter", { evaluateFunctions: false })
).toEqual("function");
});
it("considers nested properties if `parameterName` contains dots", async () => {
const timeline = createTimeline({
timeline: [],
object: {
childString: "foo",
childObject: {
childString: "bar",
},
},
});
expect(timeline.getParameterValue("object.childString")).toEqual("foo");
expect(timeline.getParameterValue("object.childObject")).toEqual({ childString: "bar" });
expect(timeline.getParameterValue("object.childObject.childString")).toEqual("bar");
});
});
describe("getResults()", () => {
it("recursively returns all results", async () => {
const timeline = createTimeline(exampleTimeline);
await timeline.run();
expect(timeline.getResults()).toEqual(
Array(3).fill(expect.objectContaining({ my: "result" }))
);
});
it("does not include `undefined` results", async () => {
const timeline = createTimeline(exampleTimeline);
await timeline.run();
jest.spyOn(timeline.children[0] as Trial, "getResult").mockReturnValue(undefined);
expect(timeline.getResults()).toEqual(
Array(2).fill(expect.objectContaining({ my: "result" }))
);
});
});
describe("getProgress()", () => {
it("always returns the current progress of a simple timeline", async () => {
TestPlugin.setManualFinishTrialMode();
const timeline = createTimeline(Array(4).fill({ type: TestPlugin }));
expect(timeline.getProgress()).toEqual(0);
const runPromise = timeline.run();
expect(timeline.getProgress()).toEqual(0);
await TestPlugin.finishTrial();
expect(timeline.getProgress()).toEqual(0.25);
await TestPlugin.finishTrial();
expect(timeline.getProgress()).toEqual(0.5);
await TestPlugin.finishTrial();
expect(timeline.getProgress()).toEqual(0.75);
await TestPlugin.finishTrial();
expect(timeline.getProgress()).toEqual(1);
await runPromise;
expect(timeline.getProgress()).toEqual(1);
});
});
describe("getNaiveTrialCount()", () => {
it("correctly estimates the length of a timeline (including nested timelines)", async () => {
const timeline = createTimeline({
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()).toEqual(estimate);
});
});
describe("getActiveNode()", () => {
it("returns the currently active `TimelineNode` or `undefined` when no node is active", async () => {
TestPlugin.setManualFinishTrialMode();
let outerTimelineActiveNode: TimelineNode;
let innerTimelineActiveNode: TimelineNode;
const timeline = createTimeline({
timeline: [
{ type: TestPlugin },
{
timeline: [{ type: TestPlugin }],
on_timeline_start: () => {
innerTimelineActiveNode = timeline.getActiveNode();
},
},
],
on_timeline_start: () => {
outerTimelineActiveNode = timeline.getActiveNode();
},
});
expect(timeline.getActiveNode()).toBeUndefined();
timeline.run();
// Avoiding direct .toBe(timeline) here to circumvent circular reference errors caused by Jest
// trying to stringify `Timeline` objects
expect(outerTimelineActiveNode).toBeInstanceOf(Timeline);
expect(outerTimelineActiveNode.index).toEqual(0);
expect(timeline.getActiveNode()).toBeInstanceOf(Trial);
expect(timeline.getActiveNode().index).toEqual(0);
await TestPlugin.finishTrial();
expect(innerTimelineActiveNode).toBeInstanceOf(Timeline);
expect(innerTimelineActiveNode.index).toEqual(1);
expect(timeline.getActiveNode()).toBeInstanceOf(Trial);
expect(timeline.getActiveNode().index).toEqual(1);
await TestPlugin.finishTrial();
expect(timeline.getActiveNode()).toBeUndefined();
});
});
});