diff --git a/packages/data/src/api.ts b/packages/data/src/api.ts index e603b3be..ae1d0672 100644 --- a/packages/data/src/api.ts +++ b/packages/data/src/api.ts @@ -1,7 +1,6 @@ import { ApiPromise, Child, - PastSession, Response, ResponseAttrsUpdate, ResponseUpdate, @@ -50,7 +49,7 @@ export const retrieveChild = () => { * @returns Promise containing list of all Past Session objects. */ export const retrievePastSessions = (uuid: string) => { - return deposit(get(`past-sessions/${uuid}/`)); + return deposit(get(`past-sessions/${uuid}/`)); }; /** diff --git a/packages/data/src/error.ts b/packages/data/src/errors.ts similarity index 100% rename from packages/data/src/error.ts rename to packages/data/src/errors.ts diff --git a/packages/data/src/lookitS3.ts b/packages/data/src/lookitS3.ts index 6f8c0753..f3efff81 100644 --- a/packages/data/src/lookitS3.ts +++ b/packages/data/src/lookitS3.ts @@ -4,7 +4,7 @@ import { S3Client, UploadPartCommand, } from "@aws-sdk/client-s3"; -import { AWSMissingAttrError, UploadPartError } from "./error"; +import { AWSMissingAttrError, UploadPartError } from "./errors"; /** Provides functionality to upload videos incrementally to an AWS S3 Bucket. */ class LookitS3 { diff --git a/packages/data/src/types.ts b/packages/data/src/types.ts index 2154876f..1f800d29 100644 --- a/packages/data/src/types.ts +++ b/packages/data/src/types.ts @@ -1,5 +1,3 @@ -import { DataCollection } from "jspsych"; - export type ApiPromise = Promise | Data[]>; export type Relationship = { @@ -55,10 +53,15 @@ export interface ChildAttrs extends Attributes { readonly pk?: number; } -export interface PastSessionAttrs extends Attributes { +export interface JsPsychExpData { + trial_index: number; + trial_type: string; +} + +export interface ResponseAttrs extends Attributes { conditions?: Record; global_event_timings?: Record; - exp_data?: DataCollection[]; + exp_data?: JsPsychExpData[]; sequence?: string[]; completed?: boolean; completed_consent_frame?: boolean; @@ -104,17 +107,7 @@ export interface Child extends Data { }; } -export interface PastSession extends Data { - type: "past_sessions"; - relationships: { - child: Relationship; - user: Relationship; - study: Relationship; - demographic_snapshot: Relationship; - }; -} - -export interface Response extends Data { +export interface Response extends Data { type: "responses"; relationships: { child: Relationship; @@ -131,17 +124,18 @@ export interface ResponseUpdate { } export interface ResponseAttrsUpdate { - exp_data?: DataCollection[]; + exp_data?: JsPsychExpData[]; completed?: boolean; survey_consent?: boolean; completed_consent_frame?: boolean; + sequence?: string[]; } export interface LookitWindow extends Window { chs: { study: Study; child: Child; - pastSessions: PastSession[]; + pastSessions: Response[]; response: Response; sessionRecorder: unknown; }; diff --git a/packages/lookit-initjspsych/src/errors.ts b/packages/lookit-initjspsych/src/errors.ts new file mode 100644 index 00000000..449b887b --- /dev/null +++ b/packages/lookit-initjspsych/src/errors.ts @@ -0,0 +1,8 @@ +/** Error when experiment data doesn't contain values on finish. */ +export class SequenceExpDataError extends Error { + /** Error when experiment data doesn't contain values on finish. */ + public constructor() { + super("Experiment sequence or data missing."); + this.name = "SequenceExpDataError"; + } +} diff --git a/packages/lookit-initjspsych/src/index.spec.ts b/packages/lookit-initjspsych/src/index.spec.ts index a52ba060..9b8a1857 100644 --- a/packages/lookit-initjspsych/src/index.spec.ts +++ b/packages/lookit-initjspsych/src/index.spec.ts @@ -1,8 +1,17 @@ import { JsPsych } from "jspsych"; import lookitInitJsPsych from "./"; +import { Timeline } from "./types"; -const mockRun = jest.fn(); -jest.spyOn(JsPsych.prototype, "run").mockImplementation(mockRun); +afterEach(() => { + jest.clearAllMocks(); +}); + +/** + * Mocked chs data function used in testing below. + * + * @returns CHS experiment data. + */ +const chsData = () => ({ key: "value" }); test("Does lookitInitJsPsych return an instance of jspsych?", () => { const jsPsych = lookitInitJsPsych("uuid-string"); @@ -14,7 +23,25 @@ test("Does lookitInitJsPsych return an instance of jspsych?", () => { }); test("Is jspsych's run called?", async () => { + const mockRun = jest.fn(); + jest.spyOn(JsPsych.prototype, "run").mockImplementation(mockRun); const jsPsych = lookitInitJsPsych("some id"); await jsPsych({}).run([]); expect(mockRun).toHaveBeenCalledTimes(1); }); + +test("Is experiment data injected into timeline w/o data?", async () => { + const jsPsych = lookitInitJsPsych("some id"); + const t: Timeline[] = [{ type: { chsData } }]; + + await jsPsych({}).run(t); + expect(t[0].data).toMatchObject({ key: "value" }); +}); + +test("Is experiment data injected into timeline w/ data?", async () => { + const jsPsych = lookitInitJsPsych("some id"); + const t: Timeline[] = [{ type: { chsData }, data: { other: "data" } }]; + + await jsPsych({}).run(t); + expect(t[0].data).toMatchObject({ key: "value", other: "data" }); +}); diff --git a/packages/lookit-initjspsych/src/index.ts b/packages/lookit-initjspsych/src/index.ts index 9188044c..ab2a17e6 100644 --- a/packages/lookit-initjspsych/src/index.ts +++ b/packages/lookit-initjspsych/src/index.ts @@ -1,7 +1,20 @@ import { initJsPsych as origInitJsPsych } from "jspsych"; -import { JsPsychOptions } from "./types"; +import { JsPsychOptions, Timeline } from "./types"; import { on_data_update, on_finish } from "./utils"; +/** + * Search timeline object for the method "chsData". When found, add to timeline + * data parameter. This will inject values into the experiment to be parsed chs + * after experiment has completed. + * + * @param t - Timeline object. + */ +const addChsData = (t: Timeline) => { + if (t.type.chsData) { + t.data = { ...t.data, ...t.type.chsData() }; + } +}; + /** * Function that returns a function to replace jsPsych's initJsPsych. * @@ -26,6 +39,10 @@ const lookitInitJsPsych = (responseUuid: string) => { */ jsPsych.run = function (timeline) { // check timeline here... + timeline.map((t: Timeline) => { + addChsData(t); + }); + return origJsPsychRun(timeline); }; diff --git a/packages/lookit-initjspsych/src/types.ts b/packages/lookit-initjspsych/src/types.ts index ac2b409c..8e6271f2 100644 --- a/packages/lookit-initjspsych/src/types.ts +++ b/packages/lookit-initjspsych/src/types.ts @@ -1,17 +1,17 @@ +import { JsPsychExpData } from "@lookit/data/dist/types"; import { DataCollection } from "jspsych"; -export type UserFunc = (data: DataCollection) => void; +export type UserFuncOnDataUpdate = (data: JsPsychExpData) => void; +export type UserFuncOnFinish = (data: DataCollection) => void; -export type ResponseData = { - id: string; - type: "responses"; - attributes: { - exp_data: DataCollection[]; - completed?: boolean; - }; +export type JsPsychOptions = { + on_data_update?: UserFuncOnDataUpdate; + on_finish?: UserFuncOnFinish; }; -export type JsPsychOptions = { - on_data_update?: UserFunc; - on_finish?: UserFunc; +export type Timeline = { + type: { + chsData?: () => object; + }; + data?: object; }; diff --git a/packages/lookit-initjspsych/src/utils.spec.ts b/packages/lookit-initjspsych/src/utils.spec.ts index 07890e2c..c40c066a 100644 --- a/packages/lookit-initjspsych/src/utils.spec.ts +++ b/packages/lookit-initjspsych/src/utils.spec.ts @@ -1,6 +1,7 @@ import { DataCollection } from "jspsych"; -import { Child, PastSession, Study } from "@lookit/data/dist/types"; +import { Child, JsPsychExpData, Study } from "@lookit/data/dist/types"; +import { Timeline } from "./types"; import { on_data_update, on_finish } from "./utils"; delete global.window.location; @@ -8,7 +9,11 @@ global.window = Object.create(window); global.window.location = { replace: jest.fn() }; test("jsPsych's on_data_update with some exp_data", async () => { - const jsonData = { data: { attributes: { exp_data: ["some data"] } } }; + const jsonData = { + data: { + attributes: { exp_data: ["some data"], sequence: ["0-first-trial"] }, + }, + }; const response = { /** * Mocked json function used in API calls. @@ -18,7 +23,7 @@ test("jsPsych's on_data_update with some exp_data", async () => { json: () => Promise.resolve(jsonData), ok: true, } as Response; - const data = {} as DataCollection; + const data = {} as JsPsychExpData; const userFn = jest.fn(); global.fetch = jest.fn(() => Promise.resolve(response)); @@ -31,7 +36,9 @@ test("jsPsych's on_data_update with some exp_data", async () => { }); test("jsPsych's on_data_update with no exp_data", async () => { - const jsonData = { data: { attributes: { exp_data: undefined } } }; + const jsonData = { + data: { attributes: { exp_data: undefined, sequence: undefined } }, + }; const response = { /** * Mocked json function used in API calls. @@ -41,7 +48,7 @@ test("jsPsych's on_data_update with no exp_data", async () => { json: () => Promise.resolve(jsonData), ok: true, } as Response; - const data = {} as DataCollection; + const data = {} as JsPsychExpData; const userFn = jest.fn(); global.fetch = jest.fn(() => Promise.resolve(response)); @@ -54,12 +61,19 @@ test("jsPsych's on_data_update with no exp_data", async () => { }); test("jsPsych's on_finish", async () => { + const exp_data = [{ key: "value" }]; const jsonData = { - data: { attributes: { exp_data: {} } }, + data: { + attributes: { exp_data, sequence: ["0-value"] }, + }, }; const data = { - /** Mocked jsPsych Data Collection. */ - values: () => {}, + /** + * Mocked jsPsych Data Collection. + * + * @returns Exp data. + */ + values: () => exp_data, } as DataCollection; const response = { /** @@ -79,12 +93,96 @@ test("jsPsych's on_finish", async () => { chs: { study: { attributes: { exit_url: "exit url" } } as Study, child: {} as Child, - pastSessions: {} as PastSession[], + pastSessions: {} as Response[], }, }); expect(await on_finish("some id", userFn)(data)).toBeUndefined(); expect(userFn).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledTimes(1); - expect(Request).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + expect(Request).toHaveBeenCalledTimes(2); +}); + +test("Is an error thrown when experiment data is empty?", () => { + const exp_data: Timeline[] = []; + const jsonData = { + data: { + attributes: { exp_data, sequence: ["0-value"] }, + }, + }; + const data = { + /** + * Mocked jsPsych Data Collection. + * + * @returns Exp data. + */ + values: () => exp_data, + } as DataCollection; + const response = { + /** + * Mocked json function used in API calls. + * + * @returns Promise containing mocked json data. + */ + json: () => Promise.resolve(jsonData), + ok: true, + } as Response; + + const userFn = jest.fn(); + global.fetch = jest.fn(() => Promise.resolve(response)); + global.Request = jest.fn(); + + Object.assign(window, { + chs: { + study: { attributes: { exit_url: "exit url" } } as Study, + child: {} as Child, + pastSessions: {} as Response[], + }, + }); + + expect(async () => { + await on_finish("some id", userFn)(data); + }).rejects.toThrow("Experiment sequence or data missing."); +}); + +test("Is an error thrown when experiment sequence is undefined?", () => { + const exp_data = [{ key: "value" }]; + const jsonData = { + data: { + attributes: { exp_data, sequence: undefined }, + }, + }; + const data = { + /** + * Mocked jsPsych Data Collection. + * + * @returns Exp data. + */ + values: () => exp_data, + } as DataCollection; + const response = { + /** + * Mocked json function used in API calls. + * + * @returns Promise containing mocked json data. + */ + json: () => Promise.resolve(jsonData), + ok: true, + } as Response; + + const userFn = jest.fn(); + global.fetch = jest.fn(() => Promise.resolve(response)); + global.Request = jest.fn(); + + Object.assign(window, { + chs: { + study: { attributes: { exit_url: "exit url" } } as Study, + child: {} as Child, + pastSessions: {} as Response[], + }, + }); + + expect(async () => { + await on_finish("some id", userFn)(data); + }).rejects.toThrow("Experiment sequence or data missing."); }); diff --git a/packages/lookit-initjspsych/src/utils.ts b/packages/lookit-initjspsych/src/utils.ts index fbe0908e..7921bcb5 100644 --- a/packages/lookit-initjspsych/src/utils.ts +++ b/packages/lookit-initjspsych/src/utils.ts @@ -1,7 +1,8 @@ import Api from "@lookit/data"; -import { LookitWindow } from "@lookit/data/dist/types"; +import { JsPsychExpData, LookitWindow } from "@lookit/data/dist/types"; import { DataCollection } from "jspsych"; -import { UserFunc } from "./types"; +import { SequenceExpDataError } from "./errors"; +import { UserFuncOnDataUpdate, UserFuncOnFinish } from "./types"; declare let window: LookitWindow; @@ -16,13 +17,18 @@ declare let window: LookitWindow; * @param userFunc - "on data update" function provided by researcher. * @returns On data update function. */ -export const on_data_update = (responseUuid: string, userFunc?: UserFunc) => { - return async function (data: DataCollection) { +export const on_data_update = ( + responseUuid: string, + userFunc?: UserFuncOnDataUpdate, +) => { + return async function (data: JsPsychExpData) { const { attributes } = await Api.retrieveResponse(responseUuid); const exp_data = attributes.exp_data ? attributes.exp_data : []; + const sequence = attributes.sequence ? attributes.sequence : []; await Api.updateResponse(responseUuid, { exp_data: [...exp_data, data], + sequence: [...sequence, `${data.trial_index}-${data.trial_type}`], }); await Api.finish(); @@ -45,9 +51,23 @@ export const on_data_update = (responseUuid: string, userFunc?: UserFunc) => { * @param userFunc - "on finish" function provided by the researcher. * @returns On finish function. */ -export const on_finish = (responseUuid: string, userFunc?: UserFunc) => { +export const on_finish = ( + responseUuid: string, + userFunc?: UserFuncOnFinish, +) => { return async function (data: DataCollection) { + const { + attributes: { sequence }, + } = await Api.retrieveResponse(responseUuid); + + const exp_data: JsPsychExpData[] = data.values(); + + if (!sequence || sequence.length === 0 || exp_data.length === 0) { + throw new SequenceExpDataError(); + } + const { exit_url } = window.chs.study.attributes; + const last_exp = exp_data[exp_data.length - 1]; // Don't call the function if not defined by user. if (typeof userFunc === "function") { @@ -56,7 +76,8 @@ export const on_finish = (responseUuid: string, userFunc?: UserFunc) => { await Api.finish(); await Api.updateResponse(responseUuid, { - exp_data: data.values(), + exp_data, + sequence: [...sequence, `${last_exp.trial_index}-${last_exp.trial_type}`], completed: true, }); diff --git a/packages/surveys/src/consent.spec.ts b/packages/surveys/src/consent.spec.ts new file mode 100644 index 00000000..0f85fc35 --- /dev/null +++ b/packages/surveys/src/consent.spec.ts @@ -0,0 +1,5 @@ +import { ConsentSurveyPlugin } from "./consent"; + +test("Does consent survey return chsData correctly?", () => { + expect(ConsentSurveyPlugin.chsData()).toMatchObject({ chs_type: "consent" }); +}); diff --git a/packages/surveys/src/consent.ts b/packages/surveys/src/consent.ts index 6c1d16ff..9c42349d 100644 --- a/packages/surveys/src/consent.ts +++ b/packages/surveys/src/consent.ts @@ -2,16 +2,12 @@ import SurveyPlugin from "@jspsych/plugin-survey"; import { TrialType } from "jspsych"; import { consentSurveyFunction } from "./utils"; -const info = { - ...SurveyPlugin.info, -}; - -type Info = typeof info; -export type Trial = TrialType; +type Info = typeof SurveyPlugin.info; +type Trial = TrialType; /** Consent Survey plugin extends jsPsych's Survey Plugin. */ export class ConsentSurveyPlugin extends SurveyPlugin { - public static readonly info = info; + public static readonly info = SurveyPlugin.info; /** * Custom consent survey function adds functionality before creating a survey * based on the user-defined survey JSON/function. @@ -25,4 +21,14 @@ export class ConsentSurveyPlugin extends SurveyPlugin { survey_function: consentSurveyFunction(trial.survey_function), }); } + /** + * Add CHS type to experiment data. This will enable Lookit API to run the + * "consent" Frame Action Dispatcher method after the experiment has + * completed. It looks like jsPsych uses snake case for these data. + * + * @returns Object containing CHS type. + */ + public static chsData() { + return { chs_type: "consent" }; + } } diff --git a/packages/surveys/src/exit.spec.ts b/packages/surveys/src/exit.spec.ts index 4d64d583..7860f1d8 100644 --- a/packages/surveys/src/exit.spec.ts +++ b/packages/surveys/src/exit.spec.ts @@ -126,3 +126,7 @@ test("Is the withdrawal example included?", () => { expect(withdrawal?.choices[0].text).toContain("state secrets"); }); + +test("Does exit survey return chsData correctly?", () => { + expect(ExitSurveyPlugin.chsData()).toMatchObject({ chs_type: "exit" }); +}); diff --git a/packages/surveys/src/exit.ts b/packages/surveys/src/exit.ts index 5ed31188..a9e834c1 100644 --- a/packages/surveys/src/exit.ts +++ b/packages/surveys/src/exit.ts @@ -47,7 +47,7 @@ export class ExitSurveyPlugin extends SurveyPlugin { * @param display_element - Display element. * @param trial - Info parameters. */ - public trial(display_element: HTMLElement, trial: Trial) { + public trial(display_element: HTMLElement, trial: TrialType) { super.trial(display_element, { ...trial, survey_json: this.surveyParameters(trial), @@ -146,4 +146,15 @@ export class ExitSurveyPlugin extends SurveyPlugin { ], }); } + + /** + * Add CHS type to experiment data. This will enable Lookit API to run the + * "exit" Frame Action Dispatcher method after the experiment has completed. + * It looks like jsPsych uses snake case for these data. + * + * @returns Object containing CHS type. + */ + public static chsData() { + return { chs_type: "exit" }; + } }