diff --git a/examples/jspsych-preload.html b/examples/jspsych-preload.html new file mode 100644 index 00000000..60c2b14b --- /dev/null +++ b/examples/jspsych-preload.html @@ -0,0 +1,140 @@ + + + + + + + + + + + + + diff --git a/jspsych.js b/jspsych.js index 9436d071..e91cabb1 100755 --- a/jspsych.js +++ b/jspsych.js @@ -97,18 +97,11 @@ window.jsPsych = (function() { 'on_close': function(){ return undefined; }, - 'preload_images': [], - 'preload_audio': [], - 'preload_video': [], 'use_webaudio': true, 'exclusions': {}, 'show_progress_bar': false, 'message_progress_bar': 'Completion Progress', - 'auto_update_progress_bar': true, - 'auto_preload': true, - 'show_preload_progress_bar': true, - 'max_load_time': 60000, - 'max_preload_attempts': 10, + 'auto_update_progress_bar': true, 'default_iti': 0, 'minimum_valid_rt': 0, 'experiment_width': null, @@ -201,23 +194,11 @@ window.jsPsych = (function() { checkExclusions(opts.exclusions, function(){ // success! user can continue... - // start experiment, with or without preloading - if(opts.auto_preload){ - jsPsych.pluginAPI.autoPreload(timeline, startExperiment, file_protocol, opts.preload_images, opts.preload_audio, opts.preload_video, opts.show_preload_progress_bar); - if(opts.max_load_time > 0){ - setTimeout(function(){ - if(!loaded && !loadfail){ - core.loadFail(); - } - }, opts.max_load_time); - } - } else { - startExperiment(); - } + // start experiment + startExperiment(); }, function(){ // fail. incompatible user. - } ); }; @@ -355,13 +336,6 @@ window.jsPsych = (function() { core.addNodeToEndOfTimeline = function(new_timeline, preload_callback){ timeline.insert(new_timeline); - if(typeof preload_callback !== 'undefined'){ - if(opts.auto_preload){ - jsPsych.pluginAPI.autoPreload(timeline, preload_callback, file_protocol); - } else { - preload_callback(); - } - } } core.pauseExperiment = function(){ @@ -382,6 +356,10 @@ window.jsPsych = (function() { DOM_target.innerHTML = message; } + core.getSafeModeStatus = function() { + return file_protocol; + } + function TimelineNode(parameters, parent, relativeID) { // a unique ID for this node, relative to the parent @@ -1204,7 +1182,8 @@ jsPsych.plugins = (function() { AUDIO: 9, VIDEO: 10, OBJECT: 11, - COMPLEX: 12 + COMPLEX: 12, + TIMELINE: 13 } module.universalPluginParameters = { @@ -2489,10 +2468,11 @@ jsPsych.pluginAPI = (function() { // preloading stimuli // var preloads = []; + var preload_requests = []; var img_cache = {}; - module.preloadAudioFiles = function(files, callback_complete, callback_load) { + module.preloadAudio = function(files, callback_complete, callback_load, callback_error) { files = jsPsych.utils.flatten(files); files = jsPsych.utils.unique(files); @@ -2500,6 +2480,7 @@ jsPsych.pluginAPI = (function() { var n_loaded = 0; var loadfn = (typeof callback_load === 'undefined') ? function() {} : callback_load; var finishfn = (typeof callback_complete === 'undefined') ? function() {} : callback_complete; + var errorfn = (typeof callback_error === 'undefined') ? function() {} : callback_error; if(files.length==0){ finishfn(); @@ -2515,24 +2496,28 @@ jsPsych.pluginAPI = (function() { context.decodeAudioData(request.response, function(buffer) { audio_buffers[source] = buffer; n_loaded++; - loadfn(n_loaded); + loadfn(source); if(n_loaded == files.length) { finishfn(); } - }, function() { - console.error('Error loading audio file: ' + bufferID); + }, function(e) { + errorfn({source: source, error: e}); }); } - request.onerror = function(){ - if(count < jsPsych.initSettings().max_preload_attempts){ - setTimeout(function(){ - load_audio_file_webaudio(source, count+1) - }, 200); - } else { - jsPsych.loadFail(); + request.onerror = function(e){ + var err = e; + if(this.status == 404) { + err = "404"; + } + errorfn({source: source, error: err}); + } + request.onloadend = function(e){ + if(this.status == 404) { + errorfn({source: source, error: "404"}); } } request.send(); + preload_requests.push(request); } function load_audio_file_html5audio(source, count){ @@ -2541,40 +2526,29 @@ jsPsych.pluginAPI = (function() { audio.addEventListener('canplaythrough', function handleCanPlayThrough(){ audio_buffers[source] = audio; n_loaded++; - loadfn(n_loaded); + loadfn(source); if(n_loaded == files.length){ finishfn(); } audio.removeEventListener('canplaythrough', handleCanPlayThrough); }); - audio.addEventListener('error', function handleError(){ - if(count < jsPsych.initSettings().max_preload_attempts){ - setTimeout(function(){ - load_audio_file_html5audio(source, count+1) - }, 200); - } else { - jsPsych.loadFail(); - } + audio.addEventListener('error', function handleError(e){ + errorfn({source: audio.src, error: e}); audio.removeEventListener('error', handleError); }); - audio.addEventListener('abort', function handleAbort(){ - if(count < jsPsych.initSettings().max_preload_attempts){ - setTimeout(function(){ - load_audio_file_html5audio(source, count+1) - }, 200); - } else { - jsPsych.loadFail(); - } + audio.addEventListener('abort', function handleAbort(e){ + errorfn({source: audio.src, error: e}); audio.removeEventListener('abort', handleAbort); }); audio.src = source; + preload_requests.push(audio); } for (var i = 0; i < files.length; i++) { var bufferID = files[i]; if (typeof audio_buffers[bufferID] !== 'undefined') { n_loaded++; - loadfn(n_loaded); + loadfn(bufferID); if(n_loaded == files.length) { finishfn(); } @@ -2590,47 +2564,41 @@ jsPsych.pluginAPI = (function() { } - module.preloadImages = function(images, callback_complete, callback_load) { + module.preloadImages = function(images, callback_complete, callback_load, callback_error) { // flatten the images array images = jsPsych.utils.flatten(images); images = jsPsych.utils.unique(images); var n_loaded = 0; - var loadfn = (typeof callback_load === 'undefined') ? function() {} : callback_load; var finishfn = (typeof callback_complete === 'undefined') ? function() {} : callback_complete; + var loadfn = (typeof callback_load === 'undefined') ? function() {} : callback_load; + var errorfn = (typeof callback_error === 'undefined') ? function() {} : callback_error; if(images.length === 0){ finishfn(); return; } - function preload_image(source, count){ - count = count || 1; - + function preload_image(source){ var img = new Image(); img.onload = function() { n_loaded++; - loadfn(n_loaded); + loadfn(img.src); if (n_loaded === images.length) { finishfn(); } }; - img.onerror = function() { - if(count < jsPsych.initSettings().max_preload_attempts){ - setTimeout(function(){ - preload_image(source, count+1); - }, 200); - } else { - jsPsych.loadFail(); - } + img.onerror = function(e) { + errorfn({source: img.src, error: e}); } img.src = source; img_cache[source] = img; + preload_requests.push(img); } for (var i = 0; i < images.length; i++) { @@ -2639,58 +2607,62 @@ jsPsych.pluginAPI = (function() { }; - module.preloadVideo = function(video, callback_complete, callback_load) { + module.preloadVideo = function(video, callback_complete, callback_load, callback_error) { - // flatten the images array - video = jsPsych.utils.flatten(video); - video = jsPsych.utils.unique(video); + // flatten the video array + video = jsPsych.utils.flatten(video); + video = jsPsych.utils.unique(video); - var n_loaded = 0; - var loadfn = !callback_load ? function() {} : callback_load; - var finishfn = !callback_complete ? function() {} : callback_complete; + var n_loaded = 0; + var finishfn = !callback_complete ? function() {} : callback_complete; + var loadfn = !callback_load ? function() {} : callback_load; + var errorfn = (typeof callback_error === 'undefined') ? function() {} : callback_error; - if(video.length===0){ - finishfn(); - return; - } + if(video.length===0){ + finishfn(); + return; + } - function preload_video(source, count){ - count = count || 1; - //based on option 4 here: http://dinbror.dk/blog/how-to-preload-entire-html5-video-before-play-solved/ - var request = new XMLHttpRequest(); - request.open('GET', source, true); - request.responseType = 'blob'; - request.onload = function() { - if (this.status === 200 || this.status === 0) { - var videoBlob = this.response; - video_buffers[source] = URL.createObjectURL(videoBlob); // IE10+ - n_loaded++; - loadfn(n_loaded); - if (n_loaded === video.length) { - finishfn(); - } - } - }; - - request.onerror = function(){ - if(count < jsPsych.initSettings().max_preload_attempts){ - setTimeout(function(){ - preload_video(source, count+1) - }, 200); - } else { - jsPsych.loadFail(); - } + function preload_video(source, count){ + count = count || 1; + //based on option 4 here: http://dinbror.dk/blog/how-to-preload-entire-html5-video-before-play-solved/ + var request = new XMLHttpRequest(); + request.open('GET', source, true); + request.responseType = 'blob'; + request.onload = function() { + if (this.status === 200 || this.status === 0) { + var videoBlob = this.response; + video_buffers[source] = URL.createObjectURL(videoBlob); // IE10+ + n_loaded++; + loadfn(source); + if (n_loaded === video.length) { + finishfn(); } - request.send(); + } + }; + request.onerror = function(e){ + var err = e; + if(this.status == 404) { + err = "404"; + } + errorfn({source: source, error: err}); } - - for (var i = 0; i < video.length; i++) { - preload_video(video[i]); + request.onloadend = function(e){ + if(this.status == 404) { + errorfn({source: source, error: "404"}); + } } + request.send(); + preload_requests.push(request); + } - }; + for (var i = 0; i < video.length; i++) { + preload_video(video[i]); + } - module.registerPreload = function(plugin_name, parameter, media_type, conditional_function) { + }; + + module.registerPreload = function(plugin_name, parameter, media_type) { if (['audio', 'image', 'video'].indexOf(media_type)===-1) { console.error('Invalid media_type parameter for jsPsych.pluginAPI.registerPreload. Please check the plugin file.'); } @@ -2698,40 +2670,65 @@ jsPsych.pluginAPI = (function() { var preload = { plugin: plugin_name, parameter: parameter, - media_type: media_type, - conditional_function: conditional_function + media_type: media_type } preloads.push(preload); } - module.autoPreload = function(timeline, callback, file_protocol, images, audio, video, progress_bar) { + module.getAutoPreloadList = function(timeline_description){ + + function getTrialsOfTypeFromTimelineDescription(td, target_type, inherited_type){ + var trials = []; + + for(var i=0; i 0){ + var trial_preloads = jsPsych.pluginAPI.getAutoPreloadList(trial.trials); + images = images.concat(trial_preloads.images); + audio = audio.concat(trial_preloads.audio); + video = video.concat(trial_preloads.video); + } + + images = images.concat(trial.images); + audio = audio.concat(trial.audio); + video = video.concat(trial.video); + + images = jsPsych.utils.unique(jsPsych.utils.flatten(images)); + audio = jsPsych.utils.unique(jsPsych.utils.flatten(audio)); + video = jsPsych.utils.unique(jsPsych.utils.flatten(video)); + + if (in_safe_mode) { + // don't preload video if in safe mode (experiment is running via file protocol) + video = []; + } + + // render display of message and progress bar + + var html = ''; + + if(trial.message !== null){ + html += trial.message; + } + + if(trial.show_progress_bar){ + html += ` +
+
+
`; + } + + display_element.innerHTML = html; + + // do preloading + + if(trial.max_load_time !== null){ + jsPsych.pluginAPI.setTimeout(on_timeout, trial.max_load_time); + } + + var total_n = images.length + audio.length + video.length; + var loaded = 0; // success or error count + var loaded_success = 0; // success count + + function load_video(cb){ + jsPsych.pluginAPI.preloadVideo(video, cb, file_loading_success, file_loading_error); + } + + function load_audio(cb){ + jsPsych.pluginAPI.preloadAudio(audio, cb, file_loading_success, file_loading_error); + } + + function load_images(cb){ + jsPsych.pluginAPI.preloadImages(images, cb, file_loading_success, file_loading_error); + } + + if (video.length > 0) { load_video(function () { }) } + if (audio.length > 0) { load_audio(function () { }) } + if (images.length > 0) { load_images(function () { }) } + + // helper functions and callbacks + + function update_loading_progress_bar(){ + loaded++; + if(trial.show_progress_bar){ + var percent_loaded = (loaded/total_n)*100; + var preload_progress_bar = jsPsych.getDisplayElement().querySelector('#jspsych-loading-progress-bar'); + if (preload_progress_bar !== null) { + preload_progress_bar.style.width = percent_loaded+"%"; + } + } + } + + // called when a single file loading fails + function file_loading_error(e) { + // update progress bar even if there's an error + update_loading_progress_bar(); + // change success flag after first file loading error + if (success == null) { + success = false; + } + // add file to failed media list + var source = "unknown file"; + if (e.source) { + source = e.source; + } + if (e.error && e.error.path && e.error.path.length > 0) { + if (e.error.path[0].localName == "img") { + failed_images.push(source); + } else if (e.error.path[0].localName == "audio") { + failed_audio.push(source); + } else if (e.error.path[0].localName == "video") { + failed_video.push(source); + } + } + // construct detailed error message + var err_msg = '

Error loading file: '+source+'
'; + if (e.error.statusText) { + err_msg += 'File request response status: '+e.error.statusText+'
'; + } + if (e.error == "404") { + err_msg += '404 - file not found.
'; + } + if (typeof e.error.loaded !== 'undefined' && e.error.loaded !== null && e.error.loaded !== 0) { + err_msg += e.error.loaded+' bytes transferred.'; + } else { + err_msg += 'File did not begin loading. Check that file path is correct and reachable by the browser,
'+ + 'and that loading is not blocked by cross-origin resource sharing (CORS) errors.'; + } + err_msg += '

'; + detailed_errors.push(err_msg); + // call trial's on_error function + after_error(source); + // if this is the last file + if (loaded == total_n) { + if (trial.continue_after_error) { + // if continue_after_error is false, then stop with an error + end_trial(); + } else { + // otherwise end the trial and continue + stop_with_error_message(); + } + } + } + + // called when a single file loads successfully + function file_loading_success(source) { + update_loading_progress_bar(); + // call trial's on_success function + after_success(source); + loaded_success++; + if (loaded_success == total_n) { + // if this is the last file and all loaded successfully, call success function + on_success(); + } else if (loaded == total_n) { + // if this is the last file and there was at least one error + if (trial.continue_after_error) { + // end the trial and continue with experiment + end_trial(); + } else { + // if continue_after_error is false, then stop with an error + stop_with_error_message(); + } + } + } + + // called if all files load successfully + function on_success() { + if (typeof timeout !== 'undefined' && timeout === false) { + // clear timeout immediately after finishing, to handle race condition with max_load_time + jsPsych.pluginAPI.clearAllTimeouts(); + // need to call cancel preload function to clear global jsPsych preload_request list, even when they've all succeeded + jsPsych.pluginAPI.cancelPreloads(); + success = true; + end_trial(); + } + } + + // called if all_files haven't finished loading when max_load_time is reached + function on_timeout() { + //console.log('timeout fired'); + jsPsych.pluginAPI.cancelPreloads(); + if (typeof success !== 'undefined' && (success === false || success === null)) { + timeout = true; + if (loaded_success < total_n) { + success = false; + } + after_error('timeout'); // call trial's on_error event handler here, in case loading timed out with no file errors + detailed_errors.push('

Loading timed out.
'+ + 'Consider compressing your stimuli files, loading your files in smaller batches,
'+ + 'and/or increasing the max_load_time parameter.

'); + if (trial.continue_after_error) { + end_trial(); + } else { + stop_with_error_message(); + } + } + } + + function stop_with_error_message() { + jsPsych.pluginAPI.clearAllTimeouts(); + jsPsych.pluginAPI.cancelPreloads(); + // show error message + display_element.innerHTML = trial.error_message; + // show detailed errors, if necessary + if (trial.show_detailed_errors) { + display_element.innerHTML += '

Error details:

'; + detailed_errors.forEach(function(e) { + display_element.innerHTML += e; + }); + } + } + + function after_error(source) { + // call on_error function and pass file name + if (trial.on_error !== null) { + trial.on_error(source); + } + } + function after_success(source) { + // call on_success function and pass file name + if (trial.on_success !== null) { + trial.on_success(source); + } + } + + function end_trial(){ + // clear timeout again when end_trial is called, to handle race condition with max_load_time + jsPsych.pluginAPI.clearAllTimeouts(); + var trial_data = { + success: success, + timeout: timeout, + failed_images: failed_images, + failed_audio: failed_audio, + failed_video: failed_video + }; + // clear the display + display_element.innerHTML = ''; + jsPsych.finishTrial(trial_data); + } + }; + + return plugin; + })(); + \ No newline at end of file diff --git a/tests/jsPsych.pluginAPI/preloads.test.js b/tests/jsPsych.pluginAPI/preloads.test.js new file mode 100644 index 00000000..2ad211e7 --- /dev/null +++ b/tests/jsPsych.pluginAPI/preloads.test.js @@ -0,0 +1,43 @@ +const root = '../../'; +const utils = require('../testing-utils.js'); + +beforeEach(function(){ + require(root + 'jspsych.js'); + require(root + 'plugins/jspsych-html-keyboard-response.js'); +}); + +describe('getAutoPreloadList', function(){ + test('gets whole timeline when no argument provided', function(){ + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + var t = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var timeline = [t]; + + jsPsych.init({ + timeline: timeline + }) + + var images = jsPsych.pluginAPI.getAutoPreloadList().images; + + expect(images[0]).toBe('img/foo.png'); + }) + test('works with images', function(){ + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + var t = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png' + } + + var timeline = [t]; + + var images = jsPsych.pluginAPI.getAutoPreloadList(timeline).images; + + expect(images[0]).toBe('img/foo.png'); + }) +}) \ No newline at end of file diff --git a/tests/jsPsych/timelines.test.js b/tests/jsPsych/timelines.test.js index 71d0c0fd..fd5418d7 100644 --- a/tests/jsPsych/timelines.test.js +++ b/tests/jsPsych/timelines.test.js @@ -535,7 +535,7 @@ describe('nested timelines', function() { describe('add node to end of timeline', function(){ - test('adds node to end of timeline, without callback', function() { + test('adds node to end of timeline', function() { var new_trial = { type: 'html-keyboard-response', stimulus: 'bar' @@ -565,31 +565,5 @@ describe('add node to end of timeline', function(){ utils.pressKey('a'); }); - test('adds node to end of timeline, with callback', function() { - var t = { - type: 'html-keyboard-response', - stimulus: 'foo', - on_finish: function(){ - jsPsych.pauseExperiment(); - jsPsych.addNodeToEndOfTimeline({ - timeline: [{ - type: 'html-keyboard-response', - stimulus: 'bar' - }] - }, jsPsych.resumeExperiment) - } - }; - - jsPsych.init({ - timeline: [t] - }); - - expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo'); - utils.pressKey('a'); - expect(jsPsych.getDisplayElement().innerHTML).toMatch('bar'); - utils.pressKey('a'); - - }); - }); diff --git a/tests/media/blue.png b/tests/media/blue.png deleted file mode 100644 index 820bdce8..00000000 Binary files a/tests/media/blue.png and /dev/null differ diff --git a/tests/media/orange.png b/tests/media/orange.png deleted file mode 100644 index 108e6e57..00000000 Binary files a/tests/media/orange.png and /dev/null differ diff --git a/tests/media/sample_video.mp4 b/tests/media/sample_video.mp4 deleted file mode 100644 index 73bbd71a..00000000 Binary files a/tests/media/sample_video.mp4 and /dev/null differ diff --git a/tests/media/sound.mp3 b/tests/media/sound.mp3 deleted file mode 100644 index a58f850c..00000000 Binary files a/tests/media/sound.mp3 and /dev/null differ diff --git a/tests/plugins/plugin-preload.test.js b/tests/plugins/plugin-preload.test.js new file mode 100644 index 00000000..489fe448 --- /dev/null +++ b/tests/plugins/plugin-preload.test.js @@ -0,0 +1,916 @@ +const root = '../../'; + +describe('preload plugin', function () { + + beforeEach(function () { + require(root + 'jspsych.js'); + require(root + 'plugins/jspsych-preload.js'); + }); + + afterEach(function() { + jest.clearAllMocks(); + }) + + test('loads correctly', function () { + expect(typeof window.jsPsych.plugins['preload']).not.toBe('undefined'); + }); + + describe('auto_preload', function() { + + test('auto_preload method works with simple timeline and image stimulus', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('auto_preload method works with simple timeline and audio stimulus', function () { + + require(root + 'plugins/jspsych-audio-keyboard-response.js'); + + jsPsych.pluginAPI.preloadAudio = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'audio-keyboard-response', + stimulus: 'sound/foo.mp3', + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + expect(jsPsych.pluginAPI.preloadAudio.mock.calls[0][0]).toStrictEqual(['sound/foo.mp3']); + + }); + + test('auto_preload method works with simple timeline and video stimulus', function () { + + require(root + 'plugins/jspsych-video-keyboard-response.js'); + + jsPsych.pluginAPI.preloadVideo = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'video-keyboard-response', + stimulus: 'video/foo.mp4' + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + expect(jsPsych.pluginAPI.preloadVideo.mock.calls[0][0]).toStrictEqual(['video/foo.mp4']); + + }); + + test('auto_preload method works with nested timeline', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'image-keyboard-response', + render_on_canvas: false, + timeline: [ + {stimulus: 'img/foo.png'} + ] + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('auto_preload method works with looping timeline', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var count = 0; + var loop = { + timeline: [trial], + loop_function: function() { + if (count == 0) { + return true; + } else { + return false; + } + } + } + + jsPsych.init({ + timeline: [preload, loop] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('auto_preload method works with conditional timeline', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var count = 0; + var conditional = { + timeline: [trial], + conditional_function: function() { + if (count == 0) { + return true; + } else { + return false; + } + } + } + + jsPsych.init({ + timeline: [preload, conditional] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('auto_preload method works with timeline variables when stim is statically defined in trial object', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var preload = { + type: 'preload', + auto_preload: true + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false, + data: jsPsych.timelineVariable('data') + } + + var trial_procedure = { + timeline: [trial], + timeline_variables: [ + {data: {trial: 1}}, + {data: {trial: 2}}, + {data: {trial: 3}} + ] + } + + jsPsych.init({ + timeline: [preload, trial_procedure] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + }); + + describe('trials parameter', function() { + + test('trials parameter works with simple timeline', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var preload = { + type: 'preload', + trials: [trial] + } + + jsPsych.init({ + timeline: [preload] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('trials parameter works with looping timeline', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var count = 0; + var loop = { + timeline: [trial], + loop_function: function() { + if (count == 0) { + return true; + } else { + return false; + } + } + } + + var preload = { + type: 'preload', + trials: [loop] + } + + jsPsych.init({ + timeline: [preload] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('trials parameter works with conditional timeline', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var count = 0; + var conditional = { + timeline: [trial], + conditional_function: function() { + if (count == 0) { + return true; + } else { + return false; + } + } + } + + var preload = { + type: 'preload', + trials: [conditional] + } + + jsPsych.init({ + timeline: [preload] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + test('trials parameter works with timeline variables when stim is statically defined in trial object', function () { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false, + data: jsPsych.timelineVariable('data') + } + + var trial_procedure = { + timeline: [trial], + timeline_variables: [ + {data: {trial: 1}}, + {data: {trial: 2}}, + {data: {trial: 3}} + ] + } + + var preload = { + type: 'preload', + trials: [trial_procedure] + } + + jsPsych.init({ + timeline: [preload] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + }); + + describe('calls to pluginAPI preload functions', function() { + + test('auto_preload, trials, and manual preload array parameters can be used together', function () { + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var trial_1 = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var trial_2 = { + type: 'image-keyboard-response', + stimulus: 'img/bar.png', + render_on_canvas: false + } + + var preload = { + type: 'preload', + auto_preload: true, + trials: [trial_2], + images: ['img/fizz.png'] + } + + jsPsych.init({ + timeline: [preload, trial_1] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls.length).toBe(1); + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0].length).toBe(3); + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toContain('img/foo.png'); + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toContain('img/bar.png'); + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toContain('img/fizz.png'); + + }); + + test('plugin only attempts to load duplicate files once', function () { + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb) => { cb(); }); + + var trial_1 = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var trial_2 = { + type: 'image-keyboard-response', + stimulus: 'img/foo.png', + render_on_canvas: false + } + + var preload = { + type: 'preload', + trials: [trial_2], + images: ['img/foo.png'] + } + + jsPsych.init({ + timeline: [preload, trial_1] + }); + + expect(jsPsych.pluginAPI.preloadImages.mock.calls.length).toBe(1); + expect(jsPsych.pluginAPI.preloadImages.mock.calls[0][0]).toStrictEqual(['img/foo.png']); + + }); + + }); + + describe('continue_after_error and error messages', function() { + + test('experiment continues when image loads successfully', function() { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb_complete, cb_load, cb_error) => { cb_load(); cb_complete(); }); + + var preload = { + type: 'preload', + auto_preload: true, + error_message: 'foo', + max_load_time: 100 + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'image.png', + render_on_canvas: false + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + + expect(jsPsych.getDisplayElement().innerHTML).toMatch(' { + cb_error({ + source: x, + error: { + } + }); + }); + + var preload = { + type: 'preload', + auto_preload: true, + error_message: 'foo', + max_load_time: 100, + on_error: function(e) { + expect(e).toContain('img/bar.png'); + } + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'img/bar.png', + render_on_canvas: false + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo'); + + }); + + test('error_message is shown when continue_after_error is false and loading times out', function() { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + jest.useFakeTimers(); + + var mock_fn = jest.fn(function(x) {return x;}); + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb_complete, cb_load, cb_error) => { + // don't return anything here to simulate waiting forever for image to load + }); + + + var preload = { + type: 'preload', + auto_preload: true, + error_message: 'foo', + max_load_time: 100, + on_error: function(e) { + mock_fn(e); + } + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'blue.png', + render_on_canvas: false + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + jest.advanceTimersByTime(101); + + expect(mock_fn).toHaveBeenCalledWith('timeout'); + expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo'); + + + }); + + test('experiment continues when continue_after_error is true and files fail', function() { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + var mock_fn = jest.fn(function(x) {return x;}); + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb_complete, cb_load, cb_error) => { + cb_error({ + source: x, + error: { + } + }); + }); + + var preload = { + type: 'preload', + images: ['img/foo.png'], + error_message: 'bar', + max_load_time: null, + continue_after_error: true, + on_error: function(e) { + mock_fn('loading failed'); + } + } + + var trial = { + type: 'image-keyboard-response', + stimulus: 'blue.png', + render_on_canvas: false + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + + expect(mock_fn).toHaveBeenCalledWith('loading failed'); + expect(jsPsych.getDisplayElement().innerHTML).toMatch(' { + // don't return anything here to simulate waiting forever for image to load + }); + + var preload = { + type: 'preload', + auto_preload: true, + error_message: 'bar', + max_load_time: 100, + continue_after_error: true, + on_error: function(e) { + mock_fn(e); + } + } + + var trial = { + type: 'image-keyboard-response', + stimulus: '../media/blue.png', + render_on_canvas: false + } + + jsPsych.init({ + timeline: [preload, trial] + }); + + jest.advanceTimersByTime(101); + + expect(mock_fn).toHaveBeenCalledWith('timeout'); + expect(jsPsych.getDisplayElement().innerHTML).toMatch(' { + cb_error({ + source: x, + error: { + } + }); + }); + + var preload = { + type: 'preload', + images: ['img/foo.png'], + error_message: 'bar', + show_detailed_errors: true, + on_error: function(e) { + mock_fn('loading failed'); + } + } + + jsPsych.init({ + timeline: [preload] + }); + + + expect(mock_fn).toHaveBeenCalledWith('loading failed'); + expect(jsPsych.getDisplayElement().innerHTML).toMatch('Error details'); + + + }); + + }); + + describe('display while loading', function() { + + test('custom loading message is shown above progress bar if specified', function() { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + var preload = { + type: 'preload', + images: ['img/foo.png'], + message: 'bar', + max_load_time: 100 + } + + jsPsych.init({ + timeline: [preload] + }); + + expect(jsPsych.getDisplayElement().innerHTML).toMatch('bar'); + expect(jsPsych.getDisplayElement().innerHTML).toMatch('
{ + if(x.includes('blue.png')){ + cb_load(); + cb_complete(); + } else { + cb_error({ + source: x, + error: { + } + }); + } + }); + jsPsych.pluginAPI.preloadVideo = jest.fn((x, cb_complete, cb_load, cb_error) => { + cb_error({ + source: x, + error: { + } + }); + }); + jsPsych.pluginAPI.preloadAudio = jest.fn((x, cb_complete, cb_load, cb_error) => { + cb_error({ + source: x, + error: { + } + }); + }); + + var preload_1 = { + type: 'preload', + images: ['foo.png'], + audio: ['bar.mp3'], + video: ['buzz.mp4'], + continue_after_error: true, + on_error: function(e) { + mock_fn('loading failed'); + }, + on_success: function(e) { + mock_fn('loading succeeded'); + } + } + + var preload_2 = { + type: 'preload', + images: ['blue.png'], + max_load_time: 100, + on_error: function(e) { + mock_fn('loading failed'); + }, + on_success: function(e) { + mock_fn('loading succeeded'); + } + } + + jsPsych.init({ + timeline: [preload_1, preload_2] + }); + + + expect(mock_fn.mock.calls[0][0]).toBe('loading failed'); + expect(mock_fn.mock.calls[1][0]).toBe('loading failed'); + expect(mock_fn.mock.calls[2][0]).toBe('loading failed'); + expect(mock_fn.mock.calls[3][0]).toBe('loading succeeded'); + + + }); + + test('on_error/on_success callbacks are not called after loading times out', function() { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + var mock_fn = jest.fn(function(x) {return x;}); + var cancel_preload_spy = jest.spyOn(jsPsych.pluginAPI, 'cancelPreloads'); + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb_complete, cb_load, cb_error) => { + // empty to simulate timeout + }); + jsPsych.pluginAPI.preloadVideo = jest.fn((x, cb_complete, cb_load, cb_error) => { + // empty to simulate timeout + }); + jsPsych.pluginAPI.preloadAudio = jest.fn((x, cb_complete, cb_load, cb_error) => { + // empty to simulate timeout + }); + jest.useFakeTimers(); + + var preload = { + type: 'preload', + images: ['img/foo.png', 'blue.png'], + audio: ['audio/bar.mp3'], + video: ['video/buzz.mp4'], + continue_after_error: true, + max_load_time: 100, + on_error: function(e) { + if (e == "timeout") { + mock_fn(e); + } else { + mock_fn('loading failed'); + } + }, + on_success: function(e) { + mock_fn('loading succeeded'); + } + } + + jsPsych.init({ + timeline: [preload] + }); + + jest.advanceTimersByTime(101); + + expect(mock_fn).toHaveBeenCalledWith('timeout'); + expect(mock_fn).toHaveBeenLastCalledWith('timeout'); + expect(cancel_preload_spy).toHaveBeenCalled(); + + + }); + + test('experiment stops with default error_message and on_error/on_success callbacks are not called after preload trial ends with error', function() { + + require(root + 'plugins/jspsych-image-keyboard-response.js'); + + var mock_fn = jest.fn(function(x) {return x;}); + var cancel_preload_spy = jest.spyOn(jsPsych.pluginAPI,'cancelPreloads'); + jest.useFakeTimers(); + jsPsych.pluginAPI.preloadImages = jest.fn((x, cb_complete, cb_load, cb_error) => { + if(x.includes('blue.png')){ + cb_load(); + cb_complete(); + } else { + + } + }); + jsPsych.pluginAPI.preloadVideo = jest.fn((x, cb_complete, cb_load, cb_error) => { + + }); + jsPsych.pluginAPI.preloadAudio = jest.fn((x, cb_complete, cb_load, cb_error) => { + + }); + + var preload_1 = { + type: 'preload', + images: ['img/foo.png'], + audio: ['audio/bar.mp3'], + video: ['video/buzz.mp4'], + max_load_time: 100, + on_error: function(e) { + if (e == 'timeout') { + mock_fn(e); + } else { + mock_fn('loading failed'); + } + }, + on_success: function(e) { + mock_fn('loading succeeded'); + } + } + + var preload_2 = { + type: 'preload', + images: ['../media/blue.png'], + max_load_time: 100, + on_error: function(e) { + mock_fn('loading failed'); + }, + on_success: function(e) { + mock_fn('loading succeeded'); + } + } + + jsPsych.init({ + timeline: [preload_1, preload_2] + }); + + jest.advanceTimersByTime(101); + + expect(mock_fn).toHaveBeenCalledWith('timeout'); + expect(mock_fn).toHaveBeenLastCalledWith('timeout'); + expect(jsPsych.getDisplayElement().innerHTML).toMatch('The experiment failed to load.'); + expect(cancel_preload_spy).toHaveBeenCalled(); + + + }); + + }); + +});