From fa4657917a32764b536209b5e7ed2780f7c9510b Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 19 Apr 2026 10:13:21 +0100 Subject: [PATCH 1/5] Use the new canvas renderer --- src/js/components/canvas.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/js/components/canvas.js b/src/js/components/canvas.js index cf8cd4f..43dfb0b 100644 --- a/src/js/components/canvas.js +++ b/src/js/components/canvas.js @@ -1,5 +1,6 @@ import "../../css/Canvas.css"; import React, { Component } from "react"; +import { CanvasRenderer } from '@design-core/core'; const SNAP_OVERRIDES = [ { label: 'None', type: 'none' }, @@ -25,6 +26,9 @@ export default class Canvas extends Component{ componentDidMount() { + // set the renderer + this.props.core.canvas.setRenderer(CanvasRenderer); + // set the paint callback this.props.core.canvas.setExternalPaintCallbackFunction(this.paint.bind(this)) @@ -45,6 +49,7 @@ export default class Canvas extends Component{ componentDidUpdate(prevProps) { if (prevProps.core !== this.props.core) { prevProps.core.canvas.setCursorCallbackFunction(undefined); + this.props.core.canvas.setRenderer(CanvasRenderer); this.props.core.canvas.setExternalPaintCallbackFunction(this.paint.bind(this)); this.props.core.canvas.setCursorCallbackFunction(this.boundOnCursorChange); this.paint(); From 9b65c2b0f8faf4d595243ddc8cc84d392324c455 Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 19 Apr 2026 10:20:23 +0100 Subject: [PATCH 2/5] clear callbacks with update and unmount --- src/js/components/canvas.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/js/components/canvas.js b/src/js/components/canvas.js index 43dfb0b..fa28571 100644 --- a/src/js/components/canvas.js +++ b/src/js/components/canvas.js @@ -21,6 +21,7 @@ export default class Canvas extends Component{ this.canvasRef = React.createRef(); this.boundHandleKeyPress = this.handleKeyPress.bind(this) this.boundOnCursorChange = this.onCursorChange.bind(this) + this.boundPaint = this.paint.bind(this) this.state = { contextMenu: null, submenu: null }; } @@ -30,7 +31,7 @@ export default class Canvas extends Component{ this.props.core.canvas.setRenderer(CanvasRenderer); // set the paint callback - this.props.core.canvas.setExternalPaintCallbackFunction(this.paint.bind(this)) + this.props.core.canvas.setExternalPaintCallbackFunction(this.boundPaint) // set the cursor callback this.props.core.canvas.setCursorCallbackFunction(this.boundOnCursorChange) @@ -48,17 +49,19 @@ export default class Canvas extends Component{ componentDidUpdate(prevProps) { if (prevProps.core !== this.props.core) { + prevProps.core.canvas.setExternalPaintCallbackFunction(undefined); prevProps.core.canvas.setCursorCallbackFunction(undefined); this.props.core.canvas.setRenderer(CanvasRenderer); - this.props.core.canvas.setExternalPaintCallbackFunction(this.paint.bind(this)); + this.props.core.canvas.setExternalPaintCallbackFunction(this.boundPaint); this.props.core.canvas.setCursorCallbackFunction(this.boundOnCursorChange); - this.paint(); + this.boundPaint(); } } componentWillUnmount() { document.removeEventListener("keydown", this.boundHandleKeyPress) this.resizeObserver.disconnect(); + this.props.core.canvas.setExternalPaintCallbackFunction(undefined); this.props.core.canvas.setCursorCallbackFunction(undefined); } From 26e4165ab940570874ce7655e1157fdd088fc634 Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 19 Apr 2026 10:22:35 +0100 Subject: [PATCH 3/5] clear renderer --- src/js/components/canvas.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/components/canvas.js b/src/js/components/canvas.js index fa28571..322b854 100644 --- a/src/js/components/canvas.js +++ b/src/js/components/canvas.js @@ -51,6 +51,7 @@ export default class Canvas extends Component{ if (prevProps.core !== this.props.core) { prevProps.core.canvas.setExternalPaintCallbackFunction(undefined); prevProps.core.canvas.setCursorCallbackFunction(undefined); + prevProps.core.canvas.setRenderer(null); this.props.core.canvas.setRenderer(CanvasRenderer); this.props.core.canvas.setExternalPaintCallbackFunction(this.boundPaint); this.props.core.canvas.setCursorCallbackFunction(this.boundOnCursorChange); From 1305fd84c4d997905b33581a006ef8a74844863d Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 19 Apr 2026 10:53:06 +0100 Subject: [PATCH 4/5] Add canvas tests --- package.json | 3 +- src/setupTests.js | 4 + src/tests/Canvas.test.js | 322 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/tests/Canvas.test.js diff --git a/package.json b/package.json index c43b208..5eb21fd 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "eslint": "^10.2.0", "eslint-config-google": "^0.14.0", "gh-pages": "^6.3.0", + "jest-canvas-mock": "^2.5.2", "serve": "^14.2.6" } -} \ No newline at end of file +} diff --git a/src/setupTests.js b/src/setupTests.js index 7b0828b..45943c2 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1 +1,5 @@ import '@testing-library/jest-dom'; +import 'jest-canvas-mock'; + +// jsdom does not provide ResizeObserver +global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; diff --git a/src/tests/Canvas.test.js b/src/tests/Canvas.test.js new file mode 100644 index 0000000..a254197 --- /dev/null +++ b/src/tests/Canvas.test.js @@ -0,0 +1,322 @@ +import { act, render, fireEvent } from '@testing-library/react'; +import React from 'react'; + +jest.mock('@design-core/core', () => ({ CanvasRenderer: class MockCanvasRenderer {} })); + +import Canvas from '../js/components/canvas'; +import { CanvasRenderer } from '@design-core/core'; + +// paint() accesses cr.canvas.width which jest-canvas-mock does not wire in jsdom. +// Stub it out so lifecycle tests are not coupled to rendering internals. +beforeEach(() => { + jest.spyOn(Canvas.prototype, 'paint').mockImplementation(function () {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function makeCore() { + return { + canvas: { + setRenderer: jest.fn(), + setExternalPaintCallbackFunction: jest.fn(), + setCursorCallbackFunction: jest.fn(), + paint: jest.fn(), + zoomExtents: jest.fn(), + }, + mouse: { + mouseMoved: jest.fn(), + mouseDown: jest.fn(), + mouseUp: jest.fn(), + wheel: jest.fn(), + positionString: jest.fn(() => '0, 0'), + }, + commandLine: { handleKeys: jest.fn() }, + scene: { + inputManager: { + activeCommand: undefined, + onCommand: jest.fn(), + snapping: { setSnapOverride: jest.fn() }, + }, + selectionManager: { selectedItems: [], selectAll: jest.fn() }, + undo: jest.fn(), + redo: jest.fn(), + }, + clipboard: { isValid: false }, + settings: { getSetting: jest.fn(() => false), setSetting: jest.fn() }, + }; +} + +function defaultProps(core, overrides = {}) { + return { core, mousePosCallback: jest.fn(), ...overrides }; +} + +// ─── componentDidMount ──────────────────────────────────────────────────────── + +describe('Canvas — componentDidMount', () => { + test('calls setRenderer(CanvasRenderer) on mount', () => { + const core = makeCore(); + render(); + expect(core.canvas.setRenderer).toHaveBeenCalledWith(CanvasRenderer); + }); + + test('calls setExternalPaintCallbackFunction with a function on mount', () => { + const core = makeCore(); + render(); + expect(core.canvas.setExternalPaintCallbackFunction).toHaveBeenCalledWith(expect.any(Function)); + }); + + test('calls setCursorCallbackFunction with a function on mount', () => { + const core = makeCore(); + render(); + expect(core.canvas.setCursorCallbackFunction).toHaveBeenCalledWith(expect.any(Function)); + }); +}); + +// ─── componentWillUnmount ───────────────────────────────────────────────────── + +describe('Canvas — componentWillUnmount', () => { + test('clears paint callback on unmount', () => { + const core = makeCore(); + const { unmount } = render(); + core.canvas.setExternalPaintCallbackFunction.mockClear(); + unmount(); + expect(core.canvas.setExternalPaintCallbackFunction).toHaveBeenCalledWith(undefined); + }); + + test('clears cursor callback on unmount', () => { + const core = makeCore(); + const { unmount } = render(); + core.canvas.setCursorCallbackFunction.mockClear(); + unmount(); + expect(core.canvas.setCursorCallbackFunction).toHaveBeenCalledWith(undefined); + }); +}); + +// ─── componentDidUpdate — core swap ────────────────────────────────────────── + +describe('Canvas — core swap', () => { + test('clears paint callback on old core', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + core1.canvas.setExternalPaintCallbackFunction.mockClear(); + act(() => rerender()); + expect(core1.canvas.setExternalPaintCallbackFunction).toHaveBeenCalledWith(undefined); + }); + + test('clears cursor callback on old core', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + core1.canvas.setCursorCallbackFunction.mockClear(); + act(() => rerender()); + expect(core1.canvas.setCursorCallbackFunction).toHaveBeenCalledWith(undefined); + }); + + test('sets renderer to null on old core', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + core1.canvas.setRenderer.mockClear(); + act(() => rerender()); + expect(core1.canvas.setRenderer).toHaveBeenCalledWith(null); + }); + + test('sets CanvasRenderer on new core', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + act(() => rerender()); + expect(core2.canvas.setRenderer).toHaveBeenCalledWith(CanvasRenderer); + }); + + test('wires paint callback on new core', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + act(() => rerender()); + const calls = core2.canvas.setExternalPaintCallbackFunction.mock.calls; + const wiredCall = calls.find(([fn]) => fn !== undefined); + expect(wiredCall).toBeDefined(); + expect(typeof wiredCall[0]).toBe('function'); + }); + + test('wires cursor callback on new core', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + act(() => rerender()); + const calls = core2.canvas.setCursorCallbackFunction.mock.calls; + const wiredCall = calls.find(([fn]) => fn !== undefined); + expect(wiredCall).toBeDefined(); + }); + + test('passes the same boundPaint reference before and after core swap', () => { + const core1 = makeCore(); + const core2 = makeCore(); + const { rerender } = render(); + const paintBefore = core1.canvas.setExternalPaintCallbackFunction.mock.calls[0][0]; + act(() => rerender()); + const paintAfter = core2.canvas.setExternalPaintCallbackFunction.mock.calls.find( + ([fn]) => fn !== undefined, + )[0]; + expect(paintAfter).toBe(paintBefore); + }); + + test('does not re-wire when same core prop is re-rendered', () => { + const core = makeCore(); + const { rerender } = render(); + core.canvas.setRenderer.mockClear(); + core.canvas.setExternalPaintCallbackFunction.mockClear(); + act(() => rerender()); + expect(core.canvas.setRenderer).not.toHaveBeenCalled(); + expect(core.canvas.setExternalPaintCallbackFunction).not.toHaveBeenCalled(); + }); +}); + +// ─── mouse events ───────────────────────────────────────────────────────────── + +describe('Canvas — mouse events', () => { + test('mouseDown calls core.mouse.mouseDown for left button', () => { + const core = makeCore(); + const { container } = render(); + const canvas = container.querySelector('canvas'); + fireEvent.mouseDown(canvas, { button: 0 }); + expect(core.mouse.mouseDown).toHaveBeenCalledWith(0); + }); + + test('mouseDown does not call core.mouse.mouseDown for right button', () => { + const core = makeCore(); + const { container } = render(); + fireEvent.mouseDown(container.querySelector('canvas'), { button: 2 }); + expect(core.mouse.mouseDown).not.toHaveBeenCalled(); + }); + + test('mouseUp calls core.mouse.mouseUp for left button', () => { + const core = makeCore(); + const { container } = render(); + fireEvent.mouseUp(container.querySelector('canvas'), { button: 0 }); + expect(core.mouse.mouseUp).toHaveBeenCalledWith(0); + }); + + test('mouseUp does not call core.mouse.mouseUp for right button', () => { + const core = makeCore(); + const { container } = render(); + fireEvent.mouseUp(container.querySelector('canvas'), { button: 2 }); + expect(core.mouse.mouseUp).not.toHaveBeenCalled(); + }); + + test('wheel calls core.mouse.wheel with +1 for scroll up', () => { + const core = makeCore(); + const { container } = render(); + fireEvent.wheel(container.querySelector('canvas'), { deltaY: -100 }); + expect(core.mouse.wheel).toHaveBeenCalledWith(1); + }); + + test('wheel calls core.mouse.wheel with -1 for scroll down', () => { + const core = makeCore(); + const { container } = render(); + fireEvent.wheel(container.querySelector('canvas'), { deltaY: 100 }); + expect(core.mouse.wheel).toHaveBeenCalledWith(-1); + }); + + test('mouseMove calls core.mouse.mouseMoved and mousePosCallback', () => { + const core = makeCore(); + const mousePosCallback = jest.fn(); + const { container } = render(); + fireEvent.mouseMove(container.querySelector('canvas'), { clientX: 10, clientY: 20 }); + expect(core.mouse.mouseMoved).toHaveBeenCalled(); + expect(mousePosCallback).toHaveBeenCalled(); + }); +}); + +// ─── keyboard shortcuts ─────────────────────────────────────────────────────── + +describe('Canvas — keyboard shortcuts', () => { + test('Enter key calls commandLine.handleKeys("Enter")', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'Enter' }); + expect(core.commandLine.handleKeys).toHaveBeenCalledWith('Enter'); + }); + + test('Escape key calls commandLine.handleKeys("Escape")', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(core.commandLine.handleKeys).toHaveBeenCalledWith('Escape'); + }); + + test('Ctrl+Z calls scene.undo', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + expect(core.scene.undo).toHaveBeenCalled(); + }); + + test('Ctrl+Y calls scene.redo', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'y', ctrlKey: true }); + expect(core.scene.redo).toHaveBeenCalled(); + }); + + test('Ctrl+A calls selectionManager.selectAll', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'a', ctrlKey: true }); + expect(core.scene.selectionManager.selectAll).toHaveBeenCalled(); + }); + + test('Ctrl+S calls onSave prop', () => { + const core = makeCore(); + const onSave = jest.fn(); + render(); + fireEvent.keyDown(document, { key: 's', ctrlKey: true }); + expect(onSave).toHaveBeenCalled(); + }); + + test('Ctrl+Shift+S calls onSaveAs prop', () => { + const core = makeCore(); + const onSaveAs = jest.fn(); + render(); + fireEvent.keyDown(document, { key: 's', ctrlKey: true, shiftKey: true }); + expect(onSaveAs).toHaveBeenCalled(); + }); + + test('printable character is forwarded to commandLine.handleKeys', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'l' }); + expect(core.commandLine.handleKeys).toHaveBeenCalledWith('l'); + }); + + test('key events on INPUT elements are ignored', () => { + const core = makeCore(); + render(); + const input = document.createElement('input'); + document.body.appendChild(input); + fireEvent.keyDown(input, { key: 'z', ctrlKey: true, target: input }); + expect(core.scene.undo).not.toHaveBeenCalled(); + document.body.removeChild(input); + }); + + test('Alt key combinations are ignored', () => { + const core = makeCore(); + render(); + fireEvent.keyDown(document, { key: 'z', altKey: true }); + expect(core.scene.undo).not.toHaveBeenCalled(); + expect(core.commandLine.handleKeys).not.toHaveBeenCalled(); + }); + + test('keydown listener is removed on unmount', () => { + const core = makeCore(); + const { unmount } = render(); + unmount(); + core.scene.undo.mockClear(); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + expect(core.scene.undo).not.toHaveBeenCalled(); + }); +}); From 8024d20736ec9bb893d5773b727dfddc686063d1 Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 19 Apr 2026 10:55:32 +0100 Subject: [PATCH 5/5] Update package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5eb21fd..719943c 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "build": "REACT_APP_GIT_COMMIT=$(git rev-parse --short HEAD) REACT_APP_CORE_VERSION=$(node -p \"require('./node_modules/@design-core/core/package.json').version\") react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "link-core": "(cd ../Design/src/Design-Core && sudo npm link) && npm link @design-core/core", - "unlink-core": "npm unlink @design-core/core && npm install" + "link-core": "(cd ../Design/src/Design-Core && sudo npm link) && npm link --no-save @design-core/core", + "unlink-core": "npm unlink --no-save @design-core/core && npm install" }, "eslintConfig": { "extends": [