From 64c968fa209c39d1ee3bdd503d257d46854c5bfc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2020 09:52:01 -0400 Subject: [PATCH 1/4] Bump websocket-extensions from 0.1.3 to 0.1.4 (#148) Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4. - [Release notes](https://github.com/faye/websocket-extensions-node/releases) - [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md) - [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b3455d16..152392f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11474,9 +11474,9 @@ websocket-driver@>=0.5.1: websocket-extensions ">=0.1.1" websocket-extensions@>=0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" - integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== whatwg-fetch@^3.0.0: version "3.0.0" From 57f58c6fe6b0aab9649b40b449c48db5431cec15 Mon Sep 17 00:00:00 2001 From: Kim Scott Date: Fri, 3 Jul 2020 16:29:03 -0400 Subject: [PATCH 2/4] fix bug in exp-lookit-video trial declaring audio/video done after n-1 presentations --- .../exp-lookit-composite-video-trial/component.js | 2 +- app/components/exp-lookit-video/component.js | 6 +++--- docs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/exp-lookit-composite-video-trial/component.js b/app/components/exp-lookit-composite-video-trial/component.js index 4b4aac6b..5d4703d8 100644 --- a/app/components/exp-lookit-composite-video-trial/component.js +++ b/app/components/exp-lookit-composite-video-trial/component.js @@ -77,7 +77,7 @@ let { ```json "sample-intermodal-trial-2": { - "kind": "exp-lookit-video", + "kind": "exp-lookit-composite-video-trial", "isLast": false, "baseDir": "https://s3.amazonaws.com/lookitcontents/intermodal/", "sources": "sbs_ramp_down_up_apple_c1_b1_NN", diff --git a/app/components/exp-lookit-video/component.js b/app/components/exp-lookit-video/component.js index c476d6c9..14a1484c 100644 --- a/app/components/exp-lookit-video/component.js +++ b/app/components/exp-lookit-video/component.js @@ -463,8 +463,7 @@ export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAsset } this.send('setTimeEvent', 'videoStarted'); - this.set('testVideoTimesPlayed', this.get('testVideoTimesPlayed') + 1); - if (this.get('testVideoTimesPlayed') === 1) { + if (this.get('testVideoTimesPlayed') === 0) { window.clearInterval(this.get('testTimer')); if (this.get('requiredDuration')) { this.set('testTimer', window.setTimeout(() => { @@ -495,6 +494,7 @@ export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAsset * @event videoStopped */ this.send('setTimeEvent', 'videoStopped'); + this.set('testVideoTimesPlayed', this.get('testVideoTimesPlayed') + 1); if (this.isReadyToFinish()) { this.readyToFinish(); } @@ -515,7 +515,6 @@ export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAsset * @event audioStarted */ this.send('setTimeEvent', 'audioStarted'); - this.set('testAudioTimesPlayed', this.get('testAudioTimesPlayed') + 1); }, audioStopped() { @@ -528,6 +527,7 @@ export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAsset * @event audioStopped */ this.send('setTimeEvent', 'audioStopped'); + this.set('testAudioTimesPlayed', this.get('testAudioTimesPlayed') + 1); if (this.isReadyToFinish()) { // in case this was the last criterion for being done this.readyToFinish(); } diff --git a/docs b/docs index 17f6cb59..dd94f812 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 17f6cb5940ce877a8a79674e09ce919063efc305 +Subproject commit dd94f812a25b0f0ed6167adbcc701bec93ee4531 From 4fa9f99fb6c3a93acaf81ce5520ed783d4426c83 Mon Sep 17 00:00:00 2001 From: Kim Scott Date: Wed, 22 Jul 2020 12:26:55 -0400 Subject: [PATCH 3/4] fix for exp-lookit-preferential-looking frame not playing audio in Firefox at test trials (due to race condition w mediaReload & starting audio) --- .../component.js | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/components/exp-lookit-preferential-looking/component.js b/app/components/exp-lookit-preferential-looking/component.js index 20b65f7d..87cd01b3 100644 --- a/app/components/exp-lookit-preferential-looking/component.js +++ b/app/components/exp-lookit-preferential-looking/component.js @@ -2,7 +2,6 @@ import Ember from 'ember'; import layout from './template'; import ExpFrameBaseComponent from '../exp-frame-base/component'; import FullScreen from '../../mixins/full-screen'; -import MediaReload from '../../mixins/media-reload'; import VideoRecord from '../../mixins/video-record'; import ExpandAssets from '../../mixins/expand-assets'; import { audioAssetOptions, videoAssetOptions, imageAssetOptions } from '../../mixins/expand-assets'; @@ -129,12 +128,11 @@ let { * @class Exp-lookit-preferential-looking * @extends Exp-frame-base * @uses Full-screen -* @uses Media-reload * @uses Video-record * @uses Expand-assets */ -export default ExpFrameBaseComponent.extend(FullScreen, MediaReload, VideoRecord, ExpandAssets, { +export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAssets, { layout: layout, type: 'exp-lookit-preferential-looking', @@ -736,6 +734,10 @@ export default ExpFrameBaseComponent.extend(FullScreen, MediaReload, VideoRecord }, segmentObserver: Ember.observer('currentTask', function(frame) { + Ember.$('video').each(function () { + this.pause(); + this.load(); + }); if (frame.get('currentTask') === 'announce') { frame.startAnnouncement(); } else if (frame.get('currentTask') === 'intro') { @@ -923,16 +925,13 @@ export default ExpFrameBaseComponent.extend(FullScreen, MediaReload, VideoRecord }); }, - // Utility to play audio object and avoid failing to actually trigger play for - // dumb browser reasons / race conditions + // Utility to play audio object playAudio(audioObj) { - //audioObj.pause(); + audioObj.pause(); audioObj.currentTime = 0; - audioObj.play().then(() => { - }).catch(() => { - audioObj.play(); - } - ); + audioObj.play(); + // var _this = this; + //.then(() => {}).catch(() => { this.playAudio(audioObj); }); }, didInsertElement() { From c510b290c7e0fd1d7adadfcdccde780917d9d92a Mon Sep 17 00:00:00 2001 From: Kim Scott Date: Tue, 11 Aug 2020 11:11:24 -0400 Subject: [PATCH 4/4] WIP: Feature/protocol generator (#158) * use sources as fallback for altSources in exp-lookit-composite-video-trial * set up to use protocol generator function if valid * add tests of protocol generator usage; minor updates for docs --- app/components/exp-frame-base/component.js | 11 +- .../component.js | 3 + .../exp-lookit-video-consent/component.js | 17 +- app/components/exp-lookit-video/component.js | 1 - app/components/exp-player/component.js | 12 +- app/models/child.js | 2 + app/models/organization.js | 5 - app/models/study.js | 5 +- app/models/user.js | 10 +- app/utils/parse-experiment.js | 54 +- docs | 2 +- package.json | 2 +- tests/unit/models/organization-test.js | 12 - tests/unit/models/study-test.js | 2 +- tests/unit/models/user-test.js | 2 +- tests/unit/utils/parse-experiment-test.js | 1300 +++++++++++++++++ 16 files changed, 1397 insertions(+), 43 deletions(-) delete mode 100644 app/models/organization.js delete mode 100644 tests/unit/models/organization-test.js diff --git a/app/components/exp-frame-base/component.js b/app/components/exp-frame-base/component.js index e144c2be..e707f79c 100644 --- a/app/components/exp-frame-base/component.js +++ b/app/components/exp-frame-base/component.js @@ -107,10 +107,15 @@ export default Ember.Component.extend(FullScreen, SessionRecord, { * - `ageAtBirth`: String; child's gestational age at birth in weeks. Possible values are * "24" through "39", "na" (not sure or prefer not to answer), * "<24" (under 24 weeks), and "40>" (40 or more weeks). - * - `birthday`: timestamp in format "Mon Apr 10 2017 20:00:00 GMT-0400 (Eastern Daylight Time)" + * - `birthday`: Date object * - `gender`: "f" (female), "m" (male), "o" (other), or "na" (prefer not to answer) * - `givenName`: String, child's given name/nickname * - `id`: String, child UUID + * - `languageList`: String, space-separated list of languages child is exposed to + * (2-letter codes) + * - `conditionList`: String, space-separated list of conditions/characteristics + * - of child from registration form, as used in criteria expression, e.g. + * "autism_spectrum_disorder deaf multiple_birth" * * * `pastSessions` is a list of previous response objects for this child and this study, @@ -119,11 +124,13 @@ export default Ember.Component.extend(FullScreen, SessionRecord, { * - `completed`: Boolean, whether they submitted an exit survey * - `completedConsentFrame`: Boolean, whether they got through at least a consent frame * - `conditions`: Object representing any conditions assigned by randomizer frames - * - `createdOn`: timestamp in format "Thu Apr 18 2019 12:33:26 GMT-0400 (Eastern Daylight Time)" + * - `createdOn`: Date object * - `expData`: Object consisting of frameId: frameData pairs * - `globalEventTimings`: list of any events stored outside of individual frames - currently * just used for attempts to leave the study early * - `sequence`: ordered list of frameIds, corresponding to keys in expData + * - `isPreview`: Boolean, whether this is from a preview session (possible in the event + * this is an experimenter's account) * * * Example: diff --git a/app/components/exp-lookit-composite-video-trial/component.js b/app/components/exp-lookit-composite-video-trial/component.js index 5d4703d8..bf4e9a12 100644 --- a/app/components/exp-lookit-composite-video-trial/component.js +++ b/app/components/exp-lookit-composite-video-trial/component.js @@ -728,6 +728,9 @@ export default ExpFrameBaseComponent.extend(FullScreen, MediaReload, VideoRecord }); if (this.get('sources_parsed').length) { + if (!this.get('altSources_parsed').length) { + this.set('altSources_parsed', this.get('sources_parsed')); + } this.set('videosShown', [this.get('sources_parsed')[0].src, this.get('altSources_parsed')[0].src]); } else { this.set('videosShown', []); diff --git a/app/components/exp-lookit-video-consent/component.js b/app/components/exp-lookit-video-consent/component.js index 39b3ccc2..b71d8364 100644 --- a/app/components/exp-lookit-video-consent/component.js +++ b/app/components/exp-lookit-video-consent/component.js @@ -19,11 +19,13 @@ document is displayed, with additional study-specific information provided by th Researchers can select from the following named templates: -`consent_001`: Original Lookit consent document (2019) -`consent_002`: Added optional GDPR section and research subject rights statement -`consent_003`: Same as consent_002 except that the 'Payment' section is renamed 'Benefits, risks, and payment' for institutions that prefer that +* `consent_001`: Original Lookit consent document (2019) -To look up the exact text of each consent template for your IRB protocol, please see https://github.com/lookit/research-resources/tree/master/Legal +* `consent_002`: Added optional GDPR section and research subject rights statement + +* `consent_003`: Same as consent_002 except that the 'Payment' section is renamed 'Benefits, risks, and payment' for institutions that prefer that + +Important: To look up the exact text of each consent template for your IRB protocol, and to understand the context for each piece of text to be inserted, please see https://github.com/lookit/research-resources/tree/master/Legal The consent document can be downloaded as PDF document by participant. @@ -249,11 +251,11 @@ export default ExpFrameBaseComponent.extend(VideoRecord, { /** Whether to include an addition step #4 prompting any other adults present to read a statement of consent (I have read and understand the consent document. I also agree to participate in this study.) - @property {String} prompt_all_adults + @property {boolean} prompt_all_adults @default false */ prompt_all_adults: { - type: 'string', + type: 'boolean', description: 'Whether to include instructions for any additional adults to consent', default: false }, @@ -317,7 +319,8 @@ export default ExpFrameBaseComponent.extend(VideoRecord, { /** List of additional custom sections of the consent form, e.g. US Patriot Act Disclosure. These are subject to Lookit approval and in general can only add information that was true anyway but that your IRB needs included; please contact us before submitting your study to check. - @property {String} research_rights_statement + @property {Array} additional_segments + @default [] */ additional_segments: { type: 'array', diff --git a/app/components/exp-lookit-video/component.js b/app/components/exp-lookit-video/component.js index 14a1484c..b85b25a5 100644 --- a/app/components/exp-lookit-video/component.js +++ b/app/components/exp-lookit-video/component.js @@ -98,7 +98,6 @@ let { * @class Exp-lookit-video * @extends Exp-frame-base * @uses Full-screen -* @uses Media-reload * @uses Video-record * @uses Expand-assets */ diff --git a/app/components/exp-player/component.js b/app/components/exp-player/component.js index aa88437a..2830ea02 100644 --- a/app/components/exp-player/component.js +++ b/app/components/exp-player/component.js @@ -146,10 +146,18 @@ export default Ember.Component.extend(FullScreen, { this._super(...arguments); this._registerHandlers(); + var structure = this.get('experiment.structure'); + if (typeof(structure) === 'string') { + structure = structure.replace(/(\r\n|\n|\r)/gm,''); + structure = JSON.parse(structure); + } + var parser = new ExperimentParser({ - structure: this.get('experiment.structure'), + structure: structure, pastSessions: this.get('pastSessions').toArray(), - child: this.get('session.child') + child: this.get('session.child'), + useGenerator: this.get('experiment.useGenerator'), + generator: this.get('experiment.generator') }); var [frameConfigs, conditions] = parser.parse(); this.set('frames', frameConfigs); // When player loads, convert structure to list of frames diff --git a/app/models/child.js b/app/models/child.js index 41aa7c9e..8cb066ba 100644 --- a/app/models/child.js +++ b/app/models/child.js @@ -7,5 +7,7 @@ export default DS.Model.extend({ ageAtBirth: DS.attr('string'), additionalInformation: DS.attr('string'), deleted: DS.attr('boolean', {default: false}), + languageList: DS.attr('string'), + conditionList: DS.attr('string'), user: DS.belongsTo('user') }); diff --git a/app/models/organization.js b/app/models/organization.js deleted file mode 100644 index ac42d4d1..00000000 --- a/app/models/organization.js +++ /dev/null @@ -1,5 +0,0 @@ -import DS from 'ember-data'; - -export default DS.Model.extend({ - name: DS.attr('string') -}); diff --git a/app/models/study.js b/app/models/study.js index 174fc64c..b2cf8be7 100644 --- a/app/models/study.js +++ b/app/models/study.js @@ -3,7 +3,6 @@ import HasManyQuery from 'ember-data-has-many-query'; export default DS.Model.extend(HasManyQuery.ModelMixin, { name: DS.attr('string'), - dateModified: DS.attr('date'), shortDescription: DS.attr('string'), longDescription: DS.attr('string'), criteria: DS.attr('string'), @@ -11,11 +10,11 @@ export default DS.Model.extend(HasManyQuery.ModelMixin, { contactInfo: DS.attr('string'), image: DS.attr('string'), structure: DS.attr(''), + generator: DS.attr('string'), + useGenerator: DS.attr('boolean'), exitURL: DS.attr('string'), state: DS.attr('string'), public: DS.attr('boolean'), - organization: DS.belongsTo('organization'), - creator: DS.belongsTo('user'), responses: DS.hasMany('response') }); diff --git a/app/models/user.js b/app/models/user.js index d22eb197..290cac3c 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -2,13 +2,17 @@ import DS from 'ember-data'; export default DS.Model.extend({ givenName: DS.attr('string'), - middleName: DS.attr('string'), familyName: DS.attr('string'), + nickname: DS.attr('string'), identicon: DS.attr('string'), isActive: DS.attr('boolean'), isStaff: DS.attr('boolean'), demographics: DS.hasMany('demographic'), - organization: DS.belongsTo('organization'), - children: DS.hasMany('child') + children: DS.hasMany('child'), + + emailNextSession: DS.attr('boolean'), + emailNewStudies: DS.attr('boolean'), + emailStudyUpdates: DS.attr('boolean'), + emailResponseQuestions: DS.attr('boolean') }); diff --git a/app/utils/parse-experiment.js b/app/utils/parse-experiment.js index 24fa8449..a24b690c 100644 --- a/app/utils/parse-experiment.js +++ b/app/utils/parse-experiment.js @@ -6,19 +6,28 @@ var urlPattern = /^(URL|JSON):(.*)$/; import randomizers from '../randomizers/index'; import Substituter from './replace-values'; +function assert(condition, message) { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } +} + var ExperimentParser = function (context = { pastSessions: [], structure: { frames: {}, sequence: [] }, - child: {} + child: {}, + useGenerator: false, + generator: '' }) { this.pastSessions = context.pastSessions; this.frames = context.structure.frames; this.sequence = context.structure.sequence; this.child = context.child; - + this.useGenerator = context.useGenerator; + this.generator = context.generator; }; /* Modifies the data in the experiment schema definition to match * the format expected by exp-player @@ -103,8 +112,8 @@ ExperimentParser.prototype._resolveFrame = function (frameId, frame) { var thisFrame; frame.frameList.forEach((fr, index) => { thisFrame = {}; - Ember.$.extend(true, thisFrame, frame.commonFrameProperties || {}); - Ember.$.extend(true, thisFrame, fr); + Ember.$.extend(thisFrame, frame.commonFrameProperties || {}); // NOT deep-copying so we can recognize instances of the same list + Ember.$.extend(thisFrame, fr); var [resolved, choice] = this._resolveFrame(null, thisFrame); resolvedFrameList.push(...resolved); if (choice) { @@ -124,6 +133,43 @@ ExperimentParser.prototype._resolveFrame = function (frameId, frame) { ExperimentParser.prototype.parse = function (prependFrameInds = true) { var expFrames = []; var choices = {}; + + // First, if useGenerator & generator defined, generate the sequence & frames. + if (this.useGenerator) { + var generatedStructure = {}; + try { + new Function(this.generator)(); + try { + let generatorFn = new Function('return ' + this.generator)(); + assert(typeof generatorFn === 'function'); + generatedStructure = generatorFn(this.child, this.pastSessions); + try { + assert(generatedStructure.hasOwnProperty('frames')); + assert(generatedStructure.hasOwnProperty('sequence')); + } catch (error) { + this.useGenerator = false; + console.error(error); + console.warn('Generator function does not return an object with "sequence" and "frames" fields.'); + } + } catch (error) { + this.useGenerator = false; + console.error(error); + console.warn('Generator function does not evaluate to single function, or error upon calling function. Falling back to standard protocol definition.'); + } + } catch (error) { + this.useGenerator = false; + console.error(error); + console.warn('Generator function provided is not valid Javascript. Falling back to standard protocol definition.'); + } finally { + if (this.useGenerator) { + console.log('Using generator function in place of standard protocol definition.'); + this.sequence = generatedStructure.sequence; + this.frames = generatedStructure.frames; + } + } + } + // After generating, process exactly as if these had been provided as a standard protocol. + this.sequence.forEach((frameId, index) => { var [resolved, choice] = this._resolveFrame(frameId); expFrames.push(...resolved); diff --git a/docs b/docs index dd94f812..fb313512 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit dd94f812a25b0f0ed6167adbcc701bec93ee4531 +Subproject commit fb3135121093caa090f116388156cf3677f76182 diff --git a/package.json b/package.json index 9ab5d88a..a35c0180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ember-lookit-frameplayer", - "version": "v1.2.0", + "version": "v1.3.0", "description": "Ember Frame Player", "private": true, "directories": { diff --git a/tests/unit/models/organization-test.js b/tests/unit/models/organization-test.js deleted file mode 100644 index 6355f522..00000000 --- a/tests/unit/models/organization-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleForModel, test } from 'ember-qunit'; - -moduleForModel('organization', 'Unit | Model | organization', { - // Specify the other units that are required for this test. - needs: ['model:organization', 'model:study'] -}); - -test('it exists', function(assert) { - let model = this.subject(); - // let store = this.store(); - assert.ok(!!model); -}); diff --git a/tests/unit/models/study-test.js b/tests/unit/models/study-test.js index af3e7e7c..af18efef 100644 --- a/tests/unit/models/study-test.js +++ b/tests/unit/models/study-test.js @@ -2,7 +2,7 @@ import { moduleForModel, test } from 'ember-qunit'; moduleForModel('study', 'Unit | Model | study', { // Specify the other units that are required for this test. - needs: ['model:study', 'model:organization', 'model:user', 'model:response'] + needs: ['model:study', 'model:user', 'model:response'] }); test('it exists', function(assert) { diff --git a/tests/unit/models/user-test.js b/tests/unit/models/user-test.js index 69a5164a..894cd642 100644 --- a/tests/unit/models/user-test.js +++ b/tests/unit/models/user-test.js @@ -2,7 +2,7 @@ import { moduleForModel, test } from 'ember-qunit'; moduleForModel('user', 'Unit | Model | user', { // Specify the other units that are required for this test. - needs: ['model:user', 'model:demographic', 'model:organization', 'model:child'] + needs: ['model:user', 'model:demographic', 'model:child'] }); test('it exists', function(assert) { diff --git a/tests/unit/utils/parse-experiment-test.js b/tests/unit/utils/parse-experiment-test.js index fc8fffc2..16da6072 100644 --- a/tests/unit/utils/parse-experiment-test.js +++ b/tests/unit/utils/parse-experiment-test.js @@ -332,3 +332,1303 @@ test('parser propagates properties through randomizer as expected', function(ass }); + + +test('parser applies nested randomization using parameters', function(assert) { + + var nestedParameterization = { + structure: { + "frames": { + "test-trials": { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "exp-lookit-images-audio", + "images": [ + { + "id": "option1", + "src": "CATEGORY1#UNIQ", + "position": "POSITIONLIST#UNIQ" + }, + { + "id": "option2", + "src": "CATEGORY2#UNIQ", + "position": "POSITIONLIST#UNIQ" + } + ], + "parameters": { + "POSITIONLIST": ["left", "right"], + "CATEGORY1": "CATEGORYPAIRING#0", + "CATEGORY2": "CATEGORYPAIRING#1" + } + } + ], + "parameters": { + "CATEGORYPAIRING": "CATEGORYPAIRINGOPTIONS#UNIQ" + } + }, + { + "kind": "group", + "frameList": [ + { + "kind": "exp-lookit-images-audio", + "images": [ + { + "id": "option1", + "src": "CATEGORY1#UNIQ", + "position": "POSITIONLIST#UNIQ" + }, + { + "id": "option2", + "src": "CATEGORY2#UNIQ", + "position": "POSITIONLIST#UNIQ" + } + ], + "parameters": { + "POSITIONLIST": ["left", "right"], + "CATEGORY1": "CATEGORYPAIRING#0", + "CATEGORY2": "CATEGORYPAIRING#1" + } + } + ], + "parameters": { + "CATEGORYPAIRING": "CATEGORYPAIRINGOPTIONS#UNIQ" + } + } + ], + "parameters": { + "A": ["imageA1.jpg", "imageA2.jpg", "imageA3.jpg", "imageA4.jpg"], + "B": ["imageB1.jpg", "imageB2.jpg", "imageB3.jpg", "imageB4.jpg"], + "C": ["imageC1.jpg", "imageC2.jpg", "imageC3.jpg", "imageC4.jpg"] + } + } + ], + "parameters": { + "CATEGORYPAIRINGOPTIONS": [["A", "B"], ["A", "B"], ["B", "C"], ["B", "C"], ["A", "C"], ["A", "C"]], + } + } + }, + "sequence": [ + "test-trials" + ] + }, + pastSessions: [] + }; + + var experiment = $.extend(true, {}, nestedParameterization); + var parser = new ExperimentParser(experiment); + var result = parser.parse()[0]; + assert.equal(result.length, 2); + var expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ["exp-lookit-images-audio", "exp-lookit-images-audio"]); + + // pairing from different categories + assert.notEqual(result[0].images[0].src.slice(0,6), result[0].images[1].src.slice(0,6)); + // pairing in different positions + assert.notEqual(result[0].images[0].position, result[0].images[1].position); + // substituted all the way down to image filenames + assert.equal(result[0].images[0].src.slice(0,5), "image"); + assert.equal(result[0].images[1].src.slice(0,5), "image"); + +}); + + +// The below establishes the problems with using complex nested randomizers to e.g. +// use unique stimuli throughout the study. +skip('parser applies nested randomization using parameters', function(assert) { + + var nRepeatedUniqOptions = 0; + var nLeftFirst = 0; + var nRightFirst = 0; + var allSamePositionWithinParse = 0; + + for (var i=0; i<100; i++) { + var nestedParameterization = { + structure: { + "frames": { + "test-trial-4": { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "group", + "frameList": [ + { + "kind": "exp-lookit-images-audio", + "audio": "AUDIO#0", + "images": [ + { + "id": "option1-test", + "src": "OBJECT1", + "position": "POSITION1" + }, + { + "id": "option2-test", + "src": "OBJECT2", + "position": "POSITION2" + } + ], + "baseDir": "https://raw.githubusercontent.com/schang198/lookit-stimuli-template/master/", + "pageColor": "gray", + "audioTypes": [ + "mp3" + ], + "parameters": { + "AUDIO": "TRIAL1_CATEGORYPAIRING#2", + "OBJECT1": "ITEM1", + "OBJECT2": "ITEM2", + "POSITION1": "POSITIONPAIRING#0", + "POSITION2": "POSITIONPAIRING#1" + }, + "autoProceed": true + } + ], + "parameters": { + "ITEM1": "TRIAL1_IMAGE1", + "ITEM2": "TRIAL1_IMAGE2", + "POSITIONPAIRING": "POSITIONPAIRINGOPTIONS#RAND" + } + }, + { + "kind": "group", + "frameList": [ + { + "kind": "exp-lookit-images-audio", + "audio": "AUDIO#0", + "images": [ + { + "id": "option1-test", + "src": "OBJECT1", + "position": "POSITION1" + }, + { + "id": "option2-test", + "src": "OBJECT2", + "position": "POSITION2" + } + ], + "baseDir": "https://raw.githubusercontent.com/schang198/lookit-stimuli-template/master/", + "pageColor": "gray", + "audioTypes": [ + "mp3" + ], + "parameters": { + "AUDIO": "TRIAL2_CATEGORYPAIRING#2", + "OBJECT1": "ITEM1", + "OBJECT2": "ITEM2", + "POSITION1": "POSITIONPAIRING#0", + "POSITION2": "POSITIONPAIRING#1" + }, + "autoProceed": true + }, + + ], + "parameters": { + "ITEM1": "TRIAL2_IMAGE1", + "ITEM2": "TRIAL2_IMAGE2", + "POSITIONPAIRING": "POSITIONPAIRINGOPTIONS#RAND" + } + }, + { + "kind": "group", + "frameList": [ + { + "kind": "exp-lookit-images-audio", + "audio": "AUDIO#0", + "images": [ + { + "id": "option1-test", + "src": "OBJECT1", + "position": "POSITION1" + }, + { + "id": "option2-test", + "src": "OBJECT2", + "position": "POSITION2" + } + ], + "baseDir": "https://raw.githubusercontent.com/schang198/lookit-stimuli-template/master/", + "pageColor": "gray", + "audioTypes": [ + "mp3" + ], + "parameters": { + "AUDIO": "TRIAL3_CATEGORYPAIRING#2", + "OBJECT1": "ITEM1", + "OBJECT2": "ITEM2", + "POSITION1": "POSITIONPAIRING#0", + "POSITION2": "POSITIONPAIRING#1" + }, + "autoProceed": true + }, + + ], + "parameters": { + "ITEM1": "TRIAL3_IMAGE1", + "ITEM2": "TRIAL3_IMAGE2", + "POSITIONPAIRING": "POSITIONPAIRINGOPTIONS#RAND" + } + }, + { + "kind": "group", + "frameList": [ + { + "kind": "exp-lookit-images-audio", + "audio": "AUDIO#0", + "images": [ + { + "id": "option1-test", + "src": "OBJECT1", + "position": "POSITION1" + }, + { + "id": "option2-test", + "src": "OBJECT2", + "position": "POSITION2" + } + ], + "baseDir": "https://raw.githubusercontent.com/schang198/lookit-stimuli-template/master/", + "pageColor": "gray", + "audioTypes": [ + "mp3" + ], + "parameters": { + "AUDIO": "TRIAL4_CATEGORYPAIRING#2", + "OBJECT1": "ITEM1", + "OBJECT2": "ITEM2", + "POSITION1": "POSITIONPAIRING#0", + "POSITION2": "POSITIONPAIRING#1" + }, + "autoProceed": true + }, + + ], + "parameters": { + "ITEM1": "TRIAL4_IMAGE1", + "ITEM2": "TRIAL4_IMAGE2", + "POSITIONPAIRING": "POSITIONPAIRINGOPTIONS#RAND" + } + } + ], + "parameters": { + "TRIAL1_IMAGE1": "TRIAL1_CATEGORY1#UNIQ", + "TRIAL1_IMAGE2": "TRIAL1_CATEGORY2#UNIQ", + "TRIAL2_IMAGE1": "TRIAL2_CATEGORY1#UNIQ", + "TRIAL2_IMAGE2": "TRIAL2_CATEGORY2#UNIQ", + "TRIAL3_IMAGE1": "TRIAL3_CATEGORY1#UNIQ", + "TRIAL3_IMAGE2": "TRIAL3_CATEGORY2#UNIQ", + "TRIAL4_IMAGE1": "TRIAL4_CATEGORY1#UNIQ", + "TRIAL4_IMAGE2": "TRIAL4_CATEGORY2#UNIQ" + } + } + ], + "parameters": { + "TRIAL1_CATEGORY1": "TRIAL1_CATEGORYPAIRING#0", + "TRIAL1_CATEGORY2": "TRIAL1_CATEGORYPAIRING#1", + "TRIAL2_CATEGORY1": "TRIAL2_CATEGORYPAIRING#0", + "TRIAL2_CATEGORY2": "TRIAL2_CATEGORYPAIRING#1", + "TRIAL3_CATEGORY1": "TRIAL3_CATEGORYPAIRING#0", + "TRIAL3_CATEGORY2": "TRIAL3_CATEGORYPAIRING#1", + "TRIAL4_CATEGORY1": "TRIAL4_CATEGORYPAIRING#0", + "TRIAL4_CATEGORY2": "TRIAL4_CATEGORYPAIRING#1" + } + } + ], + "parameters": { + "TRIAL1_CATEGORYPAIRING": "CATEGORYPAIRINGOPTIONS#0", + "TRIAL2_CATEGORYPAIRING": "CATEGORYPAIRINGOPTIONS#1", + "TRIAL3_CATEGORYPAIRING": "CATEGORYPAIRINGOPTIONS#2", + "TRIAL4_CATEGORYPAIRING": "CATEGORYPAIRINGOPTIONS#3" + } + } + ], + "parameters": { + "CATEGORYPAIRINGOPTIONS": "CATEGORYPAIRINGLIST#PERM", + "POSITIONPAIRINGOPTIONS": [ + "P", + "Q" + ] + } + } + ], + "parameters": { + "CATEGORYPAIRINGLIST": [ + [ + "A", + "B", + "X" + ], + [ + "A", + "B", + "Y" + ], + [ + "B", + "C", + "Y" + ], + [ + "B", + "C", + "Z" + ], + [ + "A", + "C", + "X" + ], + [ + "A", + "C", + "Z" + ] + ] + } + } + ], + "parameters": { + "A": [ + "Adorable_1.png", + "Adorable_2.png", + "Adorable_3.png", + "Adorable_4.png" + ], + "B": [ + "Delicious_1.png", + "Delicious_2.png", + "Delicious_3.png", + "Delicious_4.png" + ], + "C": [ + "Exciting_1.png", + "Exciting_2.png", + "Exciting_3.png", + "Exciting_4.png" + ], + "P": [ + "left", + "right" + ], + "Q": [ + "right", + "left" + ], + "X": [ + "Adorable" + ], + "Y": [ + "Delicious" + ], + "Z": [ + "Exciting" + ] + } + } + }, + "sequence": ["test-trial-4"] + }, + pastSessions: [] + }; + var experiment = $.extend(true, {}, nestedParameterization); + var parser = new ExperimentParser(experiment); + var frames = parser.parse()[0]; + assert.equal(frames.length, 4); + var expKinds = frames.map((item) => item.kind); + assert.deepEqual(expKinds, ["exp-lookit-images-audio", "exp-lookit-images-audio", "exp-lookit-images-audio", "exp-lookit-images-audio"]); + var allImages = [ + frames[0].images[0].src, + frames[0].images[1].src, + frames[1].images[0].src, + frames[1].images[1].src, + frames[2].images[0].src, + frames[2].images[1].src, + frames[3].images[0].src, + frames[3].images[1].src + ]; + console.log(allImages); + for (var iImage=0; iImage item.kind); + assert.deepEqual(expKinds, ['exp-video-config', 'exp-lookit-exit-survey'], 'Incorrect frame types when using json rather than generator function'); + let expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-json-config', '1-json-exit-survey'], 'Incorrect frame IDs when using json rather than generator function'); + assert.equal(result[1].debriefing.text, "json survey text", 'Incorrect frame data when using json rather than generator function'); +}); + + +test('parser falls back to structure if generator is invalid', function(assert) { + + let simple_generator_invalid = `function generateProtocol2(child, pastSessions) { + // Define frames that will be used for both the baby and toddler versions of the study + let frames = { + "video-config": { + "kind": "exp-video-config", + "troubleshootingIntro": "If you're having any trouble getting your webcam set up, please feel free to email the XYZ lab at xyz@abc.edu and we'd be glad to help out!" + }, + "exit-survey": { + "kind": "exp-lookit-exit-survey", + "debriefing": { + "text": "generated survey text" + } + } + } + + // typo in let + leg frame_sequence = ['generated-video-config', 'generated-exit-survey'] + + // Return a study protocol with "frames" and "sequence" fields just like when + // defining the protocol in JSON only + return { + frames: frames, + sequence: frame_sequence + }; + } + `; + + let experiment = { + structure: { + frames: { + "json-config": { + "kind": "exp-video-config", + "troubleshootingIntro": "If you're having any trouble getting your webcam set up, please feel free to email the XYZ lab at xyz@abc.edu and we'd be glad to help out!" + }, + "json-exit-survey": { + "kind": "exp-lookit-exit-survey", + "debriefing": { + "text": "json survey text" + } + } + }, + sequence: ["json-config", "json-exit-survey"] + }, + pastSessions: [], + generator: simple_generator_invalid, + useGenerator: true, + child: new Ember.Object() + }; + + // Check that it uses the JSON version + let parser = new ExperimentParser(experiment); + let result = parser.parse()[0]; + let expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-video-config', 'exp-lookit-exit-survey'], 'Incorrect frame types when using json rather than generator function'); + let expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-json-config', '1-json-exit-survey'], 'Incorrect frame IDs when using json rather than generator function'); + assert.equal(result[1].debriefing.text, "json survey text", 'Incorrect frame data when using json rather than generator function'); +}); + + +test('parser applies generator function and assigns to condition based on age', function(assert) { + + let age_based_generator = `function generateProtocol2(child, pastSessions) { + /* + * Generate the protocol for this study. + * + * @param {Object} child + * The child currently participating in this study. Includes fields: + * givenName (string) + * birthday (Date) + * gender (string, 'm' / 'f' / 'o') + * ageAtBirth (string, e.g. '25 weeks'. One of '40 or more weeks', + * '39 weeks' through '24 weeks', 'Under 24 weeks', or + * 'Not sure or prefer not to answer') + * additionalInformation (string) + * languageList (string) space-separated list of languages child is + * exposed to (2-letter codes) + * conditionList (string) space-separated list of conditions/characteristics + * of child from registration form, as used in criteria expression + * - e.g. "autism_spectrum_disorder deaf multiple_birth" + * + * Use child.get to access these fields: e.g., child.get('givenName') returns + * the child's given name. + * + * @param {!Array} pastSessions + * List of past sessions for this child and this study, in reverse time order: + * pastSessions[0] is THIS session, pastSessions[1] the previous session, + * back to pastSessions[pastSessions.length - 1] which has the very first + * session. + * + * Each session has the following fields, corresponding to values available + * in Lookit: + * + * createdOn (Date) + * conditions + * expData + * sequence + * completed + * globalEventTimings + * completedConsentFrame (note - this list will include even "responses") + * where the user did not complete the consent form! + * demographicSnapshot + * isPreview + * + * @return {Object} Protocol specification for Lookit study; object with 'frames' + * and 'sequence' keys. + */ + + let one_day = 1000 * 60 * 60 * 24; // ms in one day + let child_age_in_days = -1; + try { + child_age_in_days = (new Date() - child.get('birthday')) / one_day; + } catch (error) { + // Display what the error was for debugging, but continue with fake + // age in case we can't calculate age for some reason + console.error(error); + } + child_age_in_days = child_age_in_days || -1; // If undefined/null, set to default + + // Define frames that will be used for both the baby and toddler versions of the study + let frames = { + "video-config": { + "kind": "exp-video-config", + "troubleshootingIntro": "If you're having any trouble getting your webcam set up, please feel free to email the XYZ lab at xyz@abc.edu and we'd be glad to help out!" + }, + "video-consent": { + "kind": "exp-lookit-video-consent", + "PIName": "Jane Smith", + "datause": "We are primarily interested in your child's emotional reactions to the images and sounds. A research assistant will watch your video to measure the precise amount of delight in your child's face as he or she sees each cat picture.", + "payment": "After you finish the study, we will email you a $5 BabyStore gift card within approximately three days. To be eligible for the gift card your child must be in the age range for this study, you need to submit a valid consent statement, and we need to see that there is a child with you. But we will send a gift card even if you do not finish the whole study or we are not able to use your child's data! There are no other direct benefits to you or your child from participating, but we hope you will enjoy the experience.", + "purpose": "Why do babies love cats? This study will help us find out whether babies love cats because of their soft fur or their twitchy tails.", + "PIContact": "Jane Smith at 123 456 7890", + "procedures": "Your child will be shown pictures of lots of different cats, along with noises that cats make like meowing and purring. We are interested in which pictures and sounds make your child smile. We will ask you (the parent) to turn around to avoid influencing your child's responses. There are no anticipated risks associated with participating.", + "institution": "Science University" + }, + "exit-survey": { + "kind": "exp-lookit-exit-survey", + "debriefing": { + "text": "Here is where you would enter debriefing information for the family. This is a chance to explain the purpose of your study and how the family helped. At this point it's more obvious to the participant that skimming the info is fine if they're not super-interested, so you can elaborate in ways you might have avoided ahead of time in the interest of keeping instructions short. You may want to mention the various conditions kids were assigned to if you didn't before, and try to head off any concerns parents might have about how their child 'did' on the study, especially if there are 'correct' answers that will have been obvious to a parent.

It is great if you can link people to a layperson-accessible article on a related topic - e.g., media coverage of one of your previous studies in this research program, a talk on Youtube, a parenting resource.

If you are compensating participants, restate what the compensation is (and any conditions, and let them know when to expect their payment! E.g.: To thank you for your participation, we'll be emailing you a $4 Amazon gift card - this should arrive in your inbox within the next week after we confirm your consent video and check that your child is in the age range for this study. (If you don't hear from us by then, feel free to reach out!) If you participate again with another child in the age range, you'll receive one gift card per child.", + "title": "Thank you!" + } + } + } + + // Add a "test frame" that's different depending on the child's age. + // You could actually be defining whole separate protocols here (e.g. for + // a longitudinal study with a bunch of timepoints), using different stimuli + // in the same frames, just customizing instructions, etc. + + // If the age is -1 because there was some error, they'll get the baby version. + if (child_age_in_days <= 365) { + frames["test-frame"] = { + "kind": "exp-lookit-instructions", + "blocks": [ + { + "title": "[Example text for BABY version of study]", + "listblocks": [ + { + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + }, + { + "text": "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." + } + ] + } + ], + "showWebcam": false, + "nextButtonText": "Finish up" + }; + } else { + frames["test-frame"] = { + "kind": "exp-lookit-instructions", + "blocks": [ + { + "title": "[Example text for TODDLER version of study]", + "listblocks": [ + { + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + }, + { + "text": "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." + } + ] + } + ], + "showWebcam": false, + "nextButtonText": "Finish up" + } + } + + // Sequence of frames is the same in both cases, the 'test-frame' will just + // be differently defined base on age. + let frame_sequence = ['video-config', 'video-consent', 'test-frame', 'exit-survey'] + + // Return a study protocol with "frames" and "sequence" fields just like when + // defining the protocol in JSON only + return { + frames: frames, + sequence: frame_sequence + }; + } + `; + + + var sixMonthOldBirthday = new Date().getTime() - 1000 * 60 * 60 * 24 * 30 * 6; + var eighteenMonthOldBirthday = new Date().getTime() - 1000 * 60 * 60 * 24 * 30 * 18; + + let experiment = { + structure: { + frames: {}, + sequence: [] + }, + pastSessions: [], + generator: age_based_generator, + useGenerator: true, + child: new Ember.Object({'birthday': sixMonthOldBirthday}) + }; + + // Check that it makes the baby version for a six-month-old + let parser = new ExperimentParser(experiment); + let result = parser.parse()[0]; + assert.equal(result.length, 4); + let expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-video-config', 'exp-lookit-video-consent', 'exp-lookit-instructions', 'exp-lookit-exit-survey'], 'Incorrect frame types when generating protocol for six-month-old'); + let testFrame = result[2]; + assert.equal(testFrame["blocks"][0]["title"], "[Example text for BABY version of study]", "Incorrect text in generated protocol for baby"); + + // and the toddler version for an eighteen-month-old + experiment["child"] = new Ember.Object({'birthday': eighteenMonthOldBirthday}); + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 4); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-video-config', 'exp-lookit-video-consent', 'exp-lookit-instructions', 'exp-lookit-exit-survey'], 'Incorrect frame types when generating protocol for eighteen-month-old'); + testFrame = result[2]; + assert.equal(testFrame["blocks"][0]["title"], "[Example text for TODDLER version of study]", "Incorrect text in generated protocol for toddler"); + + // and does not error on unexpected input - default to baby version + experiment["child"] = new Ember.Object(); + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 4); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-video-config', 'exp-lookit-video-consent', 'exp-lookit-instructions', 'exp-lookit-exit-survey'], 'Incorrect frame types when generating protocol for child without birthday in database'); + testFrame = result[2]; + assert.equal(testFrame["blocks"][0]["title"], "[Example text for BABY version of study]", "Incorrect text in generated protocol for child without birthday in database"); +}); + + +test('parser applies generator function and assigns to condition based on past sessions', function(assert) { + + let session_based_generator = `function generateProtocol(child, pastSessions) { + /* + * Generate the protocol for this study. + * + */ + + // Assign condition randomly as fallback/initial value. This will be true/false + // with equal probability. + let is_happy_condition = Math.random() > 0.5; + + try { + // First, find the most recent session where the participant got to the point + // of the "test trial" + var mostRecentSession = pastSessions.find( + sess => Object.keys(sess.get('expData', {})).some(frId => frId.endsWith('-match-emotion'))); + // If there is such a session, find out what condition they were in that time + // and flip it + if (mostRecentSession) { + let expData = mostRecentSession.get('expData', {}); + let frameKey = Object.keys(expData).find(frId => frId.endsWith('-match-emotion')); + // Flip condition from last time: do happy condition this time if last + // time 'happy' was NOT in the *-match-emotion frame ID + is_happy_condition = !(frameKey.includes('happy')); + } + } catch (error) { + // Just in case - wrap the above in a try block so we fall back to + // random assignment if something is weird about the pastSessions data + console.error(error); + } + + + // Define all possible frames that might be used + let frames = { + "intro": { + "blocks": [{ + "text": "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.", + "title": "[Introduction frame]" + }, + { + "text": "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt." + }, + { + "text": "Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem." + } + ], + "showPreviousButton": false, + "kind": "exp-lookit-text" + }, + "happy-match-emotion": { + "kind": "exp-lookit-images-audio", + "audio": "matchremy", + "images": [{ + "id": "cue", + "src": "happy_remy.jpg", + "position": "center", + "nonChoiceOption": true + }, + { + "id": "option1", + "src": "happy_zenna.jpg", + "position": "left", + "displayDelayMs": 2000 + }, + { + "id": "option2", + "src": "annoyed_zenna.jpg", + "position": "right", + "displayDelayMs": 2000 + } + ], + "baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/", + "autoProceed": false, + "doRecording": false, + "choiceRequired": true, + "parentTextBlock": { + "text": "Some explanatory text for parents", + "title": "For parents" + }, + "canMakeChoiceBeforeAudioFinished": true + }, + "sad-match-emotion": { + "kind": "exp-lookit-images-audio", + "audio": "matchzenna", + "images": [{ + "id": "cue", + "src": "sad_zenna.jpg", + "position": "center", + "nonChoiceOption": true + }, + { + "id": "option1", + "src": "surprised_remy.jpg", + "position": "left", + "feedbackAudio": "negativefeedback", + "displayDelayMs": 3500 + }, + { + "id": "option2", + "src": "sad_remy.jpg", + "correct": true, + "position": "right", + "displayDelayMs": 3500 + } + ], + "baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/", + "autoProceed": false, + "doRecording": false, + "choiceRequired": true, + "parentTextBlock": { + "text": "Some explanatory text for parents", + "title": "For parents" + }, + "canMakeChoiceBeforeAudioFinished": true + }, + "exit-survey": { + "kind": "exp-lookit-exit-survey", + "debriefing": { + "text": "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.

Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.

Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.", + "title": "Thank you!" + } + } + } + + // Construct the sequence based on the condition. + let frame_sequence = [ + 'intro', + is_happy_condition ? "happy-match-emotion" : "sad-match-emotion", + 'exit-survey' + ] + + // Return a study protocol with "frames" and "sequence" fields just like when + // defining the protocol in JSON only + return { + frames: frames, + sequence: frame_sequence + }; + } + + `; + + let experiment = { + structure: { + frames: {}, + sequence: [] + }, + pastSessions: [], + generator: session_based_generator, + useGenerator: true, + child: new Ember.Object() + }; + + // Check that it works without any prior sessions + let parser = new ExperimentParser(experiment); + let result = parser.parse()[0]; + assert.equal(result.length, 3); + let expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-lookit-text', 'exp-lookit-images-audio', 'exp-lookit-exit-survey']); + + let noAssignmentSession = new Ember.Object( { + "completedConsentFrame": false, + "expData": { + "0-intro": { + "kind": "exp-lookit-text" + } + } + }); + + let happySession = new Ember.Object( { + "completedConsentFrame": false, + "expData": { + "0-intro": { + "kind": "exp-lookit-text" + }, + "1-happy-match-emotion": { + "kind": "exp-lookit-images-audio" + }, + "2-exit-survey": { + "kind": "exp-lookit-exit-survey", + "databrary": "no" + } + } + }); + + let sadSession = new Ember.Object( { + "completedConsentFrame": false, + "expData": { + "0-intro": { + "kind": "exp-lookit-text" + }, + "1-sad-match-emotion": { + "kind": "exp-lookit-images-audio" + } + } + }); + + // Check that it works with a prior session with no condition assignment + experiment["pastSessions"] = [ noAssignmentSession ]; + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 3); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-lookit-text', 'exp-lookit-images-audio', 'exp-lookit-exit-survey']); + + // Check that it assigns to sad after happy + experiment["pastSessions"] = [ happySession ]; + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 3); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-lookit-text', 'exp-lookit-images-audio', 'exp-lookit-exit-survey']); + let expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-intro', '1-sad-match-emotion', '2-exit-survey']); + assert.equal(result[1]["audio"], "matchzenna"); + + // Check that it assigns to happy after sad + experiment["pastSessions"] = [ sadSession ]; + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 3); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-lookit-text', 'exp-lookit-images-audio', 'exp-lookit-exit-survey']); + expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-intro', '1-happy-match-emotion', '2-exit-survey']); + assert.equal(result[1]["audio"], "matchremy"); + + // Check that it uses the most recent session for assignment + experiment["pastSessions"] = [ sadSession, happySession ]; + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 3); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-lookit-text', 'exp-lookit-images-audio', 'exp-lookit-exit-survey']); + expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-intro', '1-happy-match-emotion', '2-exit-survey']); + assert.equal(result[1]["audio"], "matchremy"); + + // Check that it uses the most recent session with condition if very most recent has none + experiment["pastSessions"] = [ noAssignmentSession, sadSession, happySession ]; + parser = new ExperimentParser(experiment); + result = parser.parse()[0]; + assert.equal(result.length, 3); + expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-lookit-text', 'exp-lookit-images-audio', 'exp-lookit-exit-survey']); + expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-intro', '1-happy-match-emotion', '2-exit-survey']); + assert.equal(result[1]["audio"], "matchremy"); + +}); + + +test('parser applies generator function and randomizes as expected without repeating images', function(assert) { + + let randomization_generator = `function generateProtocol3(child, pastSessions) { + /* + * Generate the protocol for this study. + * + * @param {Object} child + * The child currently participating in this study. Includes fields: + * givenName (string) + * birthday (Date) + * gender (string, 'm' / 'f' / 'o') + * ageAtBirth (string, e.g. '25 weeks'. One of '40 or more weeks', + * '39 weeks' through '24 weeks', 'Under 24 weeks', or + * 'Not sure or prefer not to answer') + * additionalInformation (string) + * languageList (string) space-separated list of languages child is + * exposed to (2-letter codes) + * conditionList (string) space-separated list of conditions/characteristics + * of child from registration form, as used in criteria expression + * - e.g. "autism_spectrum_disorder deaf multiple_birth" + * + * Use child.get to access these fields: e.g., child.get('givenName') returns + * the child's given name. + * + * @param {!Array} pastSessions + * List of past sessions for this child and this study, in reverse time order: + * pastSessions[0] is THIS session, pastSessions[1] the previous session, + * back to pastSessions[pastSessions.length - 1] which has the very first + * session. + * + * Each session has the following fields, corresponding to values available + * in Lookit: + * + * createdOn (Date) + * conditions + * expData + * sequence + * completed + * globalEventTimings + * completedConsentFrame (note - this list will include even "responses") + * where the user did not complete the consent form! + * demographicSnapshot + * isPreview + * + * @return {Object} Protocol specification for Lookit study; object with 'frames' + * and 'sequence' keys. + */ + + // -------- Helper functions ---------------------------------------------- + + // See http://stackoverflow.com/a/12646864 + // Returns a new array with elements of the array in random order. + function shuffle(array) { + var shuffled = Ember.$.extend(true, [], array); // deep copy array + for (var i = array.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var temp = shuffled[i]; + shuffled[i] = shuffled[j]; + shuffled[j] = temp; + } + return shuffled; + } + + // Returns a random element of an array, and removes that element from the array + function pop_random(array) { + var randIndex = Math.floor(Math.random() * array.length); + if (array.length) { + return array.pop(randIndex); + } + return null + } + + // -------- End helper functions ------------------------------------------- + + // Define common (non-test-trial) frames + let frames = { + "video-config": { + "kind": "exp-video-config", + "troubleshootingIntro": "If you're having any trouble getting your webcam set up, please feel free to email the XYZ lab at xyz@abc.edu and we'd be glad to help out!" + }, + "video-consent": { + "kind": "exp-lookit-video-consent", + "PIName": "Jane Smith", + "datause": "We are primarily interested in your child's emotional reactions to the images and sounds. A research assistant will watch your video to measure the precise amount of delight in your child's face as he or she sees each cat picture.", + "payment": "After you finish the study, we will email you a $5 BabyStore gift card within approximately three days. To be eligible for the gift card your child must be in the age range for this study, you need to submit a valid consent statement, and we need to see that there is a child with you. But we will send a gift card even if you do not finish the whole study or we are not able to use your child's data! There are no other direct benefits to you or your child from participating, but we hope you will enjoy the experience.", + "purpose": "Why do babies love cats? This study will help us find out whether babies love cats because of their soft fur or their twitchy tails.", + "PIContact": "Jane Smith at 123 456 7890", + "procedures": "Your child will be shown pictures of lots of different cats, along with noises that cats make like meowing and purring. We are interested in which pictures and sounds make your child smile. We will ask you (the parent) to turn around to avoid influencing your child's responses. There are no anticipated risks associated with participating.", + "institution": "Science University" + }, + "exit-survey": { + "kind": "exp-lookit-exit-survey", + "debriefing": { + "text": "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.

Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.

Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.", + "title": "Thank you!" + } + } + } + + // Start off the frame sequence with config/consent frames; we'll add test + // trials as we construct them + let frame_sequence = ['video-config', 'video-consent'] + + // start at a random point in this list and cycle through across trials. + // each element is a list: category1, category2, audio. + // category1 and category2 match up to keys in available_images; audio + // should be filenames in baseDir/mp3 + let all_category_pairings = [ + [ + "adorable", + "delicious", + "Adorable" + ], + [ + "adorable", + "delicious", + "Delicious" + ], + [ + "delicious", + "exciting", + "Delicious" + ], + [ + "delicious", + "exciting", + "Exciting" + ], + [ + "adorable", + "exciting", + "Adorable" + ], + [ + "adorable", + "exciting", + "Exciting" + ] + ] + + // Every image is just used once total, either as a target or as a distractor. + // We'll remove the images from these lists as they get used. + let available_images = { + "adorable": [ + "Adorable_1.png", + "Adorable_2.png", + "Adorable_3.png", + "Adorable_4.png" + ], + "delicious": [ + "Delicious_1.png", + "Delicious_2.png", + "Delicious_3.png", + "Delicious_4.png" + ], + "exciting": [ + "Exciting_1.png", + "Exciting_2.png", + "Exciting_3.png", + "Exciting_4.png" + ] + } + + // Make a deep copy of the original available images, in case we run out + // (e.g. after adding additional trials) and need to "refill" a category. + let all_images = Ember.$.extend(true, {}, available_images) + + // Choose a random starting point and order for the category pairings + let ordered_category_pairings = shuffle(all_category_pairings) + + for (iTrial = 0; iTrial < 4; iTrial++) { + + let category_pairing = ordered_category_pairings[iTrial] + let category_id_1 = category_pairing[0] + let category_id_2 = category_pairing[1] + let audio = category_pairing[2] + + // "Refill" available images if empty + if (!available_images[category_id_1].length) { + available_images[category_id_1] = all_images[category_id_1] + } + if (!available_images[category_id_2].length) { + available_images[category_id_2] = all_images[category_id_2] + } + + let image1 = pop_random(available_images[category_id_1]) + let image2 = pop_random(available_images[category_id_2]) + + let left_right_pairing = shuffle(["left", "right"]) + + thisTrial = { + "kind": "exp-lookit-images-audio", + "audio": audio, + "images": [{ + "id": "option1-test", + "src": image1, + "position": left_right_pairing[0] + }, + { + "id": "option2-test", + "src": image2, + "position": left_right_pairing[1] + } + ], + "baseDir": "https://raw.githubusercontent.com/schang198/lookit-stimuli-template/master/", + "pageColor": "gray", + "audioTypes": [ + "mp3" + ], + "autoProceed": true + } + + // Store this frame in frames and in the sequence + frameId = 'test-trial-' + (iTrial + 1) + frames[frameId] = thisTrial; + frame_sequence.push(frameId); + } + + // Finish up the frame sequence with the exit survey + frame_sequence = frame_sequence.concat(['exit-survey']) + + // Return a study protocol with "frames" and "sequence" fields just like when + // defining the protocol in JSON only + return { + frames: frames, + sequence: frame_sequence + }; + } + ` + + let experiment = { + structure: { + frames: {}, + sequence: [] + }, + pastSessions: [], + generator: randomization_generator, + useGenerator: true, + child: new Ember.Object() + }; + + for (var iRep=0; iRep<100; iRep++) { + let parser = new ExperimentParser(experiment); + let result = parser.parse()[0]; + let expKinds = result.map((item) => item.kind); + assert.deepEqual(expKinds, ['exp-video-config', 'exp-lookit-video-consent', "exp-lookit-images-audio", "exp-lookit-images-audio", "exp-lookit-images-audio", "exp-lookit-images-audio", 'exp-lookit-exit-survey'], 'Incorrect frame types generated by randomization generator'); + let expIds = result.map((item) => item.id); + assert.deepEqual(expIds, ['0-video-config', '1-video-consent', "2-test-trial-1", "3-test-trial-2", "4-test-trial-3", "5-test-trial-4", "6-exit-survey"], 'Incorrect frame IDs generated by randomization generator'); + + let testTrials = result.slice(2, 6); + + for (let iTrial = 0; iTrial < 4; iTrial++) { + let thisTrial = testTrials[iTrial]; + assert.notEqual(thisTrial.images[0].src, thisTrial.images[1].src, "Same image presented on left and right"); + assert.notEqual(thisTrial.images[0].position, thisTrial.images[1].position, "Images presented on same side"); + assert.ok(thisTrial.images[0].src.startsWith(thisTrial.audio) || thisTrial.images[1].src.startsWith(thisTrial.audio), "Audio doesn't match either image"); + } + + let allLeftImages = testTrials.map((item) => item.images[0].src); + let allRightImages = testTrials.map((item) => item.images[1].src); + let allImages = allLeftImages.concat(allRightImages); + for (var iImage=0; iImage<8; iImage++) { + assert.equal(allImages.indexOf(allImages[iImage], iImage + 1), -1, 'Image used more than once during study'); + assert.equal(allImages[iImage].slice(-4), '.png', 'Image property was not substituted in all the way down to filename'); + } + } + +});