diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index 5ba1009d..1a622dd3 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -109,7 +109,7 @@ export class JsPsych { }; this.opts = options; - autoBind(this); // just in case people do weird things with JsPsych methods + autoBind(this); // so we can pass JsPsych methods as callbacks and `this` remains the JsPsych instance this.webaudio_context = typeof window !== "undefined" && typeof window.AudioContext !== "undefined" @@ -422,11 +422,6 @@ export class JsPsych { } this.DOM_target.className += "jspsych-content"; - // below code resets event listeners that may have lingered from - // a previous incomplete experiment loaded in same DOM. - this.pluginAPI.reset(options.display_element); - // create keyboard event listeners - this.pluginAPI.createKeyboardEventListeners(options.display_element); // create listeners for user browser interaction this.data.createInteractionListeners(); diff --git a/packages/jspsych/src/modules/plugin-api/KeyboardListenerAPI.ts b/packages/jspsych/src/modules/plugin-api/KeyboardListenerAPI.ts index 8d8a52c9..28c14131 100644 --- a/packages/jspsych/src/modules/plugin-api/KeyboardListenerAPI.ts +++ b/packages/jspsych/src/modules/plugin-api/KeyboardListenerAPI.ts @@ -1,317 +1,164 @@ +import autoBind from "auto-bind"; + +export type KeyboardListener = (e: KeyboardEvent) => void; + +export type ValidResponses = string[] | "ALL_KEYS" | "NO_KEYS"; + +export interface GetKeyboardResponseOptions { + callback_function: any; + valid_responses?: ValidResponses; + rt_method?: "performance" | "audio"; + persist?: boolean; + audio_context?: AudioContext; + audio_context_start_time?: number; + allow_held_key?: boolean; + minimum_valid_rt?: number; +} + export class KeyboardListenerAPI { - constructor(private areResponsesCaseSensitive: boolean, private minimumValidRt = 0) {} + constructor( + private getRootElement: () => Element | undefined, + private areResponsesCaseSensitive: boolean = false, + private minimumValidRt = 0 + ) { + autoBind(this); + this.registerRootListeners(); + } - private keyboard_listeners = []; + private listeners = new Set(); + private heldKeys = new Set(); - private held_keys = {}; + private areRootListenersRegistered = false; - private root_keydown_listener(e) { - for (var i = 0; i < this.keyboard_listeners.length; i++) { - this.keyboard_listeners[i].fn(e); + /** + * If not previously done and `this.getRootElement()` returns an element, adds the root key + * listeners to that element. + */ + private registerRootListeners() { + if (!this.areRootListenersRegistered) { + const rootElement = this.getRootElement(); + if (rootElement) { + rootElement.addEventListener("keydown", this.rootKeydownListener); + rootElement.addEventListener("keyup", this.rootKeyupListener); + this.areRootListenersRegistered = true; + } } - this.held_keys[e.key] = true; } - private root_keyup_listener(e) { - this.held_keys[e.key] = false; + private rootKeydownListener(e: KeyboardEvent) { + // Iterate over a static copy of the listeners set because listeners might add other listeners + // that we do not want to be included in the loop + for (const listener of Array.from(this.listeners)) { + listener(e); + } + this.heldKeys.add(this.toLowerCaseIfInsensitive(e.key)); } - reset(root_element) { - this.keyboard_listeners = []; - this.held_keys = {}; - root_element.removeEventListener("keydown", this.root_keydown_listener); - root_element.removeEventListener("keyup", this.root_keyup_listener); + private toLowerCaseIfInsensitive(string: string) { + return this.areResponsesCaseSensitive ? string : string.toLowerCase(); } - createKeyboardEventListeners(root_element) { - root_element.addEventListener("keydown", this.root_keydown_listener); - root_element.addEventListener("keyup", this.root_keyup_listener); + private rootKeyupListener(e: KeyboardEvent) { + this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key)); } - getKeyboardResponse(parameters) { - //parameters are: callback_function, valid_responses, rt_method, persist, audio_context, audio_context_start_time, allow_held_key + private isResponseValid(validResponses: ValidResponses, allowHeldKey: boolean, key: string) { + // check if key was already held down + if (!allowHeldKey && this.heldKeys.has(key)) { + return false; + } - parameters.rt_method = - typeof parameters.rt_method === "undefined" ? "performance" : parameters.rt_method; - if (parameters.rt_method != "performance" && parameters.rt_method != "audio") { + if (validResponses === "ALL_KEYS") { + return true; + } + if (validResponses === "NO_KEYS") { + return false; + } + + return validResponses.includes(key); + } + + getKeyboardResponse({ + callback_function, + valid_responses = "ALL_KEYS", + rt_method = "performance", + persist, + audio_context, + audio_context_start_time, + allow_held_key = false, + minimum_valid_rt = this.minimumValidRt, + }: GetKeyboardResponseOptions) { + if (rt_method !== "performance" && rt_method !== "audio") { console.log( 'Invalid RT method specified in getKeyboardResponse. Defaulting to "performance" method.' ); - parameters.rt_method = "performance"; + rt_method = "performance"; } - var start_time; - if (parameters.rt_method == "performance") { - start_time = performance.now(); - } else if (parameters.rt_method === "audio") { - start_time = parameters.audio_context_start_time; + const usePerformanceRt = rt_method === "performance"; + const startTime = usePerformanceRt ? performance.now() : audio_context_start_time * 1000; + + this.registerRootListeners(); + + if (!this.areResponsesCaseSensitive && typeof valid_responses !== "string") { + valid_responses = valid_responses.map((r) => r.toLowerCase()); } - var case_sensitive = - typeof this.areResponsesCaseSensitive === "undefined" - ? false - : this.areResponsesCaseSensitive; - - var listener_id; - - const listener_function = (e) => { - var key_time; - if (parameters.rt_method == "performance") { - key_time = performance.now(); - } else if (parameters.rt_method === "audio") { - key_time = parameters.audio_context.currentTime; - } - var rt = key_time - start_time; - - // overiding via parameters for testing purposes. - // TODO (bjoluc): Why exactly? - var minimum_valid_rt = parameters.minimum_valid_rt || this.minimumValidRt; - - var rt_ms = rt; - if (parameters.rt_method == "audio") { - rt_ms = rt_ms * 1000; - } - if (rt_ms < minimum_valid_rt) { + const listener: KeyboardListener = (e) => { + const rt = + (rt_method == "performance" ? performance.now() : audio_context.currentTime * 1000) - + startTime; + if (rt < minimum_valid_rt) { return; } - var valid_response = false; - if (typeof parameters.valid_responses === "undefined") { - valid_response = true; - } else if (parameters.valid_responses == "ALL_KEYS") { - valid_response = true; - } else if (parameters.valid_responses != "NO_KEYS") { - if (parameters.valid_responses.includes(e.key)) { - valid_response = true; - } - if (!case_sensitive) { - var valid_lower = parameters.valid_responses.map(function (v) { - return v.toLowerCase(); - }); - var key_lower = e.key.toLowerCase(); - if (valid_lower.includes(key_lower)) { - valid_response = true; - } - } - } + const key = this.toLowerCaseIfInsensitive(e.key); - // check if key was already held down - if ( - (typeof parameters.allow_held_key === "undefined" || !parameters.allow_held_key) && - valid_response - ) { - if (typeof this.held_keys[e.key] !== "undefined" && this.held_keys[e.key] == true) { - valid_response = false; - } - if ( - !case_sensitive && - typeof this.held_keys[e.key.toLowerCase()] !== "undefined" && - this.held_keys[e.key.toLowerCase()] == true - ) { - valid_response = false; - } - } - - if (valid_response) { + if (this.isResponseValid(valid_responses, allow_held_key, key)) { // if this is a valid response, then we don't want the key event to trigger other actions // like scrolling via the spacebar. e.preventDefault(); - var key = e.key; - if (!case_sensitive) { - key = key.toLowerCase(); - } - parameters.callback_function({ - key: key, - rt: rt_ms, - }); - if (this.keyboard_listeners.includes(listener_id)) { - if (!parameters.persist) { - // remove keyboard listener - this.cancelKeyboardResponse(listener_id); - } + if (!persist) { + // remove keyboard listener if it exists + this.cancelKeyboardResponse(listener); } + + callback_function({ key, rt }); } }; - // create listener id object - listener_id = { - type: "keydown", - fn: listener_function, - }; - - // add this keyboard listener to the list of listeners - this.keyboard_listeners.push(listener_id); - - return listener_id; + this.listeners.add(listener); + return listener; } - cancelKeyboardResponse(listener) { - // remove the listener from the list of listeners - if (this.keyboard_listeners.includes(listener)) { - this.keyboard_listeners.splice(this.keyboard_listeners.indexOf(listener), 1); - } + cancelKeyboardResponse(listener: KeyboardListener) { + // remove the listener from the set of listeners if it is contained + this.listeners.delete(listener); } cancelAllKeyboardResponses() { - this.keyboard_listeners = []; + this.listeners.clear(); } - convertKeyCharacterToKeyCode(character) { - console.warn( - "Warning: The jsPsych.pluginAPI.convertKeyCharacterToKeyCode function will be removed in future jsPsych releases. " + - "We recommend removing this function and using strings to identify/compare keys." - ); - var code; - character = character.toLowerCase(); - if (typeof KeyboardListenerAPI.keylookup[character] !== "undefined") { - code = KeyboardListenerAPI.keylookup[character]; - } - return code; - } - - convertKeyCodeToKeyCharacter(code) { - console.warn( - "Warning: The jsPsych.pluginAPI.convertKeyCodeToKeyCharacter function will be removed in future jsPsych releases. " + - "We recommend removing this function and using strings to identify/compare keys." - ); - for (var i in Object.keys(KeyboardListenerAPI.keylookup)) { - if (KeyboardListenerAPI.keylookup[Object.keys(KeyboardListenerAPI.keylookup)[i]] == code) { - return Object.keys(KeyboardListenerAPI.keylookup)[i]; - } - } - return undefined; - } - - compareKeys(key1, key2) { - if (Number.isFinite(key1) || Number.isFinite(key2)) { - // if either value is a numeric keyCode, then convert both to numeric keyCode values and compare (maintained for backwards compatibility) - if (typeof key1 == "string") { - key1 = this.convertKeyCharacterToKeyCode(key1); - } - if (typeof key2 == "string") { - key2 = this.convertKeyCharacterToKeyCode(key2); - } - return key1 == key2; - } else if (typeof key1 === "string" && typeof key2 === "string") { - // if both values are strings, then check whether or not letter case should be converted before comparing (case_sensitive_responses in jsPsych.init) - var case_sensitive = - typeof this.areResponsesCaseSensitive === "undefined" - ? false - : this.areResponsesCaseSensitive; - if (case_sensitive) { - return key1 == key2; - } else { - return key1.toLowerCase() == key2.toLowerCase(); - } - } else if ( - (key1 === null && (typeof key2 === "string" || Number.isFinite(key2))) || - (key2 === null && (typeof key1 === "string" || Number.isFinite(key1))) + compareKeys(key1: string | null, key2: string | null) { + if ( + (typeof key1 !== "string" && key1 !== null) || + (typeof key2 !== "string" && key2 !== null) ) { - return false; - } else if (key1 === null && key2 === null) { - return true; - } else { console.error( - "Error in jsPsych.pluginAPI.compareKeys: arguments must be numeric key codes, key strings, or null." + "Error in jsPsych.pluginAPI.compareKeys: arguments must be key strings or null." ); return undefined; } - } - static keylookup = { - backspace: 8, - tab: 9, - enter: 13, - shift: 16, - ctrl: 17, - alt: 18, - pause: 19, - capslock: 20, - esc: 27, - space: 32, - spacebar: 32, - " ": 32, - pageup: 33, - pagedown: 34, - end: 35, - home: 36, - leftarrow: 37, - uparrow: 38, - rightarrow: 39, - downarrow: 40, - insert: 45, - delete: 46, - 0: 48, - 1: 49, - 2: 50, - 3: 51, - 4: 52, - 5: 53, - 6: 54, - 7: 55, - 8: 56, - 9: 57, - a: 65, - b: 66, - c: 67, - d: 68, - e: 69, - f: 70, - g: 71, - h: 72, - i: 73, - j: 74, - k: 75, - l: 76, - m: 77, - n: 78, - o: 79, - p: 80, - q: 81, - r: 82, - s: 83, - t: 84, - u: 85, - v: 86, - w: 87, - x: 88, - y: 89, - z: 90, - "0numpad": 96, - "1numpad": 97, - "2numpad": 98, - "3numpad": 99, - "4numpad": 100, - "5numpad": 101, - "6numpad": 102, - "7numpad": 103, - "8numpad": 104, - "9numpad": 105, - multiply: 106, - plus: 107, - minus: 109, - decimal: 110, - divide: 111, - f1: 112, - f2: 113, - f3: 114, - f4: 115, - f5: 116, - f6: 117, - f7: 118, - f8: 119, - f9: 120, - f10: 121, - f11: 122, - f12: 123, - "=": 187, - ",": 188, - ".": 190, - "/": 191, - "`": 192, - "[": 219, - "\\": 220, - "]": 221, - }; + if (typeof key1 === "string" && typeof key2 === "string") { + // if both values are strings, then check whether or not letter case should be converted before comparing (case_sensitive_responses in jsPsych.init) + return this.areResponsesCaseSensitive + ? key1 === key2 + : key1.toLowerCase() === key2.toLowerCase(); + } + + return key1 === null && key2 === null; + } } diff --git a/packages/jspsych/src/modules/plugin-api/index.ts b/packages/jspsych/src/modules/plugin-api/index.ts index 827c51b4..511e54f4 100644 --- a/packages/jspsych/src/modules/plugin-api/index.ts +++ b/packages/jspsych/src/modules/plugin-api/index.ts @@ -11,7 +11,11 @@ export function createJointPluginAPIObject(jsPsych: JsPsych) { return Object.assign( {}, ...[ - new KeyboardListenerAPI(settings.case_sensitive_responses, settings.minimum_valid_rt), + new KeyboardListenerAPI( + jsPsych.getDisplayContainerElement, + settings.case_sensitive_responses, + settings.minimum_valid_rt + ), new TimeoutAPI(), new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context), new HardwareAPI(), diff --git a/packages/jspsych/tests/pluginAPI/pluginapi.test.ts b/packages/jspsych/tests/pluginAPI/pluginapi.test.ts index 6dcf96ab..580b5424 100644 --- a/packages/jspsych/tests/pluginAPI/pluginapi.test.ts +++ b/packages/jspsych/tests/pluginAPI/pluginapi.test.ts @@ -1,40 +1,23 @@ -import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; +import { KeyboardListenerAPI } from "../../src/modules/plugin-api/KeyboardListenerAPI"; +import { TimeoutAPI } from "../../src/modules/plugin-api/TimeoutAPI"; +import { keyDown, keyUp, pressKey } from "../utils"; -import { JsPsych, initJsPsych } from "../../src"; -import { keyDown, keyUp, pressKey, startTimeline } from "../utils"; +jest.useFakeTimers(); -let jsPsych: JsPsych; - -// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/#recursive-conditional-types -type Awaited = T extends PromiseLike ? Awaited : T; - -beforeEach(() => { - jsPsych = initJsPsych(); -}); +const getRootElement = () => document.body; describe("#getKeyboardResponse", () => { - let helpers: Awaited>; let callback: jest.Mock; - beforeEach(async () => { - helpers = await startTimeline( - [ - { - type: htmlKeyboardResponse, - stimulus: "foo", - choices: ["q"], - }, - ], - jsPsych - ); - + beforeEach(() => { callback = jest.fn(); }); test("should execute a function after successful keypress", async () => { - jsPsych.pluginAPI.getKeyboardResponse({ callback_function: callback }); + new KeyboardListenerAPI(getRootElement).getKeyboardResponse({ + callback_function: callback, + }); - expect(callback).toHaveBeenCalledTimes(0); keyDown("a"); expect(callback).toHaveBeenCalledTimes(1); keyUp("a"); @@ -42,9 +25,11 @@ describe("#getKeyboardResponse", () => { }); test("should execute only valid keys", () => { - jsPsych.pluginAPI.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"] }); + new KeyboardListenerAPI(getRootElement).getKeyboardResponse({ + callback_function: callback, + valid_responses: ["a"], + }); - expect(callback).toHaveBeenCalledTimes(0); pressKey("b"); expect(callback).toHaveBeenCalledTimes(0); pressKey("a"); @@ -52,12 +37,11 @@ describe("#getKeyboardResponse", () => { }); test('should not respond when "NO_KEYS" is used', () => { - jsPsych.pluginAPI.getKeyboardResponse({ + new KeyboardListenerAPI(getRootElement).getKeyboardResponse({ callback_function: callback, valid_responses: "NO_KEYS", }); - expect(callback).toHaveBeenCalledTimes(0); pressKey("a"); expect(callback).toHaveBeenCalledTimes(0); pressKey("a"); @@ -65,9 +49,10 @@ describe("#getKeyboardResponse", () => { }); test("should not respond to held keys when allow_held_key is false", () => { + const api = new KeyboardListenerAPI(getRootElement); keyDown("a"); - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: "ALL_KEYS", allow_held_key: false, @@ -81,9 +66,10 @@ describe("#getKeyboardResponse", () => { }); test("should respond to held keys when allow_held_key is true", () => { + const api = new KeyboardListenerAPI(getRootElement); keyDown("a"); - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: "ALL_KEYS", allow_held_key: true, @@ -95,21 +81,26 @@ describe("#getKeyboardResponse", () => { }); describe("when case_sensitive_responses is false", () => { + let api: KeyboardListenerAPI; + + beforeEach(() => { + api = new KeyboardListenerAPI(getRootElement); + }); + test("should convert response key to lowercase before determining validity", () => { // case_sensitive_responses is false by default - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"], }); - expect(callback).toHaveBeenCalledTimes(0); pressKey("A"); expect(callback).toHaveBeenCalledTimes(1); }); test("should not respond to held key when response/valid key case differs and allow_held_key is false", () => { keyDown("A"); - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"], allow_held_key: false, @@ -124,7 +115,7 @@ describe("#getKeyboardResponse", () => { test("should respond to held keys when response/valid case differs and allow_held_key is true", () => { keyDown("A"); - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"], allow_held_key: true, @@ -137,29 +128,18 @@ describe("#getKeyboardResponse", () => { }); describe("when case_sensitive_responses is true", () => { - beforeEach(async () => { - jsPsych = initJsPsych({ - case_sensitive_responses: true, - }); - helpers = await startTimeline( - [ - { - type: htmlKeyboardResponse, - stimulus: "foo", - choices: ["q"], - }, - ], - jsPsych - ); + let api: KeyboardListenerAPI; + + beforeEach(() => { + api = new KeyboardListenerAPI(getRootElement, true); }); test("should not convert response key to lowercase before determining validity", () => { - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"], }); - expect(callback).toHaveBeenCalledTimes(0); pressKey("A"); expect(callback).toHaveBeenCalledTimes(0); }); @@ -167,7 +147,7 @@ describe("#getKeyboardResponse", () => { test("should not respond to a held key when response/valid case differs and allow_held_key is true", () => { keyDown("A"); - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"], allow_held_key: true, @@ -181,7 +161,7 @@ describe("#getKeyboardResponse", () => { test("should not respond to a held key when response/valid case differs and allow_held_key is false", () => { keyDown("A"); - jsPsych.pluginAPI.getKeyboardResponse({ + api.getKeyboardResponse({ callback_function: callback, valid_responses: ["a"], allow_held_key: false, @@ -196,152 +176,103 @@ describe("#getKeyboardResponse", () => { describe("#cancelKeyboardResponse", () => { test("should cancel a keyboard response listener", async () => { + const api = new KeyboardListenerAPI(getRootElement); const callback = jest.fn(); - await startTimeline([ - { - type: htmlKeyboardResponse, - stimulus: "foo", - choices: ["q"], - }, - ]); + api.getKeyboardResponse({ callback_function: callback }); + const listener = api.getKeyboardResponse({ callback_function: callback }); + api.cancelKeyboardResponse(listener); - const listener = jsPsych.pluginAPI.getKeyboardResponse({ callback_function: callback }); - expect(callback).toHaveBeenCalledTimes(0); - - jsPsych.pluginAPI.cancelKeyboardResponse(listener); pressKey("q"); - expect(callback).toHaveBeenCalledTimes(0); + expect(callback).toHaveBeenCalledTimes(1); }); }); describe("#cancelAllKeyboardResponses", () => { test("should cancel all keyboard response listeners", async () => { + const api = new KeyboardListenerAPI(getRootElement); const callback = jest.fn(); - await startTimeline([ - { - type: htmlKeyboardResponse, - stimulus: "foo", - choices: ["q"], - }, - ]); + api.getKeyboardResponse({ callback_function: callback }); + api.getKeyboardResponse({ callback_function: callback }); + api.cancelAllKeyboardResponses(); - jsPsych.pluginAPI.getKeyboardResponse({ callback_function: callback }); - expect(callback).toHaveBeenCalledTimes(0); - - jsPsych.pluginAPI.cancelAllKeyboardResponses(); pressKey("q"); expect(callback).toHaveBeenCalledTimes(0); }); }); describe("#compareKeys", () => { - test("should compare keys regardless of type (old key-keyCode functionality)", () => { - expect(jsPsych.pluginAPI.compareKeys("q", 81)).toBe(true); - expect(jsPsych.pluginAPI.compareKeys(81, 81)).toBe(true); - expect(jsPsych.pluginAPI.compareKeys("q", "Q")).toBe(true); - expect(jsPsych.pluginAPI.compareKeys(80, 81)).toBe(false); - expect(jsPsych.pluginAPI.compareKeys("q", "1")).toBe(false); - expect(jsPsych.pluginAPI.compareKeys("q", 80)).toBe(false); - }); - test("should be case sensitive when case_sensitive_responses is true", () => { - var t = { - type: htmlKeyboardResponse, - stimulus: "foo", - }; - jsPsych = initJsPsych({ - timeline: [t], - case_sensitive_responses: true, - }); - expect(jsPsych.pluginAPI.compareKeys("q", "Q")).toBe(false); - expect(jsPsych.pluginAPI.compareKeys("q", "q")).toBe(true); + const api = new KeyboardListenerAPI(getRootElement, true); + + expect(api.compareKeys("q", "Q")).toBe(false); + expect(api.compareKeys("q", "q")).toBe(true); }); test("should not be case sensitive when case_sensitive_responses is false", () => { - var t = { - type: htmlKeyboardResponse, - stimulus: "foo", - }; - jsPsych = initJsPsych({ - timeline: [t], - case_sensitive_responses: false, - }); - expect(jsPsych.pluginAPI.compareKeys("q", "Q")).toBe(true); - expect(jsPsych.pluginAPI.compareKeys("q", "q")).toBe(true); + const api = new KeyboardListenerAPI(getRootElement); + + expect(api.compareKeys("q", "Q")).toBe(true); + expect(api.compareKeys("q", "q")).toBe(true); }); - test("should accept null as argument, and return true if both arguments are null, and return false if one argument is null and other is string or numeric", () => { + test("should accept null as argument, and return true if both arguments are null, and return false if one argument is null and other is string", () => { const spy = jest.spyOn(console, "error").mockImplementation(() => {}); - expect(jsPsych.pluginAPI.compareKeys(null, "Q")).toBe(false); - expect(jsPsych.pluginAPI.compareKeys(80, null)).toBe(false); - expect(jsPsych.pluginAPI.compareKeys(null, null)).toBe(true); + + const api = new KeyboardListenerAPI(getRootElement); + expect(api.compareKeys(null, "Q")).toBe(false); + expect(api.compareKeys("Q", null)).toBe(false); + expect(api.compareKeys(null, null)).toBe(true); + expect(console.error).not.toHaveBeenCalled(); spy.mockRestore(); }); test("should return undefined and produce a console warning if either/both arguments are not a string, integer, or null", () => { const spy = jest.spyOn(console, "error").mockImplementation(() => {}); - var t1 = jsPsych.pluginAPI.compareKeys({}, "Q"); - var t2 = jsPsych.pluginAPI.compareKeys(true, null); - var t3 = jsPsych.pluginAPI.compareKeys(null, ["Q"]); - expect(typeof t1).toBe("undefined"); - expect(typeof t2).toBe("undefined"); - expect(typeof t3).toBe("undefined"); + const api = new KeyboardListenerAPI(getRootElement); + + // @ts-expect-error The compareKeys types forbid this + expect(api.compareKeys({}, "Q")).toBeUndefined(); + // @ts-expect-error The compareKeys types forbid this + expect(api.compareKeys(true, null)).toBeUndefined(); + // @ts-expect-error The compareKeys types forbid this + expect(api.compareKeys(null, ["Q"])).toBeUndefined(); + expect(console.error).toHaveBeenCalledTimes(3); - expect(spy.mock.calls).toEqual([ - [ - "Error in jsPsych.pluginAPI.compareKeys: arguments must be numeric key codes, key strings, or null.", - ], - [ - "Error in jsPsych.pluginAPI.compareKeys: arguments must be numeric key codes, key strings, or null.", - ], - [ - "Error in jsPsych.pluginAPI.compareKeys: arguments must be numeric key codes, key strings, or null.", - ], - ]); + for (let i = 1; i < 4; i++) { + expect(spy).toHaveBeenNthCalledWith( + i, + "Error in jsPsych.pluginAPI.compareKeys: arguments must be key strings or null." + ); + } + spy.mockRestore(); }); }); -describe("#convertKeyCharacterToKeyCode", () => { - test("should return the keyCode for a particular character", () => { - expect(jsPsych.pluginAPI.convertKeyCharacterToKeyCode("q")).toBe(81); - expect(jsPsych.pluginAPI.convertKeyCharacterToKeyCode("1")).toBe(49); - expect(jsPsych.pluginAPI.convertKeyCharacterToKeyCode("space")).toBe(32); - expect(jsPsych.pluginAPI.convertKeyCharacterToKeyCode("enter")).toBe(13); - }); -}); - -describe("#convertKeyCodeToKeyCharacter", () => { - test("should return the keyCode for a particular character", () => { - expect(jsPsych.pluginAPI.convertKeyCodeToKeyCharacter(81)).toBe("q"); - expect(jsPsych.pluginAPI.convertKeyCodeToKeyCharacter(49)).toBe("1"); - expect(jsPsych.pluginAPI.convertKeyCodeToKeyCharacter(32)).toBe("space"); - expect(jsPsych.pluginAPI.convertKeyCodeToKeyCharacter(13)).toBe("enter"); - }); -}); - describe("#setTimeout", () => { test("basic setTimeout control with centralized storage", () => { - jest.useFakeTimers(); + const api = new TimeoutAPI(); + var callback = jest.fn(); - jsPsych.pluginAPI.setTimeout(callback, 1000); - expect(callback).not.toBeCalled(); - jest.runAllTimers(); - expect(callback).toBeCalled(); + api.setTimeout(callback, 1000); + expect(callback).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalled(); }); }); describe("#clearAllTimeouts", () => { test("clear timeouts before they execute", () => { - jest.useFakeTimers(); + const api = new TimeoutAPI(); + var callback = jest.fn(); - jsPsych.pluginAPI.setTimeout(callback, 5000); - expect(callback).not.toBeCalled(); - jsPsych.pluginAPI.clearAllTimeouts(); - jest.runAllTimers(); - expect(callback).not.toBeCalled(); + api.setTimeout(callback, 5000); + expect(callback).not.toHaveBeenCalled(); + api.clearAllTimeouts(); + jest.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); }); }); diff --git a/packages/jspsych/tests/utils.ts b/packages/jspsych/tests/utils.ts index b6844337..94d3a3d6 100644 --- a/packages/jspsych/tests/utils.ts +++ b/packages/jspsych/tests/utils.ts @@ -3,7 +3,7 @@ import { setImmediate as flushMicroTasks } from "timers"; import { JsPsych } from "../src"; export function dispatchEvent(event: Event) { - document.querySelector(".jspsych-display-element").dispatchEvent(event); + document.body.dispatchEvent(event); } export function keyDown(key: string) {