From a72a455fb432f35a88756c87ea6719dd634af56e Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 12:22:25 -0400 Subject: [PATCH 01/20] add test case for #3008 --- .../tests/core/simulation-mode.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index 3567d560..9a94abb2 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -414,4 +414,36 @@ describe("data simulation mode", () => { expect(getData().values().length).toBe(2); }); + + test("Custom display_element in initJsPsych does not prevent simulation events #3008", async () => { + const target = document.createElement("div"); + target.id = "target"; + document.body.appendChild(target); + + const jsPsych = initJsPsych({ + display_element: target, + }); + + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + }, + ]; + + const { expectRunning, expectFinished, getHTML } = await simulateTimeline( + timeline, + "visual", + {}, + jsPsych + ); + + await expectRunning(); + + expect(getHTML()).toContain("foo"); + + jest.runAllTimers(); + + await expectFinished(); + }); }); From 831fe2e39fe826e636fc46169f613f2766c47755 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 12:24:51 -0400 Subject: [PATCH 02/20] merge --- packages/jspsych/src/modules/plugin-api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jspsych/src/modules/plugin-api/index.ts b/packages/jspsych/src/modules/plugin-api/index.ts index 5da34514..a101ffed 100644 --- a/packages/jspsych/src/modules/plugin-api/index.ts +++ b/packages/jspsych/src/modules/plugin-api/index.ts @@ -20,7 +20,7 @@ export function createJointPluginAPIObject(jsPsych: JsPsych) { new TimeoutAPI(), new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context), new HardwareAPI(), - new SimulationAPI(), + new SimulationAPI(jsPsych.getDisplayContainerElement), ].map((object) => autoBind(object)) ) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI; } From 464b0d2387035654d2b89d492b1a27f7557761e0 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 12:27:14 -0400 Subject: [PATCH 03/20] fix #3008 --- packages/jspsych/src/modules/plugin-api/SimulationAPI.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts b/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts index 94288c16..43f5990f 100644 --- a/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts +++ b/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts @@ -1,6 +1,8 @@ export class SimulationAPI { + constructor(private getDisplayContainerElement: () => HTMLElement) {} + dispatchEvent(event: Event) { - document.body.dispatchEvent(event); + this.getDisplayContainerElement().dispatchEvent(event); } /** From 481efec07558fac786167a091dc2582c17cd0c5d Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 12:28:30 -0400 Subject: [PATCH 04/20] add changeset --- .changeset/three-kangaroos-speak.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/three-kangaroos-speak.md diff --git a/.changeset/three-kangaroos-speak.md b/.changeset/three-kangaroos-speak.md new file mode 100644 index 00000000..2293f8e7 --- /dev/null +++ b/.changeset/three-kangaroos-speak.md @@ -0,0 +1,5 @@ +--- +"jspsych": patch +--- + +Fix target of simulation `dispatchEvent` so that simulation mode works with custom `display_element` From d0a6839ac25d2ea6d2fcf40911820e476aa4077e Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 17:45:23 -0400 Subject: [PATCH 05/20] add a test for #2911 --- .../tests/core/simulation-mode.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index 9a94abb2..f148a455 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -446,4 +446,40 @@ describe("data simulation mode", () => { await expectFinished(); }); + + test("Data parameters should be merged when setting trial-level simulation options, #2911", async () => { + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + trial_duration: 1000, + response_ends_trial: true, + }, + { + type: htmlKeyboardResponse, + stimulus: "bar", + trial_duration: 1000, + response_ends_trial: true, + simulation_options: { + data: { + response: "a", + }, + }, + }, + ]; + + const { expectRunning, expectFinished, getData } = await simulateTimeline(timeline, "visual", { + default: { data: { rt: 200 } }, + }); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values(); + + expect(data[0].rt).toBe(200); + expect(data[1].rt).toBe(200); + expect(data[1].response).toBe("a"); + }); }); From 236cac8a21bbc7091226e23cc75ca962145d8c2b Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 21:35:20 -0400 Subject: [PATCH 06/20] add deepMerge utility function --- packages/jspsych/src/modules/utils.ts | 31 +++++++++++++ packages/jspsych/tests/utils/utils.test.ts | 52 +++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/jspsych/src/modules/utils.ts b/packages/jspsych/src/modules/utils.ts index 19dbdb20..15e6efd1 100644 --- a/packages/jspsych/src/modules/utils.ts +++ b/packages/jspsych/src/modules/utils.ts @@ -28,3 +28,34 @@ export function deepCopy(obj) { return obj; } } + +/** + * Merges two objects, recursively. + * @param obj1 Object to merge + * @param obj2 Object to merge + */ +export function deepMerge(obj1: any, obj2: any): any { + let merged = {}; + for (const key in obj1) { + if (obj1.hasOwnProperty(key)) { + if (typeof obj1[key] === "object" && obj2.hasOwnProperty(key)) { + merged[key] = deepMerge(obj1[key], obj2[key]); + } else { + merged[key] = obj1[key]; + } + } + } + for (const key in obj2) { + if (obj2.hasOwnProperty(key)) { + if (!merged.hasOwnProperty(key)) { + merged[key] = obj2[key]; + } else if (typeof obj2[key] === "object") { + merged[key] = deepMerge(merged[key], obj2[key]); + } else { + merged[key] = obj2[key]; + } + } + } + + return merged; +} diff --git a/packages/jspsych/tests/utils/utils.test.ts b/packages/jspsych/tests/utils/utils.test.ts index dac8bd36..e1227f96 100644 --- a/packages/jspsych/tests/utils/utils.test.ts +++ b/packages/jspsych/tests/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { deepCopy, unique } from "../../src/modules/utils"; +import { deepCopy, deepMerge, unique } from "../../src/modules/utils"; describe("unique", () => { test("generates unique array when there are duplicates", () => { @@ -44,3 +44,53 @@ describe("deepCopy", () => { expect(o2.b()).toBe(1); }); }); + +describe("deepMerge", () => { + it("should merge two objects with nested properties", () => { + const obj1 = { a: 1, b: { c: { d: 1 } } }; + const obj2 = { b: { c: { e: 2 } }, f: 3 }; + const expected = { a: 1, b: { c: { d: 1, e: 2 } }, f: 3 }; + const result = deepMerge(obj1, obj2); + expect(result).toEqual(expected); + }); + + it("should overwrite properties in obj1 with properties in obj2", () => { + const obj1 = { a: 1, b: { c: { d: 1 } } }; + const obj2 = { a: 2, b: { c: { d: 2 } } }; + const expected = { a: 2, b: { c: { d: 2 } } }; + const result = deepMerge(obj1, obj2); + expect(result).toEqual(expected); + }); + + it("should handle null and undefined values", () => { + const obj1 = { a: null, b: { c: undefined } }; + const obj2 = { a: 1, b: { c: { d: 1 } } }; + const expected = { a: 1, b: { c: { d: 1 } } }; + const result = deepMerge(obj1, obj2); + expect(result).toEqual(expected); + }); + + it("should handle empty objects", () => { + const obj1 = { a: 1, b: {} }; + const obj2 = { b: { c: 2 } }; + const expected = { a: 1, b: { c: 2 } }; + const result = deepMerge(obj1, obj2); + expect(result).toEqual(expected); + }); + + it("should handle when one property is an object and the corresponding property is not", () => { + const obj1 = { a: 1, b: { c: { d: 1 } } }; + const obj2 = { a: 2, b: 3 }; + const expected = { a: 2, b: 3 }; + const result = deepMerge(obj1, obj2); + expect(result).toEqual(expected); + }); + + it("should handle when one property is an object and the corresponding property is not, reversed", () => { + const obj1 = { a: 1, b: { c: { d: 1 } } }; + const obj2 = { a: 2, b: 3 }; + const expected = { a: 1, b: { c: { d: 1 } } }; + const result = deepMerge(obj2, obj1); + expect(result).toEqual(expected); + }); +}); From a5e5a21cab42e6bd09771294452838f35be103bf Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 21:35:58 -0400 Subject: [PATCH 07/20] fix #2911 --- packages/jspsych/src/JsPsych.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index 7463750d..389dffed 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -656,16 +656,23 @@ export class JsPsych { trial_sim_opts = trial.simulation_options; } } - trial_sim_opts = this.utils.deepCopy(trial_sim_opts); - trial_sim_opts = this.replaceFunctionsWithValues(trial_sim_opts, null); + // merge in default options that aren't overriden by the trial's simulation_options + // including nested parameters in the simulation_options + let trial_sim_opts_merged = this.utils.deepMerge( + this.simulation_options.default, + trial_sim_opts + ); - if (trial_sim_opts?.simulate === false) { + trial_sim_opts_merged = this.utils.deepCopy(trial_sim_opts_merged); + trial_sim_opts_merged = this.replaceFunctionsWithValues(trial_sim_opts_merged, null); + + if (trial_sim_opts_merged?.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, + trial_sim_opts_merged?.mode || this.simulation_mode, + trial_sim_opts_merged, load_callback ); } From 465527a849847a1728c983a582a3b38d5e66de25 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 16 May 2023 21:37:57 -0400 Subject: [PATCH 08/20] add changeset --- .changeset/clean-insects-juggle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clean-insects-juggle.md diff --git a/.changeset/clean-insects-juggle.md b/.changeset/clean-insects-juggle.md new file mode 100644 index 00000000..4a55d011 --- /dev/null +++ b/.changeset/clean-insects-juggle.md @@ -0,0 +1,5 @@ +--- +"jspsych": patch +--- + +Fix error in how nested parameters were handled in simulation mode, #2911 From b6d4b182789698c1d88ac95e096f5feff7545172 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 10:30:56 -0400 Subject: [PATCH 09/20] add test case for #2912 --- .../tests/core/simulation-mode.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index f148a455..3a6e9b83 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -482,4 +482,50 @@ describe("data simulation mode", () => { expect(data[1].rt).toBe(200); expect(data[1].response).toBe("a"); }); + + test("Simulation mode set via string should work, #2912", async () => { + const simulation_options = { + default: { + simulate: false, + }, + long_response: { + simulate: true, + }, + }; + + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + }, + { + type: htmlKeyboardResponse, + stimulus: "bar", + trial_duration: 1000, + simulation_options: "long_response", + }, + ]; + + const { expectRunning, expectFinished, getData, getHTML } = await simulateTimeline( + timeline, + "visual", + simulation_options + ); + + await expectRunning(); + + expect(getHTML()).toContain("foo"); + + jest.runAllTimers(); + + expect(getHTML()).toContain("foo"); + + pressKey("a"); + + expect(getHTML()).toContain("bar"); + + jest.runAllTimers(); + + await expectFinished(); + }); }); From 5f2b04976344d9b04eabc4a5fe7c7bd859bc4506 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 10:47:54 -0400 Subject: [PATCH 10/20] add failing test for #2862 --- .../tests/core/simulation-mode.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index 3a6e9b83..18e5c604 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -528,4 +528,51 @@ describe("data simulation mode", () => { await expectFinished(); }); + + test("Simulation timeouts are handled correctly when user interacts with simulation, #2862", async () => { + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + simulation_options: { + data: { + rt: 1000, + }, + }, + }, + { + type: htmlKeyboardResponse, + stimulus: "bar", + simulation_options: { + data: { + rt: 1000, + }, + }, + }, + ]; + + const { expectRunning, expectFinished, getHTML } = await simulateTimeline(timeline, "visual"); + + await expectRunning(); + + expect(getHTML()).toContain("foo"); + + jest.advanceTimersByTime(500); + + expect(getHTML()).toContain("foo"); + + pressKey("a"); // this is the user responding instead of letting the simulation handle it. + + expect(getHTML()).toContain("bar"); + + jest.advanceTimersByTime(800); + + // if the timeout from the first trial is blocked, this trial shouldn't finish yet. + expect(getHTML()).toContain("bar"); + + // this should be the end of the experiment + jest.advanceTimersByTime(201); + + await expectFinished(); + }); }); From 1032c71eef86c41f3d4d5697b33ceb58c81354bb Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 11:11:56 -0400 Subject: [PATCH 11/20] fix #2862 --- .../src/modules/plugin-api/SimulationAPI.ts | 11 +++++--- .../src/modules/plugin-api/TimeoutAPI.ts | 18 ++++++++++--- .../jspsych/src/modules/plugin-api/index.ts | 25 +++++++++++-------- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts b/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts index 43f5990f..124a5905 100644 --- a/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts +++ b/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts @@ -1,5 +1,8 @@ export class SimulationAPI { - constructor(private getDisplayContainerElement: () => HTMLElement) {} + constructor( + private getDisplayContainerElement: () => HTMLElement, + private setJsPsychTimeout: (callback: () => void, delay: number) => number + ) {} dispatchEvent(event: Event) { this.getDisplayContainerElement().dispatchEvent(event); @@ -28,7 +31,7 @@ export class SimulationAPI { */ pressKey(key: string, delay = 0) { if (delay > 0) { - setTimeout(() => { + this.setJsPsychTimeout(() => { this.keyDown(key); this.keyUp(key); }, delay); @@ -45,7 +48,7 @@ export class SimulationAPI { */ clickTarget(target: Element, delay = 0) { if (delay > 0) { - setTimeout(() => { + this.setJsPsychTimeout(() => { target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); target.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -65,7 +68,7 @@ export class SimulationAPI { */ fillTextInput(target: HTMLInputElement, text: string, delay = 0) { if (delay > 0) { - setTimeout(() => { + this.setJsPsychTimeout(() => { target.value = text; }, delay); } else { diff --git a/packages/jspsych/src/modules/plugin-api/TimeoutAPI.ts b/packages/jspsych/src/modules/plugin-api/TimeoutAPI.ts index 913aca23..aebec16b 100644 --- a/packages/jspsych/src/modules/plugin-api/TimeoutAPI.ts +++ b/packages/jspsych/src/modules/plugin-api/TimeoutAPI.ts @@ -1,13 +1,25 @@ +/** + * A class that provides a wrapper around the global setTimeout and clearTimeout functions. + */ export class TimeoutAPI { - private timeout_handlers = []; + private timeout_handlers: number[] = []; - setTimeout(callback, delay) { + /** + * Calls a function after a specified delay, in milliseconds. + * @param callback The function to call after the delay. + * @param delay The number of milliseconds to wait before calling the function. + * @returns A handle that can be used to clear the timeout with clearTimeout. + */ + setTimeout(callback: () => void, delay: number): number { const handle = window.setTimeout(callback, delay); this.timeout_handlers.push(handle); return handle; } - clearAllTimeouts() { + /** + * Clears all timeouts that have been created with setTimeout. + */ + clearAllTimeouts(): void { for (const handler of this.timeout_handlers) { clearTimeout(handler); } diff --git a/packages/jspsych/src/modules/plugin-api/index.ts b/packages/jspsych/src/modules/plugin-api/index.ts index a101ffed..fc66a500 100644 --- a/packages/jspsych/src/modules/plugin-api/index.ts +++ b/packages/jspsych/src/modules/plugin-api/index.ts @@ -9,19 +9,22 @@ import { TimeoutAPI } from "./TimeoutAPI"; export function createJointPluginAPIObject(jsPsych: JsPsych) { const settings = jsPsych.getInitSettings(); + const keyboardListenerAPI = autoBind( + new KeyboardListenerAPI( + jsPsych.getDisplayContainerElement, + settings.case_sensitive_responses, + settings.minimum_valid_rt + ) + ); + const timeoutAPI = autoBind(new TimeoutAPI()); + const mediaAPI = autoBind(new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context)); + const hardwareAPI = autoBind(new HardwareAPI()); + const simulationAPI = autoBind( + new SimulationAPI(jsPsych.getDisplayContainerElement, timeoutAPI.setTimeout) + ); return Object.assign( {}, - ...[ - new KeyboardListenerAPI( - jsPsych.getDisplayContainerElement, - settings.case_sensitive_responses, - settings.minimum_valid_rt - ), - new TimeoutAPI(), - new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context), - new HardwareAPI(), - new SimulationAPI(jsPsych.getDisplayContainerElement), - ].map((object) => autoBind(object)) + ...[keyboardListenerAPI, timeoutAPI, mediaAPI, hardwareAPI, simulationAPI] ) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI; } From 852d57451668aec2975b3f8a0313409bdf332bbb Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 11:13:20 -0400 Subject: [PATCH 12/20] add changeset --- .changeset/few-badgers-rescue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/few-badgers-rescue.md diff --git a/.changeset/few-badgers-rescue.md b/.changeset/few-badgers-rescue.md new file mode 100644 index 00000000..95dba77c --- /dev/null +++ b/.changeset/few-badgers-rescue.md @@ -0,0 +1,5 @@ +--- +"jspsych": patch +--- + +Fixed how simulation mode handles `setTimeout` calls to ensure that timeouts are cleared at the end of a trial, even in cases where the user interacts with a simulated trial when the simulation is being run in `visual` mode. From 952cdf5b126f6d4058c86e59f50e96c1778bd7f9 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 11:25:57 -0400 Subject: [PATCH 13/20] Fixed `pre_target_duration` parameter --- .changeset/funny-guests-rhyme.md | 5 +++++ packages/plugin-serial-reaction-time/src/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/funny-guests-rhyme.md diff --git a/.changeset/funny-guests-rhyme.md b/.changeset/funny-guests-rhyme.md new file mode 100644 index 00000000..60e5f774 --- /dev/null +++ b/.changeset/funny-guests-rhyme.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-serial-reaction-time": patch +--- + +Fixed issue that caused `pre_target_duration` parameter to not work correctly diff --git a/packages/plugin-serial-reaction-time/src/index.ts b/packages/plugin-serial-reaction-time/src/index.ts index 0b092de2..c605832d 100644 --- a/packages/plugin-serial-reaction-time/src/index.ts +++ b/packages/plugin-serial-reaction-time/src/index.ts @@ -216,7 +216,7 @@ class SerialReactionTimePlugin implements JsPsychPlugin { if (trial.pre_target_duration <= 0) { showTarget(); } else { - this.jsPsych.pluginAPI.setTimeout(showTarget(), trial.pre_target_duration); + this.jsPsych.pluginAPI.setTimeout(showTarget, trial.pre_target_duration); } //show prompt if there is one From e0026a3f87b16a31627581e6a967aa11961784e5 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 11:38:05 -0400 Subject: [PATCH 14/20] add failing test for #2859 --- .../tests/core/simulation-mode.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index 18e5c604..2e22417f 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -575,4 +575,29 @@ describe("data simulation mode", () => { await expectFinished(); }); + + test("`on_load` function should be called when in simulation mode and `simulate` is `false`, #2859", async () => { + const on_load = jest.fn(); + + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + simulation_options: { + simulate: false, + }, + on_load, + }, + ]; + + const { expectRunning, expectFinished } = await simulateTimeline(timeline, "visual"); + + await expectRunning(); + + expect(on_load).toHaveBeenCalled(); + + jest.runAllTimers(); + + await expectFinished(); + }); }); From 7dd439fdcc0b2c0d478e209c50ced31e5e822994 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 12:10:43 -0400 Subject: [PATCH 15/20] fix test case, need to pressKey because not simulation --- packages/jspsych/tests/core/simulation-mode.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index 2e22417f..f7eba294 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -596,7 +596,7 @@ describe("data simulation mode", () => { expect(on_load).toHaveBeenCalled(); - jest.runAllTimers(); + pressKey("a"); await expectFinished(); }); From c5d3925a179e244128eb8efc34416bc1f6ab38f4 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 12:11:04 -0400 Subject: [PATCH 16/20] fix #2859 --- packages/jspsych/src/JsPsych.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index 389dffed..b1395318 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -627,13 +627,14 @@ export class JsPsych { }; let trial_complete; + let trial_sim_opts; + let trial_sim_opts_merged; 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; } @@ -658,7 +659,7 @@ export class JsPsych { } // merge in default options that aren't overriden by the trial's simulation_options // including nested parameters in the simulation_options - let trial_sim_opts_merged = this.utils.deepMerge( + trial_sim_opts_merged = this.utils.deepMerge( this.simulation_options.default, trial_sim_opts ); @@ -685,8 +686,13 @@ export class JsPsych { // 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) { + // in simulation mode we let the simulate function call the load_callback always, + // so we don't need to call it here. however, if we are in simulation mode but not simulating + // this particular trial we need to call it. + if ( + !is_promise && + (!this.simulation_mode || (this.simulation_mode && trial_sim_opts_merged?.simulate === false)) + ) { load_callback(); } From 612d9e125f9002c1ba383a4fafe4123bdfef0f17 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 12:12:40 -0400 Subject: [PATCH 17/20] add changeset --- .changeset/soft-cameras-lie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-cameras-lie.md diff --git a/.changeset/soft-cameras-lie.md b/.changeset/soft-cameras-lie.md new file mode 100644 index 00000000..71f4e6bf --- /dev/null +++ b/.changeset/soft-cameras-lie.md @@ -0,0 +1,5 @@ +--- +"jspsych": patch +--- + +Fixed issue where a trial's `on_load` was not called when using simulation mode but setting a trial's `simulate` option to `false`. From 347bbb557c4cf954e471e8d66188db0b28e364be Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 13:11:49 -0400 Subject: [PATCH 18/20] fix #2462 --- .changeset/cuddly-mails-bathe.md | 5 ++ .../plugin-instructions/src/index.spec.ts | 59 +++++++++++++++ packages/plugin-instructions/src/index.ts | 71 ++++++++++++++++--- 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 .changeset/cuddly-mails-bathe.md diff --git a/.changeset/cuddly-mails-bathe.md b/.changeset/cuddly-mails-bathe.md new file mode 100644 index 00000000..a34fc673 --- /dev/null +++ b/.changeset/cuddly-mails-bathe.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-instructions": patch +--- + +Fix simulation mode behavior so that setting RT and/or view_history correctly sets the other parameter diff --git a/packages/plugin-instructions/src/index.spec.ts b/packages/plugin-instructions/src/index.spec.ts index 2480b3df..d43b4c92 100644 --- a/packages/plugin-instructions/src/index.spec.ts +++ b/packages/plugin-instructions/src/index.spec.ts @@ -104,4 +104,63 @@ describe("instructions plugin simulation", () => { expect(data.view_history.length).toBeGreaterThanOrEqual(6); expect(data.view_history[data.view_history.length - 1].page_index).toBe(5); }); + + test("Setting RT correctly sets the total length of the trial, #2462", async () => { + const timeline = [ + { + type: instructions, + pages: ["page 1", "page 2", "page 3"], + simulation_options: { + data: { + rt: 4000, + }, + }, + }, + ]; + + const { getData, expectFinished } = await simulateTimeline(timeline); + + await expectFinished(); + + const data = getData().values()[0]; + + console.log(data.view_history); + + expect(data.rt).toBe(4000); + + let sum_view_history_rt = 0; + for (const view of data.view_history) { + sum_view_history_rt += view.viewing_time; + } + + // this may not be exactly 4000 due to rounding errors + + expect(Math.abs(sum_view_history_rt - 4000)).toBeLessThan(10); + }); + + test("Setting view history correctly sets the total RT, #2462", async () => { + const timeline = [ + { + type: instructions, + pages: ["page 1", "page 2", "page 3"], + simulation_options: { + data: { + view_history: [ + { page_index: 0, viewing_time: 1000 }, + { page_index: 1, viewing_time: 1000 }, + { page_index: 2, viewing_time: 1000 }, + ], + }, + }, + }, + ]; + + const { getData, expectFinished } = await simulateTimeline(timeline); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(data.rt).toBe(3000); + }); }); diff --git a/packages/plugin-instructions/src/index.ts b/packages/plugin-instructions/src/index.ts index 832944c6..3dbb25e2 100644 --- a/packages/plugin-instructions/src/index.ts +++ b/packages/plugin-instructions/src/index.ts @@ -258,23 +258,74 @@ class InstructionsPlugin implements JsPsychPlugin { private create_simulation_data(trial: TrialType, simulation_options) { let curr_page = 0; let rt = 0; - const view_history = []; + let view_history = []; - while (curr_page !== trial.pages.length) { - const view_time = this.jsPsych.randomization.sampleExGaussian(3000, 300, 1 / 300); - view_history.push({ page_index: curr_page, viewing_time: view_time }); - rt += view_time; - if (curr_page == 0 || !trial.allow_backward) { - curr_page++; - } else { - if (this.jsPsych.randomization.sampleBernoulli(0.9) == 1) { + // if there is no view history and no RT, simulate a random walk through the pages + if (!simulation_options.data?.view_history && !simulation_options.data?.rt) { + while (curr_page !== trial.pages.length) { + const view_time = Math.round( + this.jsPsych.randomization.sampleExGaussian(3000, 300, 1 / 300) + ); + view_history.push({ page_index: curr_page, viewing_time: view_time }); + rt += view_time; + if (curr_page == 0 || !trial.allow_backward) { curr_page++; } else { - curr_page--; + if (this.jsPsych.randomization.sampleBernoulli(0.9) == 1) { + curr_page++; + } else { + curr_page--; + } } } } + // if there is an RT but no view history, simulate a random walk through the pages + // that ends on the final page when the RT is reached + if (!simulation_options.data?.view_history && simulation_options.data?.rt) { + rt = simulation_options.data.rt; + while (curr_page !== trial.pages.length) { + view_history.push({ page_index: curr_page, viewing_time: null }); + if (curr_page == 0 || !trial.allow_backward) { + curr_page++; + } else { + if (this.jsPsych.randomization.sampleBernoulli(0.9) == 1) { + curr_page++; + } else { + curr_page--; + } + } + } + const avg_rt_per_page = simulation_options.data.rt / view_history.length; + let total_time = 0; + for (const page of view_history) { + const t = Math.round( + this.jsPsych.randomization.sampleExGaussian( + avg_rt_per_page, + avg_rt_per_page / 10, + 1 / (avg_rt_per_page / 10) + ) + ); + page.viewing_time = t; + total_time += t; + } + const diff = simulation_options.data.rt - total_time; + // remove equal diff from each page + const diff_per_page = Math.round(diff / view_history.length); + for (const page of view_history) { + page.viewing_time += diff_per_page; + } + } + + // if there is a view history but no RT, make the RT equal the sum of the view history + if (simulation_options.data?.view_history && !simulation_options.data?.rt) { + view_history = simulation_options.data.view_history; + rt = 0; + for (const page of simulation_options.data.view_history) { + rt += page.viewing_time; + } + } + const default_data = { view_history: view_history, rt: rt, From 09ee347fa55187c7d8d67e8961731664de684532 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 14:00:04 -0400 Subject: [PATCH 19/20] Add RT property to fullscreen data collection. Support setting this property in simulation mode. #2462 --- .changeset/mean-ads-clap.md | 5 +++++ docs/plugins/fullscreen.md | 1 + packages/plugin-fullscreen/src/index.spec.ts | 18 ++++++++++++++++++ packages/plugin-fullscreen/src/index.ts | 10 +++++++++- 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .changeset/mean-ads-clap.md diff --git a/.changeset/mean-ads-clap.md b/.changeset/mean-ads-clap.md new file mode 100644 index 00000000..fd254593 --- /dev/null +++ b/.changeset/mean-ads-clap.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-fullscreen": minor +--- + +Plugin now records RT of the button press to launch fullscreen mode and simulation mode supports setting this property diff --git a/docs/plugins/fullscreen.md b/docs/plugins/fullscreen.md index e056dc82..6a5a039a 100644 --- a/docs/plugins/fullscreen.md +++ b/docs/plugins/fullscreen.md @@ -25,6 +25,7 @@ In addition to the [default data collected by all plugins](../overview/plugins.m Name | Type | Value -----|------|------ success | boolean | true if the browser supports fullscreen mode (i.e., is not Safari) +rt | number | Response time to click the button that launches fullscreen mode ## Simulation Mode diff --git a/packages/plugin-fullscreen/src/index.spec.ts b/packages/plugin-fullscreen/src/index.spec.ts index 3a3a4828..19f0af7d 100644 --- a/packages/plugin-fullscreen/src/index.spec.ts +++ b/packages/plugin-fullscreen/src/index.spec.ts @@ -23,6 +23,23 @@ describe("fullscreen plugin", () => { clickTarget(document.querySelector("#jspsych-fullscreen-btn")); expect(document.documentElement.requestFullscreen).toHaveBeenCalled(); }); + + test("records RT of click", async () => { + const { getData, expectFinished } = await startTimeline([ + { + type: fullscreen, + delay_after: 0, + }, + ]); + + expect(document.documentElement.requestFullscreen).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + clickTarget(document.querySelector("#jspsych-fullscreen-btn")); + expect(document.documentElement.requestFullscreen).toHaveBeenCalled(); + jest.runAllTimers(); + await expectFinished(); + expect(getData().values()[0].rt).toBeGreaterThanOrEqual(1000); + }); }); describe("fullscreen plugin simulation", () => { @@ -63,5 +80,6 @@ describe("fullscreen plugin simulation", () => { await expectFinished(); expect(getData().values()[0].success).toBe(true); + expect(getData().values()[0].rt).toBeGreaterThan(0); }); }); diff --git a/packages/plugin-fullscreen/src/index.ts b/packages/plugin-fullscreen/src/index.ts index 2d0e638e..4978d8df 100644 --- a/packages/plugin-fullscreen/src/index.ts +++ b/packages/plugin-fullscreen/src/index.ts @@ -47,6 +47,8 @@ type Info = typeof info; */ class FullscreenPlugin implements JsPsychPlugin { static info = info; + private rt = null; + private start_time = 0; constructor(private jsPsych: JsPsych) {} @@ -73,9 +75,11 @@ class FullscreenPlugin implements JsPsychPlugin { `; display_element.querySelector("#jspsych-fullscreen-btn").addEventListener("click", () => { + this.rt = Math.round(performance.now() - this.start_time); this.enterFullScreen(); this.endTrial(display_element, true, trial); }); + this.start_time = performance.now(); } private endTrial(display_element, success, trial) { @@ -84,6 +88,7 @@ class FullscreenPlugin implements JsPsychPlugin { this.jsPsych.pluginAPI.setTimeout(() => { var trial_data = { success: success, + rt: this.rt, }; this.jsPsych.finishTrial(trial_data); @@ -137,8 +142,11 @@ class FullscreenPlugin implements JsPsychPlugin { } private create_simulation_data(trial: TrialType, simulation_options) { + const rt = this.jsPsych.randomization.sampleExGaussian(1000, 100, 1 / 200, true); + const default_data = { success: true, + rt: rt, }; const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); @@ -164,7 +172,7 @@ class FullscreenPlugin implements JsPsychPlugin { load_callback(); this.jsPsych.pluginAPI.clickTarget( display_element.querySelector("#jspsych-fullscreen-btn"), - this.jsPsych.randomization.sampleExGaussian(1000, 100, 1 / 200, true) + data.rt ); } } From 545ecbaa76a6dd3762ab418770b1c4b94c53f358 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Wed, 17 May 2023 15:42:39 -0400 Subject: [PATCH 20/20] update test case --- packages/jspsych/tests/core/simulation-mode.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts index f7eba294..da1a3b03 100644 --- a/packages/jspsych/tests/core/simulation-mode.test.ts +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -487,6 +487,9 @@ describe("data simulation mode", () => { const simulation_options = { default: { simulate: false, + data: { + rt: 200, + }, }, long_response: { simulate: true, @@ -527,6 +530,13 @@ describe("data simulation mode", () => { jest.runAllTimers(); await expectFinished(); + + const data = getData().values()[1]; + + console.log(data); + + expect(data.rt).toBeGreaterThan(0); + expect(data.response).toBeDefined(); }); test("Simulation timeouts are handled correctly when user interacts with simulation, #2862", async () => {