jspsych 8.2.2 → 8.3.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych",
3
- "version": "8.2.2",
3
+ "version": "8.3.0",
4
4
  "description": "Behavioral experiments in a browser",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@fontsource/open-sans": "4.5.3",
51
- "@jspsych/config": "^3.3.0",
51
+ "@jspsych/config": "^3.3.4",
52
52
  "@types/dom-mediacapture-record": "^1.0.11",
53
53
  "base64-inline-loader": "^2.0.1",
54
54
  "css-loader": "^6.6.0",
package/src/index.scss CHANGED
@@ -112,6 +112,27 @@
112
112
  cursor: not-allowed;
113
113
  }
114
114
 
115
+ /* Keyboard keys, e.g., press <kbd>space</kbd> in instructions */
116
+
117
+ .jspsych-display-element kbd,
118
+ .jspsych-display-element .jspsych-key {
119
+ display: inline-block;
120
+ min-width: 1em;
121
+ padding: 0.15em 0.5em;
122
+ margin: 0 0.1em 2px;
123
+ font-family: "Open Sans", "Arial", sans-serif;
124
+ font-size: 0.8em;
125
+ line-height: 1.4;
126
+ text-align: center;
127
+ white-space: nowrap;
128
+ vertical-align: middle;
129
+ color: #333;
130
+ background-color: #f7f7f7;
131
+ border: 1px solid #ccc;
132
+ border-radius: 4px;
133
+ box-shadow: 0 2px 0 #ccc;
134
+ }
135
+
115
136
  /* custom style for input[type="range] (slider) to improve alignment between positions and labels */
116
137
 
117
138
  .jspsych-slider {
@@ -13,6 +13,18 @@ export interface GetKeyboardResponseOptions {
13
13
  audio_context_start_time?: number;
14
14
  allow_held_key?: boolean;
15
15
  minimum_valid_rt?: number;
16
+ wait_for_key_release?: boolean;
17
+ }
18
+
19
+ interface PendingRelease {
20
+ /**
21
+ * physical key (`KeyboardEvent.code`) whose release is being awaited; falls back to the
22
+ * case-normalized key value for synthetic events that do not set `code`.
23
+ */
24
+ code: string;
25
+ key: string;
26
+ rt: number;
27
+ callback_function: any;
16
28
  }
17
29
 
18
30
  export class KeyboardListenerAPI {
@@ -27,6 +39,8 @@ export class KeyboardListenerAPI {
27
39
 
28
40
  private listeners = new Set<KeyboardListener>();
29
41
  private heldKeys = new Set<string>();
42
+ private keyDownTimestamps = new Map<string, number>();
43
+ private pendingReleases = new Map<KeyboardListener, PendingRelease>();
30
44
 
31
45
  private areRootListenersRegistered = false;
32
46
 
@@ -40,12 +54,21 @@ export class KeyboardListenerAPI {
40
54
  if (rootElement) {
41
55
  rootElement.addEventListener("keydown", this.rootKeydownListener);
42
56
  rootElement.addEventListener("keyup", this.rootKeyupListener);
57
+ // When the window loses focus the browser stops delivering keyup events, so a key held at
58
+ // that moment would otherwise never be seen as released. Treat a blur as releasing all keys.
59
+ window.addEventListener("blur", this.rootBlurListener);
43
60
  this.areRootListenersRegistered = true;
44
61
  }
45
62
  }
46
63
  }
47
64
 
48
65
  private rootKeydownListener(e: KeyboardEvent) {
66
+ const physicalKey = this.getPhysicalKey(e);
67
+ // Record the press timestamp only for the initial keydown, not for key-repeat events of a key
68
+ // that is already held down (the timestamp is only removed again by the key's keyup).
69
+ if (!this.keyDownTimestamps.has(physicalKey)) {
70
+ this.keyDownTimestamps.set(physicalKey, performance.now());
71
+ }
49
72
  // Iterate over a static copy of the listeners set because listeners might add other listeners
50
73
  // that we do not want to be included in the loop
51
74
  for (const listener of [...this.listeners]) {
@@ -58,10 +81,53 @@ export class KeyboardListenerAPI {
58
81
  return this.areResponsesCaseSensitive ? string : string.toLowerCase();
59
82
  }
60
83
 
84
+ /**
85
+ * identifies physical key of a keyboard event, for matching a keydown event with
86
+ * its corresponding keyup event, in the case of a change of shift state while
87
+ * the key is being held.
88
+ */
89
+ private getPhysicalKey(e: KeyboardEvent) {
90
+ return e.code || this.toLowerCaseIfInsensitive(e.key);
91
+ }
92
+
61
93
  private rootKeyupListener(e: KeyboardEvent) {
94
+ const physicalKey = this.getPhysicalKey(e);
95
+
96
+ // match pending releases by physical key so that a change in shift state while the key is held
97
+ // (which changes `e.key`, but not `e.code`) cannot orphan a pending release.
98
+ for (const [listener, pending] of this.pendingReleases) {
99
+ if (pending.code === physicalKey) {
100
+ const pressTimestamp = this.keyDownTimestamps.get(physicalKey);
101
+ const rt_key_duration =
102
+ pressTimestamp === undefined ? null : Math.round(performance.now() - pressTimestamp);
103
+ this.pendingReleases.delete(listener);
104
+ // report the key as it was at keydown, so that the deferred and immediate paths record
105
+ // the same response for the same key press
106
+ pending.callback_function({ key: pending.key, rt: pending.rt, rt_key_duration });
107
+ }
108
+ }
109
+
110
+ this.keyDownTimestamps.delete(physicalKey);
62
111
  this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key));
63
112
  }
64
113
 
114
+ /**
115
+ * When the window loses focus (`blur`), the browser will not deliver the `keyup` events for keys
116
+ * that are currently held, which would otherwise leave stale entries in `heldKeys` and
117
+ * `keyDownTimestamps` (making a key look permanently held) and orphan any pending releases. Treat
118
+ * the blur as a release of every held key: resolve each pending release with
119
+ * `rt_key_duration: null`, since the true hold duration cannot be measured once the release is
120
+ * never observed, then clear the tracked key state.
121
+ */
122
+ private rootBlurListener() {
123
+ for (const [listener, pending] of this.pendingReleases) {
124
+ this.pendingReleases.delete(listener);
125
+ pending.callback_function({ key: pending.key, rt: pending.rt, rt_key_duration: null });
126
+ }
127
+ this.heldKeys.clear();
128
+ this.keyDownTimestamps.clear();
129
+ }
130
+
65
131
  private isResponseValid(validResponses: ValidResponses, allowHeldKey: boolean, key: string) {
66
132
  // check if key was already held down
67
133
  if (!allowHeldKey && this.heldKeys.has(key)) {
@@ -87,6 +153,7 @@ export class KeyboardListenerAPI {
87
153
  audio_context_start_time,
88
154
  allow_held_key = false,
89
155
  minimum_valid_rt = this.minimumValidRt,
156
+ wait_for_key_release = false,
90
157
  }: GetKeyboardResponseOptions) {
91
158
  if (rt_method !== "performance" && rt_method !== "audio") {
92
159
  console.log(
@@ -125,7 +192,26 @@ export class KeyboardListenerAPI {
125
192
  this.cancelKeyboardResponse(listener);
126
193
  }
127
194
 
128
- callback_function({ key: e.key, rt });
195
+ if (wait_for_key_release) {
196
+ // defer the callback until this key is released, keyed by the listener handle so that
197
+ // the pending release is cancelled together with the listener (see cancelKeyboardResponse
198
+ // and cancelAllKeyboardResponses).
199
+ //
200
+ // Because the pending release is keyed by listener, each listener tracks only one pending
201
+ // release at a time. With persist: true, if a second valid key is pressed before the first
202
+ // is released, this overwrites the earlier pending release: the superseded press is
203
+ // dropped and only the most recent press's release fires the callback. This is a
204
+ // deliberate trade-off of the single-slot design — note that it differs from the
205
+ // non-deferred persist path, which fires the callback for every valid press.
206
+ this.pendingReleases.set(listener, {
207
+ code: this.getPhysicalKey(e),
208
+ key: e.key,
209
+ rt,
210
+ callback_function,
211
+ });
212
+ } else {
213
+ callback_function({ key: e.key, rt });
214
+ }
129
215
  }
130
216
  };
131
217
 
@@ -136,10 +222,13 @@ export class KeyboardListenerAPI {
136
222
  cancelKeyboardResponse(listener: KeyboardListener) {
137
223
  // remove the listener from the set of listeners if it is contained
138
224
  this.listeners.delete(listener);
225
+ // also drop any pending release so a deferred callback never fires after cancellation
226
+ this.pendingReleases.delete(listener);
139
227
  }
140
228
 
141
229
  cancelAllKeyboardResponses() {
142
230
  this.listeners.clear();
231
+ this.pendingReleases.clear();
143
232
  }
144
233
 
145
234
  compareKeys(key1: string | null, key2: string | null) {
@@ -51,6 +51,7 @@ export type ParameterInfo = (
51
51
  array?: boolean;
52
52
  pretty_name?: string;
53
53
  default?: any;
54
+ options?: any;
54
55
  };
55
56
 
56
57
  export type ParameterInfos = Record<string, ParameterInfo>;
@@ -235,7 +235,7 @@ describe("Trial", () => {
235
235
  });
236
236
 
237
237
  it("respects the `save_trial_parameters` parameter", async () => {
238
- const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
238
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
239
239
 
240
240
  TestPlugin.setParameterInfos({
241
241
  stringParameter1: { type: ParameterType.STRING },
@@ -560,6 +560,110 @@ describe("Trial", () => {
560
560
  });
561
561
  });
562
562
 
563
+ describe("with parameter type mismatches", () => {
564
+ //TODO: redo these to expect errors on v9!
565
+ let consoleSpy: jest.SpyInstance;
566
+
567
+ beforeEach(() => {
568
+ consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
569
+ });
570
+
571
+ afterEach(() => {
572
+ consoleSpy.mockRestore();
573
+ });
574
+
575
+ it("errors on non-boolean values for boolean parameters", async () => {
576
+ TestPlugin.setParameterInfos({
577
+ boolParameter: { type: ParameterType.BOOL },
578
+ });
579
+
580
+ // this should work:
581
+ await createTrial({ type: TestPlugin, boolParameter: true }).run();
582
+
583
+ // this shouldn't:
584
+ await createTrial({ type: TestPlugin, boolParameter: "foo" }).run();
585
+ expect(consoleSpy).toHaveBeenCalledWith(
586
+ 'A non-boolean value (`foo`) was provided for the boolean parameter "boolParameter" in the "test" plugin.'
587
+ );
588
+ });
589
+
590
+ it("errors on non-string values for string parameters", async () => {
591
+ TestPlugin.setParameterInfos({
592
+ stringParameter: { type: ParameterType.STRING },
593
+ });
594
+
595
+ // this should work:
596
+ await createTrial({ type: TestPlugin, stringParameter: "foo" }).run();
597
+
598
+ // this shouldn't:
599
+ await createTrial({ type: TestPlugin, stringParameter: 1 }).run();
600
+ expect(consoleSpy).toHaveBeenCalledWith(
601
+ 'A non-string value (`1`) was provided for the parameter "stringParameter" in the "test" plugin.'
602
+ );
603
+ });
604
+
605
+ it("errors on non-numeric values for numeric parameters", async () => {
606
+ TestPlugin.setParameterInfos({
607
+ intParameter: { type: ParameterType.INT },
608
+ floatParameter: { type: ParameterType.FLOAT },
609
+ });
610
+
611
+ // this should work:
612
+ await createTrial({ type: TestPlugin, intParameter: 1, floatParameter: 1.5 }).run();
613
+
614
+ // this shouldn't:
615
+ await createTrial({ type: TestPlugin, intParameter: "foo", floatParameter: 1.5 }).run();
616
+ expect(consoleSpy).toHaveBeenCalledWith(
617
+ 'A non-numeric value (`foo`) was provided for the numeric parameter "intParameter" in the "test" plugin.'
618
+ );
619
+
620
+ await createTrial({ type: TestPlugin, intParameter: 1, floatParameter: "foo" }).run();
621
+ expect(consoleSpy).toHaveBeenCalledWith(
622
+ 'A non-numeric value (`foo`) was provided for the numeric parameter "floatParameter" in the "test" plugin.'
623
+ );
624
+
625
+ // this should warn but not error (behavior in v9):
626
+ await createTrial({ type: TestPlugin, intParameter: 1.5, floatParameter: 1.5 }).run();
627
+ expect(consoleSpy).toHaveBeenCalledWith(
628
+ `A float value (\`1.5\`) was provided for the integer parameter "intParameter" in the "test" plugin. The value will be truncated to an integer.`
629
+ );
630
+ });
631
+
632
+ it("errors on non-function values for function parameters", async () => {
633
+ TestPlugin.setParameterInfos({
634
+ functionParameter: { type: ParameterType.FUNCTION },
635
+ });
636
+
637
+ // this should work:
638
+ await createTrial({ type: TestPlugin, functionParameter: () => {} }).run();
639
+
640
+ // this shouldn't:
641
+ await createTrial({ type: TestPlugin, functionParameter: "foo" }).run();
642
+ expect(consoleSpy).toHaveBeenCalledWith(
643
+ 'A non-function value (`foo`) was provided for the function parameter "functionParameter" in the "test" plugin.'
644
+ );
645
+ });
646
+
647
+ it("errors on select parameters with values not in the options array", async () => {
648
+ TestPlugin.setParameterInfos({
649
+ selectParameter: {
650
+ type: ParameterType.SELECT,
651
+ options: ["foo", "bar"],
652
+ },
653
+ });
654
+
655
+ // this should work:
656
+ await createTrial({ type: TestPlugin, selectParameter: "foo" }).run();
657
+
658
+ // this shouldn't:
659
+
660
+ await createTrial({ type: TestPlugin, selectParameter: "baz" }).run();
661
+ expect(consoleSpy).toHaveBeenCalledWith(
662
+ 'The value "baz" is not a valid option for the parameter "selectParameter" in the "test" plugin. Valid options are: foo, bar.'
663
+ );
664
+ });
665
+ });
666
+
563
667
  it("respects `default_iti` and `post_trial_gap``", async () => {
564
668
  dependencies.getDefaultIti.mockReturnValue(100);
565
669
  TestPlugin.setManualFinishTrialMode();
@@ -360,6 +360,7 @@ export class Trial extends TimelineNode {
360
360
  for (const [parameterName, parameterConfig] of Object.entries(parameterInfos)) {
361
361
  const parameterPath = [...parentParameterPath, parameterName];
362
362
 
363
+ // evaluate parameter and validate required parameter
363
364
  let parameterValue = this.getParameterValue(parameterPath, {
364
365
  evaluateFunctions: parameterConfig.type !== ParameterType.FUNCTION,
365
366
  replaceResult: (originalResult) => {
@@ -379,6 +380,83 @@ export class Trial extends TimelineNode {
379
380
  },
380
381
  });
381
382
 
383
+ // TODO: ensure that this throws an error in v9!
384
+ // major parameter type validation
385
+ if (!parameterConfig.array && parameterValue !== null) {
386
+ switch (parameterConfig.type) {
387
+ case ParameterType.BOOL:
388
+ if (typeof parameterValue !== "boolean") {
389
+ const parameterPathString = parameterPathArrayToString(parameterPath);
390
+ console.warn(
391
+ `A non-boolean value (\`${parameterValue}\`) was provided for the boolean parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
392
+ );
393
+ }
394
+ break;
395
+ // @ts-ignore falls through
396
+ case ParameterType.KEYS: // "ALL_KEYS", "NO_KEYS", and single key strings are checked here
397
+ if (Array.isArray(parameterValue)) break;
398
+ case ParameterType.STRING:
399
+ case ParameterType.HTML_STRING:
400
+ case ParameterType.KEY:
401
+ case ParameterType.AUDIO:
402
+ case ParameterType.VIDEO:
403
+ case ParameterType.IMAGE:
404
+ if (typeof parameterValue !== "string") {
405
+ const parameterPathString = parameterPathArrayToString(parameterPath);
406
+ console.warn(
407
+ `A non-string value (\`${parameterValue}\`) was provided for the parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
408
+ );
409
+ }
410
+ break;
411
+ case ParameterType.FLOAT:
412
+ case ParameterType.INT:
413
+ if (typeof parameterValue !== "number") {
414
+ const parameterPathString = parameterPathArrayToString(parameterPath);
415
+ console.warn(
416
+ `A non-numeric value (\`${parameterValue}\`) was provided for the numeric parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
417
+ );
418
+ }
419
+ break;
420
+ case ParameterType.FUNCTION:
421
+ if (typeof parameterValue !== "function") {
422
+ const parameterPathString = parameterPathArrayToString(parameterPath);
423
+ console.warn(
424
+ `A non-function value (\`${parameterValue}\`) was provided for the function parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
425
+ );
426
+ }
427
+ break;
428
+ case ParameterType.SELECT:
429
+ if (!parameterConfig.options) {
430
+ const parameterPathString = parameterPathArrayToString(parameterPath);
431
+ console.warn(
432
+ `The "options" array is required for the "select" parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
433
+ );
434
+ }
435
+ }
436
+
437
+ // truncate floats to integers if the parameter type is INT
438
+ if (parameterConfig.type === ParameterType.INT && parameterValue % 1 !== 0) {
439
+ const parameterPathString = parameterPathArrayToString(parameterPath);
440
+ console.warn(
441
+ `A float value (\`${parameterValue}\`) was provided for the integer parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin. The value will be truncated to an integer.`
442
+ );
443
+
444
+ parameterValue = Math.trunc(parameterValue);
445
+ }
446
+ }
447
+
448
+ if (parameterConfig.type === ParameterType.SELECT) {
449
+ if (!parameterConfig.options.includes(parameterValue)) {
450
+ const parameterPathString = parameterPathArrayToString(parameterPath);
451
+ console.warn(
452
+ `The value "${parameterValue}" is not a valid option for the parameter "${parameterPathString}" in the "${
453
+ this.pluginInfo.name
454
+ }" plugin. Valid options are: ${parameterConfig.options.join(", ")}.`
455
+ );
456
+ }
457
+ }
458
+
459
+ // array validation
382
460
  if (parameterConfig.array && !Array.isArray(parameterValue)) {
383
461
  const parameterPathString = parameterPathArrayToString(parameterPath);
384
462
  throw new Error(