jspsych 7.3.4 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/css/jspsych.css +18 -8
  3. package/dist/index.browser.js +3097 -4286
  4. package/dist/index.browser.js.map +1 -1
  5. package/dist/index.browser.min.js +6 -2
  6. package/dist/index.browser.min.js.map +1 -1
  7. package/dist/index.cjs +2331 -4066
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.ts +984 -6
  10. package/dist/index.js +2330 -4066
  11. package/dist/index.js.map +1 -1
  12. package/package.json +6 -5
  13. package/src/ExtensionManager.spec.ts +121 -0
  14. package/src/ExtensionManager.ts +100 -0
  15. package/src/JsPsych.ts +195 -690
  16. package/src/ProgressBar.spec.ts +60 -0
  17. package/src/ProgressBar.ts +60 -0
  18. package/src/index.scss +29 -8
  19. package/src/index.ts +4 -9
  20. package/src/modules/data/DataCollection.ts +1 -1
  21. package/src/modules/data/DataColumn.ts +12 -1
  22. package/src/modules/data/index.ts +92 -103
  23. package/src/modules/extensions.ts +4 -0
  24. package/src/modules/plugin-api/AudioPlayer.ts +101 -0
  25. package/src/modules/plugin-api/KeyboardListenerAPI.ts +1 -1
  26. package/src/modules/plugin-api/MediaAPI.ts +48 -106
  27. package/src/modules/plugin-api/__mocks__/AudioPlayer.ts +38 -0
  28. package/src/modules/plugin-api/index.ts +11 -14
  29. package/src/modules/plugins.ts +26 -27
  30. package/src/modules/randomization.ts +1 -1
  31. package/src/timeline/Timeline.spec.ts +921 -0
  32. package/src/timeline/Timeline.ts +342 -0
  33. package/src/timeline/TimelineNode.ts +174 -0
  34. package/src/timeline/Trial.spec.ts +897 -0
  35. package/src/timeline/Trial.ts +419 -0
  36. package/src/timeline/index.ts +232 -0
  37. package/src/timeline/util.spec.ts +124 -0
  38. package/src/timeline/util.ts +146 -0
  39. package/dist/JsPsych.d.ts +0 -112
  40. package/dist/TimelineNode.d.ts +0 -34
  41. package/dist/migration.d.ts +0 -3
  42. package/dist/modules/data/DataCollection.d.ts +0 -46
  43. package/dist/modules/data/DataColumn.d.ts +0 -15
  44. package/dist/modules/data/index.d.ts +0 -25
  45. package/dist/modules/data/utils.d.ts +0 -3
  46. package/dist/modules/extensions.d.ts +0 -22
  47. package/dist/modules/plugin-api/HardwareAPI.d.ts +0 -15
  48. package/dist/modules/plugin-api/KeyboardListenerAPI.d.ts +0 -34
  49. package/dist/modules/plugin-api/MediaAPI.d.ts +0 -32
  50. package/dist/modules/plugin-api/SimulationAPI.d.ts +0 -44
  51. package/dist/modules/plugin-api/TimeoutAPI.d.ts +0 -17
  52. package/dist/modules/plugin-api/index.d.ts +0 -8
  53. package/dist/modules/plugins.d.ts +0 -136
  54. package/dist/modules/randomization.d.ts +0 -42
  55. package/dist/modules/turk.d.ts +0 -40
  56. package/dist/modules/utils.d.ts +0 -13
  57. package/src/TimelineNode.ts +0 -544
  58. package/src/modules/plugin-api/HardwareAPI.ts +0 -32
@@ -0,0 +1,897 @@
1
+ import { flushPromises } from "@jspsych/test-utils";
2
+ import { ConditionalKeys } from "type-fest";
3
+
4
+ import { TimelineNodeDependenciesMock, createInvocationOrderUtils } from "../../tests/test-utils";
5
+ import TestPlugin from "../../tests/TestPlugin";
6
+ import { JsPsychPlugin, ParameterType } from "../modules/plugins";
7
+ import { Timeline } from "./Timeline";
8
+ import { Trial } from "./Trial";
9
+ import { PromiseWrapper, parameterPathArrayToString } from "./util";
10
+ import {
11
+ SimulationOptionsParameter,
12
+ TimelineVariable,
13
+ TrialDescription,
14
+ TrialExtensionsConfiguration,
15
+ } from ".";
16
+
17
+ jest.useFakeTimers();
18
+
19
+ jest.mock("./Timeline");
20
+
21
+ describe("Trial", () => {
22
+ let dependencies: TimelineNodeDependenciesMock;
23
+ let timeline: Timeline;
24
+
25
+ beforeEach(() => {
26
+ dependencies = new TimelineNodeDependenciesMock();
27
+ TestPlugin.reset();
28
+
29
+ timeline = new Timeline(dependencies, { timeline: [] });
30
+ timeline.index = 0;
31
+ });
32
+
33
+ const createTrial = (description: TrialDescription) => {
34
+ const trial = new Trial(dependencies, description, timeline);
35
+ trial.index = timeline.index;
36
+ return trial;
37
+ };
38
+
39
+ describe("run()", () => {
40
+ it("instantiates the corresponding plugin", async () => {
41
+ const trial = createTrial({ type: TestPlugin });
42
+
43
+ await trial.run();
44
+
45
+ expect(trial.pluginInstance).toBeInstanceOf(TestPlugin);
46
+ });
47
+
48
+ it("invokes the local `on_start` and the global `onTrialStart` callback", async () => {
49
+ const onStartCallback = jest.fn();
50
+ const description = { type: TestPlugin, on_start: onStartCallback };
51
+ const trial = createTrial(description);
52
+ await trial.run();
53
+
54
+ expect(onStartCallback).toHaveBeenCalledTimes(1);
55
+ expect(onStartCallback).toHaveBeenCalledWith(description);
56
+ expect(dependencies.onTrialStart).toHaveBeenCalledTimes(1);
57
+ expect(dependencies.onTrialStart).toHaveBeenCalledWith(trial);
58
+ });
59
+
60
+ it("properly invokes the plugin's `trial` method", async () => {
61
+ const trial = createTrial({ type: TestPlugin });
62
+
63
+ await trial.run();
64
+
65
+ expect(trial.pluginInstance.trial).toHaveBeenCalledTimes(1);
66
+ expect(trial.pluginInstance.trial).toHaveBeenCalledWith(
67
+ expect.any(HTMLElement),
68
+ { type: TestPlugin },
69
+ expect.any(Function)
70
+ );
71
+ });
72
+
73
+ it("accepts changes to the trial description made by the `on_start` callback", async () => {
74
+ const onStartCallback = jest.fn();
75
+ const description = { type: TestPlugin, on_start: onStartCallback };
76
+
77
+ onStartCallback.mockImplementation((trial) => {
78
+ // We should have a writeable copy here, not the original trial description:
79
+ expect(trial).not.toBe(description);
80
+ trial.stimulus = "changed";
81
+ });
82
+
83
+ const trial = createTrial(description);
84
+ await trial.run();
85
+
86
+ expect(trial.pluginInstance.trial).toHaveBeenCalledWith(
87
+ expect.anything(),
88
+ expect.objectContaining({ stimulus: "changed" }),
89
+ expect.anything()
90
+ );
91
+ });
92
+
93
+ describe("if `trial` returns a promise", () => {
94
+ it("doesn't automatically invoke the `on_load` callback", async () => {
95
+ const onLoadCallback = jest.fn();
96
+ const trial = createTrial({ type: TestPlugin, on_load: onLoadCallback });
97
+
98
+ await trial.run();
99
+
100
+ // TestPlugin invokes the callback for us in the `trial` method
101
+ expect(onLoadCallback).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it("picks up the result data from the promise or the `finishTrial()` function (where the latter one takes precedence)", async () => {
105
+ const trial1 = createTrial({ type: TestPlugin });
106
+ await trial1.run();
107
+ expect(trial1.getResult()).toEqual(expect.objectContaining({ my: "result" }));
108
+
109
+ TestPlugin.trial = async (display_element, trial, on_load) => {
110
+ on_load();
111
+ dependencies.finishTrialPromise.resolve({ finishTrial: "result" });
112
+ return { my: "result" };
113
+ };
114
+
115
+ const trial2 = createTrial({ type: TestPlugin });
116
+ await trial2.run();
117
+ expect(trial2.getResult()).toEqual(expect.objectContaining({ finishTrial: "result" }));
118
+ });
119
+ });
120
+
121
+ describe("if `trial` returns no promise", () => {
122
+ beforeAll(() => {
123
+ TestPlugin.trial = () => {
124
+ dependencies.finishTrialPromise.resolve({ my: "result" });
125
+ };
126
+ });
127
+
128
+ it("invokes the local `on_load` callback", async () => {
129
+ const onLoadCallback = jest.fn();
130
+ const trial = createTrial({ type: TestPlugin, on_load: onLoadCallback });
131
+ await trial.run();
132
+
133
+ expect(onLoadCallback).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it("picks up the result data from the `finishTrial()` function", async () => {
137
+ const trial = createTrial({ type: TestPlugin });
138
+
139
+ await trial.run();
140
+ expect(trial.getResult()).toEqual(expect.objectContaining({ my: "result" }));
141
+ });
142
+ });
143
+
144
+ it("respects the `css_classes` trial parameter", async () => {
145
+ const displayElement = dependencies.getDisplayElement();
146
+
147
+ let trial = createTrial({ type: TestPlugin, css_classes: "class1" });
148
+ expect(displayElement.classList.value).toEqual("");
149
+ trial.run();
150
+ expect(displayElement.classList.value).toEqual("class1");
151
+ await TestPlugin.finishTrial();
152
+ expect(displayElement.classList.value).toEqual("");
153
+
154
+ trial = createTrial({ type: TestPlugin, css_classes: ["class1", "class2"] });
155
+ expect(displayElement.classList.value).toEqual("");
156
+ trial.run();
157
+ expect(displayElement.classList.value).toEqual("class1 class2");
158
+ await TestPlugin.finishTrial();
159
+ expect(displayElement.classList.value).toEqual("");
160
+ });
161
+
162
+ it("invokes the local `on_finish` callback with the result data", async () => {
163
+ const onFinishCallback = jest.fn();
164
+ const trial = createTrial({ type: TestPlugin, on_finish: onFinishCallback });
165
+ await trial.run();
166
+
167
+ expect(onFinishCallback).toHaveBeenCalledTimes(1);
168
+ expect(onFinishCallback).toHaveBeenCalledWith(expect.objectContaining({ my: "result" }));
169
+ });
170
+
171
+ it("awaits async `on_finish` callbacks", async () => {
172
+ const onFinishCallbackPromise = new PromiseWrapper();
173
+ const trial = createTrial({
174
+ type: TestPlugin,
175
+ on_finish: () => onFinishCallbackPromise.get(),
176
+ });
177
+
178
+ let hasTrialCompleted = false;
179
+ trial.run().then(() => {
180
+ hasTrialCompleted = true;
181
+ });
182
+
183
+ await flushPromises();
184
+ expect(hasTrialCompleted).toBe(false);
185
+
186
+ onFinishCallbackPromise.resolve();
187
+ await flushPromises();
188
+
189
+ expect(hasTrialCompleted).toBe(true);
190
+ });
191
+
192
+ it("invokes the global `onTrialResultAvailable` and `onTrialFinished` callbacks", async () => {
193
+ const invocations: string[] = [];
194
+ dependencies.onTrialResultAvailable.mockImplementationOnce(() => {
195
+ invocations.push("onTrialResultAvailable");
196
+ });
197
+ dependencies.onTrialFinished.mockImplementationOnce(() => {
198
+ invocations.push("onTrialFinished");
199
+ });
200
+
201
+ const trial = createTrial({ type: TestPlugin });
202
+ await trial.run();
203
+
204
+ expect(dependencies.onTrialResultAvailable).toHaveBeenCalledTimes(1);
205
+ expect(dependencies.onTrialResultAvailable).toHaveBeenCalledWith(trial);
206
+
207
+ expect(dependencies.onTrialFinished).toHaveBeenCalledTimes(1);
208
+ expect(dependencies.onTrialFinished).toHaveBeenCalledWith(trial);
209
+
210
+ expect(invocations).toEqual(["onTrialResultAvailable", "onTrialFinished"]);
211
+ });
212
+
213
+ it("includes result data from the `data` parameter", async () => {
214
+ const trial = createTrial({ type: TestPlugin, data: { custom: "value" } });
215
+ await trial.run();
216
+ expect(trial.getResult()).toEqual(expect.objectContaining({ my: "result", custom: "value" }));
217
+ });
218
+
219
+ it("includes a set of trial-specific result properties", async () => {
220
+ const trial = createTrial({ type: TestPlugin });
221
+ await trial.run();
222
+ expect(trial.getResult()).toEqual(
223
+ expect.objectContaining({ trial_type: "test", trial_index: 0 })
224
+ );
225
+ });
226
+
227
+ it("respects the `save_trial_parameters` parameter", async () => {
228
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
229
+
230
+ TestPlugin.setParameterInfos({
231
+ stringParameter1: { type: ParameterType.STRING },
232
+ stringParameter2: { type: ParameterType.STRING },
233
+ stringParameter3: { type: ParameterType.STRING },
234
+ stringParameter4: { type: ParameterType.STRING },
235
+ complexArrayParameter: { type: ParameterType.COMPLEX, array: true },
236
+ functionParameter: { type: ParameterType.FUNCTION },
237
+ });
238
+ TestPlugin.defaultTrialResult = {
239
+ result: "foo",
240
+ stringParameter2: "string",
241
+ stringParameter3: "string",
242
+ };
243
+ const trial = createTrial({
244
+ type: TestPlugin,
245
+ stringParameter1: "string",
246
+ stringParameter2: "string",
247
+ stringParameter3: "string",
248
+ stringParameter4: "string",
249
+ functionParameter: jest.fn(),
250
+ complexArrayParameter: [{ child: "foo" }, () => ({ child: "bar" })],
251
+
252
+ save_trial_parameters: {
253
+ stringParameter3: false,
254
+ stringParameter4: true,
255
+ functionParameter: true,
256
+ complexArrayParameter: true,
257
+ result: false, // Since `result` is not a parameter, this should be ignored
258
+ },
259
+ });
260
+ await trial.run();
261
+ const result = trial.getResult();
262
+
263
+ // By default, parameters should not be added:
264
+ expect(result).not.toHaveProperty("stringParameter1");
265
+
266
+ // If the plugin adds them, they should not be removed either:
267
+ expect(result).toHaveProperty("stringParameter2", "string");
268
+
269
+ // When explicitly set to false, parameters should be removed if the plugin adds them
270
+ expect(result).not.toHaveProperty("stringParameter3");
271
+
272
+ // When set to true, parameters should be added
273
+ expect(result).toHaveProperty("stringParameter4", "string");
274
+
275
+ // Function parameters should be stringified
276
+ expect(result).toHaveProperty("functionParameter", jest.fn().toString());
277
+
278
+ // Non-parameter data should be left untouched and a warning should be issued
279
+ expect(result).toHaveProperty("result", "foo");
280
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
281
+ expect(consoleSpy).toHaveBeenCalledWith(
282
+ 'Non-existent parameter "result" specified in save_trial_parameters.'
283
+ );
284
+ consoleSpy.mockRestore();
285
+ });
286
+
287
+ it("respects the `save_timeline_variables` parameter", async () => {
288
+ jest.mocked(timeline.getAllTimelineVariables).mockReturnValue({ a: 1, b: 2, c: 3 });
289
+
290
+ let trial = createTrial({ type: TestPlugin });
291
+ await trial.run();
292
+ expect(trial.getResult().timeline_variables).toBeUndefined();
293
+
294
+ trial = createTrial({ type: TestPlugin, save_timeline_variables: true });
295
+ await trial.run();
296
+ expect(trial.getResult().timeline_variables).toEqual({ a: 1, b: 2, c: 3 });
297
+
298
+ trial = createTrial({ type: TestPlugin, save_timeline_variables: ["a", "d"] });
299
+ await trial.run();
300
+ expect(trial.getResult().timeline_variables).toEqual({ a: 1 });
301
+ });
302
+
303
+ describe("with a plugin parameter specification", () => {
304
+ const functionDefaultValue = () => {};
305
+ beforeEach(() => {
306
+ TestPlugin.setParameterInfos({
307
+ string: { type: ParameterType.STRING, default: null },
308
+ requiredString: { type: ParameterType.STRING },
309
+ stringArray: { type: ParameterType.STRING, default: [], array: true },
310
+ function: { type: ParameterType.FUNCTION, default: functionDefaultValue },
311
+ complex: {
312
+ type: ParameterType.COMPLEX,
313
+ default: { requiredChild: "default" },
314
+ nested: {
315
+ requiredChild: { type: ParameterType.STRING },
316
+ },
317
+ },
318
+ requiredComplexNested: {
319
+ type: ParameterType.COMPLEX,
320
+ nested: {
321
+ child: { type: ParameterType.STRING, default: "I'm nested." },
322
+ requiredChild: { type: ParameterType.STRING },
323
+ },
324
+ },
325
+ requiredComplexNestedArray: {
326
+ type: ParameterType.COMPLEX,
327
+ array: true,
328
+ nested: {
329
+ child: { type: ParameterType.STRING, default: "I'm nested." },
330
+ requiredChild: { type: ParameterType.STRING },
331
+ },
332
+ },
333
+ });
334
+ });
335
+
336
+ it("resolves missing parameter values from parent timeline and sets default values", async () => {
337
+ jest.mocked(timeline).getParameterValue.mockImplementation((parameterPath) => {
338
+ if (Array.isArray(parameterPath)) {
339
+ parameterPath = parameterPathArrayToString(parameterPath);
340
+ }
341
+
342
+ if (parameterPath === "requiredString") {
343
+ return "foo";
344
+ }
345
+ if (parameterPath === "requiredComplexNestedArray[0].requiredChild") {
346
+ return "foo";
347
+ }
348
+ return undefined;
349
+ });
350
+ const trial = createTrial({
351
+ type: TestPlugin,
352
+ requiredComplexNested: { requiredChild: "bar" },
353
+ requiredComplexNestedArray: [
354
+ // This empty object is allowed because `requiredComplexNestedArray[0]` is (simulated to
355
+ // be) set as a parent timeline parameter:
356
+ {},
357
+ { requiredChild: "bar" },
358
+ ],
359
+ });
360
+
361
+ await trial.run();
362
+
363
+ // `requiredString` should have been resolved from the parent timeline
364
+ expect(trial.pluginInstance.trial).toHaveBeenCalledWith(
365
+ expect.anything(),
366
+ {
367
+ type: TestPlugin,
368
+ string: null,
369
+ requiredString: "foo",
370
+ stringArray: [],
371
+ function: functionDefaultValue,
372
+ complex: { requiredChild: "default" },
373
+ requiredComplexNested: { child: "I'm nested.", requiredChild: "bar" },
374
+ requiredComplexNestedArray: [
375
+ { child: "I'm nested.", requiredChild: "foo" },
376
+ { child: "I'm nested.", requiredChild: "bar" },
377
+ ],
378
+ },
379
+ expect.anything()
380
+ );
381
+ });
382
+
383
+ it("errors when an `array` parameter is not an array", async () => {
384
+ TestPlugin.setParameterInfos({
385
+ stringArray: { type: ParameterType.STRING, array: true },
386
+ });
387
+
388
+ // This should work:
389
+ await createTrial({ type: TestPlugin, stringArray: [] }).run();
390
+
391
+ // This shouldn't:
392
+ await expect(
393
+ createTrial({ type: TestPlugin, stringArray: {} }).run()
394
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
395
+ '"A non-array value (`[object Object]`) was provided for the array parameter "stringArray" in the "test" plugin. Please make sure that "stringArray" is an array."'
396
+ );
397
+ await expect(
398
+ createTrial({ type: TestPlugin, stringArray: 1 }).run()
399
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
400
+ '"A non-array value (`1`) was provided for the array parameter "stringArray" in the "test" plugin. Please make sure that "stringArray" is an array."'
401
+ );
402
+ });
403
+
404
+ it("evaluates parameter functions", async () => {
405
+ const functionParameter = () => "invalid";
406
+ const trial = createTrial({
407
+ type: TestPlugin,
408
+ function: functionParameter,
409
+ requiredString: () => "foo",
410
+ requiredComplexNested: () => ({
411
+ requiredChild: () => "bar",
412
+ }),
413
+ requiredComplexNestedArray: () => [() => ({ requiredChild: () => "bar" })],
414
+ });
415
+
416
+ await trial.run();
417
+
418
+ expect(trial.pluginInstance.trial).toHaveBeenCalledWith(
419
+ expect.anything(),
420
+ expect.objectContaining({
421
+ function: functionParameter,
422
+ requiredString: "foo",
423
+ requiredComplexNested: expect.objectContaining({ requiredChild: "bar" }),
424
+ requiredComplexNestedArray: [expect.objectContaining({ requiredChild: "bar" })],
425
+ }),
426
+ expect.anything()
427
+ );
428
+ });
429
+
430
+ it("evaluates timeline variables, including those returned from parameter functions", async () => {
431
+ jest
432
+ .mocked(timeline)
433
+ .evaluateTimelineVariable.mockImplementation((variable: TimelineVariable) => {
434
+ switch (variable.name) {
435
+ case "t":
436
+ return TestPlugin;
437
+ case "x":
438
+ return "foo";
439
+ default:
440
+ return undefined;
441
+ }
442
+ });
443
+
444
+ const trial = createTrial({
445
+ type: new TimelineVariable("t"),
446
+ requiredString: new TimelineVariable("x"),
447
+ requiredComplexNested: { requiredChild: () => new TimelineVariable("x") },
448
+ requiredComplexNestedArray: [{ requiredChild: () => new TimelineVariable("x") }],
449
+ });
450
+
451
+ await trial.run();
452
+
453
+ // The `x` timeline variables should have been replaced with `foo`
454
+ expect(trial.pluginInstance.trial).toHaveBeenCalledWith(
455
+ expect.anything(),
456
+ expect.objectContaining({
457
+ requiredString: "foo",
458
+ requiredComplexNested: expect.objectContaining({ requiredChild: "foo" }),
459
+ requiredComplexNestedArray: [expect.objectContaining({ requiredChild: "foo" })],
460
+ }),
461
+ expect.anything()
462
+ );
463
+ });
464
+
465
+ it("allows null values for parameters with a non-null default value", async () => {
466
+ TestPlugin.setParameterInfos({
467
+ allowedNullString: { type: ParameterType.STRING, default: "foo" },
468
+ });
469
+
470
+ const trial = createTrial({ type: TestPlugin, allowedNullString: null });
471
+ await trial.run();
472
+
473
+ expect(trial.pluginInstance.trial).toHaveBeenCalledWith(
474
+ expect.anything(),
475
+ expect.objectContaining({ allowedNullString: null }),
476
+ expect.anything()
477
+ );
478
+ });
479
+
480
+ describe("with missing required parameters", () => {
481
+ it("errors on missing simple parameters", async () => {
482
+ TestPlugin.setParameterInfos({ requiredString: { type: ParameterType.STRING } });
483
+
484
+ // This should work:
485
+ await createTrial({ type: TestPlugin, requiredString: "foo" }).run();
486
+
487
+ // This shouldn't:
488
+ await expect(createTrial({ type: TestPlugin }).run()).rejects.toThrow(
489
+ '"requiredString" parameter'
490
+ );
491
+ });
492
+
493
+ it("errors on missing parameters nested in `COMPLEX` parameters", async () => {
494
+ TestPlugin.setParameterInfos({
495
+ requiredComplexNested: {
496
+ type: ParameterType.COMPLEX,
497
+ nested: { requiredChild: { type: ParameterType.STRING } },
498
+ },
499
+ });
500
+
501
+ // This should work:
502
+ await createTrial({
503
+ type: TestPlugin,
504
+ requiredComplexNested: { requiredChild: "bar" },
505
+ }).run();
506
+
507
+ // This shouldn't:
508
+ await expect(createTrial({ type: TestPlugin }).run()).rejects.toThrow(
509
+ '"requiredComplexNested" parameter'
510
+ );
511
+ await expect(
512
+ createTrial({ type: TestPlugin, requiredComplexNested: {} }).run()
513
+ ).rejects.toThrowError('"requiredComplexNested.requiredChild" parameter');
514
+ });
515
+
516
+ it("errors on missing parameters nested in `COMPLEX` array parameters", async () => {
517
+ TestPlugin.setParameterInfos({
518
+ requiredComplexNestedArray: {
519
+ type: ParameterType.COMPLEX,
520
+ array: true,
521
+ nested: { requiredChild: { type: ParameterType.STRING } },
522
+ },
523
+ });
524
+
525
+ // This should work:
526
+ await createTrial({ type: TestPlugin, requiredComplexNestedArray: [] }).run();
527
+ await createTrial({
528
+ type: TestPlugin,
529
+ requiredComplexNestedArray: [{ requiredChild: "bar" }],
530
+ }).run();
531
+
532
+ // This shouldn't:
533
+ await expect(createTrial({ type: TestPlugin }).run()).rejects.toThrow(
534
+ '"requiredComplexNestedArray" parameter'
535
+ );
536
+ await expect(
537
+ createTrial({ type: TestPlugin, requiredComplexNestedArray: [{}] }).run()
538
+ ).rejects.toThrow('"requiredComplexNestedArray[0].requiredChild" parameter');
539
+ await expect(
540
+ createTrial({
541
+ type: TestPlugin,
542
+ requiredComplexNestedArray: [{ requiredChild: "bar" }, {}],
543
+ }).run()
544
+ ).rejects.toThrow('"requiredComplexNestedArray[1].requiredChild" parameter');
545
+ });
546
+ });
547
+ });
548
+
549
+ it("respects `default_iti` and `post_trial_gap``", async () => {
550
+ dependencies.getDefaultIti.mockReturnValue(100);
551
+ TestPlugin.setManualFinishTrialMode();
552
+
553
+ const trial1 = createTrial({ type: TestPlugin });
554
+
555
+ let hasTrial1Completed = false;
556
+ trial1.run().then(() => {
557
+ hasTrial1Completed = true;
558
+ });
559
+
560
+ await TestPlugin.finishTrial();
561
+ expect(hasTrial1Completed).toBe(false);
562
+
563
+ jest.advanceTimersByTime(100);
564
+ await flushPromises();
565
+ expect(hasTrial1Completed).toBe(true);
566
+
567
+ const trial2 = createTrial({ type: TestPlugin, post_trial_gap: () => 200 });
568
+
569
+ let hasTrial2Completed = false;
570
+ trial2.run().then(() => {
571
+ hasTrial2Completed = true;
572
+ });
573
+
574
+ await TestPlugin.finishTrial();
575
+ expect(hasTrial2Completed).toBe(false);
576
+
577
+ jest.advanceTimersByTime(100);
578
+ await flushPromises();
579
+ expect(hasTrial2Completed).toBe(false);
580
+
581
+ jest.advanceTimersByTime(100);
582
+ await flushPromises();
583
+ expect(hasTrial2Completed).toBe(true);
584
+ });
585
+
586
+ it("skips inter-trial interval in data-only simulation mode", async () => {
587
+ dependencies.getSimulationMode.mockReturnValue("data-only");
588
+ TestPlugin.setManualFinishTrialMode();
589
+
590
+ const trial = createTrial({ type: TestPlugin, post_trial_gap: 100 });
591
+
592
+ let hasTrialCompleted = false;
593
+ trial.run().then(() => {
594
+ hasTrialCompleted = true;
595
+ });
596
+
597
+ await TestPlugin.finishTrial();
598
+ expect(hasTrialCompleted).toBe(true);
599
+ });
600
+
601
+ it("invokes extension callbacks and includes extension results", async () => {
602
+ dependencies.runOnFinishExtensionCallbacks.mockResolvedValue({ extension: "result" });
603
+
604
+ const extensionsConfig: TrialExtensionsConfiguration = [
605
+ { type: jest.fn(), params: { my: "option" } },
606
+ ];
607
+
608
+ const trial = createTrial({
609
+ type: TestPlugin,
610
+ extensions: extensionsConfig,
611
+ });
612
+ await trial.run();
613
+
614
+ expect(dependencies.runOnStartExtensionCallbacks).toHaveBeenCalledTimes(1);
615
+ expect(dependencies.runOnStartExtensionCallbacks).toHaveBeenCalledWith(extensionsConfig);
616
+
617
+ expect(dependencies.runOnLoadExtensionCallbacks).toHaveBeenCalledTimes(1);
618
+ expect(dependencies.runOnLoadExtensionCallbacks).toHaveBeenCalledWith(extensionsConfig);
619
+
620
+ expect(dependencies.runOnFinishExtensionCallbacks).toHaveBeenCalledTimes(1);
621
+ expect(dependencies.runOnFinishExtensionCallbacks).toHaveBeenCalledWith(extensionsConfig);
622
+ expect(trial.getResult()).toEqual(expect.objectContaining({ extension: "result" }));
623
+ });
624
+
625
+ it("invokes all callbacks in a proper order", async () => {
626
+ const { createInvocationOrderCallback, invocations } = createInvocationOrderUtils();
627
+
628
+ const dependencyCallbacks: Array<ConditionalKeys<TimelineNodeDependenciesMock, jest.Mock>> = [
629
+ "onTrialStart",
630
+ "onTrialResultAvailable",
631
+ "onTrialFinished",
632
+ "runOnStartExtensionCallbacks",
633
+ "runOnLoadExtensionCallbacks",
634
+ "runOnFinishExtensionCallbacks",
635
+ ];
636
+
637
+ for (const callbackName of dependencyCallbacks) {
638
+ (dependencies[callbackName] as jest.Mock).mockImplementation(
639
+ createInvocationOrderCallback(callbackName)
640
+ );
641
+ }
642
+
643
+ const trial = createTrial({
644
+ type: TestPlugin,
645
+ extensions: [{ type: jest.fn(), params: { my: "option" } }],
646
+ on_start: createInvocationOrderCallback("on_start"),
647
+ on_load: createInvocationOrderCallback("on_load"),
648
+ on_finish: createInvocationOrderCallback("on_finish"),
649
+ });
650
+
651
+ await trial.run();
652
+
653
+ expect(invocations).toEqual([
654
+ "onTrialStart",
655
+ "on_start",
656
+ "runOnStartExtensionCallbacks",
657
+
658
+ "on_load",
659
+ "runOnLoadExtensionCallbacks",
660
+
661
+ "onTrialResultAvailable",
662
+
663
+ "runOnFinishExtensionCallbacks",
664
+ "on_finish",
665
+ "onTrialFinished",
666
+ ]);
667
+ });
668
+
669
+ describe("in simulation mode", () => {
670
+ beforeEach(() => {
671
+ dependencies.getSimulationMode.mockReturnValue("data-only");
672
+ });
673
+
674
+ it("invokes the plugin's `simulate` method instead of `trial`", async () => {
675
+ const trial = createTrial({ type: TestPlugin });
676
+ await trial.run();
677
+
678
+ expect(trial.pluginInstance.trial).not.toHaveBeenCalled();
679
+
680
+ expect(trial.pluginInstance.simulate).toHaveBeenCalledTimes(1);
681
+ expect(trial.pluginInstance.simulate).toHaveBeenCalledWith(
682
+ { type: TestPlugin },
683
+ "data-only",
684
+ {},
685
+ expect.any(Function)
686
+ );
687
+ });
688
+
689
+ it("doesn't invoke `on_load`, even when `simulate` doesn't return a promise", async () => {
690
+ TestPlugin.simulate = () => {
691
+ dependencies.finishTrialPromise.resolve({});
692
+ };
693
+
694
+ const onLoad = jest.fn();
695
+ await createTrial({ type: TestPlugin, on_load: onLoad }).run();
696
+
697
+ expect(onLoad).not.toHaveBeenCalled();
698
+ });
699
+
700
+ it("invokes the plugin's `trial` method if the plugin has no `simulate` method", async () => {
701
+ const trial = createTrial({
702
+ type: class implements JsPsychPlugin<any> {
703
+ static info = { name: "test", parameters: {} };
704
+ trial = jest.fn(async () => ({}));
705
+ },
706
+ });
707
+ await trial.run();
708
+
709
+ expect(trial.pluginInstance.trial).toHaveBeenCalled();
710
+ });
711
+
712
+ it("invokes the plugin's `trial` method if `simulate` is `false` in the trial's simulation options", async () => {
713
+ const trial = createTrial({ type: TestPlugin, simulation_options: { simulate: false } });
714
+ await trial.run();
715
+
716
+ expect(trial.pluginInstance.trial).toHaveBeenCalled();
717
+ expect(trial.pluginInstance.simulate).not.toHaveBeenCalled();
718
+ });
719
+
720
+ it("respects the `mode` parameter from the trial's simulation options", async () => {
721
+ const trial = createTrial({ type: TestPlugin, simulation_options: { mode: "visual" } });
722
+ await trial.run();
723
+
724
+ expect(jest.mocked(trial.pluginInstance.simulate).mock.calls[0][1]).toBe("visual");
725
+ });
726
+ });
727
+ });
728
+
729
+ describe("getResult[s]()", () => {
730
+ it("returns the result once it is available", async () => {
731
+ TestPlugin.setManualFinishTrialMode();
732
+ const trial = createTrial({ type: TestPlugin });
733
+ trial.run();
734
+
735
+ expect(trial.getResult()).toBeUndefined();
736
+ expect(trial.getResults()).toEqual([]);
737
+
738
+ await TestPlugin.finishTrial();
739
+
740
+ expect(trial.getResult()).toEqual(expect.objectContaining({ my: "result" }));
741
+ expect(trial.getResults()).toEqual([expect.objectContaining({ my: "result" })]);
742
+ });
743
+
744
+ it("does not return the result when the `record_data` trial parameter is `false`", async () => {
745
+ TestPlugin.setManualFinishTrialMode();
746
+ const trial = createTrial({ type: TestPlugin, record_data: false });
747
+ trial.run();
748
+
749
+ expect(trial.getResult()).toBeUndefined();
750
+ expect(trial.getResults()).toEqual([]);
751
+
752
+ await TestPlugin.finishTrial();
753
+
754
+ expect(trial.getResult()).toBeUndefined();
755
+ expect(trial.getResults()).toEqual([]);
756
+ });
757
+ });
758
+
759
+ describe("evaluateTimelineVariable()", () => {
760
+ it("defers to the parent node", () => {
761
+ jest.mocked(timeline).evaluateTimelineVariable.mockReturnValue(1);
762
+
763
+ const trial = new Trial(dependencies, { type: TestPlugin }, timeline);
764
+
765
+ const variable = new TimelineVariable("x");
766
+ expect(trial.evaluateTimelineVariable(variable)).toEqual(1);
767
+ expect(timeline.evaluateTimelineVariable).toHaveBeenCalledWith(variable);
768
+ });
769
+ });
770
+
771
+ describe("getParameterValue()", () => {
772
+ it("disables recursive lookups of timeline description keys", async () => {
773
+ const trial = createTrial({ type: TestPlugin });
774
+
775
+ for (const parameter of [
776
+ "timeline",
777
+ "timeline_variables",
778
+ "repetitions",
779
+ "loop_function",
780
+ "conditional_function",
781
+ "randomize_order",
782
+ "sample",
783
+ "on_timeline_start",
784
+ "on_timeline_finish",
785
+ ]) {
786
+ expect(trial.getParameterValue(parameter)).toBeUndefined();
787
+ expect(timeline.getParameterValue).not.toHaveBeenCalled();
788
+ }
789
+ });
790
+ });
791
+
792
+ describe("getSimulationOptions()", () => {
793
+ const createSimulationTrial = (simulationOptions?: SimulationOptionsParameter | string) =>
794
+ createTrial({
795
+ type: TestPlugin,
796
+ simulation_options: simulationOptions,
797
+ });
798
+
799
+ it("merges in global default simulation options", async () => {
800
+ dependencies.getGlobalSimulationOptions.mockReturnValue({
801
+ default: { data: { rt: 0, custom: "default" } },
802
+ foo: { data: { custom: "foo" } },
803
+ });
804
+
805
+ expect(createSimulationTrial({ data: { rt: 1 } }).getSimulationOptions()).toEqual({
806
+ data: { rt: 1, custom: "default" },
807
+ });
808
+
809
+ expect(createSimulationTrial("foo").getSimulationOptions()).toEqual({
810
+ data: { rt: 0, custom: "foo" },
811
+ });
812
+ });
813
+
814
+ describe("if no trial-level simulation options are set", () => {
815
+ it("falls back to parent timeline simulation options", async () => {
816
+ jest
817
+ .mocked(timeline.getParameterValue)
818
+ .mockImplementation((parameterPath) =>
819
+ parameterPath.toString() === "simulation_options" ? { data: { rt: 1 } } : undefined
820
+ );
821
+
822
+ expect(createTrial({ type: TestPlugin }).getSimulationOptions()).toEqual({
823
+ data: { rt: 1 },
824
+ });
825
+ });
826
+
827
+ it("falls back to global default simulation options ", async () => {
828
+ expect(createTrial({ type: TestPlugin }).getSimulationOptions()).toEqual({});
829
+
830
+ dependencies.getGlobalSimulationOptions.mockReturnValue({ default: { data: { rt: 1 } } });
831
+ expect(createTrial({ type: TestPlugin }).getSimulationOptions()).toEqual({
832
+ data: { rt: 1 },
833
+ });
834
+ });
835
+ });
836
+
837
+ describe("when trial-level simulation options are a string", () => {
838
+ beforeEach(() => {
839
+ dependencies.getGlobalSimulationOptions.mockReturnValue({
840
+ default: { data: { rt: 1 } },
841
+ custom: { data: { rt: 2 } },
842
+ });
843
+ });
844
+
845
+ it("looks up the corresponding global simulation options key", async () => {
846
+ expect(createSimulationTrial("custom").getSimulationOptions()).toEqual({ data: { rt: 2 } });
847
+ });
848
+
849
+ it("falls back to the global default simulation options ", async () => {
850
+ expect(createSimulationTrial("nonexistent").getSimulationOptions()).toEqual({
851
+ data: { rt: 1 },
852
+ });
853
+ });
854
+ });
855
+
856
+ describe("when `simulation_options` is a function that returns a string", () => {
857
+ it("looks up the corresponding global simulation options key", async () => {
858
+ jest.mocked(dependencies.getGlobalSimulationOptions).mockReturnValue({
859
+ foo: { data: { rt: 1 } },
860
+ });
861
+
862
+ expect(
863
+ createTrial({ type: TestPlugin, simulation_options: () => "foo" }).getSimulationOptions()
864
+ ).toEqual({
865
+ data: { rt: 1 },
866
+ });
867
+ });
868
+ });
869
+
870
+ it("evaluates (nested) functions and timeline variables", async () => {
871
+ const timelineVariables = { x: "foo" };
872
+ jest.mocked(dependencies.getGlobalSimulationOptions).mockReturnValue({
873
+ foo: { data: { rt: 0 } },
874
+ });
875
+ jest
876
+ .mocked(timeline.evaluateTimelineVariable)
877
+ .mockImplementation((variable) => timelineVariables[variable.name]);
878
+
879
+ expect(createSimulationTrial(() => new TimelineVariable("x")).getSimulationOptions()).toEqual(
880
+ { data: { rt: 0 } }
881
+ );
882
+
883
+ expect(
884
+ createSimulationTrial(() => ({
885
+ data: () => ({ rt: () => 1 }),
886
+ simulate: () => true,
887
+ mode: () => "visual",
888
+ })).getSimulationOptions()
889
+ ).toEqual({ data: { rt: 1 }, simulate: true, mode: "visual" });
890
+
891
+ jest.mocked(timeline.evaluateTimelineVariable).mockReturnValue({ data: { rt: 2 } });
892
+ expect(createSimulationTrial(new TimelineVariable("x")).getSimulationOptions()).toEqual({
893
+ data: { rt: 2 },
894
+ });
895
+ });
896
+ });
897
+ });