Skip to content

Commit

Permalink
Injecting Experiment Data (#43)
Browse files Browse the repository at this point in the history
* Move jspsych version to 8.0.2

* Format fix

* Method to inject experiment data

* Fixed a few confusing types

* Tests

* Update sequence when trial finishes

* Updated comment for error class

* Fixed chs_type tests
  • Loading branch information
okaycj authored Sep 9, 2024
1 parent 506afd8 commit a0319bd
Show file tree
Hide file tree
Showing 14 changed files with 249 additions and 59 deletions.
3 changes: 1 addition & 2 deletions packages/data/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
ApiPromise,
Child,
PastSession,
Response,
ResponseAttrsUpdate,
ResponseUpdate,
Expand Down Expand Up @@ -50,7 +49,7 @@ export const retrieveChild = () => {
* @returns Promise containing list of all Past Session objects.
*/
export const retrievePastSessions = (uuid: string) => {
return deposit(get<PastSession[]>(`past-sessions/${uuid}/`));
return deposit(get<Response[]>(`past-sessions/${uuid}/`));
};

/**
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion packages/data/src/lookitS3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 11 additions & 17 deletions packages/data/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { DataCollection } from "jspsych";

export type ApiPromise = Promise<Data<Attributes> | Data<Attributes>[]>;

export type Relationship = {
Expand Down Expand Up @@ -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<string, never>;
global_event_timings?: Record<string, never>;
exp_data?: DataCollection[];
exp_data?: JsPsychExpData[];
sequence?: string[];
completed?: boolean;
completed_consent_frame?: boolean;
Expand Down Expand Up @@ -104,17 +107,7 @@ export interface Child extends Data<ChildAttrs> {
};
}

export interface PastSession extends Data<PastSessionAttrs> {
type: "past_sessions";
relationships: {
child: Relationship;
user: Relationship;
study: Relationship;
demographic_snapshot: Relationship;
};
}

export interface Response extends Data<PastSessionAttrs> {
export interface Response extends Data<ResponseAttrs> {
type: "responses";
relationships: {
child: Relationship;
Expand All @@ -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;
};
Expand Down
8 changes: 8 additions & 0 deletions packages/lookit-initjspsych/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
31 changes: 29 additions & 2 deletions packages/lookit-initjspsych/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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" });
});
19 changes: 18 additions & 1 deletion packages/lookit-initjspsych/src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -26,6 +39,10 @@ const lookitInitJsPsych = (responseUuid: string) => {
*/
jsPsych.run = function (timeline) {
// check timeline here...
timeline.map((t: Timeline) => {
addChsData(t);
});

return origJsPsychRun(timeline);
};

Expand Down
22 changes: 11 additions & 11 deletions packages/lookit-initjspsych/src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
120 changes: 109 additions & 11 deletions packages/lookit-initjspsych/src/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
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;
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.
Expand All @@ -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));
Expand All @@ -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.
Expand All @@ -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));
Expand All @@ -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 = {
/**
Expand All @@ -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.");
});
Loading

0 comments on commit a0319bd

Please sign in to comment.