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; 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 = this.webaudio_context =
typeof window !== "undefined" && typeof window.AudioContext !== "undefined" typeof window !== "undefined" && typeof window.AudioContext !== "undefined"
@ -422,11 +422,6 @@ export class JsPsych {
} }
this.DOM_target.className += "jspsych-content"; 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 // create listeners for user browser interaction
this.data.createInteractionListeners(); 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 { 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++) { * If not previously done and `this.getRootElement()` returns an element, adds the root key
this.keyboard_listeners[i].fn(e); * 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) { private rootKeydownListener(e: KeyboardEvent) {
this.held_keys[e.key] = false; // 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) { private toLowerCaseIfInsensitive(string: string) {
this.keyboard_listeners = []; return this.areResponsesCaseSensitive ? string : string.toLowerCase();
this.held_keys = {};
root_element.removeEventListener("keydown", this.root_keydown_listener);
root_element.removeEventListener("keyup", this.root_keyup_listener);
} }
createKeyboardEventListeners(root_element) { private rootKeyupListener(e: KeyboardEvent) {
root_element.addEventListener("keydown", this.root_keydown_listener); this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key));
root_element.addEventListener("keyup", this.root_keyup_listener);
} }
getKeyboardResponse(parameters) { private isResponseValid(validResponses: ValidResponses, allowHeldKey: boolean, key: string) {
//parameters are: callback_function, valid_responses, rt_method, persist, audio_context, audio_context_start_time, allow_held_key // check if key was already held down
if (!allowHeldKey && this.heldKeys.has(key)) {
return false;
}
parameters.rt_method = if (validResponses === "ALL_KEYS") {
typeof parameters.rt_method === "undefined" ? "performance" : parameters.rt_method; return true;
if (parameters.rt_method != "performance" && parameters.rt_method != "audio") { }
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( console.log(
'Invalid RT method specified in getKeyboardResponse. Defaulting to "performance" method.' 'Invalid RT method specified in getKeyboardResponse. Defaulting to "performance" method.'
); );
parameters.rt_method = "performance"; rt_method = "performance";
} }
var start_time; const usePerformanceRt = rt_method === "performance";
if (parameters.rt_method == "performance") { const startTime = usePerformanceRt ? performance.now() : audio_context_start_time * 1000;
start_time = performance.now();
} else if (parameters.rt_method === "audio") { this.registerRootListeners();
start_time = parameters.audio_context_start_time;
if (!this.areResponsesCaseSensitive && typeof valid_responses !== "string") {
valid_responses = valid_responses.map((r) => r.toLowerCase());
} }
var case_sensitive = const listener: KeyboardListener = (e) => {
typeof this.areResponsesCaseSensitive === "undefined" const rt =
? false (rt_method == "performance" ? performance.now() : audio_context.currentTime * 1000) -
: this.areResponsesCaseSensitive; startTime;
if (rt < minimum_valid_rt) {
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) {
return; return;
} }
var valid_response = false; const key = this.toLowerCaseIfInsensitive(e.key);
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;
}
}
}
// check if key was already held down if (this.isResponseValid(valid_responses, allow_held_key, key)) {
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 is a valid response, then we don't want the key event to trigger other actions // if this is a valid response, then we don't want the key event to trigger other actions
// like scrolling via the spacebar. // like scrolling via the spacebar.
e.preventDefault(); 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 (!persist) {
if (!parameters.persist) { // remove keyboard listener if it exists
// remove keyboard listener this.cancelKeyboardResponse(listener);
this.cancelKeyboardResponse(listener_id);
}
} }
callback_function({ key, rt });
} }
}; };
// create listener id object this.listeners.add(listener);
listener_id = { return listener;
type: "keydown",
fn: listener_function,
};
// add this keyboard listener to the list of listeners
this.keyboard_listeners.push(listener_id);
return listener_id;
} }
cancelKeyboardResponse(listener) { cancelKeyboardResponse(listener: KeyboardListener) {
// remove the listener from the list of listeners // remove the listener from the set of listeners if it is contained
if (this.keyboard_listeners.includes(listener)) { this.listeners.delete(listener);
this.keyboard_listeners.splice(this.keyboard_listeners.indexOf(listener), 1);
}
} }
cancelAllKeyboardResponses() { cancelAllKeyboardResponses() {
this.keyboard_listeners = []; this.listeners.clear();
} }
convertKeyCharacterToKeyCode(character) { compareKeys(key1: string | null, key2: string | null) {
console.warn( if (
"Warning: The jsPsych.pluginAPI.convertKeyCharacterToKeyCode function will be removed in future jsPsych releases. " + (typeof key1 !== "string" && key1 !== null) ||
"We recommend removing this function and using strings to identify/compare keys." (typeof key2 !== "string" && key2 !== null)
);
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)))
) { ) {
return false;
} else if (key1 === null && key2 === null) {
return true;
} else {
console.error( 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; return undefined;
} }
}
static keylookup = { if (typeof key1 === "string" && typeof key2 === "string") {
backspace: 8, // if both values are strings, then check whether or not letter case should be converted before comparing (case_sensitive_responses in jsPsych.init)
tab: 9, return this.areResponsesCaseSensitive
enter: 13, ? key1 === key2
shift: 16, : key1.toLowerCase() === key2.toLowerCase();
ctrl: 17, }
alt: 18,
pause: 19, return key1 === null && key2 === null;
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,
};
} }

View File

@ -11,7 +11,11 @@ export function createJointPluginAPIObject(jsPsych: JsPsych) {
return Object.assign( 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 TimeoutAPI(),
new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context), new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context),
new HardwareAPI(), 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"; jest.useFakeTimers();
import { keyDown, keyUp, pressKey, startTimeline } from "../utils";
let jsPsych: JsPsych; const getRootElement = () => document.body;
// 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();
});
describe("#getKeyboardResponse", () => { describe("#getKeyboardResponse", () => {
let helpers: Awaited<ReturnType<typeof startTimeline>>;
let callback: jest.Mock; let callback: jest.Mock;
beforeEach(async () => { beforeEach(() => {
helpers = await startTimeline(
[
{
type: htmlKeyboardResponse,
stimulus: "foo",
choices: ["q"],
},
],
jsPsych
);
callback = jest.fn(); callback = jest.fn();
}); });
test("should execute a function after successful keypress", async () => { 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"); keyDown("a");
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1);
keyUp("a"); keyUp("a");
@ -42,9 +25,11 @@ describe("#getKeyboardResponse", () => {
}); });
test("should execute only valid keys", () => { 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"); pressKey("b");
expect(callback).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(0);
pressKey("a"); pressKey("a");
@ -52,12 +37,11 @@ describe("#getKeyboardResponse", () => {
}); });
test('should not respond when "NO_KEYS" is used', () => { test('should not respond when "NO_KEYS" is used', () => {
jsPsych.pluginAPI.getKeyboardResponse({ new KeyboardListenerAPI(getRootElement).getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: "NO_KEYS", valid_responses: "NO_KEYS",
}); });
expect(callback).toHaveBeenCalledTimes(0);
pressKey("a"); pressKey("a");
expect(callback).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(0);
pressKey("a"); pressKey("a");
@ -65,9 +49,10 @@ describe("#getKeyboardResponse", () => {
}); });
test("should not respond to held keys when allow_held_key is false", () => { test("should not respond to held keys when allow_held_key is false", () => {
const api = new KeyboardListenerAPI(getRootElement);
keyDown("a"); keyDown("a");
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: "ALL_KEYS", valid_responses: "ALL_KEYS",
allow_held_key: false, allow_held_key: false,
@ -81,9 +66,10 @@ describe("#getKeyboardResponse", () => {
}); });
test("should respond to held keys when allow_held_key is true", () => { test("should respond to held keys when allow_held_key is true", () => {
const api = new KeyboardListenerAPI(getRootElement);
keyDown("a"); keyDown("a");
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: "ALL_KEYS", valid_responses: "ALL_KEYS",
allow_held_key: true, allow_held_key: true,
@ -95,21 +81,26 @@ describe("#getKeyboardResponse", () => {
}); });
describe("when case_sensitive_responses is false", () => { 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", () => { test("should convert response key to lowercase before determining validity", () => {
// case_sensitive_responses is false by default // case_sensitive_responses is false by default
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: ["a"], valid_responses: ["a"],
}); });
expect(callback).toHaveBeenCalledTimes(0);
pressKey("A"); pressKey("A");
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1);
}); });
test("should not respond to held key when response/valid key case differs and allow_held_key is false", () => { test("should not respond to held key when response/valid key case differs and allow_held_key is false", () => {
keyDown("A"); keyDown("A");
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: ["a"], valid_responses: ["a"],
allow_held_key: false, 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", () => { test("should respond to held keys when response/valid case differs and allow_held_key is true", () => {
keyDown("A"); keyDown("A");
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: ["a"], valid_responses: ["a"],
allow_held_key: true, allow_held_key: true,
@ -137,29 +128,18 @@ describe("#getKeyboardResponse", () => {
}); });
describe("when case_sensitive_responses is true", () => { describe("when case_sensitive_responses is true", () => {
beforeEach(async () => { let api: KeyboardListenerAPI;
jsPsych = initJsPsych({
case_sensitive_responses: true, beforeEach(() => {
}); api = new KeyboardListenerAPI(getRootElement, true);
helpers = await startTimeline(
[
{
type: htmlKeyboardResponse,
stimulus: "foo",
choices: ["q"],
},
],
jsPsych
);
}); });
test("should not convert response key to lowercase before determining validity", () => { test("should not convert response key to lowercase before determining validity", () => {
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: ["a"], valid_responses: ["a"],
}); });
expect(callback).toHaveBeenCalledTimes(0);
pressKey("A"); pressKey("A");
expect(callback).toHaveBeenCalledTimes(0); 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", () => { test("should not respond to a held key when response/valid case differs and allow_held_key is true", () => {
keyDown("A"); keyDown("A");
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: ["a"], valid_responses: ["a"],
allow_held_key: true, 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", () => { test("should not respond to a held key when response/valid case differs and allow_held_key is false", () => {
keyDown("A"); keyDown("A");
jsPsych.pluginAPI.getKeyboardResponse({ api.getKeyboardResponse({
callback_function: callback, callback_function: callback,
valid_responses: ["a"], valid_responses: ["a"],
allow_held_key: false, allow_held_key: false,
@ -196,152 +176,103 @@ describe("#getKeyboardResponse", () => {
describe("#cancelKeyboardResponse", () => { describe("#cancelKeyboardResponse", () => {
test("should cancel a keyboard response listener", async () => { test("should cancel a keyboard response listener", async () => {
const api = new KeyboardListenerAPI(getRootElement);
const callback = jest.fn(); const callback = jest.fn();
await startTimeline([ api.getKeyboardResponse({ callback_function: callback });
{ const listener = api.getKeyboardResponse({ callback_function: callback });
type: htmlKeyboardResponse, api.cancelKeyboardResponse(listener);
stimulus: "foo",
choices: ["q"],
},
]);
const listener = jsPsych.pluginAPI.getKeyboardResponse({ callback_function: callback });
expect(callback).toHaveBeenCalledTimes(0);
jsPsych.pluginAPI.cancelKeyboardResponse(listener);
pressKey("q"); pressKey("q");
expect(callback).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(1);
}); });
}); });
describe("#cancelAllKeyboardResponses", () => { describe("#cancelAllKeyboardResponses", () => {
test("should cancel all keyboard response listeners", async () => { test("should cancel all keyboard response listeners", async () => {
const api = new KeyboardListenerAPI(getRootElement);
const callback = jest.fn(); const callback = jest.fn();
await startTimeline([ api.getKeyboardResponse({ callback_function: callback });
{ api.getKeyboardResponse({ callback_function: callback });
type: htmlKeyboardResponse, api.cancelAllKeyboardResponses();
stimulus: "foo",
choices: ["q"],
},
]);
jsPsych.pluginAPI.getKeyboardResponse({ callback_function: callback });
expect(callback).toHaveBeenCalledTimes(0);
jsPsych.pluginAPI.cancelAllKeyboardResponses();
pressKey("q"); pressKey("q");
expect(callback).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(0);
}); });
}); });
describe("#compareKeys", () => { 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", () => { test("should be case sensitive when case_sensitive_responses is true", () => {
var t = { const api = new KeyboardListenerAPI(getRootElement, true);
type: htmlKeyboardResponse,
stimulus: "foo", expect(api.compareKeys("q", "Q")).toBe(false);
}; expect(api.compareKeys("q", "q")).toBe(true);
jsPsych = initJsPsych({
timeline: [t],
case_sensitive_responses: true,
});
expect(jsPsych.pluginAPI.compareKeys("q", "Q")).toBe(false);
expect(jsPsych.pluginAPI.compareKeys("q", "q")).toBe(true);
}); });
test("should not be case sensitive when case_sensitive_responses is false", () => { test("should not be case sensitive when case_sensitive_responses is false", () => {
var t = { const api = new KeyboardListenerAPI(getRootElement);
type: htmlKeyboardResponse,
stimulus: "foo", expect(api.compareKeys("q", "Q")).toBe(true);
}; expect(api.compareKeys("q", "q")).toBe(true);
jsPsych = initJsPsych({
timeline: [t],
case_sensitive_responses: false,
});
expect(jsPsych.pluginAPI.compareKeys("q", "Q")).toBe(true);
expect(jsPsych.pluginAPI.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(() => {}); const spy = jest.spyOn(console, "error").mockImplementation(() => {});
expect(jsPsych.pluginAPI.compareKeys(null, "Q")).toBe(false);
expect(jsPsych.pluginAPI.compareKeys(80, null)).toBe(false); const api = new KeyboardListenerAPI(getRootElement);
expect(jsPsych.pluginAPI.compareKeys(null, null)).toBe(true); 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(); expect(console.error).not.toHaveBeenCalled();
spy.mockRestore(); spy.mockRestore();
}); });
test("should return undefined and produce a console warning if either/both arguments are not a string, integer, or null", () => { 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(() => {}); const spy = jest.spyOn(console, "error").mockImplementation(() => {});
var t1 = jsPsych.pluginAPI.compareKeys({}, "Q"); const api = new KeyboardListenerAPI(getRootElement);
var t2 = jsPsych.pluginAPI.compareKeys(true, null);
var t3 = jsPsych.pluginAPI.compareKeys(null, ["Q"]); // @ts-expect-error The compareKeys types forbid this
expect(typeof t1).toBe("undefined"); expect(api.compareKeys({}, "Q")).toBeUndefined();
expect(typeof t2).toBe("undefined"); // @ts-expect-error The compareKeys types forbid this
expect(typeof t3).toBe("undefined"); 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(console.error).toHaveBeenCalledTimes(3);
expect(spy.mock.calls).toEqual([ for (let i = 1; i < 4; i++) {
[ expect(spy).toHaveBeenNthCalledWith(
"Error in jsPsych.pluginAPI.compareKeys: arguments must be numeric key codes, key strings, or null.", i,
], "Error in jsPsych.pluginAPI.compareKeys: arguments must be 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.",
],
]);
spy.mockRestore(); 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", () => { describe("#setTimeout", () => {
test("basic setTimeout control with centralized storage", () => { test("basic setTimeout control with centralized storage", () => {
jest.useFakeTimers(); const api = new TimeoutAPI();
var callback = jest.fn(); var callback = jest.fn();
jsPsych.pluginAPI.setTimeout(callback, 1000); api.setTimeout(callback, 1000);
expect(callback).not.toBeCalled(); expect(callback).not.toHaveBeenCalled();
jest.runAllTimers(); jest.advanceTimersByTime(1000);
expect(callback).toBeCalled(); expect(callback).toHaveBeenCalled();
}); });
}); });
describe("#clearAllTimeouts", () => { describe("#clearAllTimeouts", () => {
test("clear timeouts before they execute", () => { test("clear timeouts before they execute", () => {
jest.useFakeTimers(); const api = new TimeoutAPI();
var callback = jest.fn(); var callback = jest.fn();
jsPsych.pluginAPI.setTimeout(callback, 5000); api.setTimeout(callback, 5000);
expect(callback).not.toBeCalled(); expect(callback).not.toHaveBeenCalled();
jsPsych.pluginAPI.clearAllTimeouts(); api.clearAllTimeouts();
jest.runAllTimers(); jest.advanceTimersByTime(5000);
expect(callback).not.toBeCalled(); expect(callback).not.toHaveBeenCalled();
}); });
}); });

View File

@ -3,7 +3,7 @@ import { setImmediate as flushMicroTasks } from "timers";
import { JsPsych } from "../src"; import { JsPsych } from "../src";
export function dispatchEvent(event: Event) { export function dispatchEvent(event: Event) {
document.querySelector(".jspsych-display-element").dispatchEvent(event); document.body.dispatchEvent(event);
} }
export function keyDown(key: string) { export function keyDown(key: string) {