Remove JsPsych dependency from timeline nodes

This commit is contained in:
bjoluc 2022-10-11 17:07:36 +02:00
parent f23fb33a53
commit 771ee6671e
12 changed files with 118 additions and 86 deletions

2
package-lock.json generated
View File

@ -24644,7 +24644,7 @@
"@jspsych/test-utils": "^1.1.1",
"@types/dom-mediacapture-record": "^1.0.11",
"@types/lodash.get": "^4.4.6",
"@types/lodash.has": "*",
"@types/lodash.has": "^4.5.7",
"@types/lodash.set": "^4.3.7",
"auto-bind": "^4.0.0",
"lodash.get": "^4.4.2",

View File

@ -1,15 +1,17 @@
import autoBind from "auto-bind";
import { Class } from "type-fest";
import { version } from "../package.json";
import { JsPsychData } from "./modules/data";
import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api";
import { JsPsychPlugin, PluginInfo } from "./modules/plugins";
import * as randomization from "./modules/randomization";
import * as turk from "./modules/turk";
import * as utils from "./modules/utils";
import {
GlobalTimelineNodeCallbacks,
TimelineArray,
TimelineDescription,
TimelineNodeDependencies,
TimelineVariable,
TrialResult,
} from "./timeline";
@ -67,7 +69,7 @@ export class JsPsych {
*/
private simulation_options;
private timelineNodeCallbacks = new (class implements GlobalTimelineNodeCallbacks {
private timelineDependencies = new (class implements TimelineNodeDependencies {
constructor(private jsPsych: JsPsych) {
autoBind(this);
}
@ -101,6 +103,16 @@ export class JsPsych {
this.jsPsych.removeCssClasses(cssClasses);
}
}
instantiatePlugin<Info extends PluginInfo>(pluginClass: Class<JsPsychPlugin<Info>>) {
return new pluginClass(this.jsPsych);
}
defaultIti = this.jsPsych.options.default_iti;
displayElement = this.jsPsych.getDisplayElement();
finishTrialPromise = this.jsPsych.finishTrialPromise;
})(this);
constructor(options?) {
@ -175,7 +187,7 @@ export class JsPsych {
}
// create experiment timeline
this.timeline = new Timeline(this, this.timelineNodeCallbacks, timeline);
this.timeline = new Timeline(this.timelineDependencies, timeline);
await this.prepareDom();
await this.loadExtensions(this.options.extensions);
@ -416,7 +428,7 @@ export class JsPsych {
// New stuff as replacements for old methods:
finishTrialPromise = new PromiseWrapper<TrialResult>();
private finishTrialPromise = new PromiseWrapper<TrialResult | void>();
finishTrial(data?: TrialResult) {
this.finishTrialPromise.resolve(data);
}

View File

@ -1,5 +1,3 @@
import { GlobalTimelineNodeCallbacks } from "src/timeline";
import { JsPsych } from "../../JsPsych";
import { DataCollection } from "./DataCollection";
import { getQueryString } from "./utils";

View File

@ -1,6 +1,6 @@
import { SetRequired } from "type-fest";
import { TrialDescription } from "../timeline";
import { TrialDescription, TrialResult } from "../timeline";
/**
* Parameter types for plugins
@ -136,8 +136,6 @@ export const universalPluginParameters = <const>{
export type UniversalPluginParameters = InferredParameters<typeof universalPluginParameters>;
type test = undefined extends null ? "a" : "b";
export interface PluginInfo {
name: string;
parameters: ParameterInfos;
@ -148,7 +146,7 @@ export interface JsPsychPlugin<I extends PluginInfo> {
display_element: HTMLElement,
trial: TrialType<I>,
on_load?: () => void
): void | Promise<any>;
): void | Promise<TrialResult | void>;
}
export type TrialType<I extends PluginInfo> = InferredParameters<I["parameters"]> &

View File

@ -1,13 +1,12 @@
import get from "lodash.get";
import has from "lodash.has";
import { JsPsych } from "../JsPsych";
import { Timeline } from "./Timeline";
import {
GetParameterValueOptions,
GlobalTimelineNodeCallbacks,
TimelineDescription,
TimelineNode,
TimelineNodeDependencies,
TimelineNodeStatus,
TimelineVariable,
TrialDescription,
@ -24,10 +23,7 @@ export abstract class BaseTimelineNode implements TimelineNode {
protected status = TimelineNodeStatus.PENDING;
constructor(
protected readonly jsPsych: JsPsych,
protected readonly globalCallbacks: GlobalTimelineNodeCallbacks
) {}
constructor(protected readonly dependencies: TimelineNodeDependencies) {}
getStatus() {
return this.status;

View File

@ -1,8 +1,7 @@
import { flushPromises } from "@jspsych/test-utils";
import { JsPsych, initJsPsych } from "jspsych";
import { mocked } from "ts-jest/utils";
import { GlobalCallbacks, mockDomRelatedJsPsychMethods } from "../../tests/test-utils";
import { MockTimelineNodeDependencies } from "../../tests/test-utils";
import TestPlugin from "../../tests/TestPlugin";
import {
repeat,
@ -30,19 +29,15 @@ const exampleTimeline: TimelineDescription = {
timeline: [{ type: TestPlugin }, { type: TestPlugin }, { timeline: [{ type: TestPlugin }] }],
};
const globalCallbacks = new GlobalCallbacks();
const dependencies = new MockTimelineNodeDependencies();
describe("Timeline", () => {
let jsPsych: JsPsych;
const createTimeline = (description: TimelineDescription | TimelineArray, parent?: Timeline) =>
new Timeline(jsPsych, globalCallbacks, description, parent);
new Timeline(dependencies, description, parent);
beforeEach(() => {
globalCallbacks.reset();
dependencies.reset();
TestPlugin.reset();
jsPsych = initJsPsych();
mockDomRelatedJsPsychMethods(jsPsych);
});
describe("run()", () => {

View File

@ -1,4 +1,3 @@
import { JsPsych } from "../JsPsych";
import {
repeat,
sampleWithReplacement,
@ -11,10 +10,10 @@ import { Trial } from "./Trial";
import { PromiseWrapper } from "./util";
import {
GetParameterValueOptions,
GlobalTimelineNodeCallbacks,
TimelineArray,
TimelineDescription,
TimelineNode,
TimelineNodeDependencies,
TimelineNodeStatus,
TimelineVariable,
TrialDescription,
@ -28,13 +27,12 @@ export class Timeline extends BaseTimelineNode {
public readonly description: TimelineDescription;
constructor(
jsPsych: JsPsych,
globalCallbacks: GlobalTimelineNodeCallbacks,
dependencies: TimelineNodeDependencies,
description: TimelineDescription | TimelineArray,
protected readonly parent?: Timeline,
public readonly index = 0
) {
super(jsPsych, globalCallbacks);
super(dependencies);
this.description = Array.isArray(description) ? { timeline: description } : description;
this.nextChildNodeIndex = index;
}
@ -143,8 +141,8 @@ export class Timeline extends BaseTimelineNode {
const newChildNodes = this.description.timeline.map((childDescription) => {
const childNodeIndex = this.nextChildNodeIndex++;
return isTimelineDescription(childDescription)
? new Timeline(this.jsPsych, this.globalCallbacks, childDescription, this, childNodeIndex)
: new Trial(this.jsPsych, this.globalCallbacks, childDescription, this, childNodeIndex);
? new Timeline(this.dependencies, childDescription, this, childNodeIndex)
: new Trial(this.dependencies, childDescription, this, childNodeIndex);
});
this.children.push(...newChildNodes);
return newChildNodes;

View File

@ -1,8 +1,7 @@
import { flushPromises } from "@jspsych/test-utils";
import { JsPsych, initJsPsych } from "jspsych";
import { mocked } from "ts-jest/utils";
import { GlobalCallbacks, mockDomRelatedJsPsychMethods } from "../../tests/test-utils";
import { MockTimelineNodeDependencies } from "../../tests/test-utils";
import TestPlugin from "../../tests/TestPlugin";
import { ParameterType } from "../modules/plugins";
import { Timeline } from "./Timeline";
@ -14,23 +13,20 @@ jest.useFakeTimers();
jest.mock("./Timeline");
const globalCallbacks = new GlobalCallbacks();
const dependencies = new MockTimelineNodeDependencies();
describe("Trial", () => {
let jsPsych: JsPsych;
let timeline: Timeline;
beforeEach(() => {
globalCallbacks.reset();
dependencies.reset();
TestPlugin.reset();
jsPsych = initJsPsych();
mockDomRelatedJsPsychMethods(jsPsych);
timeline = new Timeline(jsPsych, globalCallbacks, { timeline: [] });
timeline = new Timeline(dependencies, { timeline: [] });
});
const createTrial = (description: TrialDescription) =>
new Trial(jsPsych, globalCallbacks, description, timeline, 0);
new Trial(dependencies, description, timeline, 0);
describe("run()", () => {
it("instantiates the corresponding plugin", async () => {
@ -49,8 +45,8 @@ describe("Trial", () => {
expect(onStartCallback).toHaveBeenCalledTimes(1);
expect(onStartCallback).toHaveBeenCalledWith(description);
expect(globalCallbacks.onTrialStart).toHaveBeenCalledTimes(1);
expect(globalCallbacks.onTrialStart).toHaveBeenCalledWith(trial);
expect(dependencies.onTrialStart).toHaveBeenCalledTimes(1);
expect(dependencies.onTrialStart).toHaveBeenCalledWith(trial);
});
it("properly invokes the plugin's `trial` method", async () => {
@ -107,7 +103,7 @@ describe("Trial", () => {
.spyOn(TestPlugin.prototype, "trial")
.mockImplementation(async (display_element, trial, on_load) => {
on_load();
jsPsych.finishTrial({ finishTrial: "result" });
dependencies.finishTrialPromise.resolve({ finishTrial: "result" });
return { my: "result" };
});
@ -120,7 +116,7 @@ describe("Trial", () => {
describe("if `trial` returns no promise", () => {
beforeAll(() => {
TestPlugin.prototype.trial.mockImplementation(() => {
jsPsych.finishTrial({ my: "result" });
dependencies.finishTrialPromise.resolve({ my: "result" });
});
});
@ -130,7 +126,7 @@ describe("Trial", () => {
await trial.run();
expect(onLoadCallback).toHaveBeenCalledTimes(1);
expect(globalCallbacks.onTrialLoaded).toHaveBeenCalledTimes(1);
expect(dependencies.onTrialLoaded).toHaveBeenCalledTimes(1);
});
it("picks up the result data from the `finishTrial()` function", async () => {
@ -154,8 +150,8 @@ describe("Trial", () => {
const trial = createTrial({ type: TestPlugin });
await trial.run();
expect(globalCallbacks.onTrialFinished).toHaveBeenCalledTimes(1);
expect(globalCallbacks.onTrialFinished).toHaveBeenCalledWith(trial);
expect(dependencies.onTrialFinished).toHaveBeenCalledTimes(1);
expect(dependencies.onTrialFinished).toHaveBeenCalledWith(trial);
});
it("includes result data from the `data` property", async () => {
@ -417,7 +413,7 @@ describe("Trial", () => {
});
it("respects `default_iti` and `post_trial_gap``", async () => {
jest.spyOn(jsPsych, "getInitSettings").mockReturnValue({ default_iti: 100 });
dependencies.defaultIti = 100;
TestPlugin.setManualFinishTrialMode();
const trial1 = createTrial({ type: TestPlugin });
@ -456,10 +452,10 @@ describe("Trial", () => {
describe("evaluateTimelineVariable()", () => {
it("defers to the parent node", () => {
const timeline = new Timeline(jsPsych, globalCallbacks, { timeline: [] });
const timeline = new Timeline(dependencies, { timeline: [] });
mocked(timeline).evaluateTimelineVariable.mockReturnValue(1);
const trial = new Trial(jsPsych, globalCallbacks, { type: TestPlugin }, timeline, 0);
const trial = new Trial(dependencies, { type: TestPlugin }, timeline, 0);
const variable = new TimelineVariable("x");
expect(trial.evaluateTimelineVariable(variable)).toEqual(1);

View File

@ -1,16 +1,16 @@
import { JsPsych, JsPsychPlugin, ParameterType, PluginInfo } from "jspsych";
import get from "lodash.get";
import set from "lodash.set";
import { ParameterInfos } from "src/modules/plugins";
import { Class } from "type-fest";
import { JsPsychPlugin, ParameterType, PluginInfo } from "../";
import { deepCopy } from "../modules/utils";
import { BaseTimelineNode } from "./BaseTimelineNode";
import { Timeline } from "./Timeline";
import { delay, parameterPathArrayToString } from "./util";
import {
GetParameterValueOptions,
GlobalTimelineNodeCallbacks,
TimelineNodeDependencies,
TimelineNodeStatus,
TimelineVariable,
TrialDescription,
@ -25,16 +25,14 @@ export class Trial extends BaseTimelineNode {
private result: TrialResult;
private readonly pluginInfo: PluginInfo;
private cssClasses?: string[];
constructor(
jsPsych: JsPsych,
globalCallbacks: GlobalTimelineNodeCallbacks,
dependencies: TimelineNodeDependencies,
public readonly description: TrialDescription,
protected readonly parent: Timeline,
public readonly index: number
) {
super(jsPsych, globalCallbacks);
super(dependencies);
this.trialObject = deepCopy(description);
this.pluginClass = this.getParameterValue("type", { evaluateFunctions: false });
this.pluginInfo = this.pluginClass["info"];
@ -46,7 +44,7 @@ export class Trial extends BaseTimelineNode {
this.onStart();
this.pluginInstance = new this.pluginClass(this.jsPsych);
this.pluginInstance = this.dependencies.instantiatePlugin(this.pluginClass);
const result = await this.executeTrial();
@ -59,8 +57,7 @@ export class Trial extends BaseTimelineNode {
this.onFinish();
const gap =
this.getParameterValue("post_trial_gap") ?? this.jsPsych.getInitSettings().default_iti;
const gap = this.getParameterValue("post_trial_gap") ?? this.dependencies.defaultIti;
if (gap !== 0) {
await delay(gap);
}
@ -69,7 +66,7 @@ export class Trial extends BaseTimelineNode {
}
private async executeTrial() {
let trialPromise = this.jsPsych.finishTrialPromise.get();
const trialPromise = this.dependencies.finishTrialPromise.get();
/** Used as a way to figure out if `finishTrial()` has ben called without awaiting `trialPromise` */
let hasTrialPromiseBeenResolved = false;
@ -78,13 +75,13 @@ export class Trial extends BaseTimelineNode {
});
const trialReturnValue = this.pluginInstance.trial(
this.jsPsych.getDisplayElement(),
this.dependencies.displayElement,
this.trialObject,
this.onLoad
);
// Wait until the trial has completed and grab result data
let result: TrialResult;
let result: TrialResult | void;
if (isPromise(trialReturnValue)) {
result = await Promise.race([trialReturnValue, trialPromise]);
@ -115,18 +112,18 @@ export class Trial extends BaseTimelineNode {
}
private onStart() {
this.globalCallbacks.onTrialStart(this);
this.dependencies.onTrialStart(this);
this.runParameterCallback("on_start", this.trialObject);
}
private onLoad = () => {
this.globalCallbacks.onTrialLoaded(this);
this.dependencies.onTrialLoaded(this);
this.runParameterCallback("on_load");
};
private onFinish() {
this.runParameterCallback("on_finish", this.getResult());
this.globalCallbacks.onTrialFinished(this);
this.dependencies.onTrialFinished(this);
}
public evaluateTimelineVariable(variable: TimelineVariable) {

View File

@ -1,7 +1,8 @@
import { Class } from "type-fest";
import { JsPsychPlugin } from "../modules/plugins";
import { JsPsychPlugin, PluginInfo } from "../modules/plugins";
import { Trial } from "./Trial";
import { PromiseWrapper } from "./util";
export function isPromise(value: any): value is Promise<any> {
return value && typeof value["then"] === "function";
@ -112,12 +113,11 @@ export enum TimelineNodeStatus {
}
/**
* Callbacks that get invoked by `TimelineNode`s. The callbacks are provided by the `JsPsych` class
* itself to avoid numerous `JsPsych` instance method calls from within timeline nodes, and to keep
* the public `JsPsych` API slim. This approach helps to decouple the `JsPsych` and timeline node
* classes and thus simplifies unit testing.
* Functions and options needed by `TimelineNode`s, provided by the `JsPsych` instance. This
* approach allows to keep the public `JsPsych` API slim and decouples the `JsPsych` and timeline
* node classes, simplifying unit testing.
*/
export interface GlobalTimelineNodeCallbacks {
export interface TimelineNodeDependencies {
/**
* Called at the start of a trial, prior to invoking the plugin's trial method.
*/
@ -132,6 +132,29 @@ export interface GlobalTimelineNodeCallbacks {
* Called after a trial has finished.
*/
onTrialFinished: (trial: Trial) => void;
/**
* Given a plugin class, creates a new instance of it and returns it.
*/
instantiatePlugin: <Info extends PluginInfo>(
pluginClass: Class<JsPsychPlugin<Info>>
) => JsPsychPlugin<Info>;
/**
* The default inter-trial interval as provided to `initJsPsych`
*/
defaultIti: number;
/**
* JsPsych's display element which is provided to plugins
*/
displayElement: HTMLElement;
/**
* A `PromiseWrapper` whose promise is resolved with result data whenever `jsPsych.finishTrial()`
* is called.
*/
finishTrialPromise: PromiseWrapper<TrialResult | void>;
}
export type GetParameterValueOptions = {

View File

@ -1,5 +1,6 @@
import { flushPromises } from "@jspsych/test-utils";
import { JsPsych, JsPsychPlugin, TrialType } from "jspsych";
import { TrialResult } from "src/timeline";
import { ParameterInfos } from "../src/modules/plugins";
import { PromiseWrapper } from "../src/timeline/util";
@ -69,7 +70,7 @@ class TestPlugin implements JsPsychPlugin<typeof testPluginInfo> {
// For convenience, `trial` is set to a `jest.fn` below using `TestPlugin.prototype` and
// `defaultTrialImplementation`
trial: jest.Mock<Promise<Record<string, any> | void> | void>;
trial: jest.Mock<Promise<TrialResult | void> | void>;
defaultTrialImplementation(
display_element: HTMLElement,

View File

@ -1,28 +1,46 @@
import { JsPsych } from "../src";
import { GlobalTimelineNodeCallbacks } from "../src/timeline";
import { Class } from "type-fest";
export function mockDomRelatedJsPsychMethods(jsPsychInstance: JsPsych) {
const displayElement = document.createElement("div");
const displayContainerElement = document.createElement("div");
jest.spyOn(jsPsychInstance, "getDisplayElement").mockImplementation(() => displayElement);
jest
.spyOn(jsPsychInstance, "getDisplayContainerElement")
.mockImplementation(() => displayContainerElement);
}
import { JsPsych, JsPsychPlugin } from "../src";
import { TimelineNodeDependencies, TrialResult } from "../src/timeline";
import { PromiseWrapper } from "../src/timeline/util";
jest.mock("../src/JsPsych");
/**
* A class to instantiate mocked `GlobalTimelineNodeCallbacks` objects that have additional
* A class to instantiate mocked `TimelineNodeDependencies` objects that have additional
* testing-related functions.
*/
export class GlobalCallbacks implements GlobalTimelineNodeCallbacks {
export class MockTimelineNodeDependencies implements TimelineNodeDependencies {
onTrialStart = jest.fn();
onTrialLoaded = jest.fn();
onTrialFinished = jest.fn();
instantiatePlugin = jest.fn(
(pluginClass: Class<JsPsychPlugin<any>>) => new pluginClass(this.jsPsych)
);
defaultIti: number;
displayElement: HTMLDivElement;
finishTrialPromise: PromiseWrapper<TrialResult>;
jsPsych: JsPsych; // So we have something for plugins in `instantiatePlugin`
constructor() {
this.initializeProperties();
}
private initializeProperties() {
this.defaultIti = 0;
this.displayElement = document.createElement("div");
this.finishTrialPromise = new PromiseWrapper<TrialResult>();
this.jsPsych = new JsPsych();
}
// Test utility functions
reset() {
this.onTrialStart.mockReset();
this.onTrialLoaded.mockReset();
this.onTrialFinished.mockReset();
this.instantiatePlugin.mockClear();
this.initializeProperties();
}
}