This commit is contained in:
Becky Gilbert 2020-10-23 16:15:06 -07:00
commit 645102b8cb
21 changed files with 341 additions and 52 deletions

View File

@ -3,11 +3,7 @@
name: Jest Test
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
on: [ push, pull_request ]
jobs:
build:

View File

@ -362,6 +362,7 @@ max_preload_attempts | numeric | The maximum number of attempts to preload each
use_webaudio | boolean | If false, then jsPsych will not attempt to use the WebAudio API for audio playback. Instead, HTML5 Audio objects will be used. The WebAudio API offers more precise control over the timing of audio events, and should be used when possible. The default value is true.
default_iti | numeric | The default inter-trial interval in ms. The default value if none is specified is 0ms.
experiment_width | numeric | The desired width of the jsPsych container in pixels. If left undefined, the width will be 100% of the display element. Usually this is the `<body>` element, and the width will be 100% of the screen size.
minimum_valid_rt | numeric | The minimum valid response time for key presses during the experiment. Any key press response time that is less than this value will be treated as invalid and ignored. Note that this parameter only applies to _keyboard responses_, and not to other response types such as buttons and sliders. The default value is 0.
Possible values for the exclusions parameter above.

View File

@ -381,7 +381,7 @@ var too_long = jsPsych.data.get().filterCustom(function(trial){
#### .first() / .last()
Returns a DataCollection containing the first/last *n* trials.
Returns a DataCollection containing the first/last *n* trials. If *n* is greater than the number of trials in the DataCollection, then these functions will return an array of length equal to the number of trials. If there are no trials in the DataCollection, then these functions will return an empty array. If the *n* argument is omitted, then the functions will use the default value of 1. If *n* is zero or a negative number, then these functions will throw an error.
```js
var first_trial = jsPsych.data.get().first(1);

View File

@ -13,7 +13,7 @@ jsPsych.turk.submitToTurk(data)
Parameter | Type | Description
----------|------|------------
data | object | The `data` parameter is an object of `key: value` pairs. Any pairs in the `data` parameter will be saved by Mechanical Turk, and can be downloaded in a CSV file through the Mechanical Turk interface.
data | object | The `data` parameter is an object of `key: value` pairs. Any pairs in the `data` parameter will be saved by Mechanical Turk, and can be downloaded in a CSV file through the Mechanical Turk interface. **Important**: the `data` parameter must contain at least one `key: value` pair, even just a dummy value, or the HIT will not be submitted correctly.
### Return value

View File

@ -57,6 +57,10 @@ window.jsPsych = (function() {
console.error('No timeline declared in jsPsych.init. Cannot start experiment.')
}
if(options.timeline.length == 0){
console.error('No trials have been added to the timeline (the timeline is an empty array). Cannot start experiment.')
}
// reset variables
timeline = null;
global_trial_index = 0;
@ -101,6 +105,7 @@ window.jsPsych = (function() {
'max_load_time': 60000,
'max_preload_attempts': 10,
'default_iti': 0,
'minimum_valid_rt': 0,
'experiment_width': null
};
@ -577,8 +582,16 @@ window.jsPsych = (function() {
// if progress.current_location is -1, then the timeline variable is being evaluated
// in a function that runs prior to the trial starting, so we should treat that trial
// as being the active trial for purposes of finding the value of the timeline variable
var loc = Math.max(0, progress.current_location);
return timeline_parameters.timeline[loc].timelineVariable(variable_name);
var loc = Math.max(0, progress.current_location);
// if loc is greater than the number of elements on this timeline, then the timeline
// variable is being evaluated in a function that runs after the trial on the timeline
// are complete but before advancing to the next (like a loop_function).
// treat the last active trial as the active trial for this purpose.
if(loc == timeline_parameters.timeline.length){
loc = loc - 1;
}
// now find the variable
return timeline_parameters.timeline[loc].timelineVariable(variable_name);
}
}
@ -922,7 +935,7 @@ window.jsPsych = (function() {
if(jsPsych.plugins[trial.type].info.parameters[param].type == jsPsych.plugins.parameterType.COMPLEX){
if(jsPsych.plugins[trial.type].info.parameters[param].array == true){
// iterate over each entry in the array
for(var i in trial[param]){
trial[param].forEach(function(ip, i){
// check each parameter in the plugin description
for(var p in jsPsych.plugins[trial.type].info.parameters[param].nested){
if(typeof trial[param][i][p] == 'undefined' || trial[param][i][p] === null){
@ -933,7 +946,7 @@ window.jsPsych = (function() {
}
}
}
}
});
}
}
// if it's not nested, checking is much easier and do that here:
@ -1129,22 +1142,48 @@ jsPsych.data = (function() {
}
}
/**
* Queries the first n elements in a collection of trials.
*
* @param {number} n A positive integer of elements to return. A value of
* n that is less than 1 will throw an error.
*
* @return {Array} First n objects of a collection of trials. If fewer than
* n trials are available, the trials.length elements will
* be returned.
*
*/
data_collection.first = function(n){
if(typeof n=='undefined'){ n = 1 }
var out = [];
for(var i=0; i<n; i++){
out.push(trials[i]);
if (typeof n == 'undefined') { n = 1 }
if (n < 1) {
throw `You must query with a positive nonzero integer. Please use a
different value for n.`;
}
return DataCollection(out);
if (trials.length == 0) return DataCollection([]);
if (n > trials.length) n = trials.length;
return DataCollection(trials.slice(0, n));
}
data_collection.last = function(n){
if(typeof n=='undefined'){ n = 1 }
var out = [];
for(var i=trials.length-n; i<trials.length; i++){
out.push(trials[i]);
/**
* Queries the last n elements in a collection of trials.
*
* @param {number} n A positive integer of elements to return. A value of
* n that is less than 1 will throw an error.
*
* @return {Array} Last n objects of a collection of trials. If fewer than
* n trials are available, the trials.length elements will
* be returned.
*
*/
data_collection.last = function(n) {
if (typeof n == 'undefined') { n = 1 }
if (n < 1) {
throw `You must query with a positive nonzero integer. Please use a
different value for n.`;
}
return DataCollection(out);
if (trials.length == 0) return DataCollection([]);
if (n > trials.length) n = trials.length;
return DataCollection(trials.slice(trials.length - n, trials.length));
}
data_collection.values = function(){
@ -2001,6 +2040,7 @@ jsPsych.pluginAPI = (function() {
}
module.getKeyboardResponse = function(parameters) {
//parameters are: callback_function, valid_responses, rt_method, persist, audio_context, audio_context_start_time, allow_held_key?
parameters.rt_method = (typeof parameters.rt_method === 'undefined') ? 'performance' : parameters.rt_method;
@ -2012,20 +2052,30 @@ jsPsych.pluginAPI = (function() {
var start_time;
if (parameters.rt_method == 'performance') {
start_time = performance.now();
} else if (parameters.rt_method == 'audio') {
} else if (parameters.rt_method === 'audio') {
start_time = parameters.audio_context_start_time;
}
var listener_id;
var listener_function = function(e) {
var key_time;
if (parameters.rt_method == 'performance') {
key_time = performance.now();
} else if (parameters.rt_method == 'audio') {
} else if (parameters.rt_method === 'audio') {
key_time = parameters.audio_context.currentTime
}
var rt = key_time - start_time;
// overiding via parameters for testing purposes.
var minimum_valid_rt = parameters.minimum_valid_rt;
if(!minimum_valid_rt){
minimum_valid_rt = jsPsych.initSettings().minimum_valid_rt || 0;
}
if(rt < minimum_valid_rt){
return;
}
var valid_response = false;
if (typeof parameters.valid_responses === 'undefined' || parameters.valid_responses == jsPsych.ALL_KEYS) {
@ -2050,7 +2100,7 @@ jsPsych.pluginAPI = (function() {
}
// check if key was already held down
if (((typeof parameters.allow_held_key == 'undefined') || !parameters.allow_held_key) && valid_response) {
if (((typeof parameters.allow_held_key === 'undefined') || !parameters.allow_held_key) && valid_response) {
if (typeof held_keys[e.keyCode] !== 'undefined' && held_keys[e.keyCode] == true) {
valid_response = false;
}
@ -2063,7 +2113,7 @@ jsPsych.pluginAPI = (function() {
parameters.callback_function({
key: e.keyCode,
rt: key_time - start_time
rt: rt,
});
if (keyboard_listeners.includes(listener_id)) {
@ -2328,15 +2378,16 @@ jsPsych.pluginAPI = (function() {
function load_audio_file_html5audio(source, count){
count = count || 1;
var audio = new Audio();
audio.addEventListener('canplaythrough', function(){
audio.addEventListener('canplaythrough', function handleCanPlayThrough(){
audio_buffers[source] = audio;
n_loaded++;
loadfn(n_loaded);
if(n_loaded == files.length){
finishfn();
}
audio.removeEventListener('canplaythrough', handleCanPlayThrough);
});
audio.addEventListener('onerror', function(){
audio.addEventListener('error', function handleError(){
if(count < jsPsych.initSettings().max_preload_attempts){
setTimeout(function(){
load_audio_file_html5audio(source, count+1)
@ -2344,8 +2395,9 @@ jsPsych.pluginAPI = (function() {
} else {
jsPsych.loadFail();
}
audio.removeEventListener('error', handleError);
});
audio.addEventListener('onstalled', function(){
audio.addEventListener('stalled', function handleStalled(){
if(count < jsPsych.initSettings().max_preload_attempts){
setTimeout(function(){
load_audio_file_html5audio(source, count+1)
@ -2353,8 +2405,9 @@ jsPsych.pluginAPI = (function() {
} else {
jsPsych.loadFail();
}
audio.removeEventListener('stalled', handleStalled);
});
audio.addEventListener('onabort', function(){
audio.addEventListener('abort', function handleAbort(){
if(count < jsPsych.initSettings().max_preload_attempts){
setTimeout(function(){
load_audio_file_html5audio(source, count+1)
@ -2362,6 +2415,7 @@ jsPsych.pluginAPI = (function() {
} else {
jsPsych.loadFail();
}
audio.removeEventListener('abort', handleAbort);
});
audio.src = source;
}

View File

@ -147,7 +147,7 @@ jsPsych.plugins["audio-button-response"] = (function() {
// measure rt
var end_time = performance.now();
var rt = end_time - start_time;
response.button = choice;
response.button = parseInt(choice);
response.rt = rt;
// disable all the buttons after a response

View File

@ -142,9 +142,9 @@ jsPsych.plugins['audio-slider-response'] = (function() {
};
if(trial.require_movement){
display_element.querySelector('#jspsych-audio-slider-response-response').addEventListener('change', function(){
display_element.querySelector('#jspsych-audio-slider-response-response').addEventListener('click', function(){
display_element.querySelector('#jspsych-audio-slider-response-next').disabled = false;
})
});
}
display_element.querySelector('#jspsych-audio-slider-response-next').addEventListener('click', function() {

View File

@ -129,7 +129,7 @@ jsPsych.plugins["html-button-response"] = (function() {
// measure rt
var end_time = performance.now();
var rt = end_time - start_time;
response.button = choice;
response.button = parseInt(choice);
response.rt = rt;
// after a valid response, the stimulus will have the CSS class 'responded'

View File

@ -137,9 +137,9 @@ jsPsych.plugins['html-slider-response'] = (function() {
};
if(trial.require_movement){
display_element.querySelector('#jspsych-html-slider-response-response').addEventListener('change', function(){
display_element.querySelector('#jspsych-html-slider-response-response').addEventListener('click', function(){
display_element.querySelector('#jspsych-html-slider-response-next').disabled = false;
})
});
}
display_element.querySelector('#jspsych-html-slider-response-next').addEventListener('click', function() {

View File

@ -163,7 +163,7 @@ jsPsych.plugins["image-button-response"] = (function() {
// measure rt
var end_time = performance.now();
var rt = end_time - start_time;
response.button = choice;
response.button = parseInt(choice);
response.rt = rt;
// after a valid response, the stimulus will have the CSS class 'responded'

View File

@ -172,9 +172,9 @@ jsPsych.plugins['image-slider-response'] = (function() {
};
if(trial.require_movement){
display_element.querySelector('#jspsych-image-slider-response-response').addEventListener('change', function(){
display_element.querySelector('#jspsych-image-slider-response-response').addEventListener('click', function(){
display_element.querySelector('#jspsych-image-slider-response-next').disabled = false;
})
});
}
display_element.querySelector('#jspsych-image-slider-response-next').addEventListener('click', function() {

View File

@ -45,7 +45,7 @@ jsPsych.plugins['same-different-html'] = (function() {
first_stim_duration: {
type: jsPsych.plugins.parameterType.INT,
pretty_name: 'First stimulus duration',
default: 1000,
default: null,
description: 'How long to show the first stimulus for in milliseconds. If null, then the stimulus will remain on the screen until any keypress is made.'
},
gap_duration: {
@ -57,8 +57,8 @@ jsPsych.plugins['same-different-html'] = (function() {
second_stim_duration: {
type: jsPsych.plugins.parameterType.INT,
pretty_name: 'Second stimulus duration',
default: 1000,
description: 'How long to show the second stimulus for in milliseconds. If null, then the stimulus will remain on the screen until any keypress is made.'
default: null,
description: 'How long to show the second stimulus for in milliseconds. If null, then the stimulus will remain on the screen until a valid response is made.'
},
prompt: {
type: jsPsych.plugins.parameterType.STRING,

View File

@ -47,7 +47,7 @@ jsPsych.plugins['same-different-image'] = (function() {
first_stim_duration: {
type: jsPsych.plugins.parameterType.INT,
pretty_name: 'First stimulus duration',
default: 1000,
default: null,
description: 'How long to show the first stimulus for in milliseconds. If null, then the stimulus will remain on the screen until any keypress is made.'
},
gap_duration: {
@ -59,8 +59,8 @@ jsPsych.plugins['same-different-image'] = (function() {
second_stim_duration: {
type: jsPsych.plugins.parameterType.INT,
pretty_name: 'Second stimulus duration',
default: 1000,
description: 'How long to show the second stimulus for in milliseconds. If null, then the stimulus will remain on the screen until any keypress is made.'
default: null,
description: 'How long to show the second stimulus for in milliseconds. If null, then the stimulus will remain on the screen until a valid response is made.'
},
prompt: {
type: jsPsych.plugins.parameterType.STRING,

View File

@ -248,7 +248,7 @@ jsPsych.plugins["video-button-response"] = (function() {
// measure rt
var end_time = performance.now();
var rt = end_time - start_time;
response.button = choice;
response.button = parseInt(choice);
response.rt = rt;
// after a valid response, the stimulus will have the CSS class 'responded'

View File

@ -234,9 +234,9 @@ jsPsych.plugins["video-slider-response"] = (function() {
video_element.playbackRate = trial.rate;
if(trial.require_movement){
display_element.querySelector('#jspsych-video-slider-response-response').addEventListener('change', function(){
display_element.querySelector('#jspsych-video-slider-response-response').addEventListener('click', function(){
display_element.querySelector('#jspsych-video-slider-response-next').disabled = false;
})
});
}
var startTime = performance.now();

View File

@ -67,10 +67,32 @@ describe('DataCollection', function(){
test('#first', function(){
expect(jsPsych.data.get().first(3).count()).toBe(3);
expect(jsPsych.data.get().first(2).values()[1].rt).toBe(200);
expect(jsPsych.data.get().first().count()).toBe(1);
expect(() => {
jsPsych.data.get().first(-1)
}).toThrow();
expect(() => {
jsPsych.data.get().first(0)
}).toThrow();
expect(jsPsych.data.get().filter({foo: "bar"}).first(1).count()).toBe(0);
var n = jsPsych.data.get().count();
var too_many = n+1;
expect(jsPsych.data.get().first(too_many).count()).toBe(n);
});
test('#last', function(){
expect(jsPsych.data.get().last(2).count(2)).toBe(2);
expect(jsPsych.data.get().last(2).values()[0].rt).toBe(400);
expect(jsPsych.data.get().last().count()).toBe(1);
expect(() => {
jsPsych.data.get().last(-1)
}).toThrow();
expect(() => {
jsPsych.data.get().last(0)
}).toThrow();
expect(jsPsych.data.get().filter({foo: "bar"}).last(1).count()).toBe(0);
var n = jsPsych.data.get().count();
var too_many = n+1;
expect(jsPsych.data.get().last(too_many).count()).toBe(n);
});
test('#join', function(){
var dc1 = jsPsych.data.get().filter({filter: true});

View File

@ -27,4 +27,32 @@ describe('nested defaults', function(){
expect(display.querySelector('input').placeholder).toBe("")
expect(display.querySelector('input').size).toBe(40)
});
test('safe against extending the array.prototype (issue #989)', function(){
Array.prototype.qq = jest.fn();
const spy = jest.spyOn(console, 'error').mockImplementation();
var t = {
type: 'survey-text',
questions: [
{
prompt: 'Question 1.'
},
{
prompt: 'Question 2.'
}
]
}
jsPsych.init({timeline: [t]})
var display = jsPsych.getDisplayElement();
expect(display.querySelector('input').placeholder).toBe("")
expect(display.querySelector('input').size).toBe(40)
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
})

View File

@ -0,0 +1,58 @@
const root = '../../';
const utils = require('../testing-utils.js');
// ideally, use fake timers for this test, but 'modern' timers that work
// with performance.now() break something in the first test. wait for fix?
//jest.useFakeTimers('modern');
//jest.useFakeTimers();
beforeEach(function(){
require(root + 'jspsych.js');
require(root + 'plugins/jspsych-html-keyboard-response.js');
});
describe('minimum_valid_rt parameter', function(){
test('has a default value of 0', function(){
var t = {
type: 'html-keyboard-response',
stimulus: 'foo'
}
var t2 = {
type: 'html-keyboard-response',
stimulus: 'bar'
}
jsPsych.init({timeline: [t,t2]});
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('bar');
utils.pressKey(32);
});
test('correctly prevents fast responses when set', function(done){
var t = {
type: 'html-keyboard-response',
stimulus: 'foo'
}
var t2 = {
type: 'html-keyboard-response',
stimulus: 'bar'
}
jsPsych.init({timeline: [t,t2], minimum_valid_rt: 100});
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
setTimeout(function(){
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('bar');
utils.pressKey(32);
done();
}, 100)
});
});

View File

@ -110,6 +110,62 @@ describe('loop function', function(){
});
test('timeline variables from nested timelines are available in loop function', function(){
var counter = 0;
var trial2 = {
type: 'html-keyboard-response',
stimulus: jsPsych.timelineVariable('word')
}
var innertimeline = {
timeline: [{
type: 'html-keyboard-response',
stimulus: 'foo'
}],
loop_function: function(){
if(jsPsych.timelineVariable('word', true) == 'b' && counter < 2){
counter++;
return true;
} else {
counter = 0;
return false;
}
}
}
var outertimeline = {
timeline: [trial2, innertimeline],
timeline_variables: [
{word: 'a'},
{word: 'b'},
{word: 'c'}
]
}
jsPsych.init({
timeline: [outertimeline]
});
expect(jsPsych.getDisplayElement().innerHTML).toMatch('a');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('b');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('c');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
});
});
describe('conditional function', function(){
@ -348,12 +404,44 @@ describe('endCurrentTimeline', function(){
})
});
describe('nested timelines', function() {
test('works without other parameters', function() {
var t1 = {
type: 'html-keyboard-response',
stimulus: 'foo'
};
var t2 = {
type: 'html-keyboard-response',
stimulus: 'bar'
};
var trials = {
timeline: [t1, t2]
};
jsPsych.init({
timeline: [trials]
});
expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo');
utils.pressKey(32);
expect(jsPsych.getDisplayElement().innerHTML).toMatch('bar');
utils.pressKey(32);
})
})
describe('add node to end of timeline', function(){
test('adds node to end of timeline, without callback', function() {
var new_trial = {
type: 'html-keyboard-response',
stimulus: 'bar'
type: 'html-keyboard-response',
stimulus: 'bar'
};
var new_timeline = {
@ -406,4 +494,5 @@ describe('add node to end of timeline', function(){
});
});
});

View File

@ -151,4 +151,24 @@ describe('image-button-response', function(){
expect(jsPsych.getDisplayElement().innerHTML).toBe('');
});
test('should show console warning when trial duration is null and response ends trial is false', function() {
const spy = jest.spyOn(console, 'warn').mockImplementation();
var trial = {
type: 'image-button-response',
stimulus: '../media/blue.png',
choices: ['button-choice'],
response_ends_trial: false,
trial_duration: null
};
jsPsych.init({
timeline: [trial],
auto_preload: false
});
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});

View File

@ -123,4 +123,25 @@ describe('image-keyboard-response', function(){
expect(jsPsych.getDisplayElement().innerHTML).toBe('');
});
test('should show console warning when trial duration is null and response ends trial is false', function() {
const spy = jest.spyOn(console, 'warn').mockImplementation();
var trial = {
type: 'image-keyboard-response',
stimulus: '../media/blue.png',
choices: ['f','j'],
response_ends_trial: false,
trial_duration: null
};
jsPsych.init({
timeline: [trial],
auto_preload: false
});
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});