mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-12 16:48:12 +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
|
Name | Type | Value
|
||||||
-----|------|------
|
-----|------|------
|
||||||
success | boolean | true if the browser supports fullscreen mode (i.e., is not Safari)
|
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
|
## Simulation Mode
|
||||||
|
|
||||||
|
@ -627,13 +627,14 @@ export class JsPsych {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let trial_complete;
|
let trial_complete;
|
||||||
|
let trial_sim_opts;
|
||||||
|
let trial_sim_opts_merged;
|
||||||
if (!this.simulation_mode) {
|
if (!this.simulation_mode) {
|
||||||
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
|
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
|
||||||
}
|
}
|
||||||
if (this.simulation_mode) {
|
if (this.simulation_mode) {
|
||||||
// check if the trial supports simulation
|
// check if the trial supports simulation
|
||||||
if (trial.type.simulate) {
|
if (trial.type.simulate) {
|
||||||
let trial_sim_opts;
|
|
||||||
if (!trial.simulation_options) {
|
if (!trial.simulation_options) {
|
||||||
trial_sim_opts = this.simulation_options.default;
|
trial_sim_opts = this.simulation_options.default;
|
||||||
}
|
}
|
||||||
@ -656,16 +657,23 @@ export class JsPsych {
|
|||||||
trial_sim_opts = trial.simulation_options;
|
trial_sim_opts = trial.simulation_options;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trial_sim_opts = this.utils.deepCopy(trial_sim_opts);
|
// merge in default options that aren't overriden by the trial's simulation_options
|
||||||
trial_sim_opts = this.replaceFunctionsWithValues(trial_sim_opts, null);
|
// 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);
|
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
|
||||||
} else {
|
} else {
|
||||||
trial_complete = trial.type.simulate(
|
trial_complete = trial.type.simulate(
|
||||||
trial,
|
trial,
|
||||||
trial_sim_opts?.mode || this.simulation_mode,
|
trial_sim_opts_merged?.mode || this.simulation_mode,
|
||||||
trial_sim_opts,
|
trial_sim_opts_merged,
|
||||||
load_callback
|
load_callback
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -678,8 +686,13 @@ export class JsPsych {
|
|||||||
// see if trial_complete is a Promise by looking for .then() function
|
// see if trial_complete is a Promise by looking for .then() function
|
||||||
const is_promise = trial_complete && typeof trial_complete.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.
|
// in simulation mode we let the simulate function call the load_callback always,
|
||||||
if (!is_promise && !this.simulation_mode) {
|
// 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();
|
load_callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
export class SimulationAPI {
|
export class SimulationAPI {
|
||||||
|
constructor(
|
||||||
|
private getDisplayContainerElement: () => HTMLElement,
|
||||||
|
private setJsPsychTimeout: (callback: () => void, delay: number) => number
|
||||||
|
) {}
|
||||||
|
|
||||||
dispatchEvent(event: Event) {
|
dispatchEvent(event: Event) {
|
||||||
document.body.dispatchEvent(event);
|
this.getDisplayContainerElement().dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,7 +31,7 @@ export class SimulationAPI {
|
|||||||
*/
|
*/
|
||||||
pressKey(key: string, delay = 0) {
|
pressKey(key: string, delay = 0) {
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(() => {
|
this.setJsPsychTimeout(() => {
|
||||||
this.keyDown(key);
|
this.keyDown(key);
|
||||||
this.keyUp(key);
|
this.keyUp(key);
|
||||||
}, delay);
|
}, delay);
|
||||||
@ -43,7 +48,7 @@ export class SimulationAPI {
|
|||||||
*/
|
*/
|
||||||
clickTarget(target: Element, delay = 0) {
|
clickTarget(target: Element, delay = 0) {
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(() => {
|
this.setJsPsychTimeout(() => {
|
||||||
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||||
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||||
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
@ -63,7 +68,7 @@ export class SimulationAPI {
|
|||||||
*/
|
*/
|
||||||
fillTextInput(target: HTMLInputElement, text: string, delay = 0) {
|
fillTextInput(target: HTMLInputElement, text: string, delay = 0) {
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(() => {
|
this.setJsPsychTimeout(() => {
|
||||||
target.value = text;
|
target.value = text;
|
||||||
}, delay);
|
}, delay);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,13 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* A class that provides a wrapper around the global setTimeout and clearTimeout functions.
|
||||||
|
*/
|
||||||
export class TimeoutAPI {
|
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);
|
const handle = window.setTimeout(callback, delay);
|
||||||
this.timeout_handlers.push(handle);
|
this.timeout_handlers.push(handle);
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAllTimeouts() {
|
/**
|
||||||
|
* Clears all timeouts that have been created with setTimeout.
|
||||||
|
*/
|
||||||
|
clearAllTimeouts(): void {
|
||||||
for (const handler of this.timeout_handlers) {
|
for (const handler of this.timeout_handlers) {
|
||||||
clearTimeout(handler);
|
clearTimeout(handler);
|
||||||
}
|
}
|
||||||
|
@ -9,19 +9,22 @@ import { TimeoutAPI } from "./TimeoutAPI";
|
|||||||
|
|
||||||
export function createJointPluginAPIObject(jsPsych: JsPsych) {
|
export function createJointPluginAPIObject(jsPsych: JsPsych) {
|
||||||
const settings = jsPsych.getInitSettings();
|
const settings = jsPsych.getInitSettings();
|
||||||
return Object.assign(
|
const keyboardListenerAPI = autoBind(
|
||||||
{},
|
|
||||||
...[
|
|
||||||
new KeyboardListenerAPI(
|
new KeyboardListenerAPI(
|
||||||
jsPsych.getDisplayContainerElement,
|
jsPsych.getDisplayContainerElement,
|
||||||
settings.case_sensitive_responses,
|
settings.case_sensitive_responses,
|
||||||
settings.minimum_valid_rt
|
settings.minimum_valid_rt
|
||||||
),
|
)
|
||||||
new TimeoutAPI(),
|
);
|
||||||
new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context),
|
const timeoutAPI = autoBind(new TimeoutAPI());
|
||||||
new HardwareAPI(),
|
const mediaAPI = autoBind(new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context));
|
||||||
new SimulationAPI(),
|
const hardwareAPI = autoBind(new HardwareAPI());
|
||||||
].map((object) => autoBind(object))
|
const simulationAPI = autoBind(
|
||||||
|
new SimulationAPI(jsPsych.getDisplayContainerElement, timeoutAPI.setTimeout)
|
||||||
|
);
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
...[keyboardListenerAPI, timeoutAPI, mediaAPI, hardwareAPI, simulationAPI]
|
||||||
) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI;
|
) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,3 +28,34 @@ export function deepCopy(obj) {
|
|||||||
return 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);
|
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", () => {
|
describe("unique", () => {
|
||||||
test("generates unique array when there are duplicates", () => {
|
test("generates unique array when there are duplicates", () => {
|
||||||
@ -44,3 +44,53 @@ describe("deepCopy", () => {
|
|||||||
expect(o2.b()).toBe(1);
|
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"));
|
clickTarget(document.querySelector("#jspsych-fullscreen-btn"));
|
||||||
expect(document.documentElement.requestFullscreen).toHaveBeenCalled();
|
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", () => {
|
describe("fullscreen plugin simulation", () => {
|
||||||
@ -63,5 +80,6 @@ describe("fullscreen plugin simulation", () => {
|
|||||||
await expectFinished();
|
await expectFinished();
|
||||||
|
|
||||||
expect(getData().values()[0].success).toBe(true);
|
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> {
|
class FullscreenPlugin implements JsPsychPlugin<Info> {
|
||||||
static info = info;
|
static info = info;
|
||||||
|
private rt = null;
|
||||||
|
private start_time = 0;
|
||||||
|
|
||||||
constructor(private jsPsych: JsPsych) {}
|
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>
|
<button id="jspsych-fullscreen-btn" class="jspsych-btn">${trial.button_label}</button>
|
||||||
`;
|
`;
|
||||||
display_element.querySelector("#jspsych-fullscreen-btn").addEventListener("click", () => {
|
display_element.querySelector("#jspsych-fullscreen-btn").addEventListener("click", () => {
|
||||||
|
this.rt = Math.round(performance.now() - this.start_time);
|
||||||
this.enterFullScreen();
|
this.enterFullScreen();
|
||||||
this.endTrial(display_element, true, trial);
|
this.endTrial(display_element, true, trial);
|
||||||
});
|
});
|
||||||
|
this.start_time = performance.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
private endTrial(display_element, success, trial) {
|
private endTrial(display_element, success, trial) {
|
||||||
@ -84,6 +88,7 @@ class FullscreenPlugin implements JsPsychPlugin<Info> {
|
|||||||
this.jsPsych.pluginAPI.setTimeout(() => {
|
this.jsPsych.pluginAPI.setTimeout(() => {
|
||||||
var trial_data = {
|
var trial_data = {
|
||||||
success: success,
|
success: success,
|
||||||
|
rt: this.rt,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.jsPsych.finishTrial(trial_data);
|
this.jsPsych.finishTrial(trial_data);
|
||||||
@ -137,8 +142,11 @@ class FullscreenPlugin implements JsPsychPlugin<Info> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
|
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
|
||||||
|
const rt = this.jsPsych.randomization.sampleExGaussian(1000, 100, 1 / 200, true);
|
||||||
|
|
||||||
const default_data = {
|
const default_data = {
|
||||||
success: true,
|
success: true,
|
||||||
|
rt: rt,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
|
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
|
||||||
@ -164,7 +172,7 @@ class FullscreenPlugin implements JsPsychPlugin<Info> {
|
|||||||
load_callback();
|
load_callback();
|
||||||
this.jsPsych.pluginAPI.clickTarget(
|
this.jsPsych.pluginAPI.clickTarget(
|
||||||
display_element.querySelector("#jspsych-fullscreen-btn"),
|
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.length).toBeGreaterThanOrEqual(6);
|
||||||
expect(data.view_history[data.view_history.length - 1].page_index).toBe(5);
|
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,10 +258,14 @@ class InstructionsPlugin implements JsPsychPlugin<Info> {
|
|||||||
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
|
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
|
||||||
let curr_page = 0;
|
let curr_page = 0;
|
||||||
let rt = 0;
|
let rt = 0;
|
||||||
const view_history = [];
|
let view_history = [];
|
||||||
|
|
||||||
|
// 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) {
|
while (curr_page !== trial.pages.length) {
|
||||||
const view_time = this.jsPsych.randomization.sampleExGaussian(3000, 300, 1 / 300);
|
const view_time = Math.round(
|
||||||
|
this.jsPsych.randomization.sampleExGaussian(3000, 300, 1 / 300)
|
||||||
|
);
|
||||||
view_history.push({ page_index: curr_page, viewing_time: view_time });
|
view_history.push({ page_index: curr_page, viewing_time: view_time });
|
||||||
rt += view_time;
|
rt += view_time;
|
||||||
if (curr_page == 0 || !trial.allow_backward) {
|
if (curr_page == 0 || !trial.allow_backward) {
|
||||||
@ -274,6 +278,53 @@ class InstructionsPlugin implements JsPsychPlugin<Info> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = {
|
const default_data = {
|
||||||
view_history: view_history,
|
view_history: view_history,
|
||||||
|
@ -216,7 +216,7 @@ class SerialReactionTimePlugin implements JsPsychPlugin<Info> {
|
|||||||
if (trial.pre_target_duration <= 0) {
|
if (trial.pre_target_duration <= 0) {
|
||||||
showTarget();
|
showTarget();
|
||||||
} else {
|
} 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
|
//show prompt if there is one
|
||||||
|
Loading…
Reference in New Issue
Block a user