mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-11 16:18:11 +00:00
Merge pull request #3039 from jspsych/simulation-mode-fixes
Fixes for simulation mode
This commit is contained in:
commit
61094d7d12
5
.changeset/clean-insects-juggle.md
Normal file
5
.changeset/clean-insects-juggle.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"jspsych": patch
|
||||
---
|
||||
|
||||
Fix error in how nested parameters were handled in simulation mode, #2911
|
5
.changeset/cuddly-mails-bathe.md
Normal file
5
.changeset/cuddly-mails-bathe.md
Normal 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
|
5
.changeset/few-badgers-rescue.md
Normal file
5
.changeset/few-badgers-rescue.md
Normal 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.
|
5
.changeset/funny-guests-rhyme.md
Normal file
5
.changeset/funny-guests-rhyme.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jspsych/plugin-serial-reaction-time": patch
|
||||
---
|
||||
|
||||
Fixed issue that caused `pre_target_duration` parameter to not work correctly
|
5
.changeset/mean-ads-clap.md
Normal file
5
.changeset/mean-ads-clap.md
Normal 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
|
5
.changeset/soft-cameras-lie.md
Normal file
5
.changeset/soft-cameras-lie.md
Normal 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`.
|
5
.changeset/three-kangaroos-speak.md
Normal file
5
.changeset/three-kangaroos-speak.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"jspsych": patch
|
||||
---
|
||||
|
||||
Fix target of simulation `dispatchEvent` so that simulation mode works with custom `display_element`
|
@ -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
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user