Merge branch 'main' into plugin-audio-input-plugins

This commit is contained in:
Josh de Leeuw 2021-11-23 15:13:27 -05:00
commit fdd2ebb0b6
133 changed files with 8452 additions and 493 deletions

View File

@ -0,0 +1,5 @@
---
"@jspsych/test-utils": minor
---
Add `simulateTimeline()` testing utility that mimics startTimeline but calls `jsPsych.simulate()` instead.

View File

@ -0,0 +1,5 @@
---
"jspsych": patch
---
The weights argument for `randomization.sampleWithReplacement()` is now explicitly marked as optional in TypeScript. This has no impact on usage, as the implementation was already treating this argument as optional.

View File

@ -0,0 +1,5 @@
---
"@jspsych/plugin-animation": patch
---
Fixed a bug that caused a crash when `frame_isi` was > 0. This bug was introduced in 1.0.0.

View File

@ -0,0 +1,5 @@
---
"jspsych": minor
---
Added `randomInt(lower, upper)`, `sampleBernoulli(p)`, `sampleNormal(mean, std)`, `sampleExponential(rate)`, and `sampleExGaussian(mean, std, rate, positive=false)` to `jsPsych.randomization`.

View File

@ -0,0 +1,5 @@
---
"jspsych": minor
---
Added the ability to run the experiment in simulation mode using `jsPsych.simulate()`. See the [simulation mode](https://www.jspsych.org/latest/overview/simulation) documentation for information about how to get started.

View File

@ -0,0 +1,5 @@
---
"jspsych": minor
---
Added methods to assist with simulation (e.g., `pressKey` for dispatching a keyboard event and `clickTarget` for dispatching a click event) to the PluginAPI module.

View File

@ -0,0 +1,5 @@
---
"@jspsych/plugin-canvas-button-response": patch
---
Fixed a bug that resulted in `data.response` being `NaN` instead of the index of the button.

View File

@ -0,0 +1,44 @@
---
"@jspsych/plugin-animation": minor
"@jspsych/plugin-audio-button-response": minor
"@jspsych/plugin-audio-keyboard-response": minor
"@jspsych/plugin-audio-slider-response": minor
"@jspsych/plugin-browser-check": minor
"@jspsych/plugin-call-function": minor
"@jspsych/plugin-canvas-button-response": minor
"@jspsych/plugin-canvas-keyboard-response": minor
"@jspsych/plugin-canvas-slider-response": minor
"@jspsych/plugin-categorize-animation": minor
"@jspsych/plugin-categorize-html": minor
"@jspsych/plugin-categorize-image": minor
"@jspsych/plugin-cloze": minor
"@jspsych/plugin-external-html": minor
"@jspsych/plugin-fullscreen": minor
"@jspsych/plugin-html-button-response": minor
"@jspsych/plugin-html-keyboard-response": minor
"@jspsych/plugin-html-slider-response": minor
"@jspsych/plugin-iat-html": minor
"@jspsych/plugin-iat-image": minor
"@jspsych/plugin-image-button-response": minor
"@jspsych/plugin-image-keyboard-response": minor
"@jspsych/plugin-image-slider-response": minor
"@jspsych/plugin-instructions": minor
"@jspsych/plugin-maxdiff": minor
"@jspsych/plugin-preload": minor
"@jspsych/plugin-reconstruction": minor
"@jspsych/plugin-same-different-html": minor
"@jspsych/plugin-same-different-image": minor
"@jspsych/plugin-serial-reaction-time": minor
"@jspsych/plugin-serial-reaction-time-mouse": minor
"@jspsych/plugin-survey-likert": minor
"@jspsych/plugin-survey-multi-choice": minor
"@jspsych/plugin-survey-multi-select": minor
"@jspsych/plugin-survey-text": minor
"@jspsych/plugin-video-button-response": minor
"@jspsych/plugin-video-keyboard-response": minor
"@jspsych/plugin-video-slider-response": minor
"@jspsych/plugin-visual-search-circle": minor
"@jspsych/test-utils": minor
---
Added support for `data-only` and `visual` simulation modes.

View File

@ -0,0 +1,5 @@
---
"@jspsych/plugin-same-different-image": patch
---
Fixed a bug where the blank screen would not show for the correct duration. Instead it would show very briefly, if at all.

View File

@ -0,0 +1,5 @@
---
"jspsych": minor
---
Added several functions to the `pluginAPI` module in order to support the new simulation feature.

View File

@ -0,0 +1,5 @@
---
"@jspsych/plugin-categorize-image": patch
---
Fixed a bug where the default value of `incorrect_text` was not defined.

View File

@ -216,6 +216,29 @@ trial(display_element, trial){
The data recorded will be that `correct` is `true` and that `rt` is `350`. [Additional data for the trial](../overview/plugins.md#data-collected-by-all-plugins) will also be collected automatically.
## Simulation mode
Plugins can optionally support [simulation modes](../overview/simulation.md).
To add simulation support, a plugin needs a `simulate()` function that accepts four arguments
`simulate(trial, simulation_mode, simulation_options, load_callback)`
* `trial`: This is the same as the `trial` parameter passed to the plugin's `trial()` method. It contains an object of the parameters for the trial.
* `simulation_mode`: A string, either `"data-only"` or `"visual"`. This specifies which simulation mode is being requested. Plugins can optionally support `"visual"` mode. If `"visual"` mode is not supported, the plugin should default to `"data-only"` mode when `"visual"` mode is requested.
* `simulation_options`: An object of simulation-specific options.
* `load_callback`: A function handle to invoke when the simulation is ready to trigger the `on_load` event for the trial. It is important to invoke this at the correct time during the simulation so that any `on_load` events in the experiment execute as expected.
Typically the flow for supporting simulation mode involves:
1. Generating artificial data that is consistent with the `trial` parameters.
2. Merging that data with any data specified by the user in `simulation_options`.
3. Verifying that the final data object is still consistent with the `trial` parameters. For example, checking that RTs are not longer than the duration of the trial.
4. In `data-only` mode, call `jsPsych.finishTrial()` with the artificial data.
5. In `visual` mode, invoke the `trial()` method of the plugin and then use the artificial data to trigger the appropriate events. There are a variety of methods in the [Plugin API module](../reference/jspsych-pluginAPI.md) to assist with things like simulating key presses and mouse clicks.
We plan to add a longer guide about simulation development in the future. For now, we recommend browsing the source code of plugins that support simulation mode to see how the flow described above is implemented.
## Advice for writing plugins
If you are developing a plugin with the aim of including it in the main jsPsych repository we encourage you to follow the [contribution guidelines](contributing.md#contributing-to-the-codebase).

177
docs/overview/simulation.md Normal file
View File

@ -0,0 +1,177 @@
# Simulation Modes
*Added in 7.1*
Simulation mode allows you run your experiment automatically and generate artificial data.
## Getting Started
To use simulation mode, replace `jsPsych.run()` with `jsPsych.simulate()`.
```js
jsPsych.simulate(timeline);
```
This will run jsPsych in the default `data-only` simulation mode.
To use the `visual` simulation mode you can specify the second parameter.
```js
jsPsych.simulate(timeline, "data-only");
jsPsych.simulate(timeline, "visual");
```
## What happens in simulation mode
In simulation mode, plugins call their `simulate()` method instead of calling their `trial()` method.
If a plugin doesn't implement a `simulate()` method, then the trial will run as usual (using the `trial()` method) and any interaction that is needed to advance to the next trial will be required.
If a plugin doesn't support `visual` mode, then it will run in `data-only` mode.
### `data-only` mode
In `data-only` mode plugins typically generate resonable artificial data given the parameters specified for the trial.
For example, if the `trial_duration` parameter is set to 2,000 ms, then any response times generated will be capped at this value.
Generally the default data generated by the plugin randomly selects any available options (e.g., buttons to click) with equal probability.
Response times are usually generated by sampling from an exponentially-modified Gaussian distribution truncated to positive values using `jsPsych.randomization.sampleExGaussian()`.
In `data-only` mode, the plugin's `trial()` method usually does not run.
The data are simply calculated based on trial parameters and the `finishTrial()` method is called immediately with the simulated data.
### `visual` mode
In `visual` mode a plugin will typically generate simulated data for the trial and then use that data to mimic the kinds of actions that a participant would do.
The plugin's `trial()` method is called by the simulation, and you'll see the experiment progress in real time.
Mouse, keyboard, and touch events are simulated to control the experiment.
In `visual` mode each plugin will generate simulated data in the same manner as `data-only` mode, but this data will instead be used to generate actions in the experiment and the plugin's `trial()` method will ultimately be responsible for generating the data.
This can create some subtle differences in data between the two modes.
For example, if the simulated data generates a response time of 500 ms, the `data.rt` value will be exactly `500` in `data-only` mode, but may be `501` or another slightly larger value in `visual` mode.
This is because the simulated response is triggered at `500` ms and small delays due to JavaScript's event loop might add a few ms to the measure.
## Controlling simulation mode with `simulation_options`
The parameters for simulation mode can be set using the `simulation_options` parameter in both `jsPsych.simulate()` and at the level of individual trials.
### Trial-level options
You can specify simulation options for an individual trial by setting the `simulation_options` parameter.
```js
const trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>Hello!</p>',
simulation_options: {
data: {
rt: 500
}
}
}
```
Currently the three options that are available are `data`, `mode`, and `simulate`.
#### `data`
Setting the `data` option will replace the default data generated by the plugin with whatever data you specify.
You can specify some or all of the `data` parameters.
Any parameters you do not specify will be generated by the plugin.
In most cases plugins will try to ensure that the data generated is consistent with the trial parameters.
For example, if a trial has a `trial_duration` parameter of `2000` but the `simulation_options` specify a `rt` of `2500`, this creates an impossible situation because the trial would have ended before the response at 2,500ms.
In most cases, the plugin will act as if a response was attempted at `2500`, which will mean that no response is generated for the trial.
As you might imagine, there are a lot of parameter combinations that can generate peculiar cases where data may be inconsistent with trial parameters.
We recommend double checking the simulation output, and please [alert us](https://github.com/jspsych/jspsych/issues) if you discover a situation where the simulation produces inconsistent data.
#### `mode`
You can override the simulation mode specified in `jsPsych.simulate()` for any trial. Setting `mode: 'data-only'` will run the trial in data-only mode and setting `mode: 'visual'` will run the trial in visual mode.
#### `simulate`
If you want to turn off simulation mode for a trial, set `simulate: false`.
#### Functions and timeline variables
The `simulation_options` parameter is compatible with both [dynamic parameters](dynamic-parameters.md) and [timeline variables](timeline.md#timeline-variables).
Dynamic parameters can be especially useful if you want to randomize the data for each run of the simulation.
For example, you can specify the `rt` as a sample from an ExGaussian distribution.
```js
const trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>Hello!</p>',
simulation_options: {
data: {
rt: ()=>{
return jsPsych.randomization.sampleExGaussian(500, 50, 1/100, true)
}
}
}
}
```
### Experiment-level options
You can also control the parameters for simulation by passing in an object to the `simulation_options` argument of `jsPsych.simulate()`.
```js
const simulation_options = {
default: {
data: {
rt: 200
}
}
}
jsPsych.simulate(timeline, "visual", simulation_options)
```
The above example will set the `rt` for any trial that doesn't have its own `simulation_options` specified to `200`.
This could be useful, for example, to create a very fast preview of the experiment to verify that everything is displaying correctly without having to wait through longer trials.
You can also specify sets of parameters by name using the experiment-level simulation options.
```js
const simulation_options = {
default: {
data: {
rt: 200
}
},
long_response: {
data: {
rt: () => {
return jsPsych.randomization.sampleExGaussian(5000, 500, 1/500, true)
}
}
}
}
const trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>This is gonna take a bit.</p>',
simulation_options: 'long_response'
}
timeline.push(trial);
jsPsych.simulate(timeline, "visual", simulation_options)
```
In the example above, we specified the `simulation_options` for `trial` using a string (`'long_response'`).
This will look up the corresponding set of options in the experiment-level `simulation_options`.
We had a few use cases in mind with this approach:
1. You could group together trials with similar behavior without needing to specify unique options for each trial.
2. You could easily swap out different simulation options to test different kinds of behavior. For example, if you want to test that a timeline with a `conditional_function` is working as expected, you could have one set of simulation options where the data will cause the `conditional_function` to evaluate to `true` and another to `false`. By using string-based identifiers, you don't need to change the timeline code at all. You can just change the object being passed to `jsPsych.simulate()`.
3. In an extreme case of the previous example, every trial on the timeline could have its own unique identifier and you could have multiple sets of simulation options to have very precise control over the data output.
## Current Limitations
Simulation mode is not yet as comprehensively tested as the rest of jsPsych.
While we are confident that the simulation is accurate enough for many use cases, it's very likely that there are circumstances where the simulated behavior will be inconsistent with what is actually possible in the experiment.
If you come across any such circumstances, please [let us know](https://github.com/jspsych/jspsych/issues)!
Currently extensions are not supported in simulation mode.
Some plugins are also not supported.
This will be noted on their documentation page.

View File

@ -164,7 +164,7 @@ var face_name_procedure = {
### Using in a function
Continung the example from the previous section, what if we wanted to show the name with the face, combining the two variables together?
To do this, we can use a [dynamic parameter](dynamic-parameters) (a function) to create an HTML-string that uses both variables in a single parameter.
To do this, we can use a [dynamic parameter](dynamic-parameters.md) (a function) to create an HTML-string that uses both variables in a single parameter.
The value of the `stimulus` parameter will be a function that returns an HTML string that contains both the image and the name.
```javascript
@ -229,7 +229,7 @@ The `type` parameter in this object controls the type of sampling that is done.
Valid values for `type` are
* `"with-replacement"`: Sample `size` items from the timeline variables with the possibility of choosing the same item multiple time.
* `"without-replacement"`: Sample `size` itesm from timeline variables, with each item being selected a maximum of 1 time.
* `"without-replacement"`: Sample `size` items from timeline variables, with each item being selected a maximum of 1 time.
* `"fixed-repetitons"`: Repeat each item in the timeline variables `size` times, in a random order. Unlike using the `repetitons` parameter, this method allows for consecutive trials to use the same timeline variable set.
* `"alternate-groups"`: Sample in an alternating order based on a declared group membership. Groups are defined by the `groups` parameter. This parameter takes an array of arrays, where each inner array is a group and the items in the inner array are the indices of the timeline variables in the `timeline_variables` array that belong to that group.
* `"custom"`: Write a function that returns a custom order of the timeline variables.

View File

@ -34,6 +34,12 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| rt | numeric | The response time in milliseconds for the subject to make a response. The time is measured from when the stimulus first began playing until the subject's response. |
| response | numeric | Indicates which button the subject pressed. The first button in the `choices` array is 0, the second is 1, and so on. |
## Simulation Mode
In `data-only` simulation mode, the `response_allowed_while_playing` parameter does not currently influence the simulated response time.
This is because the audio file is not loaded in `data-only` mode and therefore the length is unknown.
This may change in a future version as we improve the simulation modes.
## Examples
???+ example "Displaying question until subject gives a response"

View File

@ -32,6 +32,12 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| rt | numeric | The response time in milliseconds for the subject to make a response. The time is measured from when the stimulus first began playing until the subject made a key response. If no key was pressed before the trial ended, then the value will be `null`. |
| stimulus | string | Path to the audio file that played during the trial. |
## Simulation Mode
In `data-only` simulation mode, the `response_allowed_while_playing` parameter does not currently influence the simulated response time.
This is because the audio file is not loaded in `data-only` mode and therefore the length is unknown.
This may change in a future version as we improve the simulation modes.
## Examples
???+ example "Trial continues until subject gives a response"

View File

@ -39,6 +39,12 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| stimulus | string | The path of the audio file that was played. |
| slider_start | numeric | The starting value of the slider. |
## Simulation Mode
In `data-only` simulation mode, the `response_allowed_while_playing` parameter does not currently influence the simulated response time.
This is because the audio file is not loaded in `data-only` mode and therefore the length is unknown.
This may change in a future version as we improve the simulation modes.
## Examples
???+ example "A simple rating scale"

View File

@ -63,6 +63,16 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
Note that all of these values are only recorded when the corresponding key is included in the `features` parameter for the trial.
## Simulation Mode
In [simulation mode](../overview/simulation.md) the plugin will report the actual features of the browser, with the exception of `vsync_rate`, which is always 60.
In `data-only` mode, if `allow_window_resize` is true and the browser's width and height are below the maximum value then the reported width and height will be equal to `minimum_width` and `minimum_height`, as if the participant resized the browser to meet the specifications.
In `visual` mode, if `allow_window_resize` is true and the browser's width and height are below the maximum value then the experiment will wait for 3 seconds before clicking the resize fail button. During this time, you can adjust the window if you would like to.
As with all simulated plugins, you can override the default (actual) data with fake data using `simulation_options`. This allows you to test your exclusion criteria by simulating other configurations.
## Examples
???+ example "Recording all of the available features, no exclusions"

View File

@ -24,6 +24,10 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| url | string | The URL of the page. |
| rt | numeric | The response time in milliseconds for the subject to finish the trial. |
## Simulation Mode
In `visual` simulation mode, the plugin cannot interact with any form elements on the screen other than the `cont_btn` specified in the trial parameters. If your `check_fn` requires other user interaction, for example, clicking a checkbox, then you'll need to disable simulation for the trial and complete the interaction manually.
## Examples
### Loading a consent form

View File

@ -38,6 +38,10 @@ moves | array | An array containing objects representing all of the moves the pa
final_locations | array | An array containing objects representing the final locations of all the stimuli in the sorting area. Each element in the array represents a stimulus, and has a "src", "x", and "y" value. "src" is the image path, and "x" and "y" are the object location. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions.
rt | numeric | The response time in milliseconds for the participant to finish all sorting.
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Examples
???+ example "Basic example"

View File

@ -24,6 +24,12 @@ Name | Type | Value
-----|------|------
success | boolean | true if the browser supports fullscreen mode (i.e., is not Safari)
## Simulation Mode
Web browsers do not allow fullscreen mode to be triggered by a script to avoid malicious usage of fullscreen behavior when the user is not wanting it.
In `visual` simulation mode, the trial will run normally and the button will get a simulated click, but the display will not change.
If you want the display to actually enter fullscreen mode during the simulation, you should disable simulation for the fullscreen trial and manually click the button.
## Examples

View File

@ -38,6 +38,9 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| failed_audio | array | One or more audio file paths that produced a loading failure before the trial ended. |
| failed_video | array | One or more video file paths that produced a loading failure before the trial ended. |
## Simulation Mode
In `visual` simulation mode, the plugin will run the trial as if the experiment was running normally. Specifying `simulation_options.data` will not work in `visual` mode.
## Examples

View File

@ -24,6 +24,10 @@ Name | Type | Value
final_width_px | numeric | Final width of the resizable div container, in pixels.
scale_factor | numeric | Scaling factor that will be applied to the div containing jsPsych content.
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Examples
???+ example "Measuring a credit card and resizing the display to have 150 pixels equal an inch."

View File

@ -61,6 +61,9 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| png | base64 data URL string | If `save_image` is true, then this will contain the base64 encoded data URL for the image, in png format. |
| strokes | array of stroke objects | If `save_strokes` is true, then this will contain an array of stroke objects. Objects have an `action` property that is either `"start"`, `"move"`, or `"end"`. If `action` is `"start"` or `"move"` it will have an `x` and `y` property that report the coordinates of the action relative to the upper-left corner of the canvas. If `action` is `"start"` then the object will also have a `t` and `color` property, specifying the time of the action relative to the onset of the trial (ms) and the color of the stroke. If `action` is `"end"` then it will only have a `t` property. |
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Examples

View File

@ -24,6 +24,10 @@ Name | Type | Value
response | object | An object containing the response for each input. The object will have a separate key (variable) for the response to each input, with each variable being named after its corresponding input element. Each response is a string containing whatever the subject answered for this particular input. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. |
rt | numeric | The response time in milliseconds for the subject to make a response. |
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Examples
???+ example "Fill in the blanks"

View File

@ -8,7 +8,7 @@ In addition to the [parameters available in all plugins](../overview/plugins.md#
Parameter | Type | Default Value | Description
----------|------|---------------|------------
questions | array | *undefined* | An array of objects, each object represents a question that appears on the screen. Each object contains a prompt, value, required, rows, and columns parameter that will be applied to the question. See examples below for further clarification. `prompt`: Type string, default value of *undefined*. The string is the prompt for the subject to respond to. Each question gets its own response field. `value`: Type string, default value of `""`. The string will create placeholder text in the text field. `required`: Boolean; if `true` then the user must enter a response to submit. `rows`: Type integer, default value of 1. The number of rows for the response text box. `columns`: Type integer, default value of 40. The number of columns for the response text box. `name`: Name of the question. Used for storing data. If left undefined then default names (`Q0`, `Q1`, `...`) will be used for the questions.
questions | array | *undefined* | An array of objects, each object represents a question that appears on the screen. Each object contains a prompt, placeholder, required, rows, and columns parameter that will be applied to the question. See examples below for further clarification. `prompt`: Type string, default value of *undefined*. The string is the prompt for the subject to respond to. Each question gets its own response field. `placeholder`: Type string, default value of `""`. The string will create placeholder text in the text field. `required`: Boolean; if `true` then the user must enter a response to submit. `rows`: Type integer, default value of 1. The number of rows for the response text box. `columns`: Type integer, default value of 40. The number of columns for the response text box. `name`: Name of the question. Used for storing data. If left undefined then default names (`Q0`, `Q1`, `...`) will be used for the questions.
randomize_question_order | boolean | `false` | If true, the display order of `questions` is randomly determined at the start of the trial. In the data object, `Q0` will still refer to the first question in the array, regardless of where it was presented visually.
preamble | string | empty string | HTML formatted string to display at the top of the page above all the questions.
button_label | string | 'Continue' | The text that appears on the button to finish the trial.

View File

@ -39,6 +39,12 @@ response | numeric | Indicates which button the subject pressed. The first butto
rt | numeric | The response time in milliseconds for the subject to make a response. The time is measured from when the stimulus first appears on the screen until the subject's response.
stimulus | array | The `stimulus` array. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions.
## Simulation Mode
In `data-only` simulation mode, the `response_allowed_while_playing` parameter does not currently influence the simulated response time.
This is because the audio file is not loaded in `data-only` mode and therefore the length is unknown.
This may change in a future version as we improve the simulation modes.
## Example
???+ example "Responses disabled until the video is complete"

View File

@ -35,6 +35,12 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
| rt | numeric | The response time in milliseconds for the subject to make a response. The time is measured from when the stimulus first appears on the screen until the subject's response. |
stimulus | array | The `stimulus` array. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. |
## Simulation Mode
In `data-only` simulation mode, the `response_allowed_while_playing` parameter does not currently influence the simulated response time.
This is because the audio file is not loaded in `data-only` mode and therefore the length is unknown.
This may change in a future version as we improve the simulation modes.
## Examples
???+ example "Show a video and advance to next trial automatically"

View File

@ -45,6 +45,12 @@ stimulus | array | The `stimulus` array. This will be encoded as a JSON string w
slider_start | numeric | The starting value of the slider.
start | numeric | The start time of the video clip.
## Simulation Mode
In `data-only` simulation mode, the `response_allowed_while_playing` parameter does not currently influence the simulated response time.
This is because the audio file is not loaded in `data-only` mode and therefore the length is unknown.
This may change in a future version as we improve the simulation modes.
## Example
???+ example "Rate enjoyment of a video clip"

View File

@ -61,6 +61,10 @@ _Note: The deg data are **only** returned if viewing distance is estimated with
| win_height_deg | numeric | The interior height of the window in degrees. |
| view_dist_mm | numeric | Estimated distance to the screen in millimeters. |
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Example
???+ example "Measure distance to screen and pixel ratio; no resizing"

View File

@ -25,6 +25,10 @@ Name | Type | Value
No data currently added by this plugin. Use the [webgazer-validate](jspsych-webgazer-validate) plugin to measure the precision and accuracy of calibration.
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Example
Because the eye tracking plugins need to be used in conjunction with each other, please see the [example on the eye tracking overview page](../overview/eye-tracking.md#example) for an integrated example.

View File

@ -19,6 +19,10 @@ Name | Type | Value
-----|------|------
load_time | numeric | The time it took for webgazer to initialize. This can be a long time in some situations, so this value is recorded for troubleshooting when participants are reporting difficulty.
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Example
Because the eye tracking plugins need to be used in conjunction with each other, please see the [example on the eye tracking overview page](../overview/eye-tracking.md#example) for an integrated example.

View File

@ -29,6 +29,10 @@ average_offset | array | The average `x` and `y` distance from each validation p
samples_per_sec | numeric | The average number of samples per second. Calculated by finding samples per second for each point and then averaging these estimates together.
validation_points | array | The list of validation points, in the order that they appeared.
## Simulation Mode
This plugin does not yet support [simulation mode](../overview/simulation.md).
## Example
Because the eye tracking plugins need to be used in conjunction with each other, please see the [example on the eye tracking overview page](../overview/eye-tracking.md#example) for an integrated example.

View File

@ -1,28 +1,28 @@
# jsPsych.pluginAPI
The pluginAPI module contains functions that are useful when developing new plugins.
The pluginAPI module contains functions that are useful when developing plugins. All of the functions are accessible through the `pluginAPI` object. In this documentation we've divided them up based on different kinds of functionality.
---
## Keyboard Input
## jsPsych.pluginAPI.cancelAllKeyboardResponses
### cancelAllKeyboardResponses
```javascript
jsPsych.pluginAPI.cancelAllKeyboardResponses()
```
### Parameters
#### Parameters
None.
### Return value
#### Return value
Returns nothing.
### Description
#### Description
Cancels all currently active keyboard listeners created by `jsPsych.pluginAPI.getKeyboardResponse`.
### Example
#### Example
```javascript
jsPsych.pluginAPI.cancelAllKeyboardResponses();
@ -30,27 +30,27 @@ jsPsych.pluginAPI.cancelAllKeyboardResponses();
---
## jsPsych.pluginAPI.cancelKeyboardResponse
### cancelKeyboardResponse
```javascript
jsPsych.pluginAPI.cancelKeyboardResponse(listener_id)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
listener_id | object | The listener_id object generated by the call to `jsPsych.pluginAPI.getKeyboardResponse`.
### Return value
#### Return value
Returns nothing.
### Description
#### Description
Cancels a specific keyboard listener created by `jsPsych.pluginAPI.getKeyboardResponse`.
### Example
#### Example
```javascript
// create a persistent keyboard listener
@ -68,44 +68,24 @@ jsPsych.pluginAPI.cancelKeyboardResponse(listener_id);
---
## jsPsych.pluginAPI.clearAllTimeouts
```javascript
jsPsych.pluginAPI.clearAllTimeouts()
```
### Parameters
None.
### Return value
Returns nothing.
### Description
Clears any pending timeouts that were set using jsPsych.pluginAPI.setTimeout().
---
## jsPsych.pluginAPI.compareKeys
### compareKeys
```javascript
jsPsych.pluginAPI.compareKeys(key1, key2)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
key1 | string or numeric | The representation of a key, either string or keycode
key2 | string or numeric | The representation of a key, either string or keycode
### Return value
#### Return value
Returns true if keycodes or strings refer to the same key, regardless of type. Returns false if the keycodes or strings do not match.
### Description
#### Description
Compares two keys to see if they are the same, ignoring differences in representational type, and using the appropriate case sensitivity based on the experiment's settings.
@ -113,9 +93,9 @@ If `case_sensitive_responses` is set to `false` in `initJsPsych` (the default),
We recommend using this function to compare keys in all plugin and experiment code, rather than using something like `if (response == 'j')...`. This is because the response key returned by the `jsPsych.pluginAPI.getKeyboardResponse` function will be converted to lowercase when `case_sensitive_responses` is `false`, and it will match the exact key press representation when `case_sensitive_responses` is `true`. Using this `compareKeys` function will ensure that your key comparisons work appropriately based on the experiment's `case_sensitive_responses` setting, and that you do not need to remember to check key responses against different case versions of the comparison key (e.g. `if (response == 'ArrowLeft' || response == 'arrowleft')...`).
### Examples
#### Examples
#### Basic examples
##### Basic examples
```javascript
jsPsych.pluginAPI.compareKeys('a', 'A');
@ -132,7 +112,7 @@ jsPsych.pluginAPI.compareKeys('space', 31);
// returns false
```
#### Comparing a key response and key parameter value in plugins
##### Comparing a key response and key parameter value in plugins
```javascript
// this is the callback_function passed to jsPsych.pluginAPI.getKeyboardResponse
@ -143,7 +123,7 @@ var after_response = function(info) {
}
```
#### Scoring a key response in experiment code
##### Scoring a key response in experiment code
```javascript
var trial = {
@ -164,32 +144,105 @@ var trial = {
```
---
### getKeyboardResponse
## jsPsych.pluginAPI.getAudioBuffer
```javascript
jsPsych.pluginAPI.getKeyboardResponse(parameters)
```
#### Parameters
The method accepts an object of parameter values (see example below). The valid keys for this object are listed in the table below.
Parameter | Type | Description
----------|------|------------
callback_function | function | The function to execute whenever a valid keyboard response is generated.
valid_responses | array | An array of key codes or character strings representing valid responses. Responses not on the list will be ignored. An empty array indicates that all responses are acceptable.
rt_method | string | Indicates which method of recording time to use. The `'performance'` method uses calls to `performance.now()`, which is the standard way of measuring timing in jsPsych. It is [supported by up-to-date versions of all the major browsers](http://caniuse.com/#search=performance). The `audio` method is used in conjuction with an `audio_context` (set as an additional parameter). This uses the clock time of the `audio_context` when audio stimuli are being played.
audio_context | AudioContext object | The AudioContext of the audio file that is being played.
audio_context_start_time | numeric | The scheduled time of the sound file in the AudioContext. This will be used as the start time.
allow_held_key | boolean | If `true`, then responses will be registered from keys that are being held down. If `false`, then a held key can only register a response the first time that `getKeyboardResponse` is called for that key. For example, if a participant holds down the `A` key before the experiment starts, then the first time `getKeyboardResponse` is called, the `A` will register as a key press. However, any future calls to `getKeyboardResponse` will not register the `A` until the participant releases the key and presses it again.
persist | boolean | If false, then the keyboard listener will only trigger the first time a valid key is pressed. If true, then it will trigger every time a valid key is pressed until it is explicitly cancelled by `jsPsych.pluginAPI.cancelKeyboardResponse` or `jsPsych.pluginAPI.cancelAllKeyboardResponses`.
#### Return value
Return an object that uniquely identifies the keyboard listener. This object can be passed to `jsPsych.pluginAPI.cancelKeyboardResponse` to cancel the keyboard listener.
#### Description
Gets a keyboard response from the subject, recording the response time from when the function is first called until a valid response is generated.
The keyboard event listener will be bound to the `display_element` declared in `initJsPsych()` (or the `<body>` element if no `display_element` is specified). This allows jsPsych experiments to be embedded in websites with other content without disrupting the functionality of other UI elements.
A valid response triggers the `callback_function` specified in the parameters. A single argument is passed to the callback function. The argument contains an object with the properties `key` and `rt`. `key` contains the string representation of the response key, and `rt` contains the response time.
This function uses the `.key` value of the keyboard event, which is _case sensitive_. When `case_sensitive_responses` is `false` in `initJsPsych` (the default), this function will convert both the `valid_responses` strings and the response key to lowercase before comparing them, and it will pass the lowercase version of the response key to the `callback_function`. For example, if `valid_responses` is `['a']`, then both 'A' and 'a' will be considered valid key presses, and 'a' will be returned as the response key. When `case_sensitive_responses` is `true` in `initJsPsych`, this function will not convert the case when comparing the `valid_responses` and response key, and it will not convert the case of the response key that is passed to the `callback_function`. For example, if `valid_responses` is `['a']`, then 'a' will be the only valid key press, and 'A' (i.e. 'a' with CapsLock on or Shift held down) will not be accepted. Also, if `valid_responses` includes multiple letter case options (e.g. `"ALL_KEYS"`), then you may need to check the response key against both letter cases when scoring etc., e.g. `if (response == 'ArrowLeft' || response =='arrowleft') ...`.
#### Examples
##### Get a single response from any key
```javascript
var after_response = function(info){
alert('You pressed key '+info.key+' after '+info.rt+'ms');
}
jsPsych.pluginAPI.getKeyboardResponse({
callback_function:after_response,
valid_responses: "ALL_KEYS",
rt_method: 'performance',
persist: false
});
```
##### Get a responses from a key until the letter q is pressed
```javascript
var after_response = function(info){
alert('You pressed key '+info.key+' after '+info.rt+'ms');
if(jsPsych.pluginAPI.compareKeys(info.key,'q')){ /
jsPsych.pluginAPI.cancelKeyboardResponse(listener);
}
}
var listener = jsPsych.pluginAPI.getKeyboardResponse({
callback_function:after_response,
valid_responses: "ALL_KEYS",
rt_method: 'performance',
persist: true
});
```
## Media
### getAudioBuffer
```javascript
jsPsych.pluginAPI.getAudioBuffer(filepath)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
filepath | string | The path to the audio file that was preloaded.
### Return value
#### Return value
Returns a Promise that resolves when the audio file loads. Success handler's parameter will be the audio buffer. If the experiment is running using the WebAudio API it will be an AudioBuffer object. Otherwise, it will be an HTML5 Audio object. The failure handler's parameter is the error generated by `preloadAudio`.
### Description
#### Description
Gets an AudioBuffer that can be played with the WebAudio API or an Audio object that can be played with HTML5 Audio.
It is strongly recommended that you preload audio files before calling this method. This method will load the files if they are not preloaded, but this may result in delays during the experiment as audio is downloaded.
### Examples
#### Examples
#### HTML 5 Audio
##### HTML 5 Audio
```javascript
jsPsych.pluginAPI.getAudioBuffer('my-sound.mp3')
@ -201,7 +254,7 @@ jsPsych.pluginAPI.getAudioBuffer('my-sound.mp3')
})
```
#### WebAudio API
##### WebAudio API
```javascript
var context = jsPsych.pluginAPI.audioContext();
@ -222,23 +275,23 @@ See the `audio-keyboard-response` plugin for an example in a fuller context.
---
## jsPsych.pluginAPI.getAutoPreloadList
### getAutoPreloadList
```javascript
jsPsych.pluginAPI.getAutoPreloadList(timeline)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
timeline | array | An array containing the trial object(s) from which a list of media files should be automatically generated. This array can contain the entire experiment timeline, or any individual parts of a larger timeline, such as specific timeline nodes and trial objects.
### Return value
#### Return value
An object with properties for each media type: `images`, `audio`, and `video`. Each property contains an array of the unique files of that media type that were automatically extracted from the timeline. If no files are found in the timeline for a particular media type, then the array will be empty for that type.
### Description
#### Description
This method is used to automatically generate lists of unique image, audio, and video files from a timeline. It is used by the `preload` plugin to generate a list of to-be-preloaded files based on the trials passed to the `trials` parameter and/or the experiment timeline passed to `jsPsych.run` (when `auto_preload` is true). It can be used in custom plugins and experiment code to generate a list of audio/image/video files, based on a timeline.
@ -247,7 +300,7 @@ When a file path is returned to the trial parameter from a function (including t
In these cases, the file should be preloaded manually.
See [Media Preloading](../overview/media-preloading.md) for more information.
### Example
#### Example
```javascript
var audio_trial = {
@ -272,87 +325,13 @@ jsPsych.pluginAPI.getAutoPreloadList(timeline);
---
## jsPsych.pluginAPI.getKeyboardResponse
```javascript
jsPsych.pluginAPI.getKeyboardResponse(parameters)
```
### Parameters
The method accepts an object of parameter values (see example below). The valid keys for this object are listed in the table below.
Parameter | Type | Description
----------|------|------------
callback_function | function | The function to execute whenever a valid keyboard response is generated.
valid_responses | array | An array of key codes or character strings representing valid responses. Responses not on the list will be ignored. An empty array indicates that all responses are acceptable.
rt_method | string | Indicates which method of recording time to use. The `'performance'` method uses calls to `performance.now()`, which is the standard way of measuring timing in jsPsych. It is [supported by up-to-date versions of all the major browsers](http://caniuse.com/#search=performance). The `audio` method is used in conjuction with an `audio_context` (set as an additional parameter). This uses the clock time of the `audio_context` when audio stimuli are being played.
audio_context | AudioContext object | The AudioContext of the audio file that is being played.
audio_context_start_time | numeric | The scheduled time of the sound file in the AudioContext. This will be used as the start time.
allow_held_key | boolean | If `true`, then responses will be registered from keys that are being held down. If `false`, then a held key can only register a response the first time that `getKeyboardResponse` is called for that key. For example, if a participant holds down the `A` key before the experiment starts, then the first time `getKeyboardResponse` is called, the `A` will register as a key press. However, any future calls to `getKeyboardResponse` will not register the `A` until the participant releases the key and presses it again.
persist | boolean | If false, then the keyboard listener will only trigger the first time a valid key is pressed. If true, then it will trigger every time a valid key is pressed until it is explicitly cancelled by `jsPsych.pluginAPI.cancelKeyboardResponse` or `jsPsych.pluginAPI.cancelAllKeyboardResponses`.
### Return value
Return an object that uniquely identifies the keyboard listener. This object can be passed to `jsPsych.pluginAPI.cancelKeyboardResponse` to cancel the keyboard listener.
### Description
Gets a keyboard response from the subject, recording the response time from when the function is first called until a valid response is generated.
The keyboard event listener will be bound to the `display_element` declared in `initJsPsych()` (or the `<body>` element if no `display_element` is specified). This allows jsPsych experiments to be embedded in websites with other content without disrupting the functionality of other UI elements.
A valid response triggers the `callback_function` specified in the parameters. A single argument is passed to the callback function. The argument contains an object with the properties `key` and `rt`. `key` contains the string representation of the response key, and `rt` contains the response time.
This function uses the `.key` value of the keyboard event, which is _case sensitive_. When `case_sensitive_responses` is `false` in `initJsPsych` (the default), this function will convert both the `valid_responses` strings and the response key to lowercase before comparing them, and it will pass the lowercase version of the response key to the `callback_function`. For example, if `valid_responses` is `['a']`, then both 'A' and 'a' will be considered valid key presses, and 'a' will be returned as the response key. When `case_sensitive_responses` is `true` in `initJsPsych`, this function will not convert the case when comparing the `valid_responses` and response key, and it will not convert the case of the response key that is passed to the `callback_function`. For example, if `valid_responses` is `['a']`, then 'a' will be the only valid key press, and 'A' (i.e. 'a' with CapsLock on or Shift held down) will not be accepted. Also, if `valid_responses` includes multiple letter case options (e.g. `"ALL_KEYS"`), then you may need to check the response key against both letter cases when scoring etc., e.g. `if (response == 'ArrowLeft' || response =='arrowleft') ...`.
### Examples
#### Get a single response from any key
```javascript
var after_response = function(info){
alert('You pressed key '+info.key+' after '+info.rt+'ms');
}
jsPsych.pluginAPI.getKeyboardResponse({
callback_function:after_response,
valid_responses: "ALL_KEYS",
rt_method: 'performance',
persist: false
});
```
#### Get a responses from a key until the letter q is pressed
```javascript
var after_response = function(info){
alert('You pressed key '+info.key+' after '+info.rt+'ms');
if(jsPsych.pluginAPI.compareKeys(info.key,'q')){ /
jsPsych.pluginAPI.cancelKeyboardResponse(listener);
}
}
var listener = jsPsych.pluginAPI.getKeyboardResponse({
callback_function:after_response,
valid_responses: "ALL_KEYS",
rt_method: 'performance',
persist: true
});
```
---
## jsPsych.pluginAPI.preloadAudio
### preloadAudio
```javascript
jsPsych.pluginAPI.preloadAudio(files, callback_complete, callback_load, callback_error)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
@ -361,11 +340,11 @@ callback_complete | function | A function to execute when all the files have bee
callback_load | function | A function to execute after a single file has been loaded. A single parameter is passed to this function which is the file source (string) that has loaded.
callback_error | function | A function to execute after a single file has produced an error. A single parameter is passed to this function which is the file source (string) that produced the error.
### Return value
#### Return value
Returns nothing.
### Description
#### Description
This function is used to preload audio files. It is used by the `preload` plugin, and could be called directly to preload audio files in custom plugins or experiment. See [Media Preloading](../overview/media-preloading.md) for more information.
@ -373,9 +352,9 @@ It is possible to run this function without specifying a callback function. Howe
The `callback_load` and `callback_error` functions are called after each file has either loaded or produced an error, so these functions can also be used to monitor loading progress. See example below.
### Examples
#### Examples
#### Basic use
##### Basic use
```javascript
var sounds = ['file1.mp3', 'file2.mp3', 'file3.mp3'];
@ -391,7 +370,7 @@ function startExperiment(){
}
```
#### Show progress of loading
##### Show progress of loading
```javascript
var sounds = ['file1.mp3', 'file2.mp3', 'file3.mp3'];
@ -415,13 +394,13 @@ function startExperiment(){
---
## jsPsych.pluginAPI.preloadImages
### preloadImages
```javascript
jsPsych.pluginAPI.preloadImages(images, callback_complete, callback_load, callback_error)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
@ -430,11 +409,11 @@ callback_complete | function | A function to execute when all the images have be
callback_load | function | A function to execute after a single file has been loaded. A single parameter is passed to this function which is the file source (string) that has loaded.
callback_error | function | A function to execute after a single file has produced an error. A single parameter is passed to this function which is the file source (string) that produced the error.
### Return value
#### Return value
Returns nothing.
### Description
#### Description
This function is used to preload image files. It is used by the `preload` plugin, and could be called directly to preload image files in custom plugins or experiment code. See [Media Preloading](../overview/media-preloading.md) for more information.
@ -442,9 +421,9 @@ It is possible to run this function without specifying a callback function. Howe
The `callback_load` and `callback_error` functions are called after each file has either loaded or produced an error, so these functions can also be used to monitor loading progress. See example below.
### Examples
#### Examples
#### Basic use
##### Basic use
```javascript
var images = ['img/file1.png', 'img/file2.png', 'img/file3.png'];
@ -460,7 +439,7 @@ function startExperiment(){
}
```
#### Show progress of loading
##### Show progress of loading
```javascript
var images = ['img/file1.png', 'img/file2.png', 'img/file3.png'];
@ -484,13 +463,13 @@ function startExperiment(){
---
## jsPsych.pluginAPI.preloadVideo
### preloadVideo
```javascript
jsPsych.pluginAPI.preloadVideo(video, callback_complete, callback_load, callback_error)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
@ -499,11 +478,11 @@ callback_complete | function | A function to execute when all the videos have be
callback_load | function | A function to execute after a single file has been loaded. A single parameter is passed to this function which is the file source (string) that has loaded.
callback_error | function | A function to execute after a single file has produced an error. A single parameter is passed to this function which is the file source (string) that produced the error.
### Return value
#### Return value
Returns nothing.
### Description
#### Description
This function is used to preload video files. It is used by the `preload` plugin, and could be called directly to preload video files in custom plugins or experiment code. See [Media Preloading](../overview/media-preloading.md) for more information.
@ -511,9 +490,9 @@ It is possible to run this function without specifying a callback function. Howe
The `callback_load` and `callback_error` functions are called after each file has either loaded or produced an error, so these functions can also be used to monitor loading progress. See example below.
### Examples
#### Examples
#### Basic use
##### Basic use
```javascript
var videos = ['vid/file1.mp4', 'vid/file2.mp4', 'vid/file3.mp4'];
@ -529,7 +508,7 @@ function startExperiment(){
}
```
#### Show progress of loading
##### Show progress of loading
```javascript
var videos = ['vid/file1.mp4', 'vid/file2.mp4', 'vid/file3.mp4'];
@ -553,28 +532,272 @@ function startExperiment(){
---
## jsPsych.pluginAPI.setTimeout
## Simulation
### clickTarget
```javascript
jsPsych.pluginAPI.clickTarget(target, delay)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
target | Element | The DOM element to simulate clicking on.
delay | number | Time to wait in milliseconds. The click will be executed after the delay.
#### Return value
None
#### Description
Simulates clicking on a DOM element by dispatching three MouseEvents on the `target`: `'mousedown'`, then `'mouseup'`, then `'click'`. If `delay` is positive, then the events are scheduled to execute after the delay via `setTimeout`.
#### Example
```javascript
const target = document.querySelector('.jspsych-btn');
jsPsych.pluginAPI.clickTarget(target, 500);
```
---
### ensureSimulationDataConsistency
```javascript
jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
trial | object | Parameters for the trial, e.g., those passed to the plugin's `trial()` method.
data | object | An object containing data for the trial.
#### Return value
None. The `data` object is modified in place by this method.
#### Description
Performs some basic consistency checks on the `data` based on the parameters specified in `trial`. For example, if `trial.choices` is `"NO_KEYS"` but `data.response` is a key string then `data.response` and `data.rt` are set to `null`.
#### Example
```javascript
jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
```
---
### fillTextInput
```javascript
jsPsych.pluginAPI.fillTextInput(target, text, delay)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
target | HTMLInputElement | The input element to fill in with text.
text | string | The text to input.
delay | number | Time to wait in milliseconds. The text will be inserted after the delay.
#### Return value
None
#### Description
Sets the value of the `target` HTMLInputElement to equal `text`.
#### Example
```javascript
const target = document.querySelector('input[type="text"]');
jsPsych.pluginAPI.fillTextInput(target, "hello!", 500);
```
---
### getValidKey
```javascript
jsPsych.pluginAPI.getValidKey(choices)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
choices | "NO_KEYS" or "ALL_KEYS" or array of strings | Representation of the valid keys allowed for a keyboard response used by the `getKeyboardResponse` method.
#### Return value
A valid key given the `choices` parameter, chosen at random from the possible keys.
#### Description
Picks a random key given a set of options. Currently it only picks letters and numbers when `choices` is `"ALL_KEYS"`.
#### Example
```javascript
const random_key = jsPsych.pluginAPI.getValidKey(trial.choices);
```
---
### keyDown
```javascript
jsPsych.pluginAPI.keyDown(key)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
key | string | The `.key` property of the corresponding key on the keyboard.
#### Return value
None.
#### Description
Dispatches a `'keydown'` event for the specified `key`.
#### Example
```javascript
jsPsych.pluginAPI.keyDown('a');
```
---
### keyUp
```javascript
jsPsych.pluginAPI.keyUp(key)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
key | string | The `.key` property of the corresponding key on the keyboard.
#### Return value
None.
#### Description
Dispatches a `'keyup'` event for the specified `key`.
#### Example
```javascript
jsPsych.pluginAPI.keyUp('a');
```
---
### mergeSimulationData
```javascript
jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options)
```
#### Parameters
Parameter | Type | Description
----------|------|------------
default_data | object | An object containing data values for the simulated trial.
simulation_options | object | The `simulation_options` specified for the trial.
#### Return value
An object of data.
#### Description
This method merges the `default_data` with any data specified in `simulation_options.data`, giving priority to values specified in `simulation_options.data`. It returns the merged data.
#### Example
```javascript
const default_data = {
rt: 500,
response: 'a'
}
const simulation_options = {
data: {
rt: 200
}
}
const data = jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
data.rt === 200; // true
```
---
## Timeouts
### clearAllTimeouts
```javascript
jsPsych.pluginAPI.clearAllTimeouts()
```
#### Parameters
None.
#### Return value
Returns nothing.
#### Description
Clears any pending timeouts that were set using jsPsych.pluginAPI.setTimeout().
---
### setTimeout
```javascript
jsPsych.pluginAPI.setTimeout(callback, delay)
```
### Parameters
#### Parameters
Parameter | Type | Description
----------|------|------------
callback | function | A function to execute after waiting for delay.
delay | integer | Time to wait in milliseconds.
### Return value
#### Return value
Returns the ID of the setTimeout handle.
### Description
#### Description
This is simply a call to the standard setTimeout function in JavaScript with the added benefit of registering the setTimeout call in a central list. This is useful for scenarios where some other event (the trial ending, aborting the experiment) should stop the execution of queued timeouts.
### Example
#### Example
```javascript
// print the time
@ -585,3 +808,4 @@ jsPsych.pluginAPI.setTimeout(function(){
console.log(Date.now())
}, 1000);
```

View File

@ -127,6 +127,37 @@ console.log(jsPsych.randomization.randomID(8));
---
## jsPsych.randomization.randomInt
```javascript
jsPsych.randomization.randomInt(lower, upper)
```
### Parameters
| Parameter | Type | Description |
| --------- | ------- | --------------------------------------- |
| lower | integer | The smallest value it is possible to generate |
| upper | integer | The largest value it is possible to generate |
### Return value
An integer
### Description
Generates a random integer from `lower` to `upper`
### Example
```javascript
console.log(jsPsych.randomization.randomInt(2,4));
// outputs: 2 or 3 or 4.
```
---
## jsPsych.randomization.repeat
```javascript
@ -231,6 +262,136 @@ output: shuffledArray = {
---
## jsPsych.randomization.sampleBernoulli
```javascript
jsPsych.randomization.sampleBernoulli(p)
```
### Parameters
| Parameter | Type | Description |
| ---------- | ------- | ---------------------------------------- |
| p | number | Probability of sampling 1 |
### Return value
Returns 0 with probability `1-p` and 1 with probability `p`.
### Description
Generates a random sample from a Bernoulli distribution.
### Examples
#### Sample a value
```javascript
if(jsPsych.randomization.sampleBernoulli(0.8)){
// this happens 80% of the time
} else {
// this happens 20% of the time
}
```
---
## jsPsych.randomization.sampleExGaussian
```javascript
jsPsych.randomization.sampleExGaussian(mean, standard_deviation, rate, positive=false)
```
### Parameters
| Parameter | Type | Description |
| ---------- | ------- | ---------------------------------------- |
| mean | number | Mean of the normal distribution component of the exGaussian |
| standard_deviation | number | Standard deviation of the normal distribution component of the exGaussian |
| rate | number | Rate of the exponential distribution component of the exGaussian |
| positive | bool | If `true` sample will be constrained to > 0.
### Return value
A random sample from the distribution
### Description
Generates a random sample from an exponentially modified Gaussian distribution.
### Examples
#### Sample a value
```javascript
var rand_sample_exg = jsPsych.randomization.sampleExGaussian(500, 100, 0.01);
```
---
## jsPsych.randomization.sampleExponential
```javascript
jsPsych.randomization.sampleExponential(rate)
```
### Parameters
| Parameter | Type | Description |
| ---------- | ------- | ---------------------------------------- |
| rate | number | Rate of the exponential distribution |
### Return value
A random sample from the distribution
### Description
Generates a random sample from an exponential distribution.
### Examples
#### Sample a value
```javascript
var rand_sample_exg = jsPsych.randomization.sampleExponential(0.01);
```
---
## jsPsych.randomization.sampleNormal
```javascript
jsPsych.randomization.sampleNormal(mean, standard_deviation)
```
### Parameters
| Parameter | Type | Description |
| ---------- | ------- | ---------------------------------------- |
| mean | number | Mean of the normal distribution |
| standard_deviation | number | Standard deviation of the normal distribution |
### Return value
A random sample from the distribution
### Description
Generates a random sample from a normal distribution.
### Examples
#### Sample a value
```javascript
var rand_sample_exg = jsPsych.randomization.sampleNormal(500, 250);
```
---
## jsPsych.randomization.sampleWithReplacement
```javascript

View File

@ -24,6 +24,7 @@
{prompt: "I like vegetables", name: 'Vegetables', options: page_1_options, required:true},
{prompt: "I like fruit", name: 'Fruit', options: page_2_options, required: false}
],
randomize_question_order: true
};
var multi_choice_block_horizontal = {

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html>
<head>
<script src="../packages/jspsych/dist/index.browser.js"></script>
<script src="../packages/plugin-html-keyboard-response/dist/index.browser.js"></script>
<link rel="stylesheet" href="../packages/jspsych/css/jspsych.css">
</head>
<body></body>
<script>
var jsPsych = initJsPsych({
default_ITI: 250,
on_finish: function() {
jsPsych.data.displayData();
}
});
var trial_1a = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: red; font-size: 48px; font-weight: bold;">GREEN</p>',
choices: ['y', 'n'],
prompt: '<p>Does the color match the word? (y or n)</p>',
simulation_options: "long_trial"
};
var trial_1b = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: red; font-size: 48px; font-weight: bold;">GREEN</p>',
choices: ['y', 'n'],
prompt: '<p>Does the color match the word? (y or n)</p>',
simulation_options: {
data: {
rt: 10
}
}
};
var trial_1c = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: red; font-size: 48px; font-weight: bold;">GREEN</p>',
choices: ['y', 'n'],
prompt: '<p>Does the color match the word? (y or n)</p>',
};
var trial_2 = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: red; font-size: 48px; font-weight: bold;">RED</p>',
choices: ['y', 'n'],
trial_duration: 5000,
prompt: '<p>Does the color match the word? (y or n; 5s time limit)</p>'
};
var trial_3 = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: orange; font-size: 48px; font-weight: bold;">BLUE</p>',
choices: "NO_KEYS",
trial_duration: 2000,
prompt: '<p>No response allowed. 2s wait.</p>'
};
var sim_opts = {
default: {
data: {
rt: () => { return jsPsych.randomization.sampleExGaussian(250, 50, 0.02, true) }
}
},
long_trial: {
data: {
rt: 20000
}
}
}
jsPsych.simulate([trial_1a, trial_1b, trial_1c, trial_2, trial_3], "data-only", sim_opts);
</script>
</html>

View File

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html>
<head>
<script src="../packages/jspsych/dist/index.browser.js"></script>
<script src="../packages/plugin-html-keyboard-response/dist/index.browser.js"></script>
<script src="../packages/plugin-survey-text/dist/index.browser.js"></script>
<script src="../packages/plugin-instructions/dist/index.browser.js"></script>
<link rel="stylesheet" href="../packages/jspsych/css/jspsych.css" />
</head>
<body></body>
<script>
var jsPsych = initJsPsych({
default_ITI: 250,
on_finish: function() {
jsPsych.data.displayData();
}
});
var ins = {
type: jsPsychInstructions,
pages: ["page 1", "page 2", "page 3", "page 4", "page 5", "page 6"]
}
var trial_1a = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: red; font-size: 48px; font-weight: bold;">GREEN</p>',
choices: ['y', 'n'],
prompt: '<p>Does the color match the word? (y or n)</p>',
simulation_options: "long_trial"
};
var trial_1b = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: blue; font-size: 48px; font-weight: bold;">BROWN</p>',
choices: ['y', 'n'],
prompt: '<p>Does the color match the word? (y or n)</p>',
simulation_options: {
data: {
rt: 1000
}
}
};
var trial_1c = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: yellow; font-size: 48px; font-weight: bold;">YELLOW</p>',
choices: ['y', 'n'],
prompt: '<p>Does the color match the word? (y or n)</p>',
};
var trial_2 = {
type: jsPsychSurveyText,
questions: [
{prompt: 'This is Q1'},
{prompt: 'This is Q2'},
{prompt: 'This is longer Q3', rows: 5}
]
};
var trial_3 = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="color: orange; font-size: 48px; font-weight: bold;">BLUE</p>',
choices: "NO_KEYS",
trial_duration: 2000,
prompt: '<p>No response allowed. 2s wait.</p>'
};
var sim_opts = {
default: {
data: {
//rt: () => { return jsPsych.randomization.sampleExGaussian(600,50,0.02, true) }
}
},
long_trial: {
data: {
rt: 2000
}
}
}
jsPsych.simulate([ins, trial_1a, trial_1b, trial_1c, trial_2, trial_3], "visual", sim_opts);
</script>
</html>

View File

@ -49,6 +49,7 @@ nav:
- 'Dynamic Parameters': 'overview/dynamic-parameters.md'
- 'Controlling Visual Appearance': 'overview/style.md'
- 'Data Storage, Aggregation, and Manipulation': 'overview/data.md'
- 'Simulation Modes': 'overview/simulation.md'
- 'Running Experiments': 'overview/running-experiments.md'
- 'Experiment Settings': 'overview/experiment-options.md'
- 'Events': 'overview/events.md'

1733
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,8 @@
},
"homepage": "https://www.jspsych.org",
"dependencies": {
"auto-bind": "^4.0.0"
"auto-bind": "^4.0.0",
"random-words": "^1.1.1"
},
"devDependencies": {
"@jspsych/config": "^1.0.0",

View File

@ -72,6 +72,16 @@ export class JsPsych {
private finished: Promise<void>;
private resolveFinishedPromise: () => void;
/**
* is the experiment running in `simulate()` mode
*/
private simulation_mode: "data-only" | "visual" = null;
/**
* simulation options passed in via `simulate()`
*/
private simulation_options;
// storing a single webaudio context to prevent problems with multiple inits
// of jsPsych
webaudio_context: AudioContext = null;
@ -177,6 +187,16 @@ export class JsPsych {
await this.finished;
}
async simulate(
timeline: any[],
simulation_mode: "data-only" | "visual",
simulation_options = {}
) {
this.simulation_mode = simulation_mode;
this.simulation_options = simulation_options;
await this.run(timeline);
}
getProgress() {
return {
total_trials: typeof this.timeline === "undefined" ? undefined : this.timeline.length(),
@ -586,11 +606,60 @@ export class JsPsych {
}
};
const trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
let trial_complete;
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;
}
if (trial.simulation_options) {
if (typeof trial.simulation_options == "string") {
if (this.simulation_options[trial.simulation_options]) {
trial_sim_opts = this.simulation_options[trial.simulation_options];
} else if (this.simulation_options.default) {
console.log(
`No matching simulation options found for "${trial.simulation_options}". Using "default" options.`
);
trial_sim_opts = this.simulation_options.default;
} else {
console.log(
`No matching simulation options found for "${trial.simulation_options}" and no "default" options provided. Using the default values provided by the plugin.`
);
trial_sim_opts = {};
}
} else {
trial_sim_opts = trial.simulation_options;
}
}
trial_sim_opts = this.utils.deepCopy(trial_sim_opts);
trial_sim_opts = this.replaceFunctionsWithValues(trial_sim_opts, null);
if (trial_sim_opts?.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,
load_callback
);
}
} else {
// trial doesn't have a simulate method, so just run as usual
trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
}
}
// see if trial_complete is a Promise by looking for .then() function
const is_promise = trial_complete && typeof trial_complete.then == "function";
if (!is_promise) {
// in simulation mode we let the simulate function call the load_callback always.
if (!is_promise && !this.simulation_mode) {
load_callback();
}

View File

@ -0,0 +1,181 @@
export class SimulationAPI {
dispatchEvent(event: Event) {
document.body.dispatchEvent(event);
}
/**
* Dispatches a `keydown` event for the specified key
* @param key Character code (`.key` property) for the key to press.
*/
keyDown(key: string) {
this.dispatchEvent(new KeyboardEvent("keydown", { key }));
}
/**
* Dispatches a `keyup` event for the specified key
* @param key Character code (`.key` property) for the key to press.
*/
keyUp(key: string) {
this.dispatchEvent(new KeyboardEvent("keyup", { key }));
}
/**
* Dispatches a `keydown` and `keyup` event in sequence to simulate pressing a key.
* @param key Character code (`.key` property) for the key to press.
* @param delay Length of time to wait (ms) before executing action
*/
pressKey(key: string, delay = 0) {
if (delay > 0) {
setTimeout(() => {
this.keyDown(key);
this.keyUp(key);
}, delay);
} else {
this.keyDown(key);
this.keyUp(key);
}
}
/**
* Dispatches `mousedown`, `mouseup`, and `click` events on the target element
* @param target The element to click
* @param delay Length of time to wait (ms) before executing action
*/
clickTarget(target: Element, delay = 0) {
if (delay > 0) {
setTimeout(() => {
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}, delay);
} else {
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
}
/**
* Sets the value of a target text input
* @param target A text input element to fill in
* @param text Text to input
* @param delay Length of time to wait (ms) before executing action
*/
fillTextInput(target: HTMLInputElement, text: string, delay = 0) {
if (delay > 0) {
setTimeout(() => {
target.value = text;
}, delay);
} else {
target.value = text;
}
}
/**
* Picks a valid key from `choices`, taking into account jsPsych-specific
* identifiers like "NO_KEYS" and "ALL_KEYS".
* @param choices Which keys are valid.
* @returns A key selected at random from the valid keys.
*/
getValidKey(choices: "NO_KEYS" | "ALL_KEYS" | Array<string> | Array<Array<string>>) {
const possible_keys = [
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
" ",
];
let key;
if (choices == "NO_KEYS") {
key = null;
} else if (choices == "ALL_KEYS") {
key = possible_keys[Math.floor(Math.random() * possible_keys.length)];
} else {
const flat_choices = choices.flat();
key = flat_choices[Math.floor(Math.random() * flat_choices.length)];
}
return key;
}
mergeSimulationData(default_data, simulation_options) {
// override any data with data from simulation object
return {
...default_data,
...simulation_options?.data,
};
}
ensureSimulationDataConsistency(trial, data) {
// All RTs must be rounded
if (data.rt) {
data.rt = Math.round(data.rt);
}
// If a trial_duration and rt exist, make sure that the RT is not longer than the trial.
if (trial.trial_duration && data.rt && data.rt > trial.trial_duration) {
data.rt = null;
if (data.response) {
data.response = null;
}
if (data.correct) {
data.correct = false;
}
}
// If trial.choices is NO_KEYS make sure that response and RT are null
if (trial.choices && trial.choices == "NO_KEYS") {
if (data.rt) {
data.rt = null;
}
if (data.response) {
data.response = null;
}
}
// If response is not allowed before stimulus display complete, ensure RT
// is longer than display time.
if (trial.allow_response_before_complete) {
if (trial.sequence_reps && trial.frame_time) {
const min_time = trial.sequence_reps * trial.frame_time * trial.stimuli.length;
if (data.rt < min_time) {
data.rt = null;
data.response = null;
}
}
}
}
}

View File

@ -4,6 +4,7 @@ import { JsPsych } from "../../JsPsych";
import { HardwareAPI } from "./HardwareAPI";
import { KeyboardListenerAPI } from "./KeyboardListenerAPI";
import { MediaAPI } from "./MediaAPI";
import { SimulationAPI } from "./SimulationAPI";
import { TimeoutAPI } from "./TimeoutAPI";
export function createJointPluginAPIObject(jsPsych: JsPsych) {
@ -19,8 +20,9 @@ export function createJointPluginAPIObject(jsPsych: JsPsych) {
new TimeoutAPI(),
new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context),
new HardwareAPI(),
new SimulationAPI(),
].map((object) => autoBind(object))
) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI;
) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI;
}
export type PluginAPI = ReturnType<typeof createJointPluginAPIObject>;

View File

@ -1,3 +1,5 @@
import rw from "random-words";
export function repeat(array, repetitions, unpack = false) {
const arr_isArray = Array.isArray(array);
const rep_isArray = Array.isArray(repetitions);
@ -173,7 +175,7 @@ export function sampleWithoutReplacement(arr, size) {
return shuffle(arr).slice(0, size);
}
export function sampleWithReplacement(arr, size, weights) {
export function sampleWithReplacement(arr, size, weights?) {
if (!Array.isArray(arr)) {
console.error("First argument to sampleWithReplacement() must be an array");
}
@ -240,6 +242,75 @@ export function randomID(length = 32) {
return result;
}
/**
* Generate a random integer from `lower` to `upper`, inclusive of both end points.
* @param lower The lowest value it is possible to generate
* @param upper The highest value it is possible to generate
* @returns A random integer
*/
export function randomInt(lower: number, upper: number) {
if (upper < lower) {
throw new Error("Upper boundary must be less than or equal to lower boundary");
}
return lower + Math.floor(Math.random() * (upper - lower + 1));
}
/**
* Generates a random sample from a Bernoulli distribution.
* @param p The probability of sampling 1.
* @returns 0, with probability 1-p, or 1, with probability p.
*/
export function sampleBernoulli(p: number) {
return Math.random() <= p ? 1 : 0;
}
export function sampleNormal(mean: number, standard_deviation: number) {
return randn_bm() * standard_deviation + mean;
}
export function sampleExponential(rate: number) {
return -Math.log(Math.random()) / rate;
}
export function sampleExGaussian(
mean: number,
standard_deviation: number,
rate: number,
positive = false
) {
let s = sampleNormal(mean, standard_deviation) + sampleExponential(rate);
if (positive) {
while (s <= 0) {
s = sampleNormal(mean, standard_deviation) + sampleExponential(rate);
}
}
return s;
}
/**
* Generate one or more random words.
*
* This is a wrapper function for the {@link https://www.npmjs.com/package/random-words `random-words` npm package}.
*
* @param opts An object with optional properties `min`, `max`, `exactly`,
* `join`, `maxLength`, `wordsPerString`, `separator`, and `formatter`.
*
* @returns An array of words or a single string, depending on parameter choices.
*/
export function randomWords(opts) {
return rw(opts);
}
// Box-Muller transformation for a random sample from normal distribution with mean = 0, std = 1
// https://stackoverflow.com/a/36481059/3726673
function randn_bm() {
var u = 0,
v = 0;
while (u === 0) u = Math.random(); //Converting [0,1) to (0,1)
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
function unpackArray(array) {
const out = {};

View File

@ -0,0 +1,371 @@
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
import { clickTarget, pressKey, simulateTimeline } from "@jspsych/test-utils";
import { JsPsych, JsPsychPlugin, ParameterType, TrialType, initJsPsych } from "../../src";
jest.useFakeTimers();
describe("data simulation mode", () => {
test("jsPsych.simulate() runs as drop-in replacement for jsPsych.run()", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values().length).toBe(1);
});
test("Can set simulation_options at the trial level", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
data: {
rt: 100,
},
},
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBe(100);
});
test("Simulation options can be functions that eval at runtime", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
data: {
rt: () => {
return 100;
},
},
},
},
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
data: {
rt: () => {
return 200;
},
},
},
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBe(100);
expect(getData().values()[1].rt).toBe(200);
});
test("Simulation options can be set using default key, only applies if no trial opts set", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
},
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
data: {
rt: 200,
},
},
},
];
const simulation_options = {
default: {
data: {
rt: 100,
},
},
};
const { expectFinished, getData } = await simulateTimeline(
timeline,
"data-only",
simulation_options
);
await expectFinished();
expect(getData().values()[0].rt).toBe(100);
expect(getData().values()[1].rt).toBe(200);
});
test("Simulation options can be set using string lookup", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
},
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: "short_trial",
},
];
const simulation_options = {
default: {
data: {
rt: 100,
},
},
short_trial: {
data: {
rt: 10,
},
},
};
const { expectFinished, getData } = await simulateTimeline(
timeline,
"data-only",
simulation_options
);
await expectFinished();
expect(getData().values()[0].rt).toBe(100);
expect(getData().values()[1].rt).toBe(10);
});
test("Simulation options can be set with timeline variables", async () => {
const jsPsych = initJsPsych();
const timeline = [
{
timeline: [
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: jsPsych.timelineVariable("sim_opt"),
},
],
timeline_variables: [{ sim_opt: "short_trial" }, { sim_opt: "long_trial" }],
},
];
const simulation_options = {
long_trial: {
data: {
rt: 100,
},
},
short_trial: {
data: {
rt: 10,
},
},
};
const { expectFinished, getData } = await simulateTimeline(
timeline,
"data-only",
simulation_options,
jsPsych
);
await expectFinished();
expect(getData().values()[0].rt).toBe(10);
expect(getData().values()[1].rt).toBe(100);
});
test("Simulation options can be a function that evals at run time", async () => {
const jsPsych = initJsPsych();
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: () => {
return {
data: {
rt: 100,
},
};
},
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBe(100);
});
test("If a plugin doesn't support simulation, it runs as usual", async () => {
class FakePlugin {
static info = {
name: "fake-plugin",
parameters: {
foo: {
type: ParameterType.BOOL,
default: true,
},
},
};
constructor(private jsPsych: JsPsych) {}
trial(display_element, trial) {
display_element.innerHTML = "<button id='end'>CLICK</button>";
display_element.querySelector("#end").addEventListener("click", () => {
this.jsPsych.finishTrial({ foo: trial.foo });
});
}
}
const jsPsych = initJsPsych();
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
data: {
rt: 100,
},
},
},
{
type: FakePlugin,
},
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
data: {
rt: 200,
},
},
},
];
const { expectFinished, expectRunning, getData, getHTML, displayElement } =
await simulateTimeline(timeline);
await expectRunning();
expect(getHTML()).toContain("button");
clickTarget(displayElement.querySelector("#end"));
await expectFinished();
expect(getData().values()[0].rt).toBe(100);
expect(getData().values()[1].foo).toBe(true);
expect(getData().values()[2].rt).toBe(200);
});
test("endExperiment() works in simulation mode", async () => {
const jsPsych = initJsPsych();
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
on_finish: () => {
jsPsych.endExperiment("done");
},
},
{
type: htmlKeyboardResponse,
stimulus: "bar",
},
];
const { expectFinished, getData, getHTML } = await simulateTimeline(
timeline,
"data-only",
{},
jsPsych
);
await expectFinished();
expect(getHTML()).toMatch("done");
expect(getData().count()).toBe(1);
});
test("Setting mode in simulation_options will control which mode is used", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "bar",
simulation_options: {
mode: "data-only",
},
},
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
mode: "visual",
},
},
];
const { expectRunning, expectFinished, getHTML } = await simulateTimeline(timeline);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectFinished();
});
test("Trial can be run normally by specifying simulate:false in simulation options", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "bar",
},
{
type: htmlKeyboardResponse,
stimulus: "foo",
simulation_options: {
simulate: false,
},
},
];
const { expectRunning, expectFinished, getHTML } = await simulateTimeline(timeline);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectRunning();
pressKey("a");
await expectFinished();
});
});

View File

@ -1,6 +1,7 @@
import {
factorial,
randomID,
randomInt,
repeat,
shuffle,
shuffleAlternateGroups,
@ -134,3 +135,33 @@ describe("shuffleNoRepeats", function () {
expect(repeats).toBe(0);
});
});
describe("randomInt", () => {
test("generates random int between positive boundaries", () => {
var samples = [];
for (var i = 0; i < 1000; i++) {
samples.push(randomInt(3, 10));
}
expect(
samples.every((x) => {
return x >= 3 && x <= 10;
})
).toBe(true);
});
test("generates random int between negative boundaries", () => {
var samples = [];
for (var i = 0; i < 1000; i++) {
samples.push(randomInt(-5, -1));
}
expect(
samples.every((x) => {
return x >= -5 && x <= -1;
})
).toBe(true);
});
test("setting upper < lower throws an error", () => {
expect(() => {
randomInt(1, 0);
}).toThrowError();
});
});

View File

@ -1,4 +1,4 @@
import { startTimeline } from "@jspsych/test-utils";
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import animation from ".";
@ -22,3 +22,58 @@ describe("animation plugin", () => {
await expectFinished();
});
});
describe("animation simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: animation,
stimuli: ["1.png", "2.png", "3.png", "4.png"],
sequence_reps: 3,
render_on_canvas: false,
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
const data = getData().values()[0];
expect(data.animation_sequence.length).toBe(12);
expect(data.response).not.toBeUndefined();
});
test("visual mode works", async () => {
const timeline = [
{
type: animation,
stimuli: ["1.png", "2.png", "3.png", "4.png"],
sequence_reps: 3,
frame_time: 50,
frame_isi: 50,
render_on_canvas: false,
},
];
const { expectFinished, expectRunning, getHTML, getData, displayElement } =
await simulateTimeline(timeline, "visual");
await expectRunning();
expect(getHTML()).toContain("1.png");
jest.advanceTimersByTime(50);
expect(displayElement.querySelector("img").style.visibility).toBe("hidden");
jest.advanceTimersByTime(50);
expect(getHTML()).toContain("2.png");
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.animation_sequence.length).toBe(24);
expect(data.response).not.toBeUndefined();
});
});

View File

@ -123,10 +123,7 @@ class AnimationPlugin implements JsPsychPlugin<Info> {
}
}, interval_time);
// show the first frame immediately
show_next_frame();
function show_next_frame() {
const show_next_frame = () => {
if (trial.render_on_canvas) {
display_element.querySelector<HTMLElement>("#jspsych-animation-image").style.visibility =
"visible";
@ -166,7 +163,7 @@ class AnimationPlugin implements JsPsychPlugin<Info> {
});
}, trial.frame_time);
}
}
};
var after_response = (info) => {
responses.push({
@ -190,6 +187,93 @@ class AnimationPlugin implements JsPsychPlugin<Info> {
persist: true,
allow_held_key: false,
});
// show the first frame immediately
show_next_frame();
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const fake_animation_sequence = [];
const fake_responses = [];
let t = 0;
const check_if_fake_response_generated: () => boolean = () => {
return this.jsPsych.randomization.sampleWithReplacement([true, false], 1, [1, 10])[0];
};
for (let i = 0; i < trial.sequence_reps; i++) {
for (const frame of trial.stimuli) {
fake_animation_sequence.push({
stimulus: frame,
time: t,
});
if (check_if_fake_response_generated()) {
fake_responses.push({
key_press: this.jsPsych.pluginAPI.getValidKey(trial.choices),
rt: t + this.jsPsych.randomization.randomInt(0, trial.frame_time - 1),
current_stim: frame,
});
}
t += trial.frame_time;
if (trial.frame_isi > 0) {
fake_animation_sequence.push({
stimulus: "blank",
time: t,
});
if (check_if_fake_response_generated()) {
fake_responses.push({
key_press: this.jsPsych.pluginAPI.getValidKey(trial.choices),
rt: t + this.jsPsych.randomization.randomInt(0, trial.frame_isi - 1),
current_stim: "blank",
});
}
t += trial.frame_isi;
}
}
}
const default_data = {
animation_sequence: fake_animation_sequence,
response: fake_responses,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
for (const response of data.response) {
this.jsPsych.pluginAPI.pressKey(response.key_press, response.rt);
}
}
}

View File

@ -16,7 +16,7 @@
],
"source": "src/index.ts",
"scripts": {
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "npm test -- --watch",
"tsc": "tsc",
"build": "rollup --config",

View File

@ -1,4 +1,4 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "jspsych";
import audioButtonResponse from ".";
@ -15,7 +15,7 @@ describe.skip("audio-button-response", () => {
prompt: "foo",
choices: ["choice1"],
on_load: () => {
expect(getHTML()).toContain("ffgfgoo");
expect(getHTML()).toContain("foo");
clickTarget(displayElement.querySelector("button"));
},
@ -33,3 +33,54 @@ describe.skip("audio-button-response", () => {
await finished;
});
});
describe("audio-button-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: audioButtonResponse,
stimulus: "foo.mp3",
choices: ["click"],
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(getData().values()[0].response).toBe(0);
});
// can't run this until we mock Audio elements.
test.skip("visual mode works", async () => {
const jsPsych = initJsPsych({ use_webaudio: false });
const timeline = [
{
type: audioButtonResponse,
stimulus: "foo.mp3",
prompt: "foo",
choices: ["click"],
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual",
{},
jsPsych
);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(getData().values()[0].response).toBe(0);
});
});

View File

@ -83,6 +83,7 @@ type Info = typeof info;
*/
class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
static info = info;
private audio;
constructor(private jsPsych: JsPsych) {}
@ -92,7 +93,6 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
// setup stimulus
var context = this.jsPsych.pluginAPI.audioContext();
var audio;
// store response
var response = {
@ -108,12 +108,12 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
.getAudioBuffer(trial.stimulus)
.then((buffer) => {
if (context !== null) {
audio = context.createBufferSource();
audio.buffer = buffer;
audio.connect(context.destination);
this.audio = context.createBufferSource();
this.audio.buffer = buffer;
this.audio.connect(context.destination);
} else {
audio = buffer;
audio.currentTime = 0;
this.audio = buffer;
this.audio.currentTime = 0;
}
setupTrial();
})
@ -127,12 +127,12 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
const setupTrial = () => {
// set up end event if trial needs it
if (trial.trial_ends_after_audio) {
audio.addEventListener("ended", end_trial);
this.audio.addEventListener("ended", end_trial);
}
// enable buttons after audio ends if necessary
if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
audio.addEventListener("ended", enable_buttons);
this.audio.addEventListener("ended", enable_buttons);
}
//display buttons
@ -188,9 +188,9 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
// start audio
if (context !== null) {
startTime = context.currentTime;
audio.start(startTime);
this.audio.start(startTime);
} else {
audio.play();
this.audio.play();
}
// end trial if time limit is set
@ -231,13 +231,13 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
// stop the audio file if it is playing
// remove end event listeners if they exist
if (context !== null) {
audio.stop();
this.audio.stop();
} else {
audio.pause();
this.audio.pause();
}
audio.removeEventListener("ended", end_trial);
audio.removeEventListener("ended", enable_buttons);
this.audio.removeEventListener("ended", end_trial);
this.audio.removeEventListener("ended", enable_buttons);
// gather the data to store for the trial
var trial_data = {
@ -286,6 +286,65 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
trial_complete = resolve;
});
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.randomization.randomInt(0, trial.choices.length - 1),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
const respond = () => {
if (data.rt !== null) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector(`div[data-choice="${data.response}"] button`),
data.rt
);
}
};
this.trial(display_element, trial, () => {
load_callback();
if (!trial.response_allowed_while_playing) {
this.audio.addEventListener("ended", respond);
} else {
respond();
}
});
}
}
export default AudioButtonResponsePlugin;

View File

@ -16,7 +16,7 @@
],
"source": "src/index.ts",
"scripts": {
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "npm test -- --watch",
"tsc": "tsc",
"build": "rollup --config",

View File

@ -0,0 +1,55 @@
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "jspsych";
import audioKeyboardResponse from ".";
jest.useFakeTimers();
describe("audio-keyboard-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: audioKeyboardResponse,
stimulus: "foo.mp3",
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(typeof getData().values()[0].response).toBe("string");
});
// can't run this until we mock Audio elements.
test.skip("visual mode works", async () => {
const jsPsych = initJsPsych({ use_webaudio: false });
const timeline = [
{
type: audioKeyboardResponse,
stimulus: "foo.mp3",
prompt: "foo",
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual",
{},
jsPsych
);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(typeof getData().values()[0].response).toBe("string");
});
});

View File

@ -60,6 +60,7 @@ type Info = typeof info;
*/
class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
static info = info;
private audio;
constructor(private jsPsych: JsPsych) {}
@ -69,7 +70,6 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
// setup stimulus
var context = this.jsPsych.pluginAPI.audioContext();
var audio;
// store response
var response = {
@ -85,12 +85,12 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
.getAudioBuffer(trial.stimulus)
.then((buffer) => {
if (context !== null) {
audio = context.createBufferSource();
audio.buffer = buffer;
audio.connect(context.destination);
this.audio = context.createBufferSource();
this.audio.buffer = buffer;
this.audio.connect(context.destination);
} else {
audio = buffer;
audio.currentTime = 0;
this.audio = buffer;
this.audio.currentTime = 0;
}
setupTrial();
})
@ -104,7 +104,7 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
const setupTrial = () => {
// set up end event if trial needs it
if (trial.trial_ends_after_audio) {
audio.addEventListener("ended", end_trial);
this.audio.addEventListener("ended", end_trial);
}
// show prompt if there is one
@ -115,16 +115,16 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
// start audio
if (context !== null) {
startTime = context.currentTime;
audio.start(startTime);
this.audio.start(startTime);
} else {
audio.play();
this.audio.play();
}
// start keyboard listener when trial starts or sound ends
if (trial.response_allowed_while_playing) {
setup_keyboard_listener();
} else if (!trial.trial_ends_after_audio) {
audio.addEventListener("ended", setup_keyboard_listener);
this.audio.addEventListener("ended", setup_keyboard_listener);
}
// end trial if time limit is set
@ -145,13 +145,13 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
// stop the audio file if it is playing
// remove end event listeners if they exist
if (context !== null) {
audio.stop();
this.audio.stop();
} else {
audio.pause();
this.audio.pause();
}
audio.removeEventListener("ended", end_trial);
audio.removeEventListener("ended", setup_keyboard_listener);
this.audio.removeEventListener("ended", end_trial);
this.audio.removeEventListener("ended", setup_keyboard_listener);
// kill keyboard listeners
this.jsPsych.pluginAPI.cancelAllKeyboardResponses();
@ -211,6 +211,62 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin<Info> {
trial_complete = resolve;
});
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
const respond = () => {
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
};
this.trial(display_element, trial, () => {
load_callback();
if (!trial.response_allowed_while_playing) {
this.audio.addEventListener("ended", respond);
} else {
respond();
}
});
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.pluginAPI.getValidKey(trial.choices),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
}
export default AudioKeyboardResponsePlugin;

View File

@ -0,0 +1,57 @@
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "jspsych";
import audioSliderResponse from ".";
jest.useFakeTimers();
describe("audio-slider-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: audioSliderResponse,
stimulus: "foo.mp3",
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(getData().values()[0].response).toBeGreaterThanOrEqual(0);
expect(getData().values()[0].response).toBeLessThanOrEqual(100);
});
// can't run this until we mock Audio elements.
test.skip("visual mode works", async () => {
const jsPsych = initJsPsych({ use_webaudio: false });
const timeline = [
{
type: audioSliderResponse,
stimulus: "foo.mp3",
prompt: "foo",
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual",
{},
jsPsych
);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(getData().values()[0].response).toBeGreaterThanOrEqual(0);
expect(getData().values()[0].response).toBeLessThanOrEqual(100);
});
});

View File

@ -104,6 +104,7 @@ type Info = typeof info;
*/
class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
static info = info;
private audio;
constructor(private jsPsych: JsPsych) {}
@ -116,7 +117,6 @@ class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
// setup stimulus
var context = this.jsPsych.pluginAPI.audioContext();
var audio;
// record webaudio context start time
var startTime;
@ -129,12 +129,12 @@ class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
.getAudioBuffer(trial.stimulus)
.then((buffer) => {
if (context !== null) {
audio = context.createBufferSource();
audio.buffer = buffer;
audio.connect(context.destination);
this.audio = context.createBufferSource();
this.audio.buffer = buffer;
this.audio.connect(context.destination);
} else {
audio = buffer;
audio.currentTime = 0;
this.audio = buffer;
this.audio.currentTime = 0;
}
setupTrial();
})
@ -148,12 +148,12 @@ class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
const setupTrial = () => {
// set up end event if trial needs it
if (trial.trial_ends_after_audio) {
audio.addEventListener("ended", end_trial);
this.audio.addEventListener("ended", end_trial);
}
// enable slider after audio ends if necessary
if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
audio.addEventListener("ended", enable_slider);
this.audio.addEventListener("ended", enable_slider);
}
var html = '<div id="jspsych-audio-slider-response-wrapper" style="margin: 100px 0px;">';
@ -278,9 +278,9 @@ class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
// start audio
if (context !== null) {
startTime = context.currentTime;
audio.start(startTime);
this.audio.start(startTime);
} else {
audio.play();
this.audio.play();
}
// end trial if trial_duration is set
@ -310,13 +310,13 @@ class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
// stop the audio file if it is playing
// remove end event listeners if they exist
if (context !== null) {
audio.stop();
this.audio.stop();
} else {
audio.pause();
this.audio.pause();
}
audio.removeEventListener("ended", end_trial);
audio.removeEventListener("ended", enable_slider);
this.audio.removeEventListener("ended", end_trial);
this.audio.removeEventListener("ended", enable_slider);
// save data
var trialdata = {
@ -338,6 +338,71 @@ class AudioSliderResponsePlugin implements JsPsychPlugin<Info> {
trial_complete = resolve;
});
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
slider_start: trial.slider_start,
response: this.jsPsych.randomization.randomInt(trial.min, trial.max),
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
const respond = () => {
if (data.rt !== null) {
const el = display_element.querySelector<HTMLInputElement>("input[type='range']");
setTimeout(() => {
this.jsPsych.pluginAPI.clickTarget(el);
el.valueAsNumber = data.response;
}, data.rt / 2);
this.jsPsych.pluginAPI.clickTarget(display_element.querySelector("button"), data.rt);
}
};
this.trial(display_element, trial, () => {
load_callback();
if (!trial.response_allowed_while_playing) {
this.audio.addEventListener("ended", respond);
} else {
respond();
}
});
}
}
export default AudioSliderResponsePlugin;

View File

@ -1,4 +1,4 @@
import { clickTarget, pressKey, startTimeline } from "@jspsych/test-utils";
import { clickTarget, pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import browserCheck from ".";
@ -166,7 +166,7 @@ describe("browser-check", () => {
"Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19"
);
const { expectFinished, expectRunning } = await startTimeline([
const { expectFinished, expectRunning, getData } = await startTimeline([
{
type: browserCheck,
skip_features: ["vsync_rate"],
@ -186,6 +186,9 @@ describe("browser-check", () => {
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].width).toBe(2000);
expect(getData().values()[0].height).toBe(2000);
});
test("vsync rate", async () => {
@ -211,3 +214,69 @@ describe("browser-check", () => {
expect(getData().values()[0].vsync_rate).toBe(1000 / 16);
});
});
describe("browser-check simulation", () => {
test("data-only mode works", async () => {
jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19"
);
const { expectFinished, getData } = await simulateTimeline([
{
type: browserCheck,
},
]);
await expectFinished();
});
test("visual mode works", async () => {
jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19"
);
const { expectFinished, getData } = await simulateTimeline(
[
{
type: browserCheck,
},
],
"visual"
);
await expectFinished();
});
test("visual mode works when window too small", async () => {
jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19"
);
const { expectFinished, expectRunning, getData, getHTML } = await simulateTimeline(
[
{
type: browserCheck,
minimum_width: 3000,
exclusion_message: () => {
return "foo";
},
},
],
"visual"
);
await expectRunning();
jest.advanceTimersByTime(3000);
await expectFinished();
expect(getHTML()).toMatch("foo");
});
});

View File

@ -123,6 +123,7 @@ type Info = typeof info;
class BrowserCheckPlugin implements JsPsychPlugin<Info> {
static info = info;
private end_flag = false;
private t: TrialType<Info>;
constructor(private jsPsych: JsPsych) {}
@ -131,7 +132,29 @@ class BrowserCheckPlugin implements JsPsychPlugin<Info> {
}
trial(display_element: HTMLElement, trial: TrialType<Info>) {
const featureCheckFunctionsMap = new Map<string, () => any>(
this.t = trial;
const featureCheckFunctionsMap = this.create_feature_fn_map(trial);
const features_to_check = trial.features.filter((x) => !trial.skip_features.includes(x));
this.run_trial(featureCheckFunctionsMap, features_to_check);
}
private async run_trial(fnMap, features) {
const feature_data = await this.measure_features(fnMap, features);
const include = await this.inclusion_check(this.t.inclusion_function, feature_data);
if (include) {
this.end_trial(feature_data);
} else {
this.end_experiment(feature_data);
}
}
private create_feature_fn_map(trial) {
return new Map<string, () => any>(
Object.entries({
width: () => {
return window.innerWidth;
@ -249,106 +272,194 @@ class BrowserCheckPlugin implements JsPsychPlugin<Info> {
},
})
);
}
private async measure_features(fnMap, features_to_check) {
const feature_data = new Map<string, any>();
const feature_checks: Promise<void>[] = [];
const features_to_check = trial.features.filter((x) => !trial.skip_features.includes(x));
for (const feature of features_to_check) {
// this allows for feature check functions to be sync or async
feature_checks.push(Promise.resolve(featureCheckFunctionsMap.get(feature)()));
feature_checks.push(Promise.resolve(fnMap.get(feature)()));
}
Promise.allSettled(feature_checks).then((results) => {
for (let i = 0; i < features_to_check.length; i++) {
if (results[i].status === "fulfilled") {
// @ts-expect-error because .value isn't recognized for some reason
feature_data.set(features_to_check[i], results[i].value);
} else {
feature_data.set(features_to_check[i], null);
}
}
inclusion_check();
});
const results = await Promise.allSettled(feature_checks);
const inclusion_check = async () => {
await check_allow_resize();
if (!this.end_flag && trial.inclusion_function(Object.fromEntries(feature_data))) {
end_trial();
for (let i = 0; i < features_to_check.length; i++) {
if (results[i].status === "fulfilled") {
// @ts-expect-error because .value isn't recognized for some reason
feature_data.set(features_to_check[i], results[i].value);
} else {
end_experiment();
feature_data.set(features_to_check[i], null);
}
};
}
const check_allow_resize = async () => {
const w = feature_data.get("width");
const h = feature_data.get("height");
return feature_data;
}
if (
trial.allow_window_resize &&
(w || h) &&
(trial.minimum_width > 0 || trial.minimum_height > 0)
private async inclusion_check(fn, data) {
await this.check_allow_resize(data);
// screen was too small
if (this.end_flag) {
return false;
}
return fn(Object.fromEntries(data));
}
private async check_allow_resize(feature_data) {
const display_element = this.jsPsych.getDisplayElement();
const w = feature_data.get("width");
const h = feature_data.get("height");
if (
this.t.allow_window_resize &&
(w || h) &&
(this.t.minimum_width > 0 || this.t.minimum_height > 0)
) {
display_element.innerHTML =
this.t.window_resize_message +
`<p><button id="browser-check-max-size-btn" class="jspsych-btn">${this.t.resize_fail_button_text}</button></p>`;
display_element.querySelector("#browser-check-max-size-btn").addEventListener("click", () => {
display_element.innerHTML = "";
this.end_flag = true;
});
const min_width_el = display_element.querySelector("#browser-check-min-width");
const min_height_el = display_element.querySelector("#browser-check-min-height");
const actual_height_el = display_element.querySelector("#browser-check-actual-height");
const actual_width_el = display_element.querySelector("#browser-check-actual-width");
while (
!this.end_flag &&
(window.innerWidth < this.t.minimum_width || window.innerHeight < this.t.minimum_height)
) {
display_element.innerHTML =
trial.window_resize_message +
`<p><button id="browser-check-max-size-btn" class="jspsych-btn">${trial.resize_fail_button_text}</button></p>`;
if (min_width_el) {
min_width_el.innerHTML = this.t.minimum_width.toString();
}
display_element
.querySelector("#browser-check-max-size-btn")
.addEventListener("click", () => {
display_element.innerHTML = "";
this.end_flag = true;
});
if (min_height_el) {
min_height_el.innerHTML = this.t.minimum_height.toString();
}
const min_width_el = display_element.querySelector("#browser-check-min-width");
const min_height_el = display_element.querySelector("#browser-check-min-height");
const actual_height_el = display_element.querySelector("#browser-check-actual-height");
const actual_width_el = display_element.querySelector("#browser-check-actual-width");
if (actual_height_el) {
actual_height_el.innerHTML = window.innerHeight.toString();
}
while (
!this.end_flag &&
(window.innerWidth < trial.minimum_width || window.innerHeight < trial.minimum_height)
) {
if (min_width_el) {
min_width_el.innerHTML = trial.minimum_width.toString();
}
if (actual_width_el) {
actual_width_el.innerHTML = window.innerWidth.toString();
}
if (min_height_el) {
min_height_el.innerHTML = trial.minimum_height.toString();
}
await this.delay(100);
if (actual_height_el) {
actual_height_el.innerHTML = window.innerHeight.toString();
}
feature_data.set("width", window.innerWidth);
feature_data.set("height", window.innerHeight);
}
}
}
if (actual_width_el) {
actual_width_el.innerHTML = window.innerWidth.toString();
}
private end_trial(feature_data) {
this.jsPsych.getDisplayElement().innerHTML = "";
await this.delay(100);
const trial_data = { ...Object.fromEntries(feature_data) };
feature_data.set("width", window.innerWidth);
feature_data.set("height", window.innerHeight);
this.jsPsych.finishTrial(trial_data);
}
private end_experiment(feature_data) {
this.jsPsych.getDisplayElement().innerHTML = "";
const trial_data = { ...Object.fromEntries(feature_data) };
this.jsPsych.endExperiment(this.t.exclusion_message(trial_data), trial_data);
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private async create_simulation_data(trial: TrialType<Info>, simulation_options) {
const featureCheckFunctionsMap = this.create_feature_fn_map(trial);
// measure everything except vsync, which we just fake.
const features_to_check = trial.features.filter((x) => !trial.skip_features.includes(x));
const feature_data = await this.measure_features(
featureCheckFunctionsMap,
features_to_check.filter((x) => x !== "vsync_rate")
);
if (features_to_check.includes("vsync_rate")) {
feature_data.set("vsync_rate", 60);
}
const default_data = Object.fromEntries(feature_data);
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
// don't think this is necessary for this plugin...
// this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
this.create_simulation_data(trial, simulation_options).then((data) => {
if (trial.allow_window_resize) {
if (data.width < trial.minimum_width) {
data.width = trial.minimum_width;
}
if (data.height < trial.minimum_height) {
data.height = trial.minimum_height;
}
}
};
const end_trial = () => {
display_element.innerHTML = "";
// check inclusion function
if (trial.inclusion_function(data)) {
this.jsPsych.finishTrial(data);
} else {
this.jsPsych.endExperiment(trial.exclusion_message(data), data);
}
});
}
const trial_data = { ...Object.fromEntries(feature_data) };
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
this.t = trial;
load_callback();
this.create_simulation_data(trial, simulation_options).then((data) => {
const feature_data = new Map(Object.entries(data));
// run inclusion_check
// if the window size is big enough or the user resizes it within 3 seconds,
// then the plugin's trial code will finish up the trial.
// otherwise we simulate clicking the button and then the code above should
// finish it up too.
this.jsPsych.finishTrial(trial_data);
};
setTimeout(() => {
const btn = document.querySelector("#browser-check-max-size-btn");
if (btn) {
this.jsPsych.pluginAPI.clickTarget(btn);
}
}, 3000);
var end_experiment = () => {
display_element.innerHTML = "";
const trial_data = { ...Object.fromEntries(feature_data) };
this.jsPsych.endExperiment(trial.exclusion_message(trial_data), trial_data);
};
this.inclusion_check(this.t.inclusion_function, feature_data).then((include) => {
if (include) {
this.end_trial(feature_data);
} else {
this.end_experiment(feature_data);
}
});
});
}
}

View File

@ -35,10 +35,10 @@ class CallFunctionPlugin implements JsPsychPlugin<Info> {
trial(display_element: HTMLElement, trial: TrialType<Info>) {
//trial.post_trial_gap = 0; // TO DO: TS error: number not assignable to type any[]. I don't think this param should be an array..?
var return_val;
let return_val;
const end_trial = () => {
var trial_data = {
const trial_data = {
value: return_val,
};
@ -46,7 +46,7 @@ class CallFunctionPlugin implements JsPsychPlugin<Info> {
};
if (trial.async) {
var done = (data) => {
const done = (data) => {
return_val = data;
end_trial();
};
@ -56,6 +56,9 @@ class CallFunctionPlugin implements JsPsychPlugin<Info> {
end_trial();
}
}
// no explicit simulate() mode for this plugin because it would just do
// the same thing as the regular plugin
}
export default CallFunctionPlugin;

View File

@ -16,7 +16,7 @@
],
"source": "src/index.ts",
"scripts": {
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "npm test -- --watch",
"tsc": "tsc",
"build": "rollup --config",

View File

@ -0,0 +1,63 @@
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "jspsych";
import canvasButtonResponse from ".";
function drawRect(c) {
var ctx = c.getContext("2d");
ctx.beginPath();
ctx.rect(150, 225, 200, 50);
ctx.stroke();
}
jest.useFakeTimers();
describe("canvas-button-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: canvasButtonResponse,
stimulus: drawRect,
choices: ["click"],
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(getData().values()[0].response).toBe(0);
});
// can't run this until we mock canvas elements.
test("visual mode works", async () => {
const jsPsych = initJsPsych();
const timeline = [
{
type: canvasButtonResponse,
stimulus: drawRect,
choices: ["click"],
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual",
{},
jsPsych
);
await expectRunning();
expect(getHTML()).toContain("canvas");
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(getData().values()[0].response).toBe(0);
});
});

View File

@ -146,8 +146,8 @@ class CanvasButtonResponsePlugin implements JsPsychPlugin<Info> {
display_element
.querySelector<HTMLButtonElement>("#jspsych-canvas-button-response-button-" + i)
.addEventListener("click", (e: MouseEvent) => {
var choice = e.currentTarget as Element;
choice.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
var btn_el = e.currentTarget as Element;
var choice = btn_el.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
after_response(choice);
});
}
@ -217,6 +217,56 @@ class CanvasButtonResponsePlugin implements JsPsychPlugin<Info> {
}, trial.trial_duration);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.randomization.randomInt(0, trial.choices.length - 1),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector(`div[data-choice="${data.response}"] button`),
data.rt
);
}
}
}
export default CanvasButtonResponsePlugin;

View File

@ -0,0 +1,61 @@
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "jspsych";
import canvasKeyboardResponse from ".";
function drawRect(c) {
var ctx = c.getContext("2d");
ctx.beginPath();
ctx.rect(150, 225, 200, 50);
ctx.stroke();
}
jest.useFakeTimers();
describe("canvas-keyboard-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: canvasKeyboardResponse,
stimulus: drawRect,
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(typeof getData().values()[0].response).toBe("string");
});
// can't run this until we mock canvas elements.
test("visual mode works", async () => {
const jsPsych = initJsPsych();
const timeline = [
{
type: canvasKeyboardResponse,
stimulus: drawRect,
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual",
{},
jsPsych
);
await expectRunning();
expect(getHTML()).toContain("canvas");
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(typeof getData().values()[0].response).toBe("string");
});
});

View File

@ -155,6 +155,53 @@ class CanvasKeyboardResponsePlugin implements JsPsychPlugin<Info> {
}, trial.trial_duration);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.pluginAPI.getValidKey(trial.choices),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
}
export default CanvasKeyboardResponsePlugin;

View File

@ -0,0 +1,65 @@
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "jspsych";
import canvasSliderResponse from ".";
function drawRect(c) {
var ctx = c.getContext("2d");
ctx.beginPath();
ctx.rect(150, 225, 200, 50);
ctx.stroke();
}
jest.useFakeTimers();
describe("canvas-keyboard-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: canvasSliderResponse,
stimulus: drawRect,
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
const data = getData().values()[0];
expect(data.response).toBeGreaterThanOrEqual(0);
expect(data.response).toBeLessThanOrEqual(100);
expect(data.rt).toBeGreaterThan(0);
});
// can't run this until we mock canvas elements.
test("visual mode works", async () => {
const jsPsych = initJsPsych();
const timeline = [
{
type: canvasSliderResponse,
stimulus: drawRect,
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual",
{},
jsPsych
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.response).toBeGreaterThanOrEqual(0);
expect(data.response).toBeLessThanOrEqual(100);
expect(data.rt).toBeGreaterThan(0);
});
});

View File

@ -242,6 +242,60 @@ class CanvasSliderResponsePlugin implements JsPsychPlugin<Info> {
var startTime = performance.now();
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
response: this.jsPsych.randomization.randomInt(trial.min, trial.max),
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
const el = display_element.querySelector<HTMLInputElement>("input[type='range']");
setTimeout(() => {
this.jsPsych.pluginAPI.clickTarget(el);
el.valueAsNumber = data.response;
}, data.rt / 2);
this.jsPsych.pluginAPI.clickTarget(display_element.querySelector("button"), data.rt);
}
}
}
export default CanvasSliderResponsePlugin;

View File

@ -1,4 +1,4 @@
import { pressKey, startTimeline } from "@jspsych/test-utils";
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import categorizeAnimation from ".";
@ -273,3 +273,43 @@ describe("categorize-animation plugin", () => {
await expectFinished();
});
});
describe("categorize-animation plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: categorizeAnimation,
stimuli: ["img/happy_face_1.jpg", "img/sad_face_1.jpg"],
frame_time: 500,
key_answer: "d",
render_on_canvas: false,
},
]);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(1000);
});
test("visual mode works", async () => {
const { getData, expectRunning, expectFinished } = await simulateTimeline(
[
{
type: categorizeAnimation,
stimuli: ["img/happy_face_1.jpg", "img/sad_face_1.jpg"],
frame_time: 500,
key_answer: "d",
render_on_canvas: false,
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(1000);
});
});

View File

@ -272,6 +272,74 @@ class CategorizeAnimationPlugin implements JsPsychPlugin<Info> {
allow_held_key: false,
});
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const animation_length = trial.sequence_reps * trial.frame_time * trial.stimuli.length;
const key = this.jsPsych.pluginAPI.getValidKey(trial.choices);
const default_data = {
stimulus: trial.stimuli,
rt: animation_length + this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: key,
correct: key == trial.key_answer,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
if (data.rt == null || data.response == null) {
throw new Error(`
Simulated response for categorize-animation plugin was invalid.
This could be because the response RT was too fast and generated
before the animation finished when the allow_response_before_complete
parameter is false. In a real experiment this would cause the experiment
to pause indefinitely.`);
} else {
this.jsPsych.finishTrial(data);
}
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
} else {
throw new Error(`
Simulated response for categorize-animation plugin was invalid.
This could be because the response RT was too fast and generated
before the animation finished when the allow_response_before_complete
parameter is false. In a real experiment this would cause the experiment
to pause indefinitely.`);
}
}
}
export default CategorizeAnimationPlugin;

View File

@ -16,7 +16,7 @@
],
"source": "src/index.ts",
"scripts": {
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "npm test -- --watch",
"tsc": "tsc",
"build": "rollup --config",

View File

@ -0,0 +1,68 @@
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import categorizeHtml from ".";
jest.useFakeTimers();
describe("categorize-html plugin", () => {
test("basic functionality works", async () => {
const { getHTML, expectFinished } = await startTimeline([
{
type: categorizeHtml,
stimulus: "FOO",
key_answer: "d",
choices: ["p", "d"],
},
]);
expect(getHTML()).toMatch("FOO");
pressKey("d");
expect(getHTML()).toMatch("Correct");
jest.advanceTimersByTime(2000);
await expectFinished();
});
});
describe("categorize-html plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: categorizeHtml,
stimulus: "FOO",
key_answer: "d",
choices: ["p", "d"],
},
]);
await expectFinished();
const data = getData().values()[0];
expect(["p", "d"].includes(data.response)).toBe(true);
expect(data.correct).toBe(data.response == "d");
});
test("visual mode works", async () => {
const { getData, expectRunning, expectFinished } = await simulateTimeline(
[
{
type: categorizeHtml,
stimulus: "FOO",
key_answer: "d",
choices: ["p", "d"],
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(["p", "d"].includes(data.response)).toBe(true);
expect(data.correct).toBe(data.response == "d");
});
});

View File

@ -221,6 +221,61 @@ class CategorizeHtmlPlugin implements JsPsychPlugin<Info> {
}
};
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const key = this.jsPsych.pluginAPI.getValidKey(trial.choices);
const default_data = {
stimulus: trial.stimulus,
response: key,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
correct: key == trial.key_answer,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
if (trial.force_correct_button_press && !data.correct) {
this.jsPsych.pluginAPI.pressKey(trial.key_answer, data.rt + trial.feedback_duration / 2);
}
}
}
export default CategorizeHtmlPlugin;

View File

@ -16,7 +16,7 @@
],
"source": "src/index.ts",
"scripts": {
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "npm test -- --watch",
"tsc": "tsc",
"build": "rollup --config",

View File

@ -0,0 +1,68 @@
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import categorizeImage from ".";
jest.useFakeTimers();
describe("categorize-image plugin", () => {
test("basic functionality works", async () => {
const { getHTML, expectFinished } = await startTimeline([
{
type: categorizeImage,
stimulus: "FOO.png",
key_answer: "d",
choices: ["p", "d"],
},
]);
expect(getHTML()).toMatch("FOO.png");
pressKey("d");
expect(getHTML()).toMatch("Correct");
jest.advanceTimersByTime(2000);
await expectFinished();
});
});
describe("categorize-html plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: categorizeImage,
stimulus: "FOO.png",
key_answer: "d",
choices: ["p", "d"],
},
]);
await expectFinished();
const data = getData().values()[0];
expect(["p", "d"].includes(data.response)).toBe(true);
expect(data.correct).toBe(data.response == "d");
});
test("visual mode works", async () => {
const { getData, expectRunning, expectFinished } = await simulateTimeline(
[
{
type: categorizeImage,
stimulus: "FOO.png",
key_answer: "d",
choices: ["p", "d"],
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(["p", "d"].includes(data.response)).toBe(true);
expect(data.correct).toBe(data.response == "d");
});
});

View File

@ -37,6 +37,7 @@ const info = <const>{
incorrect_text: {
type: ParameterType.HTML_STRING,
pretty_name: "Incorrect text",
default: "<p class='feedback'>Wrong</p>",
},
/** Any content here will be displayed below the stimulus. */
prompt: {
@ -220,6 +221,61 @@ class CategorizeImagePlugin implements JsPsychPlugin<Info> {
}
};
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const key = this.jsPsych.pluginAPI.getValidKey(trial.choices);
const default_data = {
stimulus: trial.stimulus,
response: key,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
correct: key == trial.key_answer,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
if (trial.force_correct_button_press && !data.correct) {
this.jsPsych.pluginAPI.pressKey(trial.key_answer, data.rt + trial.feedback_duration / 2);
}
}
}
export default CategorizeImagePlugin;

View File

@ -1,10 +1,10 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import cloze from ".";
jest.useFakeTimers();
const getIntpuElementById = (id: string) => document.getElementById(id) as HTMLInputElement;
const getInputElementById = (id: string) => document.getElementById(id) as HTMLInputElement;
describe("cloze", () => {
test("displays cloze", async () => {
@ -68,7 +68,7 @@ describe("cloze", () => {
},
]);
getIntpuElementById("input0").value = "cloze";
getInputElementById("input0").value = "cloze";
clickTarget(document.querySelector("#finish_cloze_button"));
await expectFinished();
});
@ -82,7 +82,7 @@ describe("cloze", () => {
},
]);
getIntpuElementById("input0").value = "some wrong answer";
getInputElementById("input0").value = "some wrong answer";
clickTarget(document.querySelector("#finish_cloze_button"));
await expectRunning();
});
@ -99,7 +99,7 @@ describe("cloze", () => {
},
]);
getIntpuElementById("input0").value = "cloze";
getInputElementById("input0").value = "cloze";
clickTarget(document.querySelector("#finish_cloze_button"));
expect(mistakeFn).not.toHaveBeenCalled();
});
@ -116,21 +116,21 @@ describe("cloze", () => {
},
]);
getIntpuElementById("input0").value = "some wrong answer";
getInputElementById("input0").value = "some wrong answer";
clickTarget(document.querySelector("#finish_cloze_button"));
expect(mistakeFn).toHaveBeenCalled();
});
test("response data is stored as an array", async () => {
const { getData } = await startTimeline([
const { getData, getHTML } = await startTimeline([
{
type: cloze,
text: "This is a %cloze% text. Here is another cloze response box %%.",
},
]);
getIntpuElementById("input0").value = "cloze1";
getIntpuElementById("input1").value = "cloze2";
getInputElementById("input0").value = "cloze1";
getInputElementById("input1").value = "cloze2";
clickTarget(document.querySelector("#finish_cloze_button"));
const data = getData().values()[0].response;
@ -139,3 +139,41 @@ describe("cloze", () => {
expect(data[1]).toBe("cloze2");
});
});
describe("cloze simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: cloze,
text: "This is a %cloze% text. Here is another cloze response box %%.",
},
]);
await expectFinished();
const data = getData().values()[0];
expect(data.response[0]).toBe("cloze");
expect(data.response[1]).not.toBe("");
});
test("visual mode works", async () => {
const { getData, expectFinished, expectRunning } = await simulateTimeline(
[
{
type: cloze,
text: "This is a %cloze% text. Here is another cloze response box %%.",
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.response[0]).toBe("cloze");
expect(data.response[1]).not.toBe("");
});
});

View File

@ -48,14 +48,15 @@ class ClozePlugin implements JsPsychPlugin<Info> {
trial(display_element: HTMLElement, trial: TrialType<Info>) {
var html = '<div class="cloze">';
var elements = trial.text.split("%");
var solutions = [];
const solutions = this.getSolutions(trial.text);
let solution_counter = 0;
for (var i = 0; i < elements.length; i++) {
if (i % 2 === 0) {
html += elements[i];
} else {
solutions.push(elements[i].trim());
html += '<input type="text" id="input' + (solutions.length - 1) + '" value="">';
html += `<input type="text" id="input${solution_counter}" value="">`;
solution_counter++;
}
}
html += "</div>";
@ -98,6 +99,78 @@ class ClozePlugin implements JsPsychPlugin<Info> {
"</button>";
display_element.querySelector("#finish_cloze_button").addEventListener("click", check);
}
private getSolutions(text: string) {
const solutions = [];
const elements = text.split("%");
for (let i = 0; i < elements.length; i++) {
if (i % 2 == 1) {
solutions.push(elements[i].trim());
}
}
return solutions;
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const solutions = this.getSolutions(trial.text);
const responses = [];
for (const word of solutions) {
if (word == "") {
responses.push(this.jsPsych.randomization.randomWords({ exactly: 1 }));
} else {
responses.push(word);
}
}
const default_data = {
response: responses,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
//this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
const inputs = display_element.querySelectorAll('input[type="text"]');
let rt = this.jsPsych.randomization.sampleExGaussian(750, 200, 0.01, true);
for (let i = 0; i < data.response.length; i++) {
this.jsPsych.pluginAPI.fillTextInput(inputs[i] as HTMLInputElement, data.response[i], rt);
rt += this.jsPsych.randomization.sampleExGaussian(750, 200, 0.01, true);
}
this.jsPsych.pluginAPI.clickTarget(display_element.querySelector("#finish_cloze_button"), rt);
}
}
export default ClozePlugin;

View File

@ -16,7 +16,7 @@
],
"source": "src/index.ts",
"scripts": {
"test": "jest --passWithNoTests",
"test": "jest",
"test:watch": "npm test -- --watch",
"tsc": "tsc",
"build": "rollup --config",
@ -38,6 +38,7 @@
},
"devDependencies": {
"@jspsych/config": "^1.0.0",
"@jspsych/test-utils": "^1.0.0"
"@jspsych/test-utils": "^1.0.0",
"jest-fetch-mock": "^3.0.3"
}
}

View File

@ -0,0 +1,74 @@
import { clickTarget, pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import { enableFetchMocks } from "jest-fetch-mock";
import externalHtml from ".";
jest.useFakeTimers();
beforeAll(() => {
enableFetchMocks();
// @ts-ignore mockResponse isn't showing up on fetch. not sure how to fix. runs OK.
fetch.mockResponse(`<p>This is external HTML</p><button id="finished">Click</button>`);
});
describe("external-html", () => {
test("displays external html", async () => {
const { displayElement, getHTML, expectFinished, expectRunning } = await startTimeline([
{
type: externalHtml,
url: "loadme.html",
cont_btn: "finished",
},
]);
await expectRunning();
expect(getHTML()).toMatch("This is external HTML");
clickTarget(displayElement.querySelector("#finished"));
await expectFinished();
});
});
describe("external-html simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished, expectRunning } = await simulateTimeline([
{
type: externalHtml,
url: "loadme.html",
cont_btn: "finished",
},
]);
await expectFinished();
const data = getData().values()[0];
expect(data.rt).toBeGreaterThan(0);
expect(data.url).toBe("loadme.html");
});
test("visual mode works", async () => {
const { getData, expectFinished, expectRunning } = await simulateTimeline(
[
{
type: externalHtml,
url: "loadme.html",
cont_btn: "finished",
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.rt).toBeGreaterThan(0);
expect(data.url).toBe("loadme.html");
});
});

View File

@ -68,14 +68,73 @@ class ExternalHtmlPlugin implements JsPsychPlugin<Info> {
url = trial.url + "?t=" + performance.now();
}
fetch(url)
.then((response) => {
return response.text();
})
.then((html) => {
display_element.innerHTML = html;
on_load();
var t0 = performance.now();
const key_listener = (e) => {
if (this.jsPsych.pluginAPI.compareKeys(e.key, trial.cont_key)) {
finish();
}
};
const finish = () => {
if (trial.check_fn && !trial.check_fn(display_element)) {
return;
}
if (trial.cont_key) {
display_element.removeEventListener("keydown", key_listener);
}
var trial_data = {
rt: Math.round(performance.now() - t0),
url: trial.url,
};
display_element.innerHTML = "";
this.jsPsych.finishTrial(trial_data);
trial_complete();
};
// by default, scripts on the external page are not executed with XMLHttpRequest().
// To activate their content through DOM manipulation, we need to relocate all script tags
if (trial.execute_script) {
// changed for..of getElementsByTagName("script") here to for i loop due to TS error:
// Type 'HTMLCollectionOf<HTMLScriptElement>' must have a '[Symbol.iterator]()' method that returns an iterator.ts(2488)
var all_scripts = display_element.getElementsByTagName("script");
for (var i = 0; i < all_scripts.length; i++) {
const relocatedScript = document.createElement("script");
const curr_script = all_scripts[i];
relocatedScript.text = curr_script.text;
curr_script.parentNode.replaceChild(relocatedScript, curr_script);
}
}
if (trial.cont_btn) {
display_element.querySelector("#" + trial.cont_btn).addEventListener("click", finish);
}
if (trial.cont_key) {
display_element.addEventListener("keydown", key_listener);
}
})
.catch((err) => {
console.error(`Something went wrong with fetch() in plugin-external-html.`, err);
});
// helper to load via XMLHttpRequest
const load = (element, file, callback) => {
/*const load = (element, file, callback) => {
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", file, true);
xmlhttp.onload = () => {
console.log(`loaded ${xmlhttp.status}`)
if (xmlhttp.status == 200 || xmlhttp.status == 0) {
//Check if loaded
element.innerHTML = xmlhttp.responseText;
console.log(`made it ${xmlhttp.responseText}`);
callback();
}
};
@ -83,58 +142,65 @@ class ExternalHtmlPlugin implements JsPsychPlugin<Info> {
};
load(display_element, url, () => {
on_load();
var t0 = performance.now();
const key_listener = (e) => {
if (this.jsPsych.pluginAPI.compareKeys(e.key, trial.cont_key)) {
finish();
}
};
const finish = () => {
if (trial.check_fn && !trial.check_fn(display_element)) {
return;
}
if (trial.cont_key) {
display_element.removeEventListener("keydown", key_listener);
}
var trial_data = {
rt: Math.round(performance.now() - t0),
url: trial.url,
};
display_element.innerHTML = "";
this.jsPsych.finishTrial(trial_data);
trial_complete();
};
// by default, scripts on the external page are not executed with XMLHttpRequest().
// To activate their content through DOM manipulation, we need to relocate all script tags
if (trial.execute_script) {
// changed for..of getElementsByTagName("script") here to for i loop due to TS error:
// Type 'HTMLCollectionOf<HTMLScriptElement>' must have a '[Symbol.iterator]()' method that returns an iterator.ts(2488)
var all_scripts = display_element.getElementsByTagName("script");
for (var i = 0; i < all_scripts.length; i++) {
const relocatedScript = document.createElement("script");
const curr_script = all_scripts[i];
relocatedScript.text = curr_script.text;
curr_script.parentNode.replaceChild(relocatedScript, curr_script);
}
}
if (trial.cont_btn) {
display_element.querySelector("#" + trial.cont_btn).addEventListener("click", finish);
}
if (trial.cont_key) {
display_element.addEventListener("keydown", key_listener);
}
});
*/
return new Promise((resolve) => {
trial_complete = resolve;
});
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
url: trial.url,
rt: this.jsPsych.randomization.sampleExGaussian(2000, 200, 1 / 200, true),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial, () => {
load_callback();
if (trial.cont_key) {
this.jsPsych.pluginAPI.pressKey(trial.cont_key, data.rt);
} else if (trial.cont_btn) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector("#" + trial.cont_btn),
data.rt
);
}
});
}
}
export default ExternalHtmlPlugin;

View File

@ -1,7 +1,9 @@
import { startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import fullscreen from ".";
jest.useFakeTimers();
describe("fullscreen plugin", () => {
beforeEach(() => {
document.documentElement.requestFullscreen = jest
@ -15,14 +17,51 @@ describe("fullscreen plugin", () => {
type: fullscreen,
delay_after: 0,
},
{
type: "html-keyboard-response",
stimulus: "fullscreen",
},
]);
expect(document.documentElement.requestFullscreen).not.toHaveBeenCalled();
document.querySelector("#jspsych-fullscreen-btn").dispatchEvent(new MouseEvent("click", {}));
clickTarget(document.querySelector("#jspsych-fullscreen-btn"));
expect(document.documentElement.requestFullscreen).toHaveBeenCalled();
});
});
describe("fullscreen plugin simulation", () => {
beforeEach(() => {
document.documentElement.requestFullscreen = jest
.fn<Promise<void>, any[]>()
.mockResolvedValue();
});
test("data-only mode works", async () => {
const { expectFinished, getData } = await simulateTimeline([
{
type: fullscreen,
delay_after: 0,
},
]);
await expectFinished();
expect(getData().values()[0].success).toBe(true);
});
test("visual mode works", async () => {
const { expectRunning, expectFinished, getData } = await simulateTimeline(
[
{
type: fullscreen,
delay_after: 0,
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].success).toBe(true);
});
});

View File

@ -51,66 +51,123 @@ class FullscreenPlugin implements JsPsychPlugin<Info> {
constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>) {
const endTrial = () => {
display_element.innerHTML = "";
this.jsPsych.pluginAPI.setTimeout(() => {
var trial_data = {
success: !keyboardNotAllowed,
};
this.jsPsych.finishTrial(trial_data);
}, trial.delay_after);
};
// check if keys are allowed in fullscreen mode
var keyboardNotAllowed = typeof Element !== "undefined" && "ALLOW_KEYBOARD_INPUT" in Element;
if (keyboardNotAllowed) {
// This is Safari, and keyboard events will be disabled. Don't allow fullscreen here.
// do something else?
endTrial();
this.endTrial(display_element, false, trial);
} else {
if (trial.fullscreen_mode) {
display_element.innerHTML =
trial.message +
'<button id="jspsych-fullscreen-btn" class="jspsych-btn">' +
trial.button_label +
"</button>";
var listener = display_element
.querySelector("#jspsych-fullscreen-btn")
.addEventListener("click", () => {
var element = document.documentElement;
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element["mozRequestFullScreen"]) {
element["mozRequestFullScreen"]();
} else if (element["webkitRequestFullscreen"]) {
element["webkitRequestFullscreen"]();
} else if (element["msRequestFullscreen"]) {
element["msRequestFullscreen"]();
}
endTrial();
});
this.showDisplay(display_element, trial);
} else {
if (
document.fullscreenElement ||
document["mozFullScreenElement"] ||
document["webkitFullscreenElement"]
) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document["msExitFullscreen"]) {
document["msExitFullscreen"]();
} else if (document["mozCancelFullScreen"]) {
document["mozCancelFullScreen"]();
} else if (document["webkitExitFullscreen"]) {
document["webkitExitFullscreen"]();
}
}
endTrial();
this.exitFullScreen();
this.endTrial(display_element, true, trial);
}
}
}
private showDisplay(display_element, trial) {
display_element.innerHTML = `
${trial.message}
<button id="jspsych-fullscreen-btn" class="jspsych-btn">${trial.button_label}</button>
`;
display_element.querySelector("#jspsych-fullscreen-btn").addEventListener("click", () => {
this.enterFullScreen();
this.endTrial(display_element, true, trial);
});
}
private endTrial(display_element, success, trial) {
display_element.innerHTML = "";
this.jsPsych.pluginAPI.setTimeout(() => {
var trial_data = {
success: success,
};
this.jsPsych.finishTrial(trial_data);
}, trial.delay_after);
}
private enterFullScreen() {
var element = document.documentElement;
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element["mozRequestFullScreen"]) {
element["mozRequestFullScreen"]();
} else if (element["webkitRequestFullscreen"]) {
element["webkitRequestFullscreen"]();
} else if (element["msRequestFullscreen"]) {
element["msRequestFullscreen"]();
}
}
private exitFullScreen() {
if (
document.fullscreenElement ||
document["mozFullScreenElement"] ||
document["webkitFullscreenElement"]
) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document["msExitFullscreen"]) {
document["msExitFullscreen"]();
} else if (document["mozCancelFullScreen"]) {
document["mozCancelFullScreen"]();
} else if (document["webkitExitFullscreen"]) {
document["webkitExitFullscreen"]();
}
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
success: true,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
if (data.success === false) {
this.endTrial(display_element, false, trial);
} else {
this.trial(display_element, trial);
load_callback();
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector("#jspsych-fullscreen-btn"),
this.jsPsych.randomization.sampleExGaussian(1000, 100, 1 / 200, true)
);
}
}
}
export default FullscreenPlugin;

View File

@ -1,4 +1,4 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import htmlButtonResponse from ".";
@ -154,3 +154,54 @@ describe("html-button-response", () => {
);
});
});
describe("html-button-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: htmlButtonResponse,
stimulus: "foo",
choices: ["a", "b", "c"],
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
const response = getData().values()[0].response;
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(response).toBeGreaterThanOrEqual(0);
expect(response).toBeLessThanOrEqual(2);
});
test("visual mode works", async () => {
const timeline = [
{
type: htmlButtonResponse,
stimulus: "foo",
choices: ["a", "b", "c"],
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual"
);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectFinished();
const response = getData().values()[0].response;
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(response).toBeGreaterThanOrEqual(0);
expect(response).toBeLessThanOrEqual(2);
});
});

View File

@ -196,6 +196,57 @@ class HtmlButtonResponsePlugin implements JsPsychPlugin<Info> {
this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.randomization.randomInt(0, trial.choices.length - 1),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector(`div[data-choice="${data.response}"] button`),
data.rt
);
}
}
}
export default HtmlButtonResponsePlugin;

View File

@ -1,4 +1,4 @@
import { pressKey, startTimeline } from "@jspsych/test-utils";
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import htmlKeyboardResponse from ".";
@ -134,3 +134,46 @@ describe("html-keyboard-response", () => {
await expectRunning();
});
});
describe("html-keyboard-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(typeof getData().values()[0].response).toBe("string");
});
test("visual mode works", async () => {
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual"
);
await expectRunning();
expect(getHTML()).toContain("foo");
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(typeof getData().values()[0].response).toBe("string");
});
});

View File

@ -152,6 +152,54 @@ class HtmlKeyboardResponsePlugin implements JsPsychPlugin<Info> {
this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.pluginAPI.getValidKey(trial.choices),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
}
}
export default HtmlKeyboardResponsePlugin;

View File

@ -1,4 +1,4 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import htmlSliderResponse from ".";
@ -142,3 +142,50 @@ describe("html-slider-response", () => {
await expectFinished();
});
});
describe("html-slider-response simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: htmlSliderResponse,
stimulus: "this is html",
labels: ["left", "right"],
button_label: "button",
},
]);
await expectFinished();
const data = getData().values()[0];
expect(data.response).toBeGreaterThanOrEqual(0);
expect(data.response).toBeLessThanOrEqual(100);
expect(data.rt).toBeGreaterThan(0);
});
test("data-only mode works", async () => {
const { getData, expectRunning, expectFinished } = await simulateTimeline(
[
{
type: htmlSliderResponse,
stimulus: "this is html",
labels: ["left", "right"],
button_label: "button",
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.response).toBeGreaterThanOrEqual(0);
expect(data.response).toBeLessThanOrEqual(100);
expect(data.rt).toBeGreaterThan(0);
});
});

View File

@ -235,6 +235,62 @@ class HtmlSliderResponsePlugin implements JsPsychPlugin<Info> {
var startTime = performance.now();
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
slider_start: trial.slider_start,
response: this.jsPsych.randomization.randomInt(trial.min, trial.max),
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
const el = display_element.querySelector<HTMLInputElement>("input[type='range']");
setTimeout(() => {
this.jsPsych.pluginAPI.clickTarget(el);
el.valueAsNumber = data.response;
}, data.rt / 2);
this.jsPsych.pluginAPI.clickTarget(display_element.querySelector("button"), data.rt);
}
}
}
export default HtmlSliderResponsePlugin;

View File

@ -1,11 +1,11 @@
import { pressKey, startTimeline } from "@jspsych/test-utils";
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import iatHtml from ".";
jest.useFakeTimers();
describe("iat-html plugin", () => {
test("displays image by default", async () => {
test("displays html by default", async () => {
const { getHTML, expectFinished } = await startTimeline([
{
type: iatHtml,
@ -281,3 +281,53 @@ describe("iat-html plugin", () => {
await expectFinished();
});
});
describe("iat-html plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: iatHtml,
stimulus: "<p>dogs</p>",
response_ends_trial: true,
display_feedback: false,
left_category_key: "f",
right_category_key: "j",
left_category_label: ["FRIENDLY"],
right_category_label: ["UNFRIENDLY"],
stim_key_association: "left",
},
]);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
});
test("visual mode works", async () => {
const { getData, expectFinished, expectRunning } = await simulateTimeline(
[
{
type: iatHtml,
stimulus: "<p>dogs</p>",
response_ends_trial: true,
display_feedback: false,
left_category_key: "f",
right_category_key: "j",
left_category_label: ["FRIENDLY"],
right_category_label: ["UNFRIENDLY"],
stim_key_association: "left",
//trial_duration: 500,
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
});
});

View File

@ -299,6 +299,79 @@ class IatHtmlPlugin implements JsPsychPlugin<Info> {
}, trial.trial_duration);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const key = this.jsPsych.pluginAPI.getValidKey([
trial.left_category_key,
trial.right_category_key,
]);
const correct =
trial.stim_key_association == "left"
? key == trial.left_category_key
: key == trial.right_category_key;
const default_data = {
stimulus: trial.stimulus,
response: key,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
correct: correct,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.response !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
const cont_rt = data.rt == null ? trial.trial_duration : data.rt;
if (trial.force_correct_key_press) {
if (!data.correct) {
this.jsPsych.pluginAPI.pressKey(
trial.stim_key_association == "left" ? trial.left_category_key : trial.right_category_key,
cont_rt + this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true)
);
}
} else {
this.jsPsych.pluginAPI.pressKey(
this.jsPsych.pluginAPI.getValidKey(trial.key_to_move_forward),
cont_rt + this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true)
);
}
}
}
export default IatHtmlPlugin;

View File

@ -1,4 +1,4 @@
import { pressKey, startTimeline } from "@jspsych/test-utils";
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import iatImage from ".";
@ -334,3 +334,53 @@ describe("iat-image plugin", () => {
await expectFinished();
});
});
describe("iat-image plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: iatImage,
stimulus: "dog.png",
response_ends_trial: true,
display_feedback: false,
left_category_key: "f",
right_category_key: "j",
left_category_label: ["FRIENDLY"],
right_category_label: ["UNFRIENDLY"],
stim_key_association: "left",
},
]);
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
});
test("visual mode works", async () => {
const { getData, expectFinished, expectRunning } = await simulateTimeline(
[
{
type: iatImage,
stimulus: "dog.png",
response_ends_trial: true,
display_feedback: false,
left_category_key: "f",
right_category_key: "j",
left_category_label: ["FRIENDLY"],
right_category_label: ["UNFRIENDLY"],
stim_key_association: "left",
//trial_duration: 500,
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].rt).toBeGreaterThan(0);
});
});

View File

@ -299,6 +299,80 @@ class IatImagePlugin implements JsPsychPlugin<Info> {
}, trial.trial_duration);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const key = this.jsPsych.pluginAPI.getValidKey([
trial.left_category_key,
trial.right_category_key,
]);
const correct =
trial.stim_key_association == "left"
? key == trial.left_category_key
: key == trial.right_category_key;
const default_data = {
stimulus: trial.stimulus,
response: key,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
correct: correct,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.response !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
const cont_rt = data.rt == null ? trial.trial_duration : data.rt;
if (trial.force_correct_key_press) {
if (!data.correct) {
this.jsPsych.pluginAPI.pressKey(
trial.stim_key_association == "left" ? trial.left_category_key : trial.right_category_key,
cont_rt + this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true)
);
}
} else {
this.jsPsych.pluginAPI.pressKey(
this.jsPsych.pluginAPI.getValidKey(trial.key_to_move_forward),
cont_rt + this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true)
);
}
}
}
export default IatImagePlugin;

View File

@ -1,4 +1,4 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import imageButtonResponse from ".";
@ -154,3 +154,54 @@ describe("image-button-response", () => {
spy.mockRestore();
});
});
describe("image-button-response simulation", () => {
test("data mode works", async () => {
const timeline = [
{
type: imageButtonResponse,
stimulus: "foo.png",
choices: ["a", "b", "c"],
render_on_canvas: false,
},
];
const { expectFinished, getData } = await simulateTimeline(timeline);
await expectFinished();
const response = getData().values()[0].response;
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(response).toBeGreaterThanOrEqual(0);
expect(response).toBeLessThanOrEqual(2);
});
test("visual mode works", async () => {
const timeline = [
{
type: imageButtonResponse,
stimulus: "foo.png",
choices: ["a", "b", "c"],
render_on_canvas: false,
},
];
const { expectFinished, expectRunning, getHTML, getData } = await simulateTimeline(
timeline,
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const response = getData().values()[0].response;
expect(getData().values()[0].rt).toBeGreaterThan(0);
expect(response).toBeGreaterThanOrEqual(0);
expect(response).toBeLessThanOrEqual(2);
});
});

View File

@ -355,6 +355,57 @@ class ImageButtonResponsePlugin implements JsPsychPlugin<Info> {
);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.randomization.randomInt(0, trial.choices.length - 1),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector(`div[data-choice="${data.response}"] button`),
data.rt
);
}
}
}
export default ImageButtonResponsePlugin;

View File

@ -260,6 +260,54 @@ class ImageKeyboardResponsePlugin implements JsPsychPlugin<Info> {
);
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
this.jsPsych.pluginAPI.pressKey(data.response, data.rt);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
response: this.jsPsych.pluginAPI.getValidKey(trial.choices),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
}
export default ImageKeyboardResponsePlugin;

View File

@ -1,4 +1,4 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import imageSliderResponse from ".";
@ -167,3 +167,50 @@ describe("image-slider-response", () => {
await expectFinished();
});
});
describe("image-slider-response simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: imageSliderResponse,
stimulus: "foo.png",
labels: ["left", "right"],
button_label: "button",
},
]);
await expectFinished();
const data = getData().values()[0];
expect(data.response).toBeGreaterThanOrEqual(0);
expect(data.response).toBeLessThanOrEqual(100);
expect(data.rt).toBeGreaterThan(0);
});
test("data-only mode works", async () => {
const { getData, expectRunning, expectFinished } = await simulateTimeline(
[
{
type: imageSliderResponse,
stimulus: "foo.png",
labels: ["left", "right"],
button_label: "button",
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.response).toBeGreaterThanOrEqual(0);
expect(data.response).toBeLessThanOrEqual(100);
expect(data.rt).toBeGreaterThan(0);
});
});

View File

@ -420,6 +420,62 @@ class ImageSliderResponsePlugin implements JsPsychPlugin<Info> {
var startTime = performance.now();
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const default_data = {
stimulus: trial.stimulus,
slider_start: trial.slider_start,
response: this.jsPsych.randomization.randomInt(trial.min, trial.max),
rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
if (data.rt !== null) {
const el = display_element.querySelector<HTMLInputElement>("input[type='range']");
setTimeout(() => {
this.jsPsych.pluginAPI.clickTarget(el);
el.valueAsNumber = data.response;
}, data.rt / 2);
this.jsPsych.pluginAPI.clickTarget(display_element.querySelector("button"), data.rt);
}
}
}
export default ImageSliderResponsePlugin;

View File

@ -1,7 +1,9 @@
import { pressKey, startTimeline } from "@jspsych/test-utils";
import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import instructions from ".";
jest.useFakeTimers();
describe("instructions plugin", () => {
test("keys can be specified as strings", async () => {
const { getHTML, expectFinished } = await startTimeline([
@ -62,3 +64,44 @@ describe("instructions plugin", () => {
expect(data[1].page_index).toEqual(1);
});
});
describe("instructions plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: instructions,
pages: ["page 1", "page 2", "page 3", "page 4", "page 5", "page 6"],
},
]);
await expectFinished();
const data = getData().values()[0];
expect(data.view_history.length).toBeGreaterThanOrEqual(6);
expect(data.view_history[data.view_history.length - 1].page_index).toBe(5);
});
test("visual mode works", async () => {
const { getData, expectRunning, expectFinished } = await simulateTimeline(
[
{
type: instructions,
pages: ["page 1", "page 2", "page 3", "page 4", "page 5", "page 6"],
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
const data = getData().values()[0];
expect(data.view_history.length).toBeGreaterThanOrEqual(6);
expect(data.view_history[data.view_history.length - 1].page_index).toBe(5);
});
});

View File

@ -239,6 +239,107 @@ class InstructionsPlugin implements JsPsychPlugin<Info> {
});
}
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
let curr_page = 0;
let rt = 0;
const 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) {
curr_page++;
} else {
curr_page--;
}
}
}
const default_data = {
view_history: view_history,
rt: rt,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
const advance = (rt) => {
if (trial.allow_keys) {
this.jsPsych.pluginAPI.pressKey(trial.key_forward, rt);
} else if (trial.show_clickable_nav) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector("#jspsych-instructions-next"),
rt
);
}
};
const backup = (rt) => {
if (trial.allow_keys) {
this.jsPsych.pluginAPI.pressKey(trial.key_backward, rt);
} else if (trial.show_clickable_nav) {
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector("#jspsych-instructions-back"),
rt
);
}
};
let curr_page = 0;
let t = 0;
for (let i = 0; i < data.view_history.length; i++) {
if (i == data.view_history.length - 1) {
advance(t + data.view_history[i].viewing_time);
} else {
if (data.view_history[i + 1].page_index > curr_page) {
advance(t + data.view_history[i].viewing_time);
}
if (data.view_history[i + 1].page_index < curr_page) {
backup(t + data.view_history[i].viewing_time);
}
t += data.view_history[i].viewing_time;
curr_page = data.view_history[i + 1].page_index;
}
}
}
}
export default InstructionsPlugin;

View File

@ -1,7 +1,9 @@
import { clickTarget, startTimeline } from "@jspsych/test-utils";
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
import maxdiff from ".";
jest.useFakeTimers();
describe("maxdiff plugin", () => {
test("returns appropriate response with randomization", async () => {
const { getData, expectFinished } = await startTimeline([
@ -22,3 +24,44 @@ describe("maxdiff plugin", () => {
expect(getData().values()[0].response).toEqual({ left: "a", right: "b" });
});
});
describe("maxdiff plugin simulation", () => {
test("data-only mode works", async () => {
const { getData, expectFinished } = await simulateTimeline([
{
type: maxdiff,
alternatives: ["a", "b", "c", "d"],
labels: ["Most", "Least"],
required: true,
},
]);
await expectFinished();
expect(getData().values()[0].response.left).not.toBeNull();
expect(getData().values()[0].response.right).not.toBeNull();
});
test("visual mode works", async () => {
const { getData, expectFinished, expectRunning } = await simulateTimeline(
[
{
type: maxdiff,
alternatives: ["a", "b", "c", "d"],
labels: ["Most", "Least"],
required: true,
},
],
"visual"
);
await expectRunning();
jest.runAllTimers();
await expectFinished();
expect(getData().values()[0].response.left).not.toBeNull();
expect(getData().values()[0].response.right).not.toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More