@jspsych-contrib/plugin-trail-making 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ import { startTimeline } from "@jspsych/test-utils";
2
+
3
+ import jsPsychTrailMaking from ".";
4
+
5
+ jest.useFakeTimers();
6
+
7
+ describe("trail-making plugin", () => {
8
+ // Store original methods for restoration
9
+ let originalGetContext: typeof HTMLCanvasElement.prototype.getContext;
10
+ let mockCtx: any;
11
+ let arcCalls: Array<{ x: number; y: number; radius: number; fillStyle?: string }>;
12
+ let fillTextCalls: Array<{ text: string; x: number; y: number }>;
13
+
14
+ beforeEach(() => {
15
+ arcCalls = [];
16
+ fillTextCalls = [];
17
+
18
+ // Create mock context that tracks calls
19
+ mockCtx = {
20
+ fillStyle: "",
21
+ strokeStyle: "",
22
+ lineWidth: 0,
23
+ font: "",
24
+ textAlign: "",
25
+ textBaseline: "",
26
+ fillRect: jest.fn(),
27
+ beginPath: jest.fn(),
28
+ moveTo: jest.fn(),
29
+ lineTo: jest.fn(),
30
+ arc: jest.fn((x: number, y: number, radius: number) => {
31
+ arcCalls.push({ x, y, radius, fillStyle: mockCtx.fillStyle });
32
+ }),
33
+ stroke: jest.fn(),
34
+ fill: jest.fn(),
35
+ fillText: jest.fn((text: string, x: number, y: number) => {
36
+ fillTextCalls.push({ text, x, y });
37
+ }),
38
+ };
39
+
40
+ // Mock getContext to return our mock
41
+ originalGetContext = HTMLCanvasElement.prototype.getContext;
42
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => mockCtx) as any;
43
+ });
44
+
45
+ afterEach(() => {
46
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
47
+ });
48
+
49
+ it("should load with default parameters", async () => {
50
+ const { displayElement } = await startTimeline([
51
+ {
52
+ type: jsPsychTrailMaking,
53
+ },
54
+ ]);
55
+
56
+ // Check that canvas is created
57
+ const canvas = displayElement.querySelector("canvas");
58
+ expect(canvas).not.toBeNull();
59
+ expect(canvas?.width).toBe(600);
60
+ expect(canvas?.height).toBe(600);
61
+ });
62
+
63
+ it("should accept custom canvas dimensions", async () => {
64
+ const { displayElement } = await startTimeline([
65
+ {
66
+ type: jsPsychTrailMaking,
67
+ canvas_width: 800,
68
+ canvas_height: 500,
69
+ },
70
+ ]);
71
+
72
+ const canvas = displayElement.querySelector("canvas");
73
+ expect(canvas?.width).toBe(800);
74
+ expect(canvas?.height).toBe(500);
75
+ });
76
+
77
+ it("should display a prompt when provided", async () => {
78
+ const { displayElement } = await startTimeline([
79
+ {
80
+ type: jsPsychTrailMaking,
81
+ prompt: "<p>Connect the circles in order</p>",
82
+ },
83
+ ]);
84
+
85
+ expect(displayElement.innerHTML).toContain("Connect the circles in order");
86
+ });
87
+
88
+ it("should draw targets with specified target_radius", async () => {
89
+ arcCalls = [];
90
+
91
+ await startTimeline([
92
+ {
93
+ type: jsPsychTrailMaking,
94
+ target_radius: 35,
95
+ num_targets: 5,
96
+ },
97
+ ]);
98
+
99
+ // Filter arc calls for target circles (radius should be 35)
100
+ const targetCalls = arcCalls.filter((call) => call.radius === 35);
101
+ // Should have 5 targets drawn
102
+ expect(targetCalls.length).toBe(5);
103
+ });
104
+
105
+ it("should draw correct number of targets for test_type A", async () => {
106
+ fillTextCalls = [];
107
+
108
+ await startTimeline([
109
+ {
110
+ type: jsPsychTrailMaking,
111
+ test_type: "A",
112
+ num_targets: 8,
113
+ },
114
+ ]);
115
+
116
+ // Should have labels "1" through "8"
117
+ const labels = fillTextCalls.map((call) => call.text);
118
+ for (let i = 1; i <= 8; i++) {
119
+ expect(labels).toContain(i.toString());
120
+ }
121
+ });
122
+
123
+ it("should draw alternating numbers and letters for test_type B", async () => {
124
+ fillTextCalls = [];
125
+
126
+ await startTimeline([
127
+ {
128
+ type: jsPsychTrailMaking,
129
+ test_type: "B",
130
+ num_targets: 6,
131
+ },
132
+ ]);
133
+
134
+ // Should have labels 1, A, 2, B, 3, C
135
+ const labels = fillTextCalls.map((call) => call.text);
136
+ expect(labels).toContain("1");
137
+ expect(labels).toContain("A");
138
+ expect(labels).toContain("2");
139
+ expect(labels).toContain("B");
140
+ expect(labels).toContain("3");
141
+ expect(labels).toContain("C");
142
+ });
143
+
144
+ it("should use custom targets when provided", async () => {
145
+ fillTextCalls = [];
146
+
147
+ const customTargets = [
148
+ { x: 100, y: 100, label: "1" },
149
+ { x: 200, y: 200, label: "2" },
150
+ { x: 300, y: 300, label: "3" },
151
+ ];
152
+
153
+ await startTimeline([
154
+ {
155
+ type: jsPsychTrailMaking,
156
+ targets: customTargets,
157
+ },
158
+ ]);
159
+
160
+ // Should draw labels at specified positions
161
+ const labels = fillTextCalls.map((call) => call.text);
162
+ expect(labels).toContain("1");
163
+ expect(labels).toContain("2");
164
+ expect(labels).toContain("3");
165
+
166
+ // Check that targets are drawn at correct positions
167
+ const label1Call = fillTextCalls.find((call) => call.text === "1");
168
+ expect(label1Call?.x).toBe(100);
169
+ expect(label1Call?.y).toBe(100);
170
+ });
171
+
172
+ it("should complete when all targets are clicked in order", async () => {
173
+ const customTargets = [
174
+ { x: 100, y: 100, label: "1" },
175
+ { x: 200, y: 200, label: "2" },
176
+ { x: 300, y: 300, label: "3" },
177
+ ];
178
+
179
+ const { expectFinished, getData, displayElement } = await startTimeline([
180
+ {
181
+ type: jsPsychTrailMaking,
182
+ targets: customTargets,
183
+ test_type: "A",
184
+ },
185
+ ]);
186
+
187
+ const canvas = displayElement.querySelector("canvas")!;
188
+
189
+ // Simulate clicking targets in correct order
190
+ for (const target of customTargets) {
191
+ const clickEvent = new MouseEvent("click", {
192
+ clientX: target.x,
193
+ clientY: target.y,
194
+ bubbles: true,
195
+ });
196
+ // Need to mock getBoundingClientRect
197
+ canvas.getBoundingClientRect = jest.fn(() => ({
198
+ left: 0,
199
+ top: 0,
200
+ right: 600,
201
+ bottom: 600,
202
+ width: 600,
203
+ height: 600,
204
+ x: 0,
205
+ y: 0,
206
+ toJSON: () => {},
207
+ }));
208
+ canvas.dispatchEvent(clickEvent);
209
+ }
210
+
211
+ await expectFinished();
212
+
213
+ const data = getData().values()[0];
214
+ expect(data.test_type).toBe("A");
215
+ expect(data.num_errors).toBe(0);
216
+ expect(data.completion_time).toBeGreaterThanOrEqual(0);
217
+ });
218
+
219
+ it("should track errors for incorrect clicks", async () => {
220
+ const customTargets = [
221
+ { x: 100, y: 100, label: "1" },
222
+ { x: 200, y: 200, label: "2" },
223
+ { x: 300, y: 300, label: "3" },
224
+ ];
225
+
226
+ const { expectFinished, getData, displayElement } = await startTimeline([
227
+ {
228
+ type: jsPsychTrailMaking,
229
+ targets: customTargets,
230
+ test_type: "A",
231
+ error_duration: 100,
232
+ },
233
+ ]);
234
+
235
+ const canvas = displayElement.querySelector("canvas")!;
236
+ canvas.getBoundingClientRect = jest.fn(() => ({
237
+ left: 0,
238
+ top: 0,
239
+ right: 600,
240
+ bottom: 600,
241
+ width: 600,
242
+ height: 600,
243
+ x: 0,
244
+ y: 0,
245
+ toJSON: () => {},
246
+ }));
247
+
248
+ // Click target 2 first (wrong order)
249
+ canvas.dispatchEvent(
250
+ new MouseEvent("click", {
251
+ clientX: 200,
252
+ clientY: 200,
253
+ bubbles: true,
254
+ })
255
+ );
256
+
257
+ // Wait for error duration
258
+ jest.advanceTimersByTime(100);
259
+
260
+ // Now click in correct order
261
+ canvas.dispatchEvent(
262
+ new MouseEvent("click", {
263
+ clientX: 100,
264
+ clientY: 100,
265
+ bubbles: true,
266
+ })
267
+ );
268
+ canvas.dispatchEvent(
269
+ new MouseEvent("click", {
270
+ clientX: 200,
271
+ clientY: 200,
272
+ bubbles: true,
273
+ })
274
+ );
275
+ canvas.dispatchEvent(
276
+ new MouseEvent("click", {
277
+ clientX: 300,
278
+ clientY: 300,
279
+ bubbles: true,
280
+ })
281
+ );
282
+
283
+ await expectFinished();
284
+
285
+ const data = getData().values()[0];
286
+ expect(data.num_errors).toBe(1);
287
+ });
288
+
289
+ it("should use seed for reproducible layouts", async () => {
290
+ const seed = 42;
291
+
292
+ const { displayElement: display1 } = await startTimeline([
293
+ {
294
+ type: jsPsychTrailMaking,
295
+ num_targets: 5,
296
+ seed: seed,
297
+ },
298
+ ]);
299
+
300
+ // The canvas should be created with the seeded layout
301
+ const canvas1 = display1.querySelector("canvas");
302
+ expect(canvas1).not.toBeNull();
303
+ });
304
+
305
+ it("should record inter-click times between correct clicks", async () => {
306
+ const customTargets = [
307
+ { x: 100, y: 100, label: "1" },
308
+ { x: 200, y: 200, label: "2" },
309
+ { x: 300, y: 300, label: "3" },
310
+ ];
311
+
312
+ const { expectFinished, getData, displayElement } = await startTimeline([
313
+ {
314
+ type: jsPsychTrailMaking,
315
+ targets: customTargets,
316
+ },
317
+ ]);
318
+
319
+ const canvas = displayElement.querySelector("canvas")!;
320
+ canvas.getBoundingClientRect = jest.fn(() => ({
321
+ left: 0,
322
+ top: 0,
323
+ right: 600,
324
+ bottom: 600,
325
+ width: 600,
326
+ height: 600,
327
+ x: 0,
328
+ y: 0,
329
+ toJSON: () => {},
330
+ }));
331
+
332
+ // Click with time delays
333
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 100, clientY: 100, bubbles: true }));
334
+ jest.advanceTimersByTime(500);
335
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 200, clientY: 200, bubbles: true }));
336
+ jest.advanceTimersByTime(300);
337
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 300, clientY: 300, bubbles: true }));
338
+
339
+ await expectFinished();
340
+
341
+ const data = getData().values()[0];
342
+ expect(data.inter_click_times.length).toBe(2);
343
+ // First inter-click time should be around 500ms
344
+ expect(data.inter_click_times[0]).toBeGreaterThanOrEqual(500);
345
+ // Second should be around 300ms
346
+ expect(data.inter_click_times[1]).toBeGreaterThanOrEqual(300);
347
+ });
348
+
349
+ it("should calculate total path distance correctly", async () => {
350
+ const customTargets = [
351
+ { x: 0, y: 0, label: "1" },
352
+ { x: 100, y: 0, label: "2" },
353
+ { x: 100, y: 100, label: "3" },
354
+ ];
355
+
356
+ const { expectFinished, getData, displayElement } = await startTimeline([
357
+ {
358
+ type: jsPsychTrailMaking,
359
+ targets: customTargets,
360
+ target_radius: 50, // Large radius to ensure hits
361
+ },
362
+ ]);
363
+
364
+ const canvas = displayElement.querySelector("canvas")!;
365
+ canvas.getBoundingClientRect = jest.fn(() => ({
366
+ left: 0,
367
+ top: 0,
368
+ right: 600,
369
+ bottom: 600,
370
+ width: 600,
371
+ height: 600,
372
+ x: 0,
373
+ y: 0,
374
+ toJSON: () => {},
375
+ }));
376
+
377
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 0, clientY: 0, bubbles: true }));
378
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 100, clientY: 0, bubbles: true }));
379
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 100, clientY: 100, bubbles: true }));
380
+
381
+ await expectFinished();
382
+
383
+ const data = getData().values()[0];
384
+ // Distance should be 100 + 100 = 200
385
+ expect(data.total_path_distance).toBe(200);
386
+ });
387
+
388
+ it("should record click details with correct/incorrect status", async () => {
389
+ const customTargets = [
390
+ { x: 100, y: 100, label: "1" },
391
+ { x: 200, y: 200, label: "2" },
392
+ ];
393
+
394
+ const { expectFinished, getData, displayElement } = await startTimeline([
395
+ {
396
+ type: jsPsychTrailMaking,
397
+ targets: customTargets,
398
+ },
399
+ ]);
400
+
401
+ const canvas = displayElement.querySelector("canvas")!;
402
+ canvas.getBoundingClientRect = jest.fn(() => ({
403
+ left: 0,
404
+ top: 0,
405
+ right: 600,
406
+ bottom: 600,
407
+ width: 600,
408
+ height: 600,
409
+ x: 0,
410
+ y: 0,
411
+ toJSON: () => {},
412
+ }));
413
+
414
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 100, clientY: 100, bubbles: true }));
415
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 200, clientY: 200, bubbles: true }));
416
+
417
+ await expectFinished();
418
+
419
+ const data = getData().values()[0];
420
+ expect(data.clicks.length).toBe(2);
421
+ expect(data.clicks[0].correct).toBe(true);
422
+ expect(data.clicks[0].label).toBe("1");
423
+ expect(data.clicks[1].correct).toBe(true);
424
+ expect(data.clicks[1].label).toBe("2");
425
+ });
426
+
427
+ it("should export targets in data", async () => {
428
+ const customTargets = [
429
+ { x: 100, y: 100, label: "1" },
430
+ { x: 200, y: 200, label: "2" },
431
+ ];
432
+
433
+ const { expectFinished, getData, displayElement } = await startTimeline([
434
+ {
435
+ type: jsPsychTrailMaking,
436
+ targets: customTargets,
437
+ },
438
+ ]);
439
+
440
+ const canvas = displayElement.querySelector("canvas")!;
441
+ canvas.getBoundingClientRect = jest.fn(() => ({
442
+ left: 0,
443
+ top: 0,
444
+ right: 600,
445
+ bottom: 600,
446
+ width: 600,
447
+ height: 600,
448
+ x: 0,
449
+ y: 0,
450
+ toJSON: () => {},
451
+ }));
452
+
453
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 100, clientY: 100, bubbles: true }));
454
+ canvas.dispatchEvent(new MouseEvent("click", { clientX: 200, clientY: 200, bubbles: true }));
455
+
456
+ await expectFinished();
457
+
458
+ const data = getData().values()[0];
459
+ expect(data.targets).toEqual(customTargets);
460
+ });
461
+
462
+ it("should use custom target colors", async () => {
463
+ arcCalls = [];
464
+
465
+ await startTimeline([
466
+ {
467
+ type: jsPsychTrailMaking,
468
+ target_color: "#ff0000",
469
+ visited_color: "#00ff00",
470
+ num_targets: 3,
471
+ },
472
+ ]);
473
+
474
+ // Verify that target circles are drawn with the specified color
475
+ const targetColorCalls = arcCalls.filter((call) => call.fillStyle === "#ff0000");
476
+ expect(targetColorCalls.length).toBeGreaterThan(0);
477
+ });
478
+
479
+ it("should respond to touch events", async () => {
480
+ const customTargets = [
481
+ { x: 100, y: 100, label: "1" },
482
+ { x: 200, y: 200, label: "2" },
483
+ ];
484
+
485
+ const { expectFinished, displayElement } = await startTimeline([
486
+ {
487
+ type: jsPsychTrailMaking,
488
+ targets: customTargets,
489
+ },
490
+ ]);
491
+
492
+ const canvas = displayElement.querySelector("canvas")!;
493
+ canvas.getBoundingClientRect = jest.fn(() => ({
494
+ left: 0,
495
+ top: 0,
496
+ right: 600,
497
+ bottom: 600,
498
+ width: 600,
499
+ height: 600,
500
+ x: 0,
501
+ y: 0,
502
+ toJSON: () => {},
503
+ }));
504
+
505
+ // Create mock touch events using TouchEvent with mocked changedTouches
506
+ const createTouchEvent = (clientX: number, clientY: number) => {
507
+ // Create TouchEvent with empty arrays, then override changedTouches
508
+ const touchEvent = new TouchEvent("touchend", { bubbles: true });
509
+ Object.defineProperty(touchEvent, "changedTouches", {
510
+ value: [{ clientX, clientY }],
511
+ writable: false,
512
+ });
513
+ return touchEvent;
514
+ };
515
+
516
+ canvas.dispatchEvent(createTouchEvent(100, 100));
517
+ canvas.dispatchEvent(createTouchEvent(200, 200));
518
+
519
+ await expectFinished();
520
+ });
521
+ });