From 244c73548393bc3b09397e575e92030a9a450f91 Mon Sep 17 00:00:00 2001 From: Jukka Kurkela Date: Sat, 12 Sep 2026 20:10:40 +0300 Subject: [PATCH] feat: ship type declarations generated from the JSDoc Closes #16, open since 2021: a TypeScript consumer got "Could not find a declaration file for module 'chartjs-test-utils'" and every import was `any`. v1 publishes its sources rather than a bundle, which did not change that -- the sources are JavaScript. The declarations are emitted from the JSDoc by `npm run types` into `types/generated`, which `prepack` runs, so the published tarball always has declarations matching the published sources and nothing has to be maintained twice. Chart instances are typed as chart.js's own `Chart` through a type-only import; chart.js stays out of `dependencies`, because the constructor is injected into `setup()` and every consumer of this package has chart.js by definition. Two things a declaration emit cannot produce, so `types/entry.d.ts` declares them by hand: - **The matchers.** They live on Vitest's `expect`, not in this package's exports, so the entry augments Vitest's `Matchers` interface. That is what makes `expect(chart).toEqualImageData(...)` typecheck for a consumer. - **The mock context's methods.** `createMockContext()` assigns them in a loop, so the emit sees only `record`/`getCalls`/`resetCalls`. They are declared as `Pick`, so the signatures come from the DOM library rather than being retyped, with `measureText` spelled out because the mock returns a fixed subset of TextMetrics. Both hand-written lists are guarded: `src/context.js` now exports its method table, and two unit specs compare the names in `types/entry.d.ts` against the implementation -- verified by adding a method to the mock and watching them fail. This is the only new coupling, and it fails loudly rather than silently. `test/types/consumer.ts` compiles the published shape the way a consumer sees it: imports through the package name, so the `exports` map picks the types, and `skipLibCheck: false`, so the declarations themselves are checked rather than skipped. It caught two real problems while being written -- the untyped mock context, and a chart.js element that does not declare `getCenterPoint`. Its `@ts-expect-error` lines are assertions too: they fail the build if the types stop rejecting `setup({})`, a partial `toBeChartOfSize`, or `fillRect('0', ...)`. Measured on the packed tarball: `attw` is green for node10, node16 from ESM and bundler resolution, on both the root entry and `./node` -- the latter needs a `typesVersions` map, since node10 does not read `exports`. The remaining `attw` warning is that a `require` call resolves to ESM, which is inherent to the package being ESM-only and predates this change. `publint` is clean. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + README.md | 16 +++++ biome.jsonc | 11 +++- package.json | 29 +++++++-- src/canvas.js | 18 +++++ src/chart.js | 63 ++++++++++++++---- src/context.js | 100 ++++++++++++++-------------- src/index.js | 7 +- src/matchers.js | 2 +- test/types/consumer.ts | 86 ++++++++++++++++++++++++ test/types/tsconfig.json | 14 ++++ test/unit/context.test.js | 30 +++++++++ tsconfig.types.json | 15 +++++ types/entry.d.ts | 134 ++++++++++++++++++++++++++++++++++++++ 14 files changed, 458 insertions(+), 68 deletions(-) create mode 100644 test/types/consumer.ts create mode 100644 test/types/tsconfig.json create mode 100644 tsconfig.types.json create mode 100644 types/entry.d.ts diff --git a/.gitignore b/.gitignore index 505c7dc..01bfa6b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ npm-debug.log* *.log *.swp *.stackdump +types/generated/ diff --git a/README.md b/README.md index 4ada86d..e0e7858 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/biome.jsonc b/biome.jsonc index 6877965..00b723a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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, diff --git a/package.json b/package.json index abde2e7..61721e9 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,14 @@ "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": { @@ -15,17 +21,22 @@ "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", @@ -61,5 +72,13 @@ "@vitest/browser": { "optional": true } + }, + "types": "./types/entry.d.ts", + "typesVersions": { + "*": { + "node": [ + "./types/generated/node.d.ts" + ] + } } } diff --git a/src/canvas.js b/src/canvas.js index 9e19fbc..24b6389 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -6,6 +6,11 @@ * 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; @@ -13,10 +18,19 @@ export function createCanvas(width, height) { 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); @@ -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'); diff --git a/src/chart.js b/src/chart.js index d27be4c..0c683cf 100644 --- a/src/chart.js +++ b/src/chart.js @@ -6,6 +6,22 @@ */ 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 = {}; @@ -13,12 +29,13 @@ 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; } @@ -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) { @@ -116,6 +128,7 @@ export function buildChart(config = {}, options = {}, ctx) { return chart; } +/** @param {ChartInstance} chart */ export function destroyChart(chart) { spritingOff(chart.ctx); chart.destroy(); @@ -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]; @@ -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) { @@ -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) { @@ -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} the dispatched event + */ export async function triggerMouseEvent(chart, type, el) { const node = chart.canvas; const rect = node.getBoundingClientRect(); diff --git a/src/context.js b/src/context.js index eeef58f..a5e2369 100644 --- a/src/context.js +++ b/src/context.js @@ -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 @@ -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); }; }); } diff --git a/src/index.js b/src/index.js index 588665d..54f1e24 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,11 @@ export {compareOptions} from './matchers.options.js'; export {spritingOff, spritingOn} from './spriting.js'; export {Context}; +/** + * A 2d context that records the calls made to it, for tests that assert what a + * chart drew rather than how it looked. Works outside the browser. + * @returns {Context} + */ export function createMockContext() { return new Context(); } @@ -53,7 +58,7 @@ function injectWrapperCSS() { * the reference images were captured with. Call it once, from a setup file. * * @param {object} options - * @param {Function} options.Chart - the `Chart` export of chart.js. Injected + * @param {typeof import('chart.js').Chart} options.Chart - the `Chart` export of chart.js. Injected * rather than read from a global: Karma loaded the UMD bundle into `window`, * a bundler does not. * @param {number} [options.devicePixelRatio] - pinned to 1 by default, so the diff --git a/src/matchers.js b/src/matchers.js index 3709837..8d765c4 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -144,7 +144,7 @@ export function toBeChartOfSize(actual, expected) { /** * Compares a rendered canvas against a reference image. * @param {object} actual - a Chart, a canvas or a 2d context - * @param {ImageData} expected - the reference image data + * @param {ImageData} [expected] - the reference image data * @param {object} [opts] - comparison options * @param {number} [opts.threshold] - per pixel color distance, see pixelmatch * @param {number} [opts.tolerance] - accepted ratio of differing pixels diff --git a/test/types/consumer.ts b/test/types/consumer.ts new file mode 100644 index 0000000..3c964d0 --- /dev/null +++ b/test/types/consumer.ts @@ -0,0 +1,86 @@ +/** + * Compiles the published declarations the way a consumer sees them: through the + * package name, so the `exports` map decides which types are found, with + * `skipLibCheck: false` so the declarations themselves are checked. + * + * Nothing here runs; it only has to compile. The `@ts-expect-error` lines are + * assertions too -- they fail the build if the types stop catching the mistake. + */ +import {Chart, registerables} from 'chart.js'; +import { + acquireChart, + createFixtures, + createMockContext, + releaseChart, + setup, + triggerMouseEvent +} from 'chartjs-test-utils'; +import {createSaveFixtureImage} from 'chartjs-test-utils/node'; +import {expect} from 'vitest'; + +Chart.register(...registerables); +setup({Chart}); +setup({Chart, devicePixelRatio: 2, wrapperCSS: false}); + +// @ts-expect-error -- the Chart constructor is required +setup({}); + +// A stand-in for the Vitest test context, which is all `acquireChart` needs. +const testContext = {skip: (_reason?: string) => {}}; + +export async function charts() { + const chart = acquireChart( + {type: 'bar', data: {labels: ['a'], datasets: [{data: [1]}]}}, + {canvas: {height: 64, width: 64}, spriteText: true}, + testContext + ); + + // A chart.js Chart, not `any`: its own API has to typecheck. + const element = chart.getDatasetMeta(0).data[0]; + chart.update(); + + const event: MouseEvent = await triggerMouseEvent(chart, 'mousemove', element); + expect(event.type).toBe('mousemove'); + + expect(chart).toBeValidChart(); + expect(chart).toBeChartOfSize({dh: 64, dw: 64, rh: 64, rw: 64}); + expect(chart.width).toBeCloseToPixel(64); + expect({x: element.x, y: element.y}).toBeCloseToPoint({x: 32, y: 32}); + expect('a').toEqualOneOf(['a', 'b']); + expect(chart.options).toEqualOptions({responsive: false}); + + // @ts-expect-error -- a size needs all four dimensions + expect(chart).toBeChartOfSize({dh: 64}); + + releaseChart(chart); +} + +export function mockContext() { + const ctx = createMockContext(); + + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, 1, 1); + ctx.setTransform(1, 0, 0, 1, 0, 0); + + const width: number = ctx.measureText('abc').width; + const calls: {name: string; args: unknown[]}[] = ctx.getCalls(); + ctx.resetCalls(); + + // @ts-expect-error -- fillRect takes four numbers, like the real context + ctx.fillRect('0', 0, 1, 1); + + return {calls, width}; +} + +export function fixtures() { + const specsFromFixtures = createFixtures({ + configs: {'./fixtures/basic/bar.json': {}}, + images: {'./fixtures/basic/bar.png': 'data:image/png;base64,'}, + prefix: './fixtures/' + }); + const suite: () => void = specsFromFixtures('basic'); + + return suite; +} + +export const saveFixtureImage = createSaveFixtureImage({dir: 'test/fixtures'}); diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 0000000..9a64f0b --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + // The point of this project: prove the published declarations compile for a + // consumer. `skipLibCheck: true` would hide exactly the mistakes it exists + // to catch. + "skipLibCheck": false, + "strict": true, + "target": "ES2022" + }, + "include": ["./consumer.ts"] +} diff --git a/test/unit/context.test.js b/test/unit/context.test.js index f7dc66a..02da1cb 100644 --- a/test/unit/context.test.js +++ b/test/unit/context.test.js @@ -1,9 +1,39 @@ import assert from 'node:assert'; +import {readFileSync} from 'node:fs'; import {describe, it} from 'vitest'; +import {mockContextMethods} from '../../src/context.js'; import {createMockContext} from '../../src/index.js'; +/** The names in one `type X = 'a' | 'b';` union in the published type entry. */ +function declaredNames(unionName) { + const entry = readFileSync(new URL('../../types/entry.d.ts', import.meta.url), 'utf8'); + const union = entry.match(new RegExp(`type ${unionName} =([^;]*);`)); + assert.ok(union, `${unionName} is not declared in types/entry.d.ts`); + return union[1] + .match(/'([^']+)'/g) + .map((name) => name.slice(1, -1)) + .sort(); +} + describe('createMockContext', () => { + // The mock assigns its methods in a loop, so a declaration emit cannot see + // them and types/entry.d.ts spells them out by hand. These two specs are + // what keeps that list honest. + it('should record every method the published types declare', () => { + assert.deepStrictEqual(declaredNames('RecordedMethod'), Object.keys(mockContextMethods).sort()); + }); + + it('should record every property the published types declare', () => { + const ctx = createMockContext(); + const accessors = Object.getOwnPropertyNames(ctx) + .filter((name) => !name.startsWith('_')) + .filter((name) => typeof Object.getOwnPropertyDescriptor(ctx, name).get === 'function') + .sort(); + + assert.deepStrictEqual(declaredNames('RecordedProperty'), accessors); + }); + it('should record calls and property assignments', () => { const ctx = createMockContext(); diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..f4967ba --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "types/generated", + "rootDir": "src", + "skipLibCheck": false, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.js"] +} diff --git a/types/entry.d.ts b/types/entry.d.ts new file mode 100644 index 0000000..2483c99 --- /dev/null +++ b/types/entry.d.ts @@ -0,0 +1,134 @@ +/** + * The published type entry. + * + * Everything below `./generated` is emitted from the JSDoc in `src` by + * `npm run types`, so it cannot drift from the implementation. This file adds + * the one thing a declaration emit cannot produce: the matchers, which live on + * Vitest's `expect` rather than in this package's exports. + */ +export * from './generated/index.js'; + +import type Context from './generated/context.js'; + +/** + * The 2d context methods the mock records. Kept in step with + * `mockContextMethods` in `src/context.js` by a unit test, because the mock + * assigns them in a loop and a declaration emit cannot see that. + */ +type RecordedMethod = + | 'arc' + | 'arcTo' + | 'beginPath' + | 'bezierCurveTo' + | 'clearRect' + | 'clip' + | 'closePath' + | 'fill' + | 'fillRect' + | 'fillText' + | 'lineTo' + | 'measureText' + | 'moveTo' + | 'quadraticCurveTo' + | 'rect' + | 'restore' + | 'rotate' + | 'save' + | 'setLineDash' + | 'setTransform' + | 'stroke' + | 'strokeRect' + | 'strokeText' + | 'translate'; + +/** The style properties the mock records when they are assigned. */ +type RecordedProperty = + | 'fillStyle' + | 'font' + | 'lineCap' + | 'lineDashOffset' + | 'lineJoin' + | 'lineWidth' + | 'strokeStyle' + | 'textAlign' + | 'textBaseline'; + +/** What the mock's `measureText` returns: a fixed subset of TextMetrics. */ +export interface MockTextMetrics { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + actualBoundingBoxLeft: number; + actualBoundingBoxRight: number; + width: number; +} + +/** One recorded call. */ +export interface RecordedCall { + name: string; + args: unknown[]; +} + +/** + * A 2d context that records what was drawn on it. The signatures come from + * `CanvasRenderingContext2D`, so a test written against the mock matches the + * real context -- except `measureText`, which returns fake metrics. + */ +export interface MockContext + extends Context, + Pick>, + Pick { + measureText(text?: string): MockTextMetrics; + getCalls(): RecordedCall[]; + resetCalls(): void; +} + +/** Overrides the generated signature, which cannot see the recorded methods. */ +export declare function createMockContext(): MockContext; + +/** Options accepted by `toEqualImageData`. */ +export interface ImageComparisonOptions { + /** Per-pixel color distance, passed to pixelmatch. Defaults to 0.1. */ + threshold?: number; + /** Accepted ratio of differing pixels. Defaults to 0.001. */ + tolerance?: number; + /** + * Blend transparency against a checkerboard instead of white. Opt in per + * fixture, once its reference image has been re-validated against it. + */ + checkerboard?: boolean; + /** Always fail and log the preview. */ + debug?: boolean; + /** Label for the logged preview. */ + description?: string; +} + +/** The size assertions made by `toBeChartOfSize`. */ +export interface ChartSize { + /** display height, in CSS pixels */ + dh: number; + /** display width, in CSS pixels */ + dw: number; + /** render height, in backing-store pixels */ + rh: number; + /** render width, in backing-store pixels */ + rw: number; +} + +declare module 'vitest' { + interface Matchers = void | Promise, T = unknown> { + /** Compares the rendered canvas against a reference image. */ + toEqualImageData(expected: ImageData, opts?: ImageComparisonOptions): R; + /** Compares resolved chart options, ignoring `_`-prefixed properties. */ + toEqualOptions(expected: object): R; + /** Asserts the chart, its canvas, its context and a finite size. */ + toBeValidChart(): R; + /** Asserts the display and render size of a chart. */ + toBeChartOfSize(expected: ChartSize): R; + /** Asserts two pixel values are within 0.5% or 2px of each other. */ + toBeCloseToPixel(expected: number): R; + /** Asserts two points are equal when rounded to two decimals. */ + toBeCloseToPoint(expected: {x: number; y: number}): R; + /** Asserts the value is one of the expected values. */ + toEqualOneOf(expected: readonly unknown[]): R; + } +}