web based infinite canvas
at main 520 lines 17 kB view raw
1import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons } from "inkfinite-core"; 2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3import { createInputAdapter, InputAdapter, type PointerState } from "../input"; 4 5/** 6 * Create a mock canvas element with getBoundingClientRect 7 */ 8function createMockCanvas(): HTMLCanvasElement { 9 const canvas = document.createElement("canvas"); 10 canvas.width = 800; 11 canvas.height = 600; 12 13 vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({ 14 left: 0, 15 top: 0, 16 right: 800, 17 bottom: 600, 18 width: 800, 19 height: 600, 20 x: 0, 21 y: 0, 22 toJSON: () => ({}), 23 }); 24 25 return canvas; 26} 27 28/** 29 * Create a mock pointer event 30 */ 31function createPointerEvent( 32 type: string, 33 options: { 34 clientX?: number; 35 clientY?: number; 36 button?: number; 37 buttons?: number; 38 ctrlKey?: boolean; 39 shiftKey?: boolean; 40 altKey?: boolean; 41 metaKey?: boolean; 42 } = {}, 43): PointerEvent { 44 return new PointerEvent(type, { 45 clientX: options.clientX ?? 0, 46 clientY: options.clientY ?? 0, 47 button: options.button ?? 0, 48 buttons: options.buttons ?? 0, 49 ctrlKey: options.ctrlKey ?? false, 50 shiftKey: options.shiftKey ?? false, 51 altKey: options.altKey ?? false, 52 metaKey: options.metaKey ?? false, 53 bubbles: true, 54 cancelable: true, 55 }); 56} 57 58/** 59 * Create a mock wheel event 60 */ 61function createWheelEvent( 62 options: { 63 clientX?: number; 64 clientY?: number; 65 deltaY?: number; 66 ctrlKey?: boolean; 67 shiftKey?: boolean; 68 altKey?: boolean; 69 metaKey?: boolean; 70 } = {}, 71): WheelEvent { 72 return new WheelEvent("wheel", { 73 clientX: options.clientX ?? 0, 74 clientY: options.clientY ?? 0, 75 deltaY: options.deltaY ?? 0, 76 ctrlKey: options.ctrlKey ?? false, 77 shiftKey: options.shiftKey ?? false, 78 altKey: options.altKey ?? false, 79 metaKey: options.metaKey ?? false, 80 bubbles: true, 81 cancelable: true, 82 }); 83} 84 85/** 86 * Create a mock keyboard event 87 */ 88function createKeyboardEvent( 89 type: string, 90 options: { 91 key?: string; 92 code?: string; 93 ctrlKey?: boolean; 94 shiftKey?: boolean; 95 altKey?: boolean; 96 metaKey?: boolean; 97 repeat?: boolean; 98 } = {}, 99): KeyboardEvent { 100 return new KeyboardEvent(type, { 101 key: options.key ?? "a", 102 code: options.code ?? "KeyA", 103 ctrlKey: options.ctrlKey ?? false, 104 shiftKey: options.shiftKey ?? false, 105 altKey: options.altKey ?? false, 106 metaKey: options.metaKey ?? false, 107 repeat: options.repeat ?? false, 108 bubbles: true, 109 cancelable: true, 110 }); 111} 112 113describe("InputAdapter", () => { 114 let canvas: HTMLCanvasElement; 115 let camera: Camera; 116 let actions: ActionType[]; 117 let adapter: InputAdapter; 118 119 beforeEach(() => { 120 canvas = createMockCanvas(); 121 camera = Camera.create(0, 0, 1); 122 actions = []; 123 124 adapter = new InputAdapter({ 125 canvas, 126 getCamera: () => camera, 127 getViewport: () => ({ width: 800, height: 600 }), 128 onAction: (action) => actions.push(action), 129 preventDefault: true, 130 captureKeyboard: true, 131 }); 132 }); 133 134 afterEach(() => { 135 adapter.dispose(); 136 }); 137 138 describe("constructor and disposal", () => { 139 it("should create adapter and attach event listeners", () => { 140 const onAction = vi.fn(); 141 const testAdapter = new InputAdapter({ 142 canvas, 143 getCamera: () => camera, 144 getViewport: () => ({ width: 800, height: 600 }), 145 onAction, 146 }); 147 148 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100 }); 149 canvas.dispatchEvent(event); 150 151 expect(onAction).toHaveBeenCalled(); 152 testAdapter.dispose(); 153 }); 154 155 it("should remove event listeners on dispose", () => { 156 const onAction = vi.fn(); 157 const testAdapter = new InputAdapter({ 158 canvas, 159 getCamera: () => camera, 160 getViewport: () => ({ width: 800, height: 600 }), 161 onAction, 162 }); 163 164 testAdapter.dispose(); 165 166 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100 }); 167 canvas.dispatchEvent(event); 168 169 expect(onAction).not.toHaveBeenCalled(); 170 }); 171 }); 172 173 describe("pointer events", () => { 174 it("should dispatch pointer down action", () => { 175 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 200, button: 0, buttons: 1 }); 176 canvas.dispatchEvent(event); 177 178 expect(actions).toHaveLength(1); 179 expect(actions[0].type).toBe("pointer-down"); 180 expect(actions[0]).toMatchObject({ screen: { x: 100, y: 200 }, button: 0 }); 181 }); 182 183 it("should dispatch pointer move action", () => { 184 const event = createPointerEvent("pointermove", { clientX: 150, clientY: 250, buttons: 1 }); 185 canvas.dispatchEvent(event); 186 187 expect(actions).toHaveLength(1); 188 expect(actions[0].type).toBe("pointer-move"); 189 expect(actions[0]).toMatchObject({ screen: { x: 150, y: 250 } }); 190 }); 191 192 it("should dispatch pointer up action", () => { 193 const event = createPointerEvent("pointerup", { clientX: 100, clientY: 200, button: 0, buttons: 0 }); 194 canvas.dispatchEvent(event); 195 196 expect(actions).toHaveLength(1); 197 expect(actions[0].type).toBe("pointer-up"); 198 expect(actions[0]).toMatchObject({ screen: { x: 100, y: 200 }, button: 0 }); 199 }); 200 201 it("should convert screen coordinates to world coordinates", () => { 202 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 203 canvas.dispatchEvent(event); 204 205 expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 0, y: 0 } }); 206 }); 207 208 it("should track pointer state", () => { 209 let state = adapter.getPointerState(); 210 expect(state.isDown).toBe(false); 211 expect(state.startWorld).toBe(null); 212 213 const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 }); 214 canvas.dispatchEvent(downEvent); 215 216 state = adapter.getPointerState(); 217 expect(state.isDown).toBe(true); 218 expect(state.startWorld).not.toBe(null); 219 expect(state.startScreen).toEqual({ x: 100, y: 100 }); 220 221 const upEvent = createPointerEvent("pointerup", { clientX: 150, clientY: 150, buttons: 0 }); 222 canvas.dispatchEvent(upEvent); 223 224 state = adapter.getPointerState(); 225 expect(state.isDown).toBe(false); 226 expect(state.startWorld).toBe(null); 227 }); 228 229 it("should handle modifier keys in pointer events", () => { 230 const event = createPointerEvent("pointerdown", { 231 clientX: 100, 232 clientY: 100, 233 buttons: 1, 234 ctrlKey: true, 235 shiftKey: false, 236 }); 237 canvas.dispatchEvent(event); 238 239 expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); 240 }); 241 242 it("should decode pointer buttons bitmask", () => { 243 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 5 }); 244 canvas.dispatchEvent(event); 245 246 expect(actions[0]).toMatchObject({ buttons: { left: true, middle: true, right: false } }); 247 }); 248 }); 249 250 describe("cursor updates", () => { 251 it("throttles onCursorUpdate via requestAnimationFrame", () => { 252 adapter.dispose(); 253 const rafCallbacks: FrameRequestCallback[] = []; 254 const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { 255 rafCallbacks.push(cb); 256 return rafCallbacks.length; 257 }); 258 const cancelSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); 259 const onCursorUpdate = vi.fn(); 260 261 adapter = new InputAdapter({ 262 canvas, 263 getCamera: () => camera, 264 getViewport: () => ({ width: 800, height: 600 }), 265 onAction: (action) => actions.push(action), 266 onCursorUpdate, 267 }); 268 269 canvas.dispatchEvent(createPointerEvent("pointermove", { clientX: 10, clientY: 20 })); 270 canvas.dispatchEvent(createPointerEvent("pointermove", { clientX: 50, clientY: 60 })); 271 272 expect(onCursorUpdate).not.toHaveBeenCalled(); 273 expect(rafCallbacks).toHaveLength(1); 274 275 rafCallbacks[0](16); 276 277 expect(onCursorUpdate).toHaveBeenCalledTimes(1); 278 expect(onCursorUpdate).toHaveBeenCalledWith({ x: -350, y: -240 }, { x: 50, y: 60 }); 279 280 rafSpy.mockRestore(); 281 cancelSpy.mockRestore(); 282 }); 283 }); 284 285 describe("wheel events", () => { 286 it("should dispatch wheel action", () => { 287 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: -100 }); 288 canvas.dispatchEvent(event); 289 290 expect(actions).toHaveLength(1); 291 expect(actions[0].type).toBe("wheel"); 292 expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, deltaY: -100 }); 293 }); 294 295 it("should include modifiers in wheel events", () => { 296 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 100, ctrlKey: true }); 297 canvas.dispatchEvent(event); 298 299 expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); 300 }); 301 }); 302 303 describe("keyboard events", () => { 304 it("should dispatch key down action", () => { 305 const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA" }); 306 window.dispatchEvent(event); 307 308 expect(actions).toHaveLength(1); 309 expect(actions[0].type).toBe("key-down"); 310 expect(actions[0]).toMatchObject({ key: "a", code: "KeyA", repeat: false }); 311 }); 312 313 it("should dispatch key up action", () => { 314 const event = createKeyboardEvent("keyup", { key: "b", code: "KeyB" }); 315 window.dispatchEvent(event); 316 317 expect(actions).toHaveLength(1); 318 expect(actions[0].type).toBe("key-up"); 319 expect(actions[0]).toMatchObject({ key: "b", code: "KeyB" }); 320 }); 321 322 it("should handle repeat key events", () => { 323 const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA", repeat: true }); 324 window.dispatchEvent(event); 325 326 expect(actions[0]).toMatchObject({ repeat: true }); 327 }); 328 329 it("should include modifiers in keyboard events", () => { 330 const event = createKeyboardEvent("keydown", { key: "z", code: "KeyZ", ctrlKey: true }); 331 window.dispatchEvent(event); 332 333 expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); 334 }); 335 336 it("should not capture keyboard events when captureKeyboard is false", () => { 337 const testActions: ActionType[] = []; 338 const testAdapter = new InputAdapter({ 339 canvas, 340 getCamera: () => camera, 341 getViewport: () => ({ width: 800, height: 600 }), 342 onAction: (action) => testActions.push(action), 343 captureKeyboard: false, 344 }); 345 346 const event = createKeyboardEvent("keydown", { key: "a" }); 347 window.dispatchEvent(event); 348 349 expect(testActions).toHaveLength(0); 350 testAdapter.dispose(); 351 }); 352 353 it("should ignore keyboard events from input elements", () => { 354 const input = document.createElement("input"); 355 document.body.appendChild(input); 356 357 const event = new KeyboardEvent("keydown", { key: "a", code: "KeyA", bubbles: true, cancelable: true }); 358 359 Object.defineProperty(event, "target", { value: input, enumerable: true }); 360 window.dispatchEvent(event); 361 362 expect(actions).toHaveLength(0); 363 364 document.body.removeChild(input); 365 }); 366 }); 367 368 describe("coordinate transformation", () => { 369 it("should handle camera panning", () => { 370 camera = Camera.create(100, 50, 1); 371 372 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 373 canvas.dispatchEvent(event); 374 375 expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 100, y: 50 } }); 376 }); 377 378 it("should handle camera zoom", () => { 379 camera = Camera.create(0, 0, 2); 380 381 const event = createPointerEvent("pointerdown", { clientX: 500, clientY: 300, buttons: 1 }); 382 canvas.dispatchEvent(event); 383 expect(actions[0]).toMatchObject({ screen: { x: 500, y: 300 }, world: { x: 50, y: 0 } }); 384 }); 385 386 it("should handle combined camera transform", () => { 387 camera = Camera.create(200, 100, 0.5); 388 389 const event = createPointerEvent("pointerdown", { clientX: 600, clientY: 450, buttons: 1 }); 390 canvas.dispatchEvent(event); 391 392 expect(actions[0]).toMatchObject({ screen: { x: 600, y: 450 }, world: { x: 600, y: 400 } }); 393 }); 394 }); 395 396 describe("edge cases", () => { 397 it("should handle pointer events at canvas edges", () => { 398 let event = createPointerEvent("pointerdown", { clientX: 0, clientY: 0, buttons: 1 }); 399 canvas.dispatchEvent(event); 400 expect(actions[0]).toMatchObject({ screen: { x: 0, y: 0 } }); 401 402 actions.length = 0; 403 404 event = createPointerEvent("pointerdown", { clientX: 800, clientY: 600, buttons: 1 }); 405 canvas.dispatchEvent(event); 406 expect(actions[0]).toMatchObject({ screen: { x: 800, y: 600 } }); 407 }); 408 409 it("should handle multiple pointer buttons simultaneously", () => { 410 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 2, buttons: 7 }); 411 canvas.dispatchEvent(event); 412 413 expect(actions[0]).toMatchObject({ button: 2, buttons: { left: true, middle: true, right: true } }); 414 }); 415 416 it("should handle zero deltaY in wheel events", () => { 417 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 0 }); 418 canvas.dispatchEvent(event); 419 420 expect(actions[0]).toMatchObject({ deltaY: 0 }); 421 }); 422 423 it("should handle special keys", () => { 424 const specialKeys = [ 425 { key: "Escape", code: "Escape" }, 426 { key: "Enter", code: "Enter" }, 427 { key: " ", code: "Space" }, 428 { key: "ArrowUp", code: "ArrowUp" }, 429 { key: "Tab", code: "Tab" }, 430 ]; 431 432 specialKeys.forEach(({ key, code }) => { 433 actions.length = 0; 434 const event = createKeyboardEvent("keydown", { key, code }); 435 window.dispatchEvent(event); 436 437 expect(actions[0]).toMatchObject({ key, code }); 438 }); 439 }); 440 441 it("should handle rapid pointer move events", () => { 442 for (let i = 0; i < 100; i++) { 443 const event = createPointerEvent("pointermove", { clientX: 100 + i, clientY: 100 + i, buttons: 1 }); 444 canvas.dispatchEvent(event); 445 } 446 447 expect(actions).toHaveLength(100); 448 expect(actions.every((action) => action.type === "pointer-move")).toBe(true); 449 }); 450 451 it("should update pointer state on each move", () => { 452 const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 }); 453 canvas.dispatchEvent(downEvent); 454 455 const moveEvent = createPointerEvent("pointermove", { clientX: 200, clientY: 200, buttons: 1 }); 456 canvas.dispatchEvent(moveEvent); 457 458 const state = adapter.getPointerState(); 459 expect(state.lastScreen).toEqual({ x: 200, y: 200 }); 460 expect(state.startScreen).toEqual({ x: 100, y: 100 }); 461 }); 462 463 it("should preserve start position until all buttons released", () => { 464 const down1 = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 1 }); 465 canvas.dispatchEvent(down1); 466 467 let state = adapter.getPointerState(); 468 expect(state.startScreen).toEqual({ x: 100, y: 100 }); 469 470 const down2 = createPointerEvent("pointerdown", { clientX: 150, clientY: 150, button: 1, buttons: 5 }); 471 canvas.dispatchEvent(down2); 472 473 const up1 = createPointerEvent("pointerup", { clientX: 200, clientY: 200, button: 0, buttons: 4 }); 474 canvas.dispatchEvent(up1); 475 476 state = adapter.getPointerState(); 477 expect(state.startScreen).not.toBe(null); 478 479 const up2 = createPointerEvent("pointerup", { clientX: 250, clientY: 250, button: 1, buttons: 0 }); 480 canvas.dispatchEvent(up2); 481 482 state = adapter.getPointerState(); 483 expect(state.startScreen).toBe(null); 484 }); 485 486 it("should handle negative coordinates from camera transform", () => { 487 camera = Camera.create(-1000, -1000, 1); 488 489 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 490 canvas.dispatchEvent(event); 491 492 expect(actions[0]).toMatchObject({ world: { x: -1000, y: -1000 } }); 493 }); 494 495 it("should handle very large coordinates", () => { 496 camera = Camera.create(1e10, 1e10, 0.001); 497 498 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 499 canvas.dispatchEvent(event); 500 501 const action = actions[0] as { world: { x: number; y: number } }; 502 expect(action.world.x).toBeCloseTo(1e10, -5); 503 expect(action.world.y).toBeCloseTo(1e10, -5); 504 }); 505 }); 506 507 describe("createInputAdapter", () => { 508 it("should create and return an InputAdapter instance", () => { 509 const testAdapter = createInputAdapter({ 510 canvas, 511 getCamera: () => camera, 512 getViewport: () => ({ width: 800, height: 600 }), 513 onAction: (action) => actions.push(action), 514 }); 515 516 expect(testAdapter).toBeInstanceOf(InputAdapter); 517 testAdapter.dispose(); 518 }); 519 }); 520});