mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-12 08:38:11 +00:00
Merge pull request #2087 from bjoluc/refactor-keyboard-listener-api
Refactor KeyboardListenerAPI
This commit is contained in:
commit
a66d29c31f
@ -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();
|
||||||
|
|
||||||
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user