Merge pull request #3039 from jspsych/simulation-mode-fixes

Fixes for simulation mode
This commit is contained in:
Josh de Leeuw 2023-05-17 15:56:33 -04:00 committed by GitHub
commit 61094d7d12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 521 additions and 39 deletions

View File

@ -0,0 +1,5 @@
---
"jspsych": patch
---
Fix error in how nested parameters were handled in simulation mode, #2911

View File

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

View File

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

View File

@ -0,0 +1,5 @@
---
"@jspsych/plugin-serial-reaction-time": patch
---
Fixed issue that caused `pre_target_duration` parameter to not work correctly

View File

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

View File

@ -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`.

View File

@ -0,0 +1,5 @@
---
"jspsych": patch
---
Fix target of simulation `dispatchEvent` so that simulation mode works with custom `display_element`

View File

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

View File

@ -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;
}
@ -656,16 +657,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
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
);
}
@ -678,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();
}

View File

@ -1,6 +1,11 @@
export class SimulationAPI {
constructor(
private getDisplayContainerElement: () => HTMLElement,
private setJsPsychTimeout: (callback: () => void, delay: number) => number
) {}
dispatchEvent(event: Event) {
document.body.dispatchEvent(event);
this.getDisplayContainerElement().dispatchEvent(event);
}
/**
@ -26,7 +31,7 @@ export class SimulationAPI {
*/
pressKey(key: string, delay = 0) {
if (delay > 0) {
setTimeout(() => {
this.setJsPsychTimeout(() => {
this.keyDown(key);
this.keyUp(key);
}, delay);
@ -43,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 }));
@ -63,7 +68,7 @@ export class SimulationAPI {
*/
fillTextInput(target: HTMLInputElement, text: string, delay = 0) {
if (delay > 0) {
setTimeout(() => {
this.setJsPsychTimeout(() => {
target.value = text;
}, delay);
} else {

View File

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

View File

@ -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(),
].map((object) => autoBind(object))
...[keyboardListenerAPI, timeoutAPI, mediaAPI, hardwareAPI, simulationAPI]
) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI;
}

View File

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

View File

@ -414,4 +414,200 @@ 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();
});
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");
});
test("Simulation mode set via string should work, #2912", async () => {
const simulation_options = {
default: {
simulate: false,
data: {
rt: 200,
},
},
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();
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 () => {
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();
});
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();
pressKey("a");
await expectFinished();
});
});

View File

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

View File

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

View File

@ -47,6 +47,8 @@ type Info = typeof info;
*/
class FullscreenPlugin implements JsPsychPlugin<Info> {
static info = info;
private rt = null;
private start_time = 0;
constructor(private jsPsych: JsPsych) {}
@ -73,9 +75,11 @@ class FullscreenPlugin implements JsPsychPlugin<Info> {
<button id="jspsych-fullscreen-btn" class="jspsych-btn">${trial.button_label}</button>
`;
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<Info> {
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<Info> {
}
private create_simulation_data(trial: TrialType<Info>, 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<Info> {
load_callback();
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector("#jspsych-fullscreen-btn"),
this.jsPsych.randomization.sampleExGaussian(1000, 100, 1 / 200, true)
data.rt
);
}
}

View File

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

View File

@ -258,23 +258,74 @@ class InstructionsPlugin implements JsPsychPlugin<Info> {
private create_simulation_data(trial: TrialType<Info>, 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,

View File

@ -216,7 +216,7 @@ class SerialReactionTimePlugin implements JsPsychPlugin<Info> {
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