diff --git a/.changeset/eighty-chairs-happen.md b/.changeset/eighty-chairs-happen.md new file mode 100644 index 00000000..f164852a --- /dev/null +++ b/.changeset/eighty-chairs-happen.md @@ -0,0 +1,5 @@ +--- +"@jspsych/test-utils": minor +--- + +Add `simulateTimeline()` testing utility that mimics startTimeline but calls `jsPsych.simulate()` instead. diff --git a/.changeset/few-teachers-beg.md b/.changeset/few-teachers-beg.md new file mode 100644 index 00000000..a7005863 --- /dev/null +++ b/.changeset/few-teachers-beg.md @@ -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. diff --git a/.changeset/fifty-cameras-remember.md b/.changeset/fifty-cameras-remember.md new file mode 100644 index 00000000..96f60cc8 --- /dev/null +++ b/.changeset/fifty-cameras-remember.md @@ -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. diff --git a/.changeset/hungry-bats-hide.md b/.changeset/hungry-bats-hide.md new file mode 100644 index 00000000..2639a47d --- /dev/null +++ b/.changeset/hungry-bats-hide.md @@ -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`. diff --git a/.changeset/khaki-fishes-pull.md b/.changeset/khaki-fishes-pull.md new file mode 100644 index 00000000..db9be390 --- /dev/null +++ b/.changeset/khaki-fishes-pull.md @@ -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. diff --git a/.changeset/large-lions-leap.md b/.changeset/large-lions-leap.md new file mode 100644 index 00000000..8287c928 --- /dev/null +++ b/.changeset/large-lions-leap.md @@ -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. diff --git a/.changeset/lazy-parents-sort.md b/.changeset/lazy-parents-sort.md new file mode 100644 index 00000000..30037f0a --- /dev/null +++ b/.changeset/lazy-parents-sort.md @@ -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. diff --git a/.changeset/proud-rings-warn.md b/.changeset/proud-rings-warn.md new file mode 100644 index 00000000..973bd4a0 --- /dev/null +++ b/.changeset/proud-rings-warn.md @@ -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. diff --git a/.changeset/strong-crabs-nail.md b/.changeset/strong-crabs-nail.md new file mode 100644 index 00000000..06366807 --- /dev/null +++ b/.changeset/strong-crabs-nail.md @@ -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. diff --git a/.changeset/tidy-tomatoes-cross.md b/.changeset/tidy-tomatoes-cross.md new file mode 100644 index 00000000..7388dc53 --- /dev/null +++ b/.changeset/tidy-tomatoes-cross.md @@ -0,0 +1,5 @@ +--- +"jspsych": minor +--- + +Added several functions to the `pluginAPI` module in order to support the new simulation feature. diff --git a/.changeset/tricky-vans-sell.md b/.changeset/tricky-vans-sell.md new file mode 100644 index 00000000..f5622731 --- /dev/null +++ b/.changeset/tricky-vans-sell.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-categorize-image": patch +--- + +Fixed a bug where the default value of `incorrect_text` was not defined. diff --git a/docs/developers/plugin-development.md b/docs/developers/plugin-development.md index f8857c6e..1cede3f7 100644 --- a/docs/developers/plugin-development.md +++ b/docs/developers/plugin-development.md @@ -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). diff --git a/docs/overview/simulation.md b/docs/overview/simulation.md new file mode 100644 index 00000000..10bc1046 --- /dev/null +++ b/docs/overview/simulation.md @@ -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: '

Hello!

', + 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: '

Hello!

', + 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: '

This is gonna take a bit.

', + 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. + + diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index 2e80fca0..33b6cc22 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -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. diff --git a/docs/plugins/audio-button-response.md b/docs/plugins/audio-button-response.md index f6e6bf2d..fde24a66 100644 --- a/docs/plugins/audio-button-response.md +++ b/docs/plugins/audio-button-response.md @@ -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" diff --git a/docs/plugins/audio-keyboard-response.md b/docs/plugins/audio-keyboard-response.md index 468cd62a..8d281841 100644 --- a/docs/plugins/audio-keyboard-response.md +++ b/docs/plugins/audio-keyboard-response.md @@ -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" diff --git a/docs/plugins/audio-slider-response.md b/docs/plugins/audio-slider-response.md index 57349975..dc121896 100644 --- a/docs/plugins/audio-slider-response.md +++ b/docs/plugins/audio-slider-response.md @@ -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" diff --git a/docs/plugins/browser-check.md b/docs/plugins/browser-check.md index 49599cc1..882f0eeb 100644 --- a/docs/plugins/browser-check.md +++ b/docs/plugins/browser-check.md @@ -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" diff --git a/docs/plugins/external-html.md b/docs/plugins/external-html.md index 8dd6af5b..40f0f294 100644 --- a/docs/plugins/external-html.md +++ b/docs/plugins/external-html.md @@ -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 diff --git a/docs/plugins/free-sort.md b/docs/plugins/free-sort.md index b3ae552f..725846d2 100644 --- a/docs/plugins/free-sort.md +++ b/docs/plugins/free-sort.md @@ -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" diff --git a/docs/plugins/fullscreen.md b/docs/plugins/fullscreen.md index 352ea3b9..07891c87 100644 --- a/docs/plugins/fullscreen.md +++ b/docs/plugins/fullscreen.md @@ -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 diff --git a/docs/plugins/preload.md b/docs/plugins/preload.md index 9b89cc9c..8ff11796 100644 --- a/docs/plugins/preload.md +++ b/docs/plugins/preload.md @@ -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 diff --git a/docs/plugins/resize.md b/docs/plugins/resize.md index 1d9d61ea..57476d3e 100644 --- a/docs/plugins/resize.md +++ b/docs/plugins/resize.md @@ -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." diff --git a/docs/plugins/sketchpad.md b/docs/plugins/sketchpad.md index 429fb925..b6e8f43b 100644 --- a/docs/plugins/sketchpad.md +++ b/docs/plugins/sketchpad.md @@ -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 diff --git a/docs/plugins/survey-html-form.md b/docs/plugins/survey-html-form.md index a35f4343..44fe112c 100644 --- a/docs/plugins/survey-html-form.md +++ b/docs/plugins/survey-html-form.md @@ -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" diff --git a/docs/plugins/survey-text.md b/docs/plugins/survey-text.md index 9abe5065..0d6639cf 100644 --- a/docs/plugins/survey-text.md +++ b/docs/plugins/survey-text.md @@ -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. diff --git a/docs/plugins/video-button-response.md b/docs/plugins/video-button-response.md index 037e6067..566274e8 100644 --- a/docs/plugins/video-button-response.md +++ b/docs/plugins/video-button-response.md @@ -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" diff --git a/docs/plugins/video-keyboard-response.md b/docs/plugins/video-keyboard-response.md index be166e55..033fc33b 100644 --- a/docs/plugins/video-keyboard-response.md +++ b/docs/plugins/video-keyboard-response.md @@ -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" diff --git a/docs/plugins/video-slider-response.md b/docs/plugins/video-slider-response.md index a68edf43..39631f47 100644 --- a/docs/plugins/video-slider-response.md +++ b/docs/plugins/video-slider-response.md @@ -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" diff --git a/docs/plugins/virtual-chinrest.md b/docs/plugins/virtual-chinrest.md index 3a0d4c83..aae315ac 100644 --- a/docs/plugins/virtual-chinrest.md +++ b/docs/plugins/virtual-chinrest.md @@ -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" diff --git a/docs/plugins/webgazer-calibrate.md b/docs/plugins/webgazer-calibrate.md index 6c0f41fd..13038472 100644 --- a/docs/plugins/webgazer-calibrate.md +++ b/docs/plugins/webgazer-calibrate.md @@ -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. diff --git a/docs/plugins/webgazer-init-camera.md b/docs/plugins/webgazer-init-camera.md index 458e369b..921af4cb 100644 --- a/docs/plugins/webgazer-init-camera.md +++ b/docs/plugins/webgazer-init-camera.md @@ -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. diff --git a/docs/plugins/webgazer-validate.md b/docs/plugins/webgazer-validate.md index b84dc590..23692ef8 100644 --- a/docs/plugins/webgazer-validate.md +++ b/docs/plugins/webgazer-validate.md @@ -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. diff --git a/docs/reference/jspsych-pluginAPI.md b/docs/reference/jspsych-pluginAPI.md index 2af82698..32e56fc7 100644 --- a/docs/reference/jspsych-pluginAPI.md +++ b/docs/reference/jspsych-pluginAPI.md @@ -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 `` 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 `` 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); ``` + diff --git a/docs/reference/jspsych-randomization.md b/docs/reference/jspsych-randomization.md index cfbd6dd6..8a18234e 100644 --- a/docs/reference/jspsych-randomization.md +++ b/docs/reference/jspsych-randomization.md @@ -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 diff --git a/examples/jspsych-survey-multi-choice.html b/examples/jspsych-survey-multi-choice.html index a0d219ba..0eae924c 100644 --- a/examples/jspsych-survey-multi-choice.html +++ b/examples/jspsych-survey-multi-choice.html @@ -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 = { diff --git a/examples/simulation-data-only-mode.html b/examples/simulation-data-only-mode.html new file mode 100644 index 00000000..55975d8c --- /dev/null +++ b/examples/simulation-data-only-mode.html @@ -0,0 +1,77 @@ + + + + + + + + + + \ No newline at end of file diff --git a/examples/simulation-visual-mode.html b/examples/simulation-visual-mode.html new file mode 100644 index 00000000..f2950f4e --- /dev/null +++ b/examples/simulation-visual-mode.html @@ -0,0 +1,84 @@ + + + + + + + + + + + + diff --git a/mkdocs.yml b/mkdocs.yml index c7a44962..6ad791f6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/package-lock.json b/package-lock.json index efc0e613..cfcab522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4188,6 +4188,11 @@ "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==" + }, "node_modules/browserslist": { "version": "4.17.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.17.2.tgz", @@ -4898,6 +4903,24 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz", + "integrity": "sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==", + "dev": true, + "dependencies": { + "node-fetch": "2.6.1" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "dev": true, + "engines": { + "node": "4.x || >=6.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -5228,6 +5251,14 @@ "node": ">=0.10.0" } }, + "node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "27.0.6", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz", @@ -5356,6 +5387,55 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", + "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", + "dependencies": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.4", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.1", + "is-string": "^1.0.7", + "is-weakref": "^1.0.1", + "object-inspect": "^1.11.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es5-ext": { "version": "0.10.53", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", @@ -6081,6 +6161,39 @@ "node": ">= 0.10" } }, + "node_modules/flat": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz", + "integrity": "sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA==", + "dependencies": { + "is-buffer": "~2.0.3" + }, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, "node_modules/flush-write-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", @@ -6316,6 +6429,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -6733,6 +6861,14 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "engines": { + "node": ">=4.x" + } + }, "node_modules/gulp": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", @@ -6897,6 +7033,14 @@ "node": ">= 0.4.0" } }, + "node_modules/has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -6916,6 +7060,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -6979,6 +7137,14 @@ "node": ">=0.10.0" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -7285,6 +7451,19 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "dependencies": { + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -7329,6 +7508,17 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7340,6 +7530,21 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -7357,6 +7562,17 @@ "node": ">=6" } }, + "node_modules/is-callable": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", + "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -7391,6 +7607,20 @@ "node": ">=0.10.0" } }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-descriptor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", @@ -7483,6 +7713,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7491,6 +7732,20 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", + "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", @@ -7530,6 +7785,21 @@ "@types/estree": "*" } }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -7550,6 +7820,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", + "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7561,6 +7839,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -7573,6 +7865,20 @@ "node": ">=4" } }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -7614,6 +7920,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz", + "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==", + "dependencies": { + "call-bind": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -8391,6 +8708,16 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "27.0.6", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", @@ -10609,6 +10936,368 @@ "node": ">=10" } }, + "node_modules/mocha": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz", + "integrity": "sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==", + "dependencies": { + "ansi-colors": "3.2.3", + "browser-stdout": "1.3.1", + "chokidar": "3.3.0", + "debug": "3.2.6", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "find-up": "3.0.0", + "glob": "7.1.3", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "3.13.1", + "log-symbols": "3.0.0", + "minimatch": "3.0.4", + "mkdirp": "0.5.5", + "ms": "2.1.1", + "node-environment-flags": "1.0.6", + "object.assign": "4.1.0", + "strip-json-comments": "2.0.1", + "supports-color": "6.0.0", + "which": "1.3.1", + "wide-align": "1.1.3", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", + "yargs-unparser": "1.6.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/ansi-colors": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", + "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/chokidar": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", + "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", + "dependencies": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.2.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.1.1" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/mocha/node_modules/debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + }, + "node_modules/mocha/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "deprecated": "\"Please update to latest v2.3 or v2.2\"", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/mocha/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha/node_modules/js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/mocha/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dependencies": { + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "node_modules/mocha/node_modules/object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dependencies": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mocha/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", + "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", + "dependencies": { + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mocha/node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "node_modules/mocha/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", + "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/mocha/node_modules/which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + }, + "node_modules/mocha/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/mocha/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -10663,6 +11352,15 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" }, + "node_modules/node-environment-flags": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", + "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", + "dependencies": { + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + } + }, "node_modules/node-fetch": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.5.tgz", @@ -10857,6 +11555,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", + "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -10907,6 +11613,22 @@ "node": ">=0.10.0" } }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz", + "integrity": "sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object.map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", @@ -11477,6 +12199,12 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-polyfill": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.1.tgz", + "integrity": "sha512-3p9zj0cEHbp7NVUxEYUWjQlffXqnXaZIMPkAO7HhFh8u5636xLRDHOUo2vpWSK0T2mqm6fKLXYn1KP6PAZ2gKg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.1.tgz", @@ -11556,6 +12284,14 @@ "node": ">=8" } }, + "node_modules/random-words": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/random-words/-/random-words-1.1.1.tgz", + "integrity": "sha512-Rdk5EoQePyt9Tz3RjeMELi2BSaCI+jDiOkBr4U+3fyBRiiW3qqEuaegGAUMOZ4yGWlQscFQGqQpdic3mAbNkrw==", + "dependencies": { + "mocha": "^7.1.1" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -12254,6 +12990,19 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.5.tgz", @@ -13029,6 +13778,30 @@ "node": ">=4" } }, + "node_modules/string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -13091,6 +13864,14 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -13721,6 +14502,20 @@ "node": ">=4.2.0" } }, + "node_modules/unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dependencies": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -14123,6 +14918,21 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", @@ -14341,6 +15151,186 @@ "node": ">=6" } }, + "node_modules/yargs-unparser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", + "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", + "dependencies": { + "flat": "^4.1.0", + "lodash": "^4.17.15", + "yargs": "^13.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/yargs-unparser/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + }, + "node_modules/yargs-unparser/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/yargs-unparser/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "engines": { + "node": ">=4" + } + }, + "node_modules/yargs-unparser/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "engines": { + "node": ">=4" + } + }, + "node_modules/yargs-unparser/node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "node_modules/yargs-unparser/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + }, + "node_modules/yargs-unparser/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/yargs-unparser/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/yargs-unparser/node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "node_modules/yargs/node_modules/ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", @@ -14585,7 +15575,8 @@ "version": "7.0.0", "license": "MIT", "dependencies": { - "auto-bind": "^4.0.0" + "auto-bind": "^4.0.0", + "random-words": "^1.1.1" }, "devDependencies": { "@jspsych/config": "^1.0.0", @@ -14757,7 +15748,8 @@ "license": "MIT", "devDependencies": { "@jspsych/config": "^1.0.0", - "@jspsych/test-utils": "^1.0.0" + "@jspsych/test-utils": "^1.0.0", + "jest-fetch-mock": "^3.0.3" }, "peerDependencies": { "jspsych": ">=7.0.0" @@ -17073,7 +18065,8 @@ "version": "file:packages/plugin-external-html", "requires": { "@jspsych/config": "^1.0.0", - "@jspsych/test-utils": "^1.0.0" + "@jspsych/test-utils": "^1.0.0", + "jest-fetch-mock": "^3.0.3" } }, "@jspsych/plugin-free-sort": { @@ -18607,6 +19600,11 @@ "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==" + }, "browserslist": { "version": "4.17.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.17.2.tgz", @@ -19155,6 +20153,23 @@ "yaml": "^1.10.0" } }, + "cross-fetch": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz", + "integrity": "sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==", + "dev": true, + "requires": { + "node-fetch": "2.6.1" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "dev": true + } + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -19415,6 +20430,11 @@ "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", "dev": true }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" + }, "diff-sequences": { "version": "27.0.6", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz", @@ -19520,6 +20540,43 @@ "is-arrayish": "^0.2.1" } }, + "es-abstract": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", + "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.4", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.1", + "is-string": "^1.0.7", + "is-weakref": "^1.0.1", + "object-inspect": "^1.11.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, "es5-ext": { "version": "0.10.53", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", @@ -20105,6 +21162,21 @@ "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==" }, + "flat": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz", + "integrity": "sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA==", + "requires": { + "is-buffer": "~2.0.3" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==" + } + } + }, "flush-write-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", @@ -20289,6 +21361,15 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, "get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -20627,6 +21708,11 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==" + }, "gulp": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", @@ -20750,6 +21836,11 @@ "function-bind": "^1.1.1" } }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -20760,6 +21851,14 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "requires": { + "has-symbols": "^1.0.2" + } + }, "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -20812,6 +21911,11 @@ } } }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -21058,6 +22162,16 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "requires": { + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + } + }, "interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -21090,6 +22204,14 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "requires": { + "has-bigints": "^1.0.1" + } + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -21098,6 +22220,15 @@ "binary-extensions": "^2.0.0" } }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -21112,6 +22243,11 @@ "builtin-modules": "^3.0.0" } }, + "is-callable": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", + "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==" + }, "is-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", @@ -21137,6 +22273,14 @@ "kind-of": "^6.0.0" } }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-descriptor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", @@ -21204,11 +22348,24 @@ "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=" }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==" + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, + "is-number-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", + "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", @@ -21239,6 +22396,15 @@ "@types/estree": "*" } }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, "is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -21253,11 +22419,24 @@ "is-unc-path": "^1.0.0" } }, + "is-shared-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", + "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==" + }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -21267,6 +22446,14 @@ "better-path-resolve": "1.0.0" } }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "requires": { + "has-symbols": "^1.0.2" + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -21296,6 +22483,14 @@ "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", "integrity": "sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao=" }, + "is-weakref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz", + "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==", + "requires": { + "call-bind": "^1.0.0" + } + }, "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -21857,6 +23052,16 @@ "jest-util": "^27.2.4" } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "27.0.6", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.0.6.tgz", @@ -22794,7 +23999,8 @@ "requires": { "@jspsych/config": "^1.0.0", "@jspsych/test-utils": "^1.0.0", - "auto-bind": "^4.0.0" + "auto-bind": "^4.0.0", + "random-words": "^1.1.1" } }, "just-debounce": { @@ -23523,6 +24729,285 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, + "mocha": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz", + "integrity": "sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==", + "requires": { + "ansi-colors": "3.2.3", + "browser-stdout": "1.3.1", + "chokidar": "3.3.0", + "debug": "3.2.6", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "find-up": "3.0.0", + "glob": "7.1.3", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "3.13.1", + "log-symbols": "3.0.0", + "minimatch": "3.0.4", + "mkdirp": "0.5.5", + "ms": "2.1.1", + "node-environment-flags": "1.0.6", + "object.assign": "4.1.0", + "strip-json-comments": "2.0.1", + "supports-color": "6.0.0", + "which": "1.3.1", + "wide-align": "1.1.3", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", + "yargs-unparser": "1.6.0" + }, + "dependencies": { + "ansi-colors": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", + "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==" + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "chokidar": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", + "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.1", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.2.0" + } + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "requires": { + "locate-path": "^3.0.0" + } + }, + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "optional": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "requires": { + "chalk": "^2.4.2" + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + }, + "readdirp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", + "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", + "requires": { + "picomatch": "^2.0.4" + } + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "supports-color": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", + "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -23571,6 +25056,15 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" }, + "node-environment-flags": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", + "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", + "requires": { + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + } + }, "node-fetch": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.5.tgz", @@ -23721,6 +25215,11 @@ } } }, + "object-inspect": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", + "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==" + }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -23756,6 +25255,16 @@ "isobject": "^3.0.0" } }, + "object.getownpropertydescriptors": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz", + "integrity": "sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, "object.map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", @@ -24163,6 +25672,12 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "promise-polyfill": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.1.tgz", + "integrity": "sha512-3p9zj0cEHbp7NVUxEYUWjQlffXqnXaZIMPkAO7HhFh8u5636xLRDHOUo2vpWSK0T2mqm6fKLXYn1KP6PAZ2gKg==", + "dev": true + }, "prompts": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.1.tgz", @@ -24219,6 +25734,14 @@ "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true }, + "random-words": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/random-words/-/random-words-1.1.1.tgz", + "integrity": "sha512-Rdk5EoQePyt9Tz3RjeMELi2BSaCI+jDiOkBr4U+3fyBRiiW3qqEuaegGAUMOZ4yGWlQscFQGqQpdic3mAbNkrw==", + "requires": { + "mocha": "^7.1.1" + } + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -24765,6 +26288,16 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, "signal-exit": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.5.tgz", @@ -25400,6 +26933,24 @@ } } }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, "stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -25444,6 +26995,11 @@ "min-indent": "^1.0.0" } }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -25922,6 +27478,17 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==" }, + "unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + } + }, "unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -26254,6 +27821,18 @@ "isexe": "^2.0.0" } }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, "which-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", @@ -26521,6 +28100,152 @@ } } }, + "yargs-unparser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", + "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", + "requires": { + "flat": "^4.1.0", + "lodash": "^4.17.15", + "yargs": "^13.3.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, "yazl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", diff --git a/packages/jspsych/package.json b/packages/jspsych/package.json index 6e64fa27..7e7418ee 100644 --- a/packages/jspsych/package.json +++ b/packages/jspsych/package.json @@ -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", diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index 2c1af491..58f61376 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -72,6 +72,16 @@ export class JsPsych { private finished: Promise; 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(); } diff --git a/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts b/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts new file mode 100644 index 00000000..94288c16 --- /dev/null +++ b/packages/jspsych/src/modules/plugin-api/SimulationAPI.ts @@ -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 | Array>) { + 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; + } + } + } + } +} diff --git a/packages/jspsych/src/modules/plugin-api/index.ts b/packages/jspsych/src/modules/plugin-api/index.ts index 511e54f4..5da34514 100644 --- a/packages/jspsych/src/modules/plugin-api/index.ts +++ b/packages/jspsych/src/modules/plugin-api/index.ts @@ -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; diff --git a/packages/jspsych/src/modules/randomization.ts b/packages/jspsych/src/modules/randomization.ts index 300be13d..c19c1604 100644 --- a/packages/jspsych/src/modules/randomization.ts +++ b/packages/jspsych/src/modules/randomization.ts @@ -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 = {}; diff --git a/packages/jspsych/tests/core/simulation-mode.test.ts b/packages/jspsych/tests/core/simulation-mode.test.ts new file mode 100644 index 00000000..ea0a5b42 --- /dev/null +++ b/packages/jspsych/tests/core/simulation-mode.test.ts @@ -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 = ""; + 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(); + }); +}); diff --git a/packages/jspsych/tests/randomization/randomziation.test.ts b/packages/jspsych/tests/randomization/randomziation.test.ts index 3009c7fa..53cc6a58 100644 --- a/packages/jspsych/tests/randomization/randomziation.test.ts +++ b/packages/jspsych/tests/randomization/randomziation.test.ts @@ -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(); + }); +}); diff --git a/packages/plugin-animation/src/index.spec.ts b/packages/plugin-animation/src/index.spec.ts index 72c75b82..6a8b47b5 100644 --- a/packages/plugin-animation/src/index.spec.ts +++ b/packages/plugin-animation/src/index.spec.ts @@ -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(); + }); +}); diff --git a/packages/plugin-animation/src/index.ts b/packages/plugin-animation/src/index.ts index e3aa0d79..9f37a38b 100644 --- a/packages/plugin-animation/src/index.ts +++ b/packages/plugin-animation/src/index.ts @@ -123,10 +123,7 @@ class AnimationPlugin implements JsPsychPlugin { } }, 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("#jspsych-animation-image").style.visibility = "visible"; @@ -166,7 +163,7 @@ class AnimationPlugin implements JsPsychPlugin { }); }, trial.frame_time); } - } + }; var after_response = (info) => { responses.push({ @@ -190,6 +187,93 @@ class AnimationPlugin implements JsPsychPlugin { persist: true, allow_held_key: false, }); + + // show the first frame immediately + show_next_frame(); + } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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); + } } } diff --git a/packages/plugin-audio-button-response/package.json b/packages/plugin-audio-button-response/package.json index 11198ba7..734aa58a 100644 --- a/packages/plugin-audio-button-response/package.json +++ b/packages/plugin-audio-button-response/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-audio-button-response/src/index.spec.ts b/packages/plugin-audio-button-response/src/index.spec.ts index 4405e2f3..4a252f82 100644 --- a/packages/plugin-audio-button-response/src/index.spec.ts +++ b/packages/plugin-audio-button-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-audio-button-response/src/index.ts b/packages/plugin-audio-button-response/src/index.ts index eb054782..94206316 100644 --- a/packages/plugin-audio-button-response/src/index.ts +++ b/packages/plugin-audio-button-response/src/index.ts @@ -83,6 +83,7 @@ type Info = typeof info; */ class AudioButtonResponsePlugin implements JsPsychPlugin { static info = info; + private audio; constructor(private jsPsych: JsPsych) {} @@ -92,7 +93,6 @@ class AudioButtonResponsePlugin implements JsPsychPlugin { // setup stimulus var context = this.jsPsych.pluginAPI.audioContext(); - var audio; // store response var response = { @@ -108,12 +108,12 @@ class AudioButtonResponsePlugin implements JsPsychPlugin { .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 { 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 { // 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 { // 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 { trial_complete = resolve; }); } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-audio-keyboard-response/package.json b/packages/plugin-audio-keyboard-response/package.json index fbc34244..4ab21528 100644 --- a/packages/plugin-audio-keyboard-response/package.json +++ b/packages/plugin-audio-keyboard-response/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-audio-keyboard-response/src/index.spec.ts b/packages/plugin-audio-keyboard-response/src/index.spec.ts new file mode 100644 index 00000000..cd5e0737 --- /dev/null +++ b/packages/plugin-audio-keyboard-response/src/index.spec.ts @@ -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"); + }); +}); diff --git a/packages/plugin-audio-keyboard-response/src/index.ts b/packages/plugin-audio-keyboard-response/src/index.ts index aa2deafe..aaae336b 100644 --- a/packages/plugin-audio-keyboard-response/src/index.ts +++ b/packages/plugin-audio-keyboard-response/src/index.ts @@ -60,6 +60,7 @@ type Info = typeof info; */ class AudioKeyboardResponsePlugin implements JsPsychPlugin { static info = info; + private audio; constructor(private jsPsych: JsPsych) {} @@ -69,7 +70,6 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin { // setup stimulus var context = this.jsPsych.pluginAPI.audioContext(); - var audio; // store response var response = { @@ -85,12 +85,12 @@ class AudioKeyboardResponsePlugin implements JsPsychPlugin { .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 { 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 { // 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 { // 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 { trial_complete = resolve; }); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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, 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; diff --git a/packages/plugin-audio-slider-response/src/index.spec.ts b/packages/plugin-audio-slider-response/src/index.spec.ts new file mode 100644 index 00000000..180214d9 --- /dev/null +++ b/packages/plugin-audio-slider-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-audio-slider-response/src/index.ts b/packages/plugin-audio-slider-response/src/index.ts index 67b756da..a38200aa 100644 --- a/packages/plugin-audio-slider-response/src/index.ts +++ b/packages/plugin-audio-slider-response/src/index.ts @@ -104,6 +104,7 @@ type Info = typeof info; */ class AudioSliderResponsePlugin implements JsPsychPlugin { static info = info; + private audio; constructor(private jsPsych: JsPsych) {} @@ -116,7 +117,6 @@ class AudioSliderResponsePlugin implements JsPsychPlugin { // 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 { .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 { 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 = '
'; @@ -278,9 +278,9 @@ class AudioSliderResponsePlugin implements JsPsychPlugin { // 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 { // 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 { trial_complete = resolve; }); } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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("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; diff --git a/packages/plugin-browser-check/src/index.spec.ts b/packages/plugin-browser-check/src/index.spec.ts index dd7e23aa..b3e5296e 100644 --- a/packages/plugin-browser-check/src/index.spec.ts +++ b/packages/plugin-browser-check/src/index.spec.ts @@ -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"); + }); +}); diff --git a/packages/plugin-browser-check/src/index.ts b/packages/plugin-browser-check/src/index.ts index e01dfb01..912cc189 100644 --- a/packages/plugin-browser-check/src/index.ts +++ b/packages/plugin-browser-check/src/index.ts @@ -123,6 +123,7 @@ type Info = typeof info; class BrowserCheckPlugin implements JsPsychPlugin { static info = info; private end_flag = false; + private t: TrialType; constructor(private jsPsych: JsPsych) {} @@ -131,7 +132,29 @@ class BrowserCheckPlugin implements JsPsychPlugin { } trial(display_element: HTMLElement, trial: TrialType) { - const featureCheckFunctionsMap = new Map 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 any>( Object.entries({ width: () => { return window.innerWidth; @@ -249,106 +272,194 @@ class BrowserCheckPlugin implements JsPsychPlugin { }, }) ); + } + private async measure_features(fnMap, features_to_check) { const feature_data = new Map(); const feature_checks: Promise[] = []; - 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 + + `

`; + + 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 + - `

`; + 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, + 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, 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, 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, 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); + } + }); + }); } } diff --git a/packages/plugin-call-function/src/index.ts b/packages/plugin-call-function/src/index.ts index 82110942..6371acea 100644 --- a/packages/plugin-call-function/src/index.ts +++ b/packages/plugin-call-function/src/index.ts @@ -35,10 +35,10 @@ class CallFunctionPlugin implements JsPsychPlugin { trial(display_element: HTMLElement, trial: TrialType) { //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 { }; if (trial.async) { - var done = (data) => { + const done = (data) => { return_val = data; end_trial(); }; @@ -56,6 +56,9 @@ class CallFunctionPlugin implements JsPsychPlugin { end_trial(); } } + + // no explicit simulate() mode for this plugin because it would just do + // the same thing as the regular plugin } export default CallFunctionPlugin; diff --git a/packages/plugin-canvas-button-response/package.json b/packages/plugin-canvas-button-response/package.json index 2dcef961..efbfefa5 100644 --- a/packages/plugin-canvas-button-response/package.json +++ b/packages/plugin-canvas-button-response/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-canvas-button-response/src/index.spec.ts b/packages/plugin-canvas-button-response/src/index.spec.ts new file mode 100644 index 00000000..02ea41e7 --- /dev/null +++ b/packages/plugin-canvas-button-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-canvas-button-response/src/index.ts b/packages/plugin-canvas-button-response/src/index.ts index 2174ec63..07302b88 100644 --- a/packages/plugin-canvas-button-response/src/index.ts +++ b/packages/plugin-canvas-button-response/src/index.ts @@ -146,8 +146,8 @@ class CanvasButtonResponsePlugin implements JsPsychPlugin { display_element .querySelector("#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 { }, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-canvas-keyboard-response/src/index.spec.ts b/packages/plugin-canvas-keyboard-response/src/index.spec.ts new file mode 100644 index 00000000..4aeec157 --- /dev/null +++ b/packages/plugin-canvas-keyboard-response/src/index.spec.ts @@ -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"); + }); +}); diff --git a/packages/plugin-canvas-keyboard-response/src/index.ts b/packages/plugin-canvas-keyboard-response/src/index.ts index f341816e..fa11c123 100644 --- a/packages/plugin-canvas-keyboard-response/src/index.ts +++ b/packages/plugin-canvas-keyboard-response/src/index.ts @@ -155,6 +155,53 @@ class CanvasKeyboardResponsePlugin implements JsPsychPlugin { }, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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, 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; diff --git a/packages/plugin-canvas-slider-response/src/index.spec.ts b/packages/plugin-canvas-slider-response/src/index.spec.ts new file mode 100644 index 00000000..b3b3a95b --- /dev/null +++ b/packages/plugin-canvas-slider-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-canvas-slider-response/src/index.ts b/packages/plugin-canvas-slider-response/src/index.ts index 4e547eec..957466d7 100644 --- a/packages/plugin-canvas-slider-response/src/index.ts +++ b/packages/plugin-canvas-slider-response/src/index.ts @@ -242,6 +242,60 @@ class CanvasSliderResponsePlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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("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; diff --git a/packages/plugin-categorize-animation/src/index.spec.ts b/packages/plugin-categorize-animation/src/index.spec.ts index 4fabe126..c1d5e828 100644 --- a/packages/plugin-categorize-animation/src/index.spec.ts +++ b/packages/plugin-categorize-animation/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-categorize-animation/src/index.ts b/packages/plugin-categorize-animation/src/index.ts index 94572cfb..76b4f9e5 100644 --- a/packages/plugin-categorize-animation/src/index.ts +++ b/packages/plugin-categorize-animation/src/index.ts @@ -272,6 +272,74 @@ class CategorizeAnimationPlugin implements JsPsychPlugin { allow_held_key: false, }); } + + simulate( + trial: TrialType, + 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, 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, 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, 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; diff --git a/packages/plugin-categorize-html/package.json b/packages/plugin-categorize-html/package.json index 70aea0ce..67be8ecd 100644 --- a/packages/plugin-categorize-html/package.json +++ b/packages/plugin-categorize-html/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-categorize-html/src/index.spec.ts b/packages/plugin-categorize-html/src/index.spec.ts new file mode 100644 index 00000000..408c04f2 --- /dev/null +++ b/packages/plugin-categorize-html/src/index.spec.ts @@ -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"); + }); +}); diff --git a/packages/plugin-categorize-html/src/index.ts b/packages/plugin-categorize-html/src/index.ts index 6df01121..68cd88ab 100644 --- a/packages/plugin-categorize-html/src/index.ts +++ b/packages/plugin-categorize-html/src/index.ts @@ -221,6 +221,61 @@ class CategorizeHtmlPlugin implements JsPsychPlugin { } }; } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-categorize-image/package.json b/packages/plugin-categorize-image/package.json index d991a241..0cf4da63 100644 --- a/packages/plugin-categorize-image/package.json +++ b/packages/plugin-categorize-image/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-categorize-image/src/index.spec.ts b/packages/plugin-categorize-image/src/index.spec.ts new file mode 100644 index 00000000..821f173d --- /dev/null +++ b/packages/plugin-categorize-image/src/index.spec.ts @@ -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"); + }); +}); diff --git a/packages/plugin-categorize-image/src/index.ts b/packages/plugin-categorize-image/src/index.ts index 0bb2e0e9..3fdab1f2 100644 --- a/packages/plugin-categorize-image/src/index.ts +++ b/packages/plugin-categorize-image/src/index.ts @@ -37,6 +37,7 @@ const info = { incorrect_text: { type: ParameterType.HTML_STRING, pretty_name: "Incorrect text", + default: "", }, /** Any content here will be displayed below the stimulus. */ prompt: { @@ -220,6 +221,61 @@ class CategorizeImagePlugin implements JsPsychPlugin { } }; } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-cloze/src/index.spec.ts b/packages/plugin-cloze/src/index.spec.ts index d6fd380f..5821aa21 100644 --- a/packages/plugin-cloze/src/index.spec.ts +++ b/packages/plugin-cloze/src/index.spec.ts @@ -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(""); + }); +}); diff --git a/packages/plugin-cloze/src/index.ts b/packages/plugin-cloze/src/index.ts index 9544b30a..71ae791e 100644 --- a/packages/plugin-cloze/src/index.ts +++ b/packages/plugin-cloze/src/index.ts @@ -48,14 +48,15 @@ class ClozePlugin implements JsPsychPlugin { trial(display_element: HTMLElement, trial: TrialType) { var html = '
'; 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 += ''; + html += ``; + solution_counter++; } } html += "
"; @@ -98,6 +99,78 @@ class ClozePlugin implements JsPsychPlugin { ""; 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, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-external-html/package.json b/packages/plugin-external-html/package.json index 53f88b55..77cd6374 100644 --- a/packages/plugin-external-html/package.json +++ b/packages/plugin-external-html/package.json @@ -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" } } diff --git a/packages/plugin-external-html/src/index.spec.ts b/packages/plugin-external-html/src/index.spec.ts new file mode 100644 index 00000000..4a27c406 --- /dev/null +++ b/packages/plugin-external-html/src/index.spec.ts @@ -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(`

This is external HTML

`); +}); + +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"); + }); +}); diff --git a/packages/plugin-external-html/src/index.ts b/packages/plugin-external-html/src/index.ts index 290ad2d7..590544be 100644 --- a/packages/plugin-external-html/src/index.ts +++ b/packages/plugin-external-html/src/index.ts @@ -68,14 +68,73 @@ class ExternalHtmlPlugin implements JsPsychPlugin { 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' 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 { }; 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' 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, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-fullscreen/src/index.spec.ts b/packages/plugin-fullscreen/src/index.spec.ts index 4cb54c1f..3a3a4828 100644 --- a/packages/plugin-fullscreen/src/index.spec.ts +++ b/packages/plugin-fullscreen/src/index.spec.ts @@ -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, 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); + }); +}); diff --git a/packages/plugin-fullscreen/src/index.ts b/packages/plugin-fullscreen/src/index.ts index 7613b2dd..2d0e638e 100644 --- a/packages/plugin-fullscreen/src/index.ts +++ b/packages/plugin-fullscreen/src/index.ts @@ -51,66 +51,123 @@ class FullscreenPlugin implements JsPsychPlugin { constructor(private jsPsych: JsPsych) {} trial(display_element: HTMLElement, trial: TrialType) { - 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 + - '"; - 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} + + `; + 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, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-html-button-response/src/index.spec.ts b/packages/plugin-html-button-response/src/index.spec.ts index f768522a..45830a72 100644 --- a/packages/plugin-html-button-response/src/index.spec.ts +++ b/packages/plugin-html-button-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-html-button-response/src/index.ts b/packages/plugin-html-button-response/src/index.ts index ca7d1972..7040588e 100644 --- a/packages/plugin-html-button-response/src/index.ts +++ b/packages/plugin-html-button-response/src/index.ts @@ -196,6 +196,57 @@ class HtmlButtonResponsePlugin implements JsPsychPlugin { this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-html-keyboard-response/src/index.spec.ts b/packages/plugin-html-keyboard-response/src/index.spec.ts index b2e7c5af..34282719 100644 --- a/packages/plugin-html-keyboard-response/src/index.spec.ts +++ b/packages/plugin-html-keyboard-response/src/index.spec.ts @@ -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"); + }); +}); diff --git a/packages/plugin-html-keyboard-response/src/index.ts b/packages/plugin-html-keyboard-response/src/index.ts index 10ee9ee3..d086ea56 100644 --- a/packages/plugin-html-keyboard-response/src/index.ts +++ b/packages/plugin-html-keyboard-response/src/index.ts @@ -152,6 +152,54 @@ class HtmlKeyboardResponsePlugin implements JsPsychPlugin { this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-html-slider-response/src/index.spec.ts b/packages/plugin-html-slider-response/src/index.spec.ts index 734d40f7..df0e8450 100644 --- a/packages/plugin-html-slider-response/src/index.spec.ts +++ b/packages/plugin-html-slider-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-html-slider-response/src/index.ts b/packages/plugin-html-slider-response/src/index.ts index abd98022..4a8be111 100644 --- a/packages/plugin-html-slider-response/src/index.ts +++ b/packages/plugin-html-slider-response/src/index.ts @@ -235,6 +235,62 @@ class HtmlSliderResponsePlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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("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; diff --git a/packages/plugin-iat-html/src/index.spec.ts b/packages/plugin-iat-html/src/index.spec.ts index 7029ce42..b2f0115e 100644 --- a/packages/plugin-iat-html/src/index.spec.ts +++ b/packages/plugin-iat-html/src/index.spec.ts @@ -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: "

dogs

", + 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: "

dogs

", + 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); + }); +}); diff --git a/packages/plugin-iat-html/src/index.ts b/packages/plugin-iat-html/src/index.ts index 98916aa0..8f0220c6 100644 --- a/packages/plugin-iat-html/src/index.ts +++ b/packages/plugin-iat-html/src/index.ts @@ -299,6 +299,79 @@ class IatHtmlPlugin implements JsPsychPlugin { }, trial.trial_duration); } } + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-iat-image/src/index.spec.ts b/packages/plugin-iat-image/src/index.spec.ts index 82b3e066..3552a680 100644 --- a/packages/plugin-iat-image/src/index.spec.ts +++ b/packages/plugin-iat-image/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-iat-image/src/index.ts b/packages/plugin-iat-image/src/index.ts index bd3f4703..79f070f3 100644 --- a/packages/plugin-iat-image/src/index.ts +++ b/packages/plugin-iat-image/src/index.ts @@ -299,6 +299,80 @@ class IatImagePlugin implements JsPsychPlugin { }, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-image-button-response/src/index.spec.ts b/packages/plugin-image-button-response/src/index.spec.ts index 91d7829a..3e9eee97 100644 --- a/packages/plugin-image-button-response/src/index.spec.ts +++ b/packages/plugin-image-button-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-image-button-response/src/index.ts b/packages/plugin-image-button-response/src/index.ts index 6d59454b..c9f9ac33 100644 --- a/packages/plugin-image-button-response/src/index.ts +++ b/packages/plugin-image-button-response/src/index.ts @@ -355,6 +355,57 @@ class ImageButtonResponsePlugin implements JsPsychPlugin { ); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-image-keyboard-response/src/index.ts b/packages/plugin-image-keyboard-response/src/index.ts index 722695e3..0f0f880b 100644 --- a/packages/plugin-image-keyboard-response/src/index.ts +++ b/packages/plugin-image-keyboard-response/src/index.ts @@ -260,6 +260,54 @@ class ImageKeyboardResponsePlugin implements JsPsychPlugin { ); } } + + simulate( + trial: TrialType, + 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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, 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; diff --git a/packages/plugin-image-slider-response/src/index.spec.ts b/packages/plugin-image-slider-response/src/index.spec.ts index 6e086247..49f2f46a 100644 --- a/packages/plugin-image-slider-response/src/index.spec.ts +++ b/packages/plugin-image-slider-response/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-image-slider-response/src/index.ts b/packages/plugin-image-slider-response/src/index.ts index a2b82f1b..0ea814a8 100644 --- a/packages/plugin-image-slider-response/src/index.ts +++ b/packages/plugin-image-slider-response/src/index.ts @@ -420,6 +420,62 @@ class ImageSliderResponsePlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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("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; diff --git a/packages/plugin-instructions/src/index.spec.ts b/packages/plugin-instructions/src/index.spec.ts index b6dfaa86..2480b3df 100644 --- a/packages/plugin-instructions/src/index.spec.ts +++ b/packages/plugin-instructions/src/index.spec.ts @@ -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); + }); +}); diff --git a/packages/plugin-instructions/src/index.ts b/packages/plugin-instructions/src/index.ts index d4cf5390..832944c6 100644 --- a/packages/plugin-instructions/src/index.ts +++ b/packages/plugin-instructions/src/index.ts @@ -239,6 +239,107 @@ class InstructionsPlugin implements JsPsychPlugin { }); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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; diff --git a/packages/plugin-maxdiff/src/index.spec.ts b/packages/plugin-maxdiff/src/index.spec.ts index 5941f3b3..2692c7f6 100644 --- a/packages/plugin-maxdiff/src/index.spec.ts +++ b/packages/plugin-maxdiff/src/index.spec.ts @@ -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(); + }); +}); diff --git a/packages/plugin-maxdiff/src/index.ts b/packages/plugin-maxdiff/src/index.ts index 1c4255c3..2eeb59f7 100644 --- a/packages/plugin-maxdiff/src/index.ts +++ b/packages/plugin-maxdiff/src/index.ts @@ -204,6 +204,100 @@ class MaxdiffPlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const choices = this.jsPsych.randomization.sampleWithoutReplacement(trial.alternatives, 2); + const response = { left: null, right: null }; + if (!trial.required && this.jsPsych.randomization.sampleBernoulli(0.1)) { + choices.pop(); + if (this.jsPsych.randomization.sampleBernoulli(0.8)) { + choices.pop(); + } + } + + if (choices.length == 1) { + if (this.jsPsych.randomization.sampleBernoulli(0.5)) { + response.left = choices[0]; + } else { + response.right = choices[0]; + } + } + + if (choices.length == 2) { + response.left = choices[0]; + response.right = choices[1]; + } + + const default_data = { + rt: this.jsPsych.randomization.sampleExGaussian(3000, 300, 1 / 300, true), + labels: trial.labels, + response: response, + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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(); + + //@ts-ignore something about symbol iterators? + const list = [...display_element.querySelectorAll("[id^=jspsych-maxdiff-alternative]")].map( + (x) => { + return x.innerHTML; + } + ); + + if (data.response.left !== null) { + const index_left = list.indexOf(data.response.left); + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector(`.jspsych-maxdiff-alt-${index_left}[name="left"]`), + data.rt / 3 + ); + } + + if (data.response.right !== null) { + const index_right = list.indexOf(data.response.right); + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector(`.jspsych-maxdiff-alt-${index_right}[name="right"]`), + (data.rt / 3) * 2 + ); + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-maxdiff-next"), + data.rt + ); + } } export default MaxdiffPlugin; diff --git a/packages/plugin-preload/src/index.spec.ts b/packages/plugin-preload/src/index.spec.ts index 070e1380..e66f25c4 100644 --- a/packages/plugin-preload/src/index.spec.ts +++ b/packages/plugin-preload/src/index.spec.ts @@ -1,7 +1,7 @@ import audioKeyboardResponse from "@jspsych/plugin-audio-keyboard-response"; import imageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; import videoKeyboardResponse from "@jspsych/plugin-video-keyboard-response"; -import { startTimeline } from "@jspsych/test-utils"; +import { simulateTimeline, startTimeline } from "@jspsych/test-utils"; import { JsPsych, initJsPsych } from "jspsych"; import preloadPlugin from "."; @@ -787,4 +787,56 @@ describe("preload plugin", () => { expect(cancelPreloadSpy).toHaveBeenCalled(); }); }); + + describe("simulation", () => { + test("data-only mode works", async () => { + const { expectFinished, getData } = await simulateTimeline( + [ + { + type: preloadPlugin, + auto_preload: true, + }, + { + type: imageKeyboardResponse, + stimulus: "img/foo.png", + render_on_canvas: false, + }, + ], + "data-only", + jsPsych + ); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(data).toMatchObject({ + success: true, + timeout: false, + failed_images: [], + failed_audio: [], + failed_video: [], + }); + }); + + // confirmed that this works in browser. something doesn't work with the spy + // here for some unknown reason. + test.skip("visual mode works", async () => { + const spy = spyOnPreload("Images"); + + const { expectFinished, getData } = await simulateTimeline( + [ + { + type: preloadPlugin, + images: ["img/foo.png"], + }, + ], + "visual", + jsPsych + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual(["img/foo.png"]); + }); + }); }); diff --git a/packages/plugin-preload/src/index.ts b/packages/plugin-preload/src/index.ts index f3f7b45a..0e61a71b 100644 --- a/packages/plugin-preload/src/index.ts +++ b/packages/plugin-preload/src/index.ts @@ -387,6 +387,48 @@ class PreloadPlugin implements JsPsychPlugin { } } } + + simulate( + trial: TrialType, + 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, simulation_options) { + const default_data = { + success: true, + timeout: false, + failed_images: [], + failed_audio: [], + failed_video: [], + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, simulation_options, load_callback: () => void) { + const display_element = this.jsPsych.getDisplayElement(); + + this.trial(display_element, trial); + load_callback(); + } } export default PreloadPlugin; diff --git a/packages/plugin-reconstruction/src/index.spec.ts b/packages/plugin-reconstruction/src/index.spec.ts index e4905f18..1195612b 100644 --- a/packages/plugin-reconstruction/src/index.spec.ts +++ b/packages/plugin-reconstruction/src/index.spec.ts @@ -1,4 +1,4 @@ -import { clickTarget, pressKey, startTimeline } from "@jspsych/test-utils"; +import { clickTarget, pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import reconstruction from "."; @@ -172,3 +172,49 @@ describe("reconstruction", () => { expect(getData().values()[0].final_value).toEqual(0.55); }); }); + +describe("reconstruction simulation", () => { + test("data-only mode works", async () => { + const timeline = [ + { + type: reconstruction, + stim_function: function (val) { + return `

${Math.round(val * 10)}

`; + }, + starting_value: 0.5, + step_size: 0.05, + }, + ]; + + const { getData, expectFinished } = await simulateTimeline(timeline); + + await expectFinished(); + + expect(getData().values()[0].final_value).toBeGreaterThanOrEqual(0); + expect(getData().values()[0].final_value).toBeLessThanOrEqual(1); + }); + + test("visual mode works", async () => { + const timeline = [ + { + type: reconstruction, + stim_function: function (val) { + return `

${Math.round(val * 10)}

`; + }, + starting_value: 0.5, + step_size: 0.05, + }, + ]; + + const { getData, expectFinished, expectRunning } = await simulateTimeline(timeline, "visual"); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + expect(getData().values()[0].final_value).toBeGreaterThanOrEqual(0); + expect(getData().values()[0].final_value).toBeLessThanOrEqual(1); + }); +}); diff --git a/packages/plugin-reconstruction/src/index.ts b/packages/plugin-reconstruction/src/index.ts index c3e5ebff..dd21288b 100644 --- a/packages/plugin-reconstruction/src/index.ts +++ b/packages/plugin-reconstruction/src/index.ts @@ -132,6 +132,71 @@ class ReconstructionPlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const default_data = { + rt: this.jsPsych.randomization.sampleExGaussian(2000, 200, 1 / 200, true), + start_value: trial.starting_value, + final_value: + this.jsPsych.randomization.randomInt(0, Math.round(1 / trial.step_size)) * trial.step_size, + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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(); + + let steps = Math.round((data.final_value - trial.starting_value) / trial.step_size); + const rt_per_step = (data.rt - 300) / steps; + + let t = 0; + while (steps != 0) { + if (steps > 0) { + this.jsPsych.pluginAPI.pressKey(trial.key_increase, t + rt_per_step); + steps--; + } else { + this.jsPsych.pluginAPI.pressKey(trial.key_decrease, t + rt_per_step); + steps++; + } + t += rt_per_step; + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-reconstruction-next"), + data.rt + ); + } } export default ReconstructionPlugin; diff --git a/packages/plugin-same-different-html/package.json b/packages/plugin-same-different-html/package.json index 2c4f4f10..515987b0 100644 --- a/packages/plugin-same-different-html/package.json +++ b/packages/plugin-same-different-html/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-same-different-html/src/index.spec.ts b/packages/plugin-same-different-html/src/index.spec.ts new file mode 100644 index 00000000..dd8e4154 --- /dev/null +++ b/packages/plugin-same-different-html/src/index.spec.ts @@ -0,0 +1,75 @@ +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; + +import sameDifferentHtml from "."; + +jest.useFakeTimers(); + +describe("same-different-html", () => { + test("runs trial", async () => { + const { displayElement, expectFinished, getData, getHTML } = await startTimeline([ + { + type: sameDifferentHtml, + stimuli: ["foo", "bar"], + answer: "same", + }, + ]); + + expect(getHTML()).toMatch("foo"); + + jest.advanceTimersByTime(1000); // first_stim_duration + + expect(getHTML()).not.toMatch("foo"); // cleared display + + jest.advanceTimersByTime(500); // gap_duration + + expect(getHTML()).toMatch("bar"); + + jest.advanceTimersByTime(1000); // second_stim_duration + + expect(getHTML()).toMatch("visibility: hidden"); + + pressKey("q"); // same_key + await expectFinished(); + + expect(getData().values()[0].correct).toBe(true); + }); +}); + +describe("same-different-html simulation", () => { + test("data mode works", async () => { + const { expectFinished, getData } = await simulateTimeline([ + { + type: sameDifferentHtml, + stimuli: ["foo", "bar"], + answer: "same", + }, + ]); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(data.response == "q"); + }); + + test("visual mode works", async () => { + const { expectRunning, expectFinished, getData } = await simulateTimeline( + [ + { + type: sameDifferentHtml, + stimuli: ["foo", "bar"], + answer: "same", + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(data.response == "q"); + }); +}); diff --git a/packages/plugin-same-different-html/src/index.ts b/packages/plugin-same-different-html/src/index.ts index 71c4c1ec..4033874a 100644 --- a/packages/plugin-same-different-html/src/index.ts +++ b/packages/plugin-same-different-html/src/index.ts @@ -159,6 +159,71 @@ class SameDifferentHtmlPlugin implements JsPsychPlugin { }); }; } + + simulate( + trial: TrialType, + 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, simulation_options) { + const key = this.jsPsych.pluginAPI.getValidKey([trial.same_key, trial.different_key]); + + const default_data = { + stimuli: trial.stimuli, + response: key, + answer: trial.answer, + correct: trial.answer == "same" ? key == trial.same_key : key == trial.different_key, + rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true), + }; + + if (trial.first_stim_duration == null) { + default_data.rt_stim1 = this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true); + default_data.response_stim1 = this.jsPsych.pluginAPI.getValidKey([ + trial.same_key, + trial.different_key, + ]); + } + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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.first_stim_duration == null) { + this.jsPsych.pluginAPI.pressKey(data.response_stim1, data.rt_stim1); + } + + this.jsPsych.pluginAPI.pressKey( + data.response, + trial.first_stim_duration + trial.gap_duration + data.rt + ); + } } export default SameDifferentHtmlPlugin; diff --git a/packages/plugin-same-different-image/package.json b/packages/plugin-same-different-image/package.json index 43e2c965..6444ae66 100644 --- a/packages/plugin-same-different-image/package.json +++ b/packages/plugin-same-different-image/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-same-different-image/src/index.spec.ts b/packages/plugin-same-different-image/src/index.spec.ts new file mode 100644 index 00000000..ca60777f --- /dev/null +++ b/packages/plugin-same-different-image/src/index.spec.ts @@ -0,0 +1,75 @@ +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; + +import sameDifferentImage from "."; + +jest.useFakeTimers(); + +describe("same-different-image", () => { + test("runs trial", async () => { + const { displayElement, expectFinished, getData, getHTML } = await startTimeline([ + { + type: sameDifferentImage, + stimuli: ["foo.png", "bar.png"], + answer: "same", + }, + ]); + + expect(getHTML()).toMatch("foo.png"); + + jest.advanceTimersByTime(1000); // first_stim_duration + + expect(getHTML()).not.toMatch("foo.png"); // cleared display + + jest.advanceTimersByTime(500); // gap_duration + + expect(getHTML()).toMatch("bar.png"); + + jest.advanceTimersByTime(1000); // second_stim_duration + + expect(getHTML()).toMatch("visibility: hidden"); + + pressKey("q"); // same_key + await expectFinished(); + + expect(getData().values()[0].correct).toBe(true); + }); +}); + +describe("same-different-image simulation", () => { + test("data mode works", async () => { + const { expectFinished, getData } = await simulateTimeline([ + { + type: sameDifferentImage, + stimuli: ["foo.png", "bar.png"], + answer: "same", + }, + ]); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(data.response == "q"); + }); + + test("visual mode works", async () => { + const { expectRunning, expectFinished, getData } = await simulateTimeline( + [ + { + type: sameDifferentImage, + stimuli: ["foo.png", "bar.png"], + answer: "same", + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(data.response == "q"); + }); +}); diff --git a/packages/plugin-same-different-image/src/index.ts b/packages/plugin-same-different-image/src/index.ts index cfc0bd2f..b8fc554d 100644 --- a/packages/plugin-same-different-image/src/index.ts +++ b/packages/plugin-same-different-image/src/index.ts @@ -75,7 +75,7 @@ class SameDifferentImagePlugin implements JsPsychPlugin { const showBlankScreen = () => { display_element.innerHTML = ""; - this.jsPsych.pluginAPI.setTimeout(showSecondStim(), trial.gap_duration); + this.jsPsych.pluginAPI.setTimeout(showSecondStim, trial.gap_duration); }; display_element.innerHTML = @@ -159,6 +159,71 @@ class SameDifferentImagePlugin implements JsPsychPlugin { }); }; } + + simulate( + trial: TrialType, + 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, simulation_options) { + const key = this.jsPsych.pluginAPI.getValidKey([trial.same_key, trial.different_key]); + + const default_data = { + stimuli: trial.stimuli, + response: key, + answer: trial.answer, + correct: trial.answer == "same" ? key == trial.same_key : key == trial.different_key, + rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true), + }; + + if (trial.first_stim_duration == null) { + default_data.rt_stim1 = this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true); + default_data.response_stim1 = this.jsPsych.pluginAPI.getValidKey([ + trial.same_key, + trial.different_key, + ]); + } + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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.first_stim_duration == null) { + this.jsPsych.pluginAPI.pressKey(data.response_stim1, data.rt_stim1); + } + + this.jsPsych.pluginAPI.pressKey( + data.response, + trial.first_stim_duration + trial.gap_duration + data.rt + ); + } } export default SameDifferentImagePlugin; diff --git a/packages/plugin-serial-reaction-time-mouse/src/index.spec.ts b/packages/plugin-serial-reaction-time-mouse/src/index.spec.ts index ac37469c..98b8e6aa 100644 --- a/packages/plugin-serial-reaction-time-mouse/src/index.spec.ts +++ b/packages/plugin-serial-reaction-time-mouse/src/index.spec.ts @@ -1,7 +1,9 @@ -import { mouseDownMouseUpTarget, startTimeline } from "@jspsych/test-utils"; +import { mouseDownMouseUpTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import serialReactionTimeMouse from "."; +jest.useFakeTimers(); + const getCellElement = (cellId: string) => document.querySelector(`#jspsych-serial-reaction-time-stimulus-cell-${cellId}`) as HTMLElement; @@ -28,3 +30,46 @@ describe("serial-reaction-time-mouse plugin", () => { await expectFinished(); }); }); + +describe("serial-reaction-time plugin simulation", () => { + test("data-only mode works", async () => { + const { expectFinished, getData } = await simulateTimeline([ + { + type: serialReactionTimeMouse, + grid: [[1, 1, 1, 1]], + target: [0, 0], + }, + ]); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(data.correct).toBe(data.response[1] == data.target[1]); + expect(data.rt).toBeGreaterThan(0); + }); + + test("visual mode works", async () => { + const { expectFinished, expectRunning, getData } = await simulateTimeline( + [ + { + type: serialReactionTimeMouse, + grid: [[1, 1, 1, 1]], + target: [0, 0], + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(data.correct).toBe(data.response[1] == data.target[1]); + expect(data.rt).toBeGreaterThan(0); + }); +}); diff --git a/packages/plugin-serial-reaction-time-mouse/src/index.ts b/packages/plugin-serial-reaction-time-mouse/src/index.ts index 3119ac06..dab0ad0d 100644 --- a/packages/plugin-serial-reaction-time-mouse/src/index.ts +++ b/packages/plugin-serial-reaction-time-mouse/src/index.ts @@ -233,6 +233,70 @@ class SerialReactionTimeMousePlugin implements JsPsychPlugin { return stimulus; } + + simulate( + trial: TrialType, + 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, simulation_options) { + let response = this.jsPsych.utils.deepCopy(trial.target); + if (trial.allow_nontarget_responses && this.jsPsych.randomization.sampleBernoulli(0.8) !== 1) { + while (response[0] == trial.target[0] && response[1] == trial.target[1]) { + response[0] == this.jsPsych.randomization.randomInt(0, trial.grid.length); + //@ts-ignore array typing is not quite right + response[1] == this.jsPsych.randomization.randomInt(0, trial.grid[response[0]].length); + } + } + + const default_data = { + grid: trial.grid, + target: trial.target, + response: response, + rt: + trial.pre_target_duration + + this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true), + correct: response[0] == trial.target[0] && response[1] == trial.target[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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 target = display_element.querySelector( + `.jspsych-serial-reaction-time-stimulus-cell[data-row="${data.response[0]}"][data-column="${data.response[1]}"]` + ); + this.jsPsych.pluginAPI.clickTarget(target, data.rt); + } + } } export default SerialReactionTimeMousePlugin; diff --git a/packages/plugin-serial-reaction-time/src/index.spec.ts b/packages/plugin-serial-reaction-time/src/index.spec.ts index 726b14d3..b750a113 100644 --- a/packages/plugin-serial-reaction-time/src/index.spec.ts +++ b/packages/plugin-serial-reaction-time/src/index.spec.ts @@ -1,4 +1,4 @@ -import { pressKey, startTimeline } from "@jspsych/test-utils"; +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import serialReactionTime from "."; @@ -87,3 +87,48 @@ describe("serial-reaction-time plugin", () => { expect(trial_data[1].correct).toBe(false); }); }); + +describe("serial-reaction-time plugin simulation", () => { + test("data-only mode works", async () => { + const { expectFinished, getData } = await simulateTimeline([ + { + type: serialReactionTime, + grid: [[1, 1, 1, 1]], + target: [0, 0], + choices: [["a", "b", "c", "d"]], + }, + ]); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(data.correct).toBe(data.response == "a"); + expect(data.rt).toBeGreaterThan(0); + }); + + test("visual mode works", async () => { + const { expectFinished, expectRunning, getData } = await simulateTimeline( + [ + { + type: serialReactionTime, + grid: [[1, 1, 1, 1]], + target: [0, 0], + choices: [["a", "b", "c", "d"]], + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(data.correct).toBe(data.response == "a"); + expect(data.rt).toBeGreaterThan(0); + }); +}); diff --git a/packages/plugin-serial-reaction-time/src/index.ts b/packages/plugin-serial-reaction-time/src/index.ts index 9c20c398..0b092de2 100644 --- a/packages/plugin-serial-reaction-time/src/index.ts +++ b/packages/plugin-serial-reaction-time/src/index.ts @@ -272,6 +272,68 @@ class SerialReactionTimePlugin implements JsPsychPlugin { return stimulus; }; + + simulate( + trial: TrialType, + 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, simulation_options) { + let key; + if (this.jsPsych.randomization.sampleBernoulli(0.8) == 1) { + key = trial.choices[trial.target[0]][trial.target[1]]; + } else { + // @ts-ignore something wrong with trial.choices type here? + key = this.jsPsych.pluginAPI.getValidKey(trial.choices); + while (key == trial.choices[trial.target[0]][trial.target[1]]) { + // @ts-ignore something wrong with trial.choices type here? + key = this.jsPsych.pluginAPI.getValidKey(trial.choices); + } + } + + const default_data = { + grid: trial.grid, + target: trial.target, + response: key, + rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true), + correct: key == trial.choices[trial.target[0]][trial.target[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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 SerialReactionTimePlugin; diff --git a/packages/plugin-survey-likert/src/index.spec.ts b/packages/plugin-survey-likert/src/index.spec.ts index 75ee5f9c..c63a4555 100644 --- a/packages/plugin-survey-likert/src/index.spec.ts +++ b/packages/plugin-survey-likert/src/index.spec.ts @@ -1,7 +1,9 @@ -import { clickTarget, startTimeline } from "@jspsych/test-utils"; +import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import surveyLikert from "."; +jest.useFakeTimers(); + const selectInput = (name: string, value: string) => document.querySelector(`input[name="${name}"][value="${value}"]`) as HTMLInputElement; @@ -40,3 +42,62 @@ describe("survey-likert plugin", () => { expect(surveyData.Q4).toEqual(4); }); }); + +describe("survey-likert plugin simulation", () => { + test("data-only mode works", async () => { + const scale = ["a", "b", "c", "d", "e"]; + const { getData, expectFinished } = await simulateTimeline([ + { + type: surveyLikert, + questions: [ + { prompt: "Q0", labels: scale }, + { prompt: "Q1", labels: scale }, + { prompt: "Q2", labels: scale }, + { prompt: "Q3", labels: scale }, + { prompt: "Q4", labels: scale }, + ], + randomize_question_order: true, + }, + ]); + + await expectFinished(); + + const surveyData = getData().values()[0].response; + const all_valid = Object.entries(surveyData).every((x) => { + return x[1] <= 4 && x[1] >= 0; + }); + expect(all_valid).toBe(true); + }); + + test("visual mode works", async () => { + const scale = ["a", "b", "c", "d", "e"]; + const { getData, expectFinished, expectRunning } = await simulateTimeline( + [ + { + type: surveyLikert, + questions: [ + { prompt: "Q0", labels: scale }, + { prompt: "Q1", labels: scale }, + { prompt: "Q2", labels: scale }, + { prompt: "Q3", labels: scale }, + { prompt: "Q4", labels: scale }, + ], + randomize_question_order: true, + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const surveyData = getData().values()[0].response; + const all_valid = Object.entries(surveyData).every((x) => { + return x[1] <= 4 && x[1] >= 0; + }); + expect(all_valid).toBe(true); + }); +}); diff --git a/packages/plugin-survey-likert/src/index.ts b/packages/plugin-survey-likert/src/index.ts index 9d4316a0..9d92ac38 100644 --- a/packages/plugin-survey-likert/src/index.ts +++ b/packages/plugin-survey-likert/src/index.ts @@ -217,6 +217,76 @@ class SurveyLikertPlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const question_data = {}; + let rt = 1000; + + for (const q of trial.questions) { + const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; + question_data[name] = this.jsPsych.randomization.randomInt(0, q.labels.length - 1); + rt += this.jsPsych.randomization.sampleExGaussian(1500, 400, 1 / 200, true); + } + + const default_data = { + response: question_data, + rt: rt, + question_order: trial.randomize_question_order + ? this.jsPsych.randomization.shuffle([...Array(trial.questions.length).keys()]) + : [...Array(trial.questions.length).keys()], + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 answers = Object.entries(data.response); + for (let i = 0; i < answers.length; i++) { + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector( + `input[type="radio"][name="${answers[i][0]}"][value="${answers[i][1]}"]` + ), + ((data.rt - 1000) / answers.length) * (i + 1) + ); + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-survey-likert-next"), + data.rt + ); + } } export default SurveyLikertPlugin; diff --git a/packages/plugin-survey-multi-choice/src/index.spec.ts b/packages/plugin-survey-multi-choice/src/index.spec.ts index 7bfe545b..74177f15 100644 --- a/packages/plugin-survey-multi-choice/src/index.spec.ts +++ b/packages/plugin-survey-multi-choice/src/index.spec.ts @@ -1,7 +1,9 @@ -import { clickTarget, startTimeline } from "@jspsych/test-utils"; +import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import surveyMultiChoice from "."; +jest.useFakeTimers(); + const getInputElement = (choiceId: number, value: string) => document.querySelector( `#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]` @@ -42,3 +44,62 @@ describe("survey-multi-choice plugin", () => { expect(surveyData.Q4).toBe("e"); }); }); + +describe("survey-likert plugin simulation", () => { + test("data-only mode works", async () => { + const scale = ["a", "b", "c", "d", "e"]; + const { getData, expectFinished } = await simulateTimeline([ + { + type: surveyMultiChoice, + questions: [ + { prompt: "Q0", options: scale }, + { prompt: "Q1", options: scale }, + { prompt: "Q2", options: scale }, + { prompt: "Q3", options: scale }, + { prompt: "Q4", options: scale }, + ], + randomize_question_order: true, + }, + ]); + + await expectFinished(); + + const surveyData = getData().values()[0].response; + const all_valid = Object.entries(surveyData).every((x) => { + return scale.includes(x[1] as string); + }); + expect(all_valid).toBe(true); + }); + + test("visual mode works", async () => { + const scale = ["a", "b", "c", "d", "e"]; + const { getData, expectFinished, expectRunning } = await simulateTimeline( + [ + { + type: surveyMultiChoice, + questions: [ + { prompt: "Q0", options: scale }, + { prompt: "Q1", options: scale }, + { prompt: "Q2", options: scale }, + { prompt: "Q3", options: scale }, + { prompt: "Q4", options: scale }, + ], + randomize_question_order: true, + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const surveyData = getData().values()[0].response; + const all_valid = Object.entries(surveyData).every((x) => { + return scale.includes(x[1] as string); + }); + expect(all_valid).toBe(true); + }); +}); diff --git a/packages/plugin-survey-multi-choice/src/index.ts b/packages/plugin-survey-multi-choice/src/index.ts index f2600bd6..1b19bb4a 100644 --- a/packages/plugin-survey-multi-choice/src/index.ts +++ b/packages/plugin-survey-multi-choice/src/index.ts @@ -234,6 +234,78 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const question_data = {}; + let rt = 1000; + + for (const q of trial.questions) { + const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; + question_data[name] = this.jsPsych.randomization.sampleWithoutReplacement(q.options, 1)[0]; + rt += this.jsPsych.randomization.sampleExGaussian(1500, 400, 1 / 200, true); + } + + const default_data = { + response: question_data, + rt: rt, + question_order: trial.randomize_question_order + ? this.jsPsych.randomization.shuffle([...Array(trial.questions.length).keys()]) + : [...Array(trial.questions.length).keys()], + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 answers = Object.entries(data.response); + for (let i = 0; i < answers.length; i++) { + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector( + `#jspsych-survey-multi-choice-response-${i}-${trial.questions[i].options.indexOf( + answers[i][1] + )}` + ), + ((data.rt - 1000) / answers.length) * (i + 1) + ); + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-survey-multi-choice-next"), + data.rt + ); + } } export default SurveyMultiChoicePlugin; diff --git a/packages/plugin-survey-multi-select/src/index.spec.ts b/packages/plugin-survey-multi-select/src/index.spec.ts index ff81568f..fe548904 100644 --- a/packages/plugin-survey-multi-select/src/index.spec.ts +++ b/packages/plugin-survey-multi-select/src/index.spec.ts @@ -1,4 +1,4 @@ -import { clickTarget, startTimeline } from "@jspsych/test-utils"; +import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import surveyMultiSelect from "."; @@ -75,3 +75,62 @@ describe("survey-multi-select plugin", () => { expect(surveyData.Q4[0]).toBe("e"); }); }); + +describe("survey-likert plugin simulation", () => { + test("data-only mode works", async () => { + const scale = ["a", "b", "c", "d", "e"]; + const { getData, expectFinished } = await simulateTimeline([ + { + type: surveyMultiSelect, + questions: [ + { prompt: "Q0", options: scale }, + { prompt: "Q1", options: scale }, + { prompt: "Q2", options: scale }, + { prompt: "Q3", options: scale }, + { prompt: "Q4", options: scale }, + ], + randomize_question_order: true, + }, + ]); + + await expectFinished(); + + const surveyData = getData().values()[0].response; + const responses = Object.entries(surveyData); + for (const r of responses) { + expect(scale).toEqual(expect.arrayContaining(r[1] as [])); + } + }); + + test("visual mode works", async () => { + const scale = ["a", "b", "c", "d", "e"]; + const { getData, expectFinished, expectRunning } = await simulateTimeline( + [ + { + type: surveyMultiSelect, + questions: [ + { prompt: "Q0", options: scale }, + { prompt: "Q1", options: scale }, + { prompt: "Q2", options: scale }, + { prompt: "Q3", options: scale }, + { prompt: "Q4", options: scale }, + ], + randomize_question_order: true, + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const surveyData = getData().values()[0].response; + const responses = Object.entries(surveyData); + for (const r of responses) { + expect(scale).toEqual(expect.arrayContaining(r[1] as [])); + } + }); +}); diff --git a/packages/plugin-survey-multi-select/src/index.ts b/packages/plugin-survey-multi-select/src/index.ts index 67509782..403c6a79 100644 --- a/packages/plugin-survey-multi-select/src/index.ts +++ b/packages/plugin-survey-multi-select/src/index.ts @@ -271,6 +271,85 @@ class SurveyMultiSelectPlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const question_data = {}; + let rt = 1000; + + for (const q of trial.questions) { + let n_answers; + if (q.required) { + n_answers = this.jsPsych.randomization.randomInt(1, q.options.length); + } else { + n_answers = this.jsPsych.randomization.randomInt(0, q.options.length); + } + const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; + const selections = this.jsPsych.randomization.sampleWithoutReplacement(q.options, n_answers); + question_data[name] = selections; + rt += this.jsPsych.randomization.sampleExGaussian(1500, 400, 1 / 200, true); + } + + const default_data = { + response: question_data, + rt: rt, + question_order: trial.randomize_question_order + ? this.jsPsych.randomization.shuffle([...Array(trial.questions.length).keys()]) + : [...Array(trial.questions.length).keys()], + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 answers: [string, []][] = Object.entries(data.response); + for (let i = 0; i < answers.length; i++) { + for (const a of answers[i][1]) { + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector( + `#jspsych-survey-multi-select-response-${i}-${trial.questions[i].options.indexOf(a)}` + ), + ((data.rt - 1000) / answers.length) * (i + 1) + ); + } + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-survey-multi-select-next"), + data.rt + ); + } } export default SurveyMultiSelectPlugin; diff --git a/packages/plugin-survey-text/src/index.spec.ts b/packages/plugin-survey-text/src/index.spec.ts index 798876d6..ca1274a3 100644 --- a/packages/plugin-survey-text/src/index.spec.ts +++ b/packages/plugin-survey-text/src/index.spec.ts @@ -1,9 +1,11 @@ -import { clickTarget, startTimeline } from "@jspsych/test-utils"; +import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; import surveyText from "."; const selectInput = (selector: string) => document.querySelector(selector); +jest.useFakeTimers(); + describe("survey-text plugin", () => { test("default parameters work correctly", async () => { const { displayElement, expectFinished } = await startTimeline([ @@ -91,3 +93,42 @@ describe("survey-text plugin", () => { expect(surveyData.Q4).toBe("a4"); }); }); + +describe("survey-text simulation", () => { + test("data-only mode works", async () => { + const { getData, expectFinished } = await simulateTimeline([ + { + type: surveyText, + questions: [{ prompt: "How old are you?" }, { prompt: "Where were you born?" }], + }, + ]); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(Object.entries(data.response).length).toBe(2); + }); + + test("visual mode works", async () => { + const { getData, expectFinished, expectRunning } = await simulateTimeline( + [ + { + type: surveyText, + questions: [{ prompt: "How old are you?" }, { prompt: "Where were you born?" }], + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values()[0]; + + expect(Object.entries(data.response).length).toBe(2); + }); +}); diff --git a/packages/plugin-survey-text/src/index.ts b/packages/plugin-survey-text/src/index.ts index 22e50356..e5445a51 100644 --- a/packages/plugin-survey-text/src/index.ts +++ b/packages/plugin-survey-text/src/index.ts @@ -229,6 +229,81 @@ class SurveyTextPlugin implements JsPsychPlugin { var startTime = performance.now(); } + + simulate( + trial: TrialType, + 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, simulation_options) { + const question_data = {}; + let rt = 1000; + + for (const q of trial.questions) { + const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; + const ans_words = + q.rows == 1 + ? this.jsPsych.randomization.sampleExponential(0.25) + : this.jsPsych.randomization.randomInt(1, 10) * q.rows; + question_data[name] = this.jsPsych.randomization.randomWords({ + exactly: ans_words, + join: " ", + }); + rt += this.jsPsych.randomization.sampleExGaussian(2000, 400, 0.004, true); + } + + const default_data = { + response: question_data, + 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 answers = Object.entries(data.response).map((x) => { + return x[1] as string; + }); + for (let i = 0; i < answers.length; i++) { + this.jsPsych.pluginAPI.fillTextInput( + display_element.querySelector(`#input-${i}`), + answers[i], + ((data.rt - 1000) / answers.length) * (i + 1) + ); + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-survey-text-next"), + data.rt + ); + } } export default SurveyTextPlugin; diff --git a/packages/plugin-video-button-response/src/index.spec.ts b/packages/plugin-video-button-response/src/index.spec.ts new file mode 100644 index 00000000..e9d46236 --- /dev/null +++ b/packages/plugin-video-button-response/src/index.spec.ts @@ -0,0 +1,61 @@ +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; +import { initJsPsych } from "jspsych"; + +import videoButtonResponse from "."; + +jest.useFakeTimers(); + +beforeAll(() => { + window.HTMLMediaElement.prototype.pause = () => {}; +}); + +describe("video-button-response simulation", () => { + test("data mode works", async () => { + const timeline = [ + { + type: videoButtonResponse, + stimulus: ["foo.mp4"], + 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 video elements. + test("visual mode works", async () => { + const jsPsych = initJsPsych(); + + const timeline = [ + { + type: videoButtonResponse, + stimulus: ["foo.mp4"], + 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); + }); +}); diff --git a/packages/plugin-video-button-response/src/index.ts b/packages/plugin-video-button-response/src/index.ts index bff4c895..c46f5afa 100644 --- a/packages/plugin-video-button-response/src/index.ts +++ b/packages/plugin-video-button-response/src/index.ts @@ -364,6 +364,69 @@ class VideoButtonResponsePlugin implements JsPsychPlugin { this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 video_element = display_element.querySelector( + "#jspsych-video-button-response-stimulus" + ); + + const respond = () => { + if (data.rt !== null) { + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector(`div[data-choice="${data.response}"] button`), + data.rt + ); + } + }; + + if (!trial.response_allowed_while_playing) { + video_element.addEventListener("ended", respond); + } else { + respond(); + } + } } export default VideoButtonResponsePlugin; diff --git a/packages/plugin-video-keyboard-response/src/index.spec.ts b/packages/plugin-video-keyboard-response/src/index.spec.ts new file mode 100644 index 00000000..9f9ead5f --- /dev/null +++ b/packages/plugin-video-keyboard-response/src/index.spec.ts @@ -0,0 +1,59 @@ +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; +import { initJsPsych } from "jspsych"; + +import videoKeyboardResponse from "."; + +jest.useFakeTimers(); + +beforeAll(() => { + window.HTMLMediaElement.prototype.pause = () => {}; +}); + +describe("video-keyboard-response simulation", () => { + test("data mode works", async () => { + const timeline = [ + { + type: videoKeyboardResponse, + stimulus: ["foo.mp4"], + }, + ]; + + 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 video elements. + test("visual mode works", async () => { + const jsPsych = initJsPsych(); + + const timeline = [ + { + type: videoKeyboardResponse, + stimulus: ["foo.mp4"], + 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"); + }); +}); diff --git a/packages/plugin-video-keyboard-response/src/index.ts b/packages/plugin-video-keyboard-response/src/index.ts index b45754ad..64d0e3d4 100644 --- a/packages/plugin-video-keyboard-response/src/index.ts +++ b/packages/plugin-video-keyboard-response/src/index.ts @@ -295,6 +295,66 @@ class VideoKeyboardResponsePlugin implements JsPsychPlugin { this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 video_element = display_element.querySelector( + "#jspsych-video-button-response-stimulus" + ); + + const respond = () => { + if (data.rt !== null) { + this.jsPsych.pluginAPI.pressKey(data.response, data.rt); + } + }; + + if (!trial.response_allowed_while_playing) { + video_element.addEventListener("ended", respond); + } else { + respond(); + } + } + + private create_simulation_data(trial: TrialType, 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 VideoKeyboardResponsePlugin; diff --git a/packages/plugin-video-slider-response/src/index.spec.ts b/packages/plugin-video-slider-response/src/index.spec.ts new file mode 100644 index 00000000..6c1d1a46 --- /dev/null +++ b/packages/plugin-video-slider-response/src/index.spec.ts @@ -0,0 +1,61 @@ +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; +import { initJsPsych } from "jspsych"; + +import videoSliderResponse from "."; + +jest.useFakeTimers(); + +beforeAll(() => { + window.HTMLMediaElement.prototype.pause = () => {}; +}); + +describe("video-slider-response simulation", () => { + test("data mode works", async () => { + const timeline = [ + { + type: videoSliderResponse, + stimulus: ["foo.mp4"], + }, + ]; + + 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 video elements. + test("visual mode works", async () => { + const jsPsych = initJsPsych(); + + const timeline = [ + { + type: videoSliderResponse, + stimulus: ["foo.mp4"], + 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); + }); +}); diff --git a/packages/plugin-video-slider-response/src/index.ts b/packages/plugin-video-slider-response/src/index.ts index e56ae448..ef2f4265 100644 --- a/packages/plugin-video-slider-response/src/index.ts +++ b/packages/plugin-video-slider-response/src/index.ts @@ -410,6 +410,75 @@ class VideoSliderResponsePlugin implements JsPsychPlugin { this.jsPsych.pluginAPI.setTimeout(end_trial, trial.trial_duration); } } + + simulate( + trial: TrialType, + 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, 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), + start: trial.start, + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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 video_element = display_element.querySelector( + "#jspsych-video-button-response-stimulus" + ); + + const respond = () => { + if (data.rt !== null) { + const el = display_element.querySelector("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); + } + }; + + if (!trial.response_allowed_while_playing) { + video_element.addEventListener("ended", respond); + } else { + respond(); + } + } } export default VideoSliderResponsePlugin; diff --git a/packages/plugin-visual-search-circle/package.json b/packages/plugin-visual-search-circle/package.json index 414f695d..5c508561 100644 --- a/packages/plugin-visual-search-circle/package.json +++ b/packages/plugin-visual-search-circle/package.json @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "scripts": { - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "npm test -- --watch", "tsc": "tsc", "build": "rollup --config", diff --git a/packages/plugin-visual-search-circle/src/index.spec.ts b/packages/plugin-visual-search-circle/src/index.spec.ts new file mode 100644 index 00000000..c2008b33 --- /dev/null +++ b/packages/plugin-visual-search-circle/src/index.spec.ts @@ -0,0 +1,81 @@ +import { pressKey, simulateTimeline, startTimeline } from "@jspsych/test-utils"; + +import visualSearchCircle from "."; + +jest.useFakeTimers(); + +describe("visual-search-circle", () => { + test("displays search array circle", async () => { + const { displayElement, expectFinished, getData } = await startTimeline([ + { + type: visualSearchCircle, + target: "target.png", + foil: "foil.png", + fixation_image: "fixation.png", + set_size: 4, + target_present: true, + target_present_key: "a", + target_absent_key: "b", + }, + ]); + + expect(displayElement.querySelectorAll("img").length).toBe(1); + + jest.advanceTimersByTime(1000); // fixation duration + + expect(displayElement.querySelectorAll("img").length).toBe(5); + pressKey("a"); + await expectFinished(); + + expect(getData().values()[0].correct).toBe(true); + }); +}); + +describe("visual-search-circle simulation", () => { + test("data mode works", async () => { + const { expectFinished, getData } = await simulateTimeline([ + { + type: visualSearchCircle, + target: "target.png", + foil: "foil.png", + fixation_image: "fixation.png", + set_size: 4, + target_present: true, + target_present_key: "a", + target_absent_key: "b", + }, + ]); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(data.response == "a"); + }); + + test("visual mode works", async () => { + const { expectRunning, expectFinished, getData } = await simulateTimeline( + [ + { + type: visualSearchCircle, + target: "target.png", + foil: "foil.png", + fixation_image: "fixation.png", + set_size: 4, + target_present: true, + target_present_key: "a", + target_absent_key: "b", + }, + ], + "visual" + ); + + await expectRunning(); + + jest.runAllTimers(); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(data.response == "a"); + }); +}); diff --git a/packages/plugin-visual-search-circle/src/index.ts b/packages/plugin-visual-search-circle/src/index.ts index 791c7e83..1232970d 100644 --- a/packages/plugin-visual-search-circle/src/index.ts +++ b/packages/plugin-visual-search-circle/src/index.ts @@ -110,60 +110,16 @@ class VisualSearchCirclePlugin implements JsPsychPlugin { constructor(private jsPsych: JsPsych) {} trial(display_element: HTMLElement, trial: TrialType) { - // circle params - var diam = trial.circle_diameter; // pixels - var radi = diam / 2; - var paper_size = diam + trial.target_size[0]; - - // stimuli width, height - var stimh = trial.target_size[0]; - var stimw = trial.target_size[1]; - var hstimh = stimh / 2; - var hstimw = stimw / 2; + var paper_size = trial.circle_diameter + trial.target_size[0]; // fixation location - var fix_loc = [ - Math.floor(paper_size / 2 - trial.fixation_size[0] / 2), - Math.floor(paper_size / 2 - trial.fixation_size[1] / 2), - ]; + var fix_loc = this.generateFixationLoc(trial); // check for correct combination of parameters and create stimuli set - var possible_display_locs: number; - var to_present = []; - if (trial.target !== null && trial.foil !== null && trial.set_size !== null) { - possible_display_locs = trial.set_size; - if (trial.target_present) { - for (var i = 0; i < trial.set_size - 1; i++) { - to_present.push(trial.foil); - } - to_present.push(trial.target); - } else { - for (var i = 0; i < trial.set_size; i++) { - to_present.push(trial.foil); - } - } - } else if (trial.stimuli !== null) { - possible_display_locs = trial.stimuli.length; - to_present = trial.stimuli; - } else { - console.error( - "Error in visual-search-circle plugin: you must specify an array of images via the stimuli parameter OR specify the target, foil and set_size parameters." - ); - } + var to_present = this.generatePresentationSet(trial); - // possible stimulus locations on the circle - var display_locs = []; - var random_offset = Math.floor(Math.random() * 360); - for (var i = 0; i < possible_display_locs; i++) { - display_locs.push([ - Math.floor( - paper_size / 2 + cosd(random_offset + i * (360 / possible_display_locs)) * radi - hstimw - ), - Math.floor( - paper_size / 2 - sind(random_offset + i * (360 / possible_display_locs)) * radi - hstimh - ), - ]); - } + // stimulus locations on the circle + var display_locs = this.generateDisplayLocs(to_present.length, trial); // get target to draw on display_element.innerHTML += @@ -283,14 +239,126 @@ class VisualSearchCirclePlugin implements JsPsychPlugin { display_element.innerHTML = ""; } }; + } - // helper function for determining stimulus locations - function cosd(num: number) { - return Math.cos((num / 180) * Math.PI); + private generateFixationLoc(trial) { + var paper_size = trial.circle_diameter + trial.target_size[0]; + return [ + Math.floor(paper_size / 2 - trial.fixation_size[0] / 2), + Math.floor(paper_size / 2 - trial.fixation_size[1] / 2), + ]; + } + + private generateDisplayLocs(n_locs, trial) { + // circle params + var diam = trial.circle_diameter; // pixels + var radi = diam / 2; + var paper_size = diam + trial.target_size[0]; + + // stimuli width, height + var stimh = trial.target_size[0]; + var stimw = trial.target_size[1]; + var hstimh = stimh / 2; + var hstimw = stimw / 2; + + var display_locs = []; + var random_offset = Math.floor(Math.random() * 360); + for (var i = 0; i < n_locs; i++) { + display_locs.push([ + Math.floor(paper_size / 2 + this.cosd(random_offset + i * (360 / n_locs)) * radi - hstimw), + Math.floor(paper_size / 2 - this.sind(random_offset + i * (360 / n_locs)) * radi - hstimh), + ]); } + return display_locs; + } - function sind(num: number) { - return Math.sin((num / 180) * Math.PI); + private generatePresentationSet(trial) { + var to_present = []; + if (trial.target !== null && trial.foil !== null && trial.set_size !== null) { + if (trial.target_present) { + for (var i = 0; i < trial.set_size - 1; i++) { + to_present.push(trial.foil); + } + to_present.push(trial.target); + } else { + for (var i = 0; i < trial.set_size; i++) { + to_present.push(trial.foil); + } + } + } else if (trial.stimuli !== null) { + to_present = trial.stimuli; + } else { + console.error( + "Error in visual-search-circle plugin: you must specify an array of images via the stimuli parameter OR specify the target, foil and set_size parameters." + ); + } + return to_present; + } + + private cosd(num: number) { + return Math.cos((num / 180) * Math.PI); + } + + private sind(num: number) { + return Math.sin((num / 180) * Math.PI); + } + + simulate( + trial: TrialType, + 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, simulation_options) { + const key = this.jsPsych.pluginAPI.getValidKey([ + trial.target_present_key, + trial.target_absent_key, + ]); + const set = this.generatePresentationSet(trial); + + const default_data = { + correct: trial.target_present + ? key == trial.target_present_key + : key == trial.target_absent_key, + response: key, + rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true), + set_size: set.length, + target_present: trial.target_present, + locations: this.generateDisplayLocs(set.length, trial), + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, 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, trial.fixation_duration + data.rt); } } } diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 12ea1282..9cfa708c 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -129,3 +129,53 @@ export async function startTimeline(timeline: any[], jsPsych: JsPsych | any = {} finished, }; } + +/** + * Runs the given timeline by calling `jsPsych.simulate()` on the provided JsPsych object. + * + * @param timeline The timeline that is passed to `jsPsych.run()` + * @param simulation_mode Either 'data-only' mode or 'visual' mode. + * @param simulation_options Options to pass to `jsPsych.simulate()` + * @param jsPsych The jsPsych instance to be used. If left empty, a new instance will be created. If + * a settings object is passed instead, the settings will be used to create the jsPsych instance. + * + * @returns An object containing test helper functions, the jsPsych instance, and the jsPsych + * display element + */ +export async function simulateTimeline( + timeline: any[], + simulation_mode: "data-only" | "visual" = "data-only", + simulation_options: any = {}, + jsPsych: JsPsych | any = {} +) { + const jsPsychInstance = jsPsych instanceof JsPsych ? jsPsych : new JsPsych(jsPsych); + + let hasFinished = false; + const finished = jsPsychInstance + .simulate(timeline, simulation_mode, simulation_options) + .then(() => { + hasFinished = true; + }); + await flushPromises(); + + const displayElement = jsPsychInstance.getDisplayElement(); + + return { + jsPsych: jsPsychInstance, + displayElement, + /** Shorthand for `jsPsych.getDisplayElement().innerHTML` */ + getHTML: () => displayElement.innerHTML, + /** Shorthand for `jsPsych.data.get()` */ + getData: () => jsPsychInstance.data.get(), + expectFinished: async () => { + await flushPromises(); + expect(hasFinished).toBe(true); + }, + expectRunning: async () => { + await flushPromises(); + expect(hasFinished).toBe(false); + }, + /** A promise that is resolved when `jsPsych.simulate()` is done. */ + finished, + }; +}