UNPKG

@lookit/lookit-initjspsych

Version:

This package overloads jsPsych's init function.

635 lines (546 loc) 21.3 kB
import { Child, JsPsychExpData, Study } from "@lookit/data/dist/types"; import type { DataCollection } from "jspsych"; import * as jspsychModule from "jspsych"; import TestPlugin from "../fixtures/TestPlugin"; import lookitInitJsPsych from "./"; import { UndefinedTimelineError, UndefinedTypeError } from "./errors"; import type { ChsTimelineArray, ChsTimelineDescription, ChsTrialDescription, JsPsychOptions, } from "./types"; describe("lookit-initjspsych initializes and runs", () => { beforeEach(() => { TestPlugin.reset(); }); test("lookitInitJsPsych returns an instance of jspsych", () => { const jsPsych = lookitInitJsPsych("uuid-string"); const opts = { on_data_update: jest.fn(), on_finish: jest.fn(), }; expect(jsPsych(opts)).toBeInstanceOf(jspsychModule.JsPsych); }); test("jsPsych's run is called", async () => { const mockRun = jest.fn(); jest .spyOn(jspsychModule.JsPsych.prototype, "run") .mockImplementation(mockRun); const jsPsych = lookitInitJsPsych("some id"); await jsPsych({}).run([]); expect(mockRun).toHaveBeenCalledTimes(1); }); test("jsPsych initializes with onDataUpdate/on_data_update when no init opts are provided", async () => { await jest.isolateModulesAsync(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockInitJsPsych = jest.fn((opts?: JsPsychOptions) => ({ data: { /** * Mock jsPsych.data.get in the returned instance * * @returns Data collection with a values() method */ get: () => ({ /** * Mock jsPsych.data.get().values() in the returned instance * * @returns Mocked data array */ values: () => [] as JsPsychExpData[], }), }, run: jest.fn(), })); jest.mock("jspsych", () => ({ initJsPsych: mockInitJsPsych, })); // Dynamically import lookitInitJsPsych after mocking jsPsych/initJsPsych const { default: lookitInitJsPsych } = await import("./index"); // Call with no user-defined init options lookitInitJsPsych("uuid")(); expect(mockInitJsPsych).toHaveBeenCalled(); const callArgs = mockInitJsPsych.mock.calls[0][0]; // The original initJsPsych function should be called with an on_data_update function // even though it was not passed in by the user (no opts argument) expect(typeof callArgs!.on_data_update).toBe("function"); }); }); test("jsPsych initializes with onDataUpdate/on_data_update when init opts is empty", async () => { await jest.isolateModulesAsync(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockInitJsPsych = jest.fn((opts?: JsPsychOptions) => ({ data: { /** * Mock jsPsych.data.get in the returned instance * * @returns Data collection with a values() method */ get: () => ({ /** * Mock jsPsych.data.get().values() in the returned instance * * @returns Mocked data array */ values: () => [] as JsPsychExpData[], }), }, run: jest.fn(), })); jest.mock("jspsych", () => ({ initJsPsych: mockInitJsPsych, })); // Dynamically import lookitInitJsPsych after mocking jsPsych/initJsPsych const { default: lookitInitJsPsych } = await import("./index"); // call with empty opts object const opts: JsPsychOptions = {}; lookitInitJsPsych("uuid")(opts); expect(mockInitJsPsych).toHaveBeenCalled(); const callArgs = mockInitJsPsych.mock.calls[0][0]; // The original initJsPsych function should be called with an on_data_update function // even though it was not passed in by the user (empty opts argument) expect(typeof callArgs!.on_data_update).toBe("function"); }); }); test("After initializing, when jsPsych data updates, onDataUpdate closure returns the on_data_update function with correct arguments", async () => { jest.doMock("jspsych", () => ({ __esModule: true, // eslint-disable-next-line @typescript-eslint/no-unused-vars initJsPsych: jest.fn((opts?: JsPsychOptions) => ({ data: { /** * Mock jsPsych.data.get in the returned instance * * @returns Data collection with a values() method */ get: () => ({ /** * Mock jsPsych.data.get().values() in the returned instance * * @returns Mocked data array */ values: () => [] as JsPsychExpData[], }), }, run: jest.fn(), })), })); // Track API mocks separately so we can assert on them const mockRetrieveResponse = jest.fn().mockResolvedValue({ attributes: { exp_data: [] }, }); const mockUpdateResponse = jest.fn().mockResolvedValue(undefined); const mockFinish = jest.fn().mockResolvedValue(undefined); // Mock Api from @lookit/data jest.doMock("@lookit/data", () => ({ __esModule: true, default: { retrieveResponse: mockRetrieveResponse, updateResponse: mockUpdateResponse, finish: mockFinish, }, })); // use jest.isolateModulesAsync to ensure that the mocks are applied before index.ts and its imports are loaded await jest.isolateModulesAsync(async () => { const { default: lookitInitJsPsych } = await import("./index"); const { initJsPsych } = await import("jspsych"); lookitInitJsPsych("uuid")({}); const callArgs = (initJsPsych as jest.Mock).mock.calls[0][0]; const onDataUpdate = callArgs.on_data_update!; // Simulate jsPsych calling onDataUpdate/on_data_update with trial data await expect( onDataUpdate({ trial_index: 0, trial_type: "test" } as unknown), ).resolves.not.toThrow(); expect(mockRetrieveResponse).not.toHaveBeenCalled(); expect(mockUpdateResponse).toHaveBeenCalledWith("uuid", { exp_data: [], // from jsPsych.data.get().values() }); expect(mockFinish).toHaveBeenCalled(); }); }); test("jsPsych initializes with onFinish/on_finish when no init opts are provided", async () => { await jest.isolateModulesAsync(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockInitJsPsych = jest.fn((opts?: JsPsychOptions) => ({ /** * Mock for getDisplayElement * * @returns Object with an innerHTML property */ getDisplayElement: () => ({ /** * Mocked jsPsych.getDisplayElement().innerHTML used in on_finish * * @returns Empty string */ innerHTML: "", }), run: jest.fn(), })); jest.mock("jspsych", () => ({ initJsPsych: mockInitJsPsych, })); // Dynamically import lookitInitJsPsych after mocking jsPsych/initJsPsych const { default: lookitInitJsPsych } = await import("./index"); // Call with no user-defined init options lookitInitJsPsych("uuid")(); expect(mockInitJsPsych).toHaveBeenCalled(); const callArgs = mockInitJsPsych.mock.calls[0][0]; // The original initJsPsych function should be called with an on_finish function // even though it was not passed in by the user (no opts argument) expect(typeof callArgs!.on_finish).toBe("function"); }); }); test("jsPsych initializes with onFinish/on_finish when init opts is empty", async () => { await jest.isolateModulesAsync(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockInitJsPsych = jest.fn((opts?: JsPsychOptions) => ({ /** * Mock for getDisplayElement * * @returns Object with an innerHTML property */ getDisplayElement: () => ({ /** * Mocked jsPsych.getDisplayElement().innerHTML used in on_finish * * @returns Empty string */ innerHTML: "", }), run: jest.fn(), })); jest.mock("jspsych", () => ({ initJsPsych: mockInitJsPsych, })); // Dynamically import lookitInitJsPsych after mocking jsPsych/initJsPsych const { default: lookitInitJsPsych } = await import("./index"); // call with empty opts object const opts: JsPsychOptions = {}; lookitInitJsPsych("uuid")(opts); expect(mockInitJsPsych).toHaveBeenCalled(); const callArgs = mockInitJsPsych.mock.calls[0][0]; // The original initJsPsych function should be called with an on_finish function // even though it was not passed in by the user (empty opts argument) expect(typeof callArgs!.on_finish).toBe("function"); }); }); test("After initializing, when jsPsych finishes, onFinish closure returns the on_finish function with correct arguments", async () => { // needed to stub out the window.location.replace call inside on_finish Object.defineProperty(window, "location", { value: { ...window.location, replace: jest.fn(), }, writable: true, }); Object.assign(window, { chs: { study: { attributes: { exit_url: "https://example.com/exit" }, } as Study, child: { id: "child-id" } as Child, response: { id: "response-uuid", attributes: { hash_child_id: "hash-child-id" }, }, pastSessions: {} as Response[], pendingUploads: [], }, }); const exp_data = [{ key: "value" }]; const data = { /** * Mocked jsPsych Data Collection. * * @returns Exp data. */ values: () => exp_data, } as DataCollection; jest.doMock("jspsych", () => ({ __esModule: true, // eslint-disable-next-line @typescript-eslint/no-unused-vars initJsPsych: jest.fn((opts?: JsPsychOptions) => ({ data: { /** * Mock jsPsych.data.get in the returned instance * * @returns Data collection with a values() method */ get: () => data, }, /** * Mock for getDisplayElement * * @returns Object with an innerHTML property */ getDisplayElement: () => ({ /** * Mocked jsPsych.getDisplayElement().innerHTML used in on_finish * * @returns Empty string */ innerHTML: "", }), run: jest.fn(), })), })); // Track API mocks separately so we can assert on them const mockRetrieveResponse = jest.fn().mockResolvedValue({ attributes: { exp_data: [] }, }); const mockUpdateResponse = jest.fn().mockResolvedValue(undefined); const mockFinish = jest.fn().mockResolvedValue(undefined); // Mock Api from @lookit/data jest.doMock("@lookit/data", () => ({ __esModule: true, default: { retrieveResponse: mockRetrieveResponse, updateResponse: mockUpdateResponse, finish: mockFinish, }, })); // use jest.isolateModulesAsync to ensure that the mocks are applied before index.ts and its imports are loaded await jest.isolateModulesAsync(async () => { const { default: lookitInitJsPsych } = await import("./index"); const { initJsPsych } = await import("jspsych"); lookitInitJsPsych("uuid")({}); const callArgs = (initJsPsych as jest.Mock).mock.calls[0][0]; const onFinish = callArgs.on_finish!; // Simulate jsPsych calling onFinish/on_finish with data collection await expect(onFinish(data)).resolves.not.toThrow(); expect(mockRetrieveResponse).not.toHaveBeenCalled(); expect(mockUpdateResponse).toHaveBeenCalledWith("uuid", { exp_data: [{ key: "value" }], // from mocked data.values() completed: true, }); expect(mockFinish).toHaveBeenCalled(); }); }); }); describe("lookit-initjspsych data handling", () => { beforeEach(() => { TestPlugin.reset(); }); test("Experiment data is injected into timeline w/o data", async () => { const jsPsych = lookitInitJsPsych("some id"); const trial: ChsTrialDescription = { type: TestPlugin }; const t: ChsTimelineArray = [trial]; await jsPsych({}).run(t); // TestPlugin has a chsData() method that returns { chs_type: "test" } expect((t[0] as ChsTrialDescription).data).toMatchObject({ chs_type: "test", }); }); test("Experiment data is injected into timeline w/ data", async () => { const jsPsych = lookitInitJsPsych("some id"); const trial: ChsTrialDescription = { type: TestPlugin, data: { other: "data" }, }; const t: ChsTimelineArray = [trial]; await jsPsych({}).run(t); expect((t[0] as ChsTrialDescription).data).toMatchObject({ chs_type: "test", other: "data", }); }); test("User on_data_update and other options are passed through to initJsPsych", async () => { await jest.isolateModulesAsync(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockInitJsPsych = jest.fn((opts?: JsPsychOptions) => ({ data: { /** * Mock jsPsych.data.get in the returned instance * * @returns Data collection with a values() method */ get: () => ({ /** * Mock jsPsych.data.get().values() in the returned instance * * @returns Mocked data array */ values: () => [] as JsPsychExpData[], }), }, run: jest.fn(), })); jest.mock("jspsych", () => ({ initJsPsych: mockInitJsPsych, })); const { default: lookitInitJsPsych } = await import("./index"); // User-specified on_data_update const userOnDataUpdate = jest.fn(); // Any extra user-specified initJsPsych options that should be passed directly to the original initJsPsych const otherInitOptions = { default_iti: 500 }; const opts: JsPsychOptions = { on_data_update: userOnDataUpdate, ...otherInitOptions, } as JsPsychOptions; lookitInitJsPsych("uuid")(opts); expect(mockInitJsPsych).toHaveBeenCalled(); const callArgs = mockInitJsPsych.mock.calls[0][0]; // (1) We always replace on_data_update with a closure in the original initJsPsych, // so that parameter will exist and not match the user-defined function expect(callArgs!.on_data_update).not.toBe(userOnDataUpdate); expect(typeof callArgs!.on_data_update).toBe("function"); // (2) Any other user-specified init options are passed through untouched expect(callArgs!.default_iti).toBe(500); }); }); test("User on_finish and other options are passed through to initJsPsych", async () => { await jest.isolateModulesAsync(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockInitJsPsych = jest.fn((opts?: JsPsychOptions) => ({ data: { /** * Mock jsPsych.data.get in the returned instance * * @returns Data collection with a values() method */ get: () => ({ /** * Mock jsPsych.data.get().values() in the returned instance * * @returns Mocked data array */ values: () => [] as JsPsychExpData[], }), }, run: jest.fn(), })); jest.mock("jspsych", () => ({ initJsPsych: mockInitJsPsych, })); const { default: lookitInitJsPsych } = await import("./index"); // User-specified on_finish const userOnFinish = jest.fn(); // Any extra user-specified initJsPsych options that should be passed directly to the original initJsPsych const otherInitOptions = { default_iti: 500 } as JsPsychOptions; const opts: JsPsychOptions = { on_finish: userOnFinish, ...otherInitOptions, } as JsPsychOptions; lookitInitJsPsych("uuid")(opts); expect(mockInitJsPsych).toHaveBeenCalled(); const callArgs = mockInitJsPsych.mock.calls[0][0]; // (1) We always replace on_finish with a closure in the original initJsPsych, // so that parameter will exist and not match the user-defined function expect(callArgs!.on_finish).not.toBe(userOnFinish); expect(typeof callArgs!.on_finish).toBe("function"); // (2) Any other user-specified init options are passed through untouched expect(callArgs!.default_iti).toBe(500); }); }); }); describe("lookit-initjspsych timeline/trial handling", () => { beforeEach(() => { TestPlugin.reset(); }); test("Throws UndefinedTypeError when trial description has no type", () => { const jsPsych = lookitInitJsPsych("some id"); [ [ { type: undefined, data: { other: "data" }, } as unknown as ChsTrialDescription, ], [ { type: null, data: { other: "data" }, } as unknown as ChsTrialDescription, ], ].forEach((t) => { expect( async () => await jsPsych({}).run(t as ChsTimelineArray), ).rejects.toThrow(UndefinedTypeError); }); }); test("Does the experiment run when the timeline contains a valid timeline node?", async () => { const jsPsych = lookitInitJsPsych("some id"); const timeline_node: ChsTimelineDescription = { timeline: [ { timeline: [{ type: TestPlugin, data: { other: "data" } }], }, ], }; const t: ChsTimelineArray = [timeline_node]; await jsPsych({}).run(t); const trial_data = ( ((t[0] as ChsTimelineDescription).timeline[0] as ChsTimelineDescription) .timeline[0] as ChsTrialDescription ).data; expect(trial_data).toMatchObject({ chs_type: "test", other: "data" }); }); test("Throws UndefinedTimelineError when timeline object is invalid", () => { const jsPsych = lookitInitJsPsych("some id"); const t1 = [ { timeline: { type: TestPlugin } }, ] as unknown as ChsTimelineArray; expect( async () => await jsPsych({}).run(t1 as ChsTimelineArray), ).rejects.toThrow(UndefinedTimelineError); const t2 = [{ timeline: true }] as unknown as ChsTimelineArray; expect( async () => await jsPsych({}).run(t2 as ChsTimelineArray), ).rejects.toThrow(UndefinedTimelineError); const t3 = [true] as unknown as ChsTimelineArray; expect( async () => await jsPsych({}).run(t3 as ChsTimelineArray), ).rejects.toThrow(UndefinedTimelineError); const t4 = [42] as unknown as ChsTimelineArray; expect( async () => await jsPsych({}).run(t4 as ChsTimelineArray), ).rejects.toThrow(UndefinedTimelineError); }); test("When the timeline array element is an array, handleTrialTypes is called on that array", async () => { const jsPsych = lookitInitJsPsych("some id"); const timeline_node_nested_array: ChsTimelineDescription = { timeline: [[{ type: TestPlugin, data: { other: "data" } }]], }; const t: ChsTimelineArray = [timeline_node_nested_array]; await jsPsych({}).run(t); const outerTimelineNode = t[0] as ChsTimelineDescription; const trial_data = ( ( outerTimelineNode.timeline[0] as ChsTimelineArray )[0] as ChsTrialDescription ).data; expect(trial_data).toMatchObject({ chs_type: "test", other: "data" }); }); test("When a trial description contains a type and nested timeline, handleTrialTypes treats it as a trial instead of timeline node", async () => { const jsPsych = lookitInitJsPsych("some id"); const nested_timeline: ChsTrialDescription = { type: TestPlugin, timeline: [{ data: { trialnumber: 1 } }, { data: { trialnumber: 2 } }], }; const t: ChsTimelineArray = [nested_timeline]; await jsPsych({}).run(t); expect((t[0] as ChsTrialDescription).data).toMatchObject({ chs_type: "test", }); }); test("When a trial description contains a nested timeline with no type, handleTrialTypes handles it as a timeline node", async () => { const jsPsych = lookitInitJsPsych("some id"); const nested_timeline = { data: { somekey: "somevalue" }, timeline: [{ type: TestPlugin }, { type: TestPlugin }], } as unknown as ChsTrialDescription; const t: ChsTimelineArray = [nested_timeline]; await jsPsych({}).run(t); // lookit-initjspsych should get the CHS data from TestPlugin and add it as data in the nested timeline. expect((t[0] as ChsTrialDescription).data).toMatchObject({ somekey: "somevalue", }); expect((t[0] as ChsTrialDescription).timeline[0].data).toMatchObject({ chs_type: "test", }); expect((t[0] as ChsTrialDescription).timeline[1].data).toMatchObject({ chs_type: "test", }); }); });