Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ npm-debug.log*
*.log
*.swp
*.stackdump
types/generated/
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,21 @@ measurement rather than a stricter one, and every reference image this package
has ever compared was captured against white. `checkerboard: true` opts a
fixture in once its image has been re-validated.

## Types

The package ships type declarations, generated from the JSDoc in `src` by
`npm run types` so they cannot drift from the implementation. Chart instances
are typed as chart.js's own `Chart`, through a type-only import — chart.js is
not a runtime dependency of this package, it is injected into `setup()`.

The matchers are declared as an augmentation of Vitest's `Matchers` interface,
so `expect(chart).toEqualImageData(...)` typechecks once the package is
imported anywhere in the project. That part is hand-written in
`types/entry.d.ts`, since a declaration emit cannot produce it; the mock
context's recorded methods are declared there too, because the mock assigns
them in a loop. A unit test compares both lists against the implementation, so
they cannot fall behind it silently.

## Node

`createMockContext()` records the calls a chart makes to a 2d context, and works
Expand All @@ -182,6 +197,7 @@ ctx.getCalls(); // [{name: 'fillRect', args: [1, 2, 3, 4]}]
npm run lint # biome check
npm run format # biome check --write
npm run typecheck # the Vitest configs, through tsconfig.tooling.json
npm run types # emit the declarations into types/generated
npm test # lint, typecheck, node specs, browser specs
npm run dev # the browser suite in watch mode
npm run fixtures:update # rewrite reference images from a Chromium render
Expand Down
11 changes: 10 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
// (2-space indent, single quotes, semicolons, no spacing inside braces),
// so that adopting the formatter is not also a restyling of the sources.
"files": {
"includes": ["**/*.js", "**/*.mjs", "**/*.ts", "**/*.json", "**/*.jsonc", "!package-lock.json"]
"includes": [
"**/*.js",
"**/*.mjs",
"**/*.ts",
"**/*.json",
"**/*.jsonc",
"!package-lock.json",
// Emitted from the JSDoc by `npm run types`, not edited by hand.
"!types/generated"
]
},
"formatter": {
"enabled": true,
Expand Down
29 changes: 24 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,37 @@
"main": "./src/index.js",
"module": "./src/index.js",
"exports": {
".": "./src/index.js",
"./node": "./src/node.js",
".": {
"types": "./types/entry.d.ts",
"default": "./src/index.js"
},
"./node": {
"types": "./types/generated/node.d.ts",
"default": "./src/node.js"
},
"./package.json": "./package.json"
},
"scripts": {
"dev": "vitest --config vitest.browser.config.ts",
"fixtures:update": "node scripts/update-fixtures.mjs",
"format": "biome check --write",
"lint": "biome check",
"test": "npm run lint && npm run typecheck && npm run test:unit && npm run test:browser",
"prepack": "npm run types",
"pretypes": "node -e \"require('node:fs').rmSync('types/generated', {recursive: true, force: true})\"",
"test": "npm run lint && npm run typecheck && npm run test:types && npm run test:unit && npm run test:browser",
"test:browser": "vitest run --config vitest.browser.config.ts",
"test:types": "npm run types && tsc --noEmit -p test/types/tsconfig.json",
"test:unit": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.tooling.json"
"typecheck": "tsc --noEmit -p tsconfig.tooling.json",
"types": "tsc -p tsconfig.types.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/chartjs/chartjs-test-utils.git"
},
"files": [
"src/*.js"
"src/*.js",
"types/**/*.d.ts"
],
"keywords": [
"chart.js",
Expand Down Expand Up @@ -61,5 +72,13 @@
"@vitest/browser": {
"optional": true
}
},
"types": "./types/entry.d.ts",
"typesVersions": {
"*": {
"node": [
"./types/generated/node.d.ts"
]
}
}
}
18 changes: 18 additions & 0 deletions src/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,31 @@
* instead of taking a callback.
*/

/**
* @param {number} width
* @param {number} height
* @returns {HTMLCanvasElement}
*/
export function createCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.height = height;
canvas.width = width;
return canvas;
}

/**
* @param {number} width
* @param {number} height
* @returns {ImageData} blank image data of the given size
*/
export function createImageData(width, height) {
return createCanvas(width, height).getContext('2d').getImageData(0, 0, width, height);
}

/**
* @param {ImageData} data
* @returns {HTMLCanvasElement} a canvas with the image data drawn on it
*/
export function canvasFromImageData(data) {
const canvas = createCanvas(data.width, data.height);
canvas.getContext('2d').putImageData(data, 0, 0);
Expand All @@ -42,6 +56,10 @@ export function readImageData(url) {
});
}

/**
* Appends a stylesheet to the document.
* @param {string} css
*/
export function injectCSS(css) {
// https://stackoverflow.com/q/3922139
const style = document.createElement('style');
Expand Down
63 changes: 52 additions & 11 deletions src/chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,36 @@
*/
import {spritingOff, spritingOn} from './spriting.js';

/**
* The chart.js types are used type-only: the constructor itself is injected
* through `setup({Chart})`, so chart.js is not a runtime dependency here. A
* consumer of this package always has chart.js -- that is what it is for.
* @typedef {import('chart.js').Chart} ChartInstance
* @typedef {typeof import('chart.js').Chart} ChartConstructor
* @typedef {object} AcquireOptions
* @property {object} [canvas] - Canvas attributes.
* @property {object} [wrapper] - Canvas wrapper attributes.
* @property {boolean} [useOffscreenCanvas] - use an OffscreenCanvas instead of the normal HTMLCanvasElement.
* @property {boolean} [useShadowDOM] - use shadowDom.
* @property {boolean} [spriteText] - draw text from a bitmap sprite sheet.
* @property {boolean} [persistent] - If true, the chart will not be released after the spec.
* @typedef {{skip: (reason?: string) => void}} TestContext
*/

// Every chart acquired by a spec, so they can all be released afterwards.
const charts = {};

let Chart;

/**
* Registers the Chart.js constructor used to build charts. Called by `setup()`.
* @param {Function} chartConstructor - the `Chart` export of chart.js
* @param {ChartConstructor} chartConstructor - the `Chart` export of chart.js
*/
export function useChart(chartConstructor) {
Chart = chartConstructor;
}

/** @returns {ChartConstructor} the registered Chart.js constructor */
export function getChart() {
return Chart;
}
Expand Down Expand Up @@ -65,14 +82,9 @@ function acquireContext(canvas, options) {
* Injects a new canvas (and div wrapper) and creates the associated Chart instance
* using the given config. Additional options allow tweaking elements generation.
* @param {object} [config] - Chart config.
* @param {object} [options] - Chart acquisition options.
* @param {object} [options.canvas] - Canvas attributes.
* @param {object} [options.wrapper] - Canvas wrapper attributes.
* @param {boolean} [options.useOffscreenCanvas] - use an OffscreenCanvas instead of the normal HTMLCanvasElement.
* @param {boolean} [options.useShadowDOM] - use shadowDom
* @param {boolean} [options.spriteText] - draw text from a bitmap sprite sheet.
* @param {boolean} [options.persistent] - If true, the chart will not be released after the spec.
* @param {object} [ctx] - Vitest test context, required by options that may be unsupported.
* @param {AcquireOptions} [options] - Chart acquisition options.
* @param {TestContext} [ctx] - Vitest test context, required by options that may be unsupported.
* @returns {ChartInstance} the chart
*/
export function buildChart(config = {}, options = {}, ctx) {
if (!Chart) {
Expand Down Expand Up @@ -116,6 +128,7 @@ export function buildChart(config = {}, options = {}, ctx) {
return chart;
}

/** @param {ChartInstance} chart */
export function destroyChart(chart) {
spritingOff(chart.ctx);
chart.destroy();
Expand All @@ -124,12 +137,23 @@ export function destroyChart(chart) {
wrapper?.parentNode?.removeChild(wrapper);
}

/**
* Builds a chart and registers it for release after the spec.
* @param {object} [config] - Chart config.
* @param {AcquireOptions} [options] - Chart acquisition options.
* @param {TestContext} [ctx] - Vitest test context, required by options that may be unsupported.
* @returns {ChartInstance} the chart
*/
export function acquireChart(config, options, ctx) {
const chart = buildChart(config, options, ctx);
charts[chart.id] = chart;
return chart;
}

/**
* Destroys a chart and removes it from the registry.
* @param {ChartInstance} chart
*/
export function releaseChart(chart) {
destroyChart(chart);
delete charts[chart.id];
Expand All @@ -146,7 +170,12 @@ export function releaseCharts() {
}
}

/** Runs `callback` once the chart has handled an event of the given type. */
/**
* Runs `callback` once the chart has handled an event of the given type.
* @param {ChartInstance} chart
* @param {string} type - event type, e.g. `mousemove`
* @param {() => void} callback
*/
export function afterEvent(chart, type, callback) {
const override = chart._eventHandler;
chart._eventHandler = function (event) {
Expand All @@ -158,6 +187,11 @@ export function afterEvent(chart, type, callback) {
};
}

/**
* Runs `callback` after the chart's next resize.
* @param {ChartInstance} chart
* @param {() => void} callback
*/
export function waitForResize(chart, callback) {
const override = chart.resize;
chart.resize = function (...args) {
Expand All @@ -179,7 +213,14 @@ function resolveElementPoint(el) {
return {x: 0, y: 0};
}

/** Dispatches a mouse event at an element's position and awaits its handling. */
/**
* Dispatches a mouse event at an element's position and awaits its handling.
* @param {ChartInstance} chart
* @param {string} type - event type, e.g. `mousemove`
* @param {{x?: number, y?: number, getCenterPoint?: () => {x: number, y: number}}} [el]
* element to aim at; the chart's origin when omitted
* @returns {Promise<MouseEvent>} the dispatched event
*/
export async function triggerMouseEvent(chart, type, el) {
const node = chart.canvas;
const rect = node.getBoundingClientRect();
Expand Down
100 changes: 51 additions & 49 deletions src/context.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,53 @@
// Code from https://stackoverflow.com/questions/4406864/html-canvas-unit-testing

// The 2d context methods the mock records. Module scope, and exported, so the
// declared surface in types/entry.d.ts can be checked against it -- see
// test/unit/context.test.js.
export const mockContextMethods = {
arc: () => {},
arcTo: () => {},
beginPath: () => {},
bezierCurveTo: () => {},
clearRect: () => {},
clip: () => {},
closePath: () => {},
fill: () => {},
fillRect: () => {},
fillText: () => {},
strokeText: () => {},
lineTo: () => {},
measureText: (text) => {
// return the number of characters * fixed size
// Uses fake numbers for the bounding box
return text
? {
actualBoundingBoxAscent: 4,
actualBoundingBoxDescent: 8,
actualBoundingBoxLeft: 15,
actualBoundingBoxRight: 25,
width: text.length * 10
}
: {
actualBoundingBoxAscent: 0,
actualBoundingBoxDescent: 0,
actualBoundingBoxLeft: 0,
actualBoundingBoxRight: 0,
width: 0
};
},
moveTo: () => {},
quadraticCurveTo: () => {},
rect: () => {},
restore: () => {},
rotate: () => {},
save: () => {},
setLineDash: () => {},
stroke: () => {},
strokeRect: () => {},
setTransform: () => {},
translate: () => {}
};

export default class Context {
constructor() {
this._calls = []; // names/args of recorded calls
Expand Down Expand Up @@ -100,57 +149,10 @@ export default class Context {
});
}
_initMethods() {
// define methods to test here
// no way to introspect so we have to do some extra work :(
var methods = {
arc: () => {},
arcTo: () => {},
beginPath: () => {},
bezierCurveTo: () => {},
clearRect: () => {},
clip: () => {},
closePath: () => {},
fill: () => {},
fillRect: () => {},
fillText: () => {},
strokeText: () => {},
lineTo: () => {},
measureText: (text) => {
// return the number of characters * fixed size
// Uses fake numbers for the bounding box
return text
? {
actualBoundingBoxAscent: 4,
actualBoundingBoxDescent: 8,
actualBoundingBoxLeft: 15,
actualBoundingBoxRight: 25,
width: text.length * 10
}
: {
actualBoundingBoxAscent: 0,
actualBoundingBoxDescent: 0,
actualBoundingBoxLeft: 0,
actualBoundingBoxRight: 0,
width: 0
};
},
moveTo: () => {},
quadraticCurveTo: () => {},
rect: () => {},
restore: () => {},
rotate: () => {},
save: () => {},
setLineDash: () => {},
stroke: () => {},
strokeRect: () => {},
setTransform: () => {},
translate: () => {}
};

Object.keys(methods).forEach((name) => {
Object.keys(mockContextMethods).forEach((name) => {
this[name] = (...args) => {
this.record(name, args);
return methods[name].apply(this, args);
return mockContextMethods[name].apply(this, args);
};
});
}
Expand Down
Loading