Merge pull request #2087 from bjoluc/refactor-keyboard-listener-api

Refactor KeyboardListenerAPI
This commit is contained in:
Josh de Leeuw 2021-09-01 14:59:26 -04:00 committed by GitHub
commit a66d29c31f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 211 additions and 434 deletions

View File

@ -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();

View File

@ -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<KeyboardListener>();
private heldKeys = new Set<string>();
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;
}
}

View File

@ -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(),

View File

@ -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> = T extends PromiseLike<infer U> ? Awaited<U> : T;
beforeEach(() => {
jsPsych = initJsPsych();
});
const getRootElement = () => document.body;
describe("#getKeyboardResponse", () => {
let helpers: Awaited<ReturnType<typeof startTimeline>>;
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();
});
});

View File

@ -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) {