feat(test): config integration test env and add modular canvas (#5374)

* feat(test): config integration test env

* feat(runtime): add modular canvas

* refactor(test): update snapshot naming rule

* chore(runtime): adjust variable naming

* chore(test): adjust folder naming

* refactor(runtime): remove unnecessary variable usage
This commit is contained in:
Aaron 2024-01-26 10:59:52 +08:00 committed by GitHub
parent d48787e345
commit 4e11baeb51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 660 additions and 5 deletions

View File

@ -18,6 +18,12 @@ module.exports = {
sourceType: 'script',
},
},
{
files: ['**/__tests__/**'],
rules: {
'jsdoc/require-jsdoc': 0,
},
},
],
parser: '@typescript-eslint/parser',
parserOptions: {

View File

@ -37,5 +37,5 @@ jobs:
with:
name: snapshots
path: |
packages/g6/tests/integration/snapshots/**/*-actual.svg
packages/g6/__tests__/integration/snapshots/**/*-actual.svg
retention-days: 1

View File

@ -1,2 +1,2 @@
tests/integration/snapshots/**/*-actual.*
tests/integration/snapshots/**/*-diff.*
__tests__/integration/snapshots/**/*-actual.*
__tests__/integration/snapshots/**/*-diff.*

View File

@ -0,0 +1 @@
export * from './static';

View File

@ -0,0 +1,44 @@
import { Circle, Rect } from '@antv/g';
import { Canvas } from '../../../src/runtime/canvas';
import type { TestCase } from '../types';
export const layeredCanvas: TestCase = async (context) => {
const {
container,
width,
height,
renderer,
canvas = new Canvas({
width,
height,
container,
renderer,
}),
} = context;
const circle = new Circle({
style: {
cx: 50,
cy: 50,
r: 25,
fill: 'pink',
},
});
const rect = new Rect({
style: {
x: 100,
y: 100,
width: 50,
height: 50,
fill: 'purple',
},
});
await canvas.init();
canvas.appendChild(circle);
canvas.appendChild(rect);
return;
};

View File

@ -0,0 +1 @@
export * from './canvas';

View File

@ -0,0 +1,19 @@
import type { IRenderer } from '@antv/g';
import type { Canvas } from '../../src/runtime/canvas';
import type { CanvasLayer } from '../../src/types/canvas';
type TestCaseContext = {
canvas?: Canvas;
container: HTMLElement;
renderer?: (layer: CanvasLayer) => IRenderer;
width: number;
height: number;
};
export type TestCase = {
(context: TestCaseContext): Promise<void>;
only?: boolean;
skip?: boolean;
preprocess?: () => Promise<void>;
postprocess?: () => Promise<void>;
};

View File

@ -0,0 +1,48 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="500"
height="500"
style="background: transparent; position: absolute; outline: none;"
color-interpolation-filters="sRGB"
tabindex="1"
>
<defs />
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
<g
id="g-root"
fill="none"
stroke="none"
visibility="visible"
font-size="16px"
font-family="sans-serif"
font-style="normal"
font-weight="normal"
font-variant="normal"
text-anchor="left"
stroke-dashoffset="0px"
transform="matrix(1,0,0,1,0,0)"
>
<g transform="matrix(1,0,0,1,50,50)">
<circle
id="g-svg-5"
fill="rgba(255,192,203,1)"
transform="translate(-25,-25)"
cx="25"
cy="25"
stroke="none"
r="25px"
/>
</g>
<g transform="matrix(1,0,0,1,100,100)">
<path
id="g-svg-6"
fill="rgba(128,0,128,1)"
d="M 0,0 l 50,0 l 0,50 l-50 0 z"
stroke="none"
width="50px"
height="50px"
/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,33 @@
import * as staticCases from '../demo/static';
import { createNodeGCanvas } from './utils/create-node-g-canvas';
import { getCases } from './utils/get-cases';
import { sleep } from './utils/sleep';
import './utils/use-snapshot-matchers';
describe('static', () => {
const cases = getCases(staticCases);
for (const [name, testCase] of cases) {
it(`[static]: ${name}`, async () => {
const canvas = createNodeGCanvas();
try {
const { preprocess, postprocess } = testCase;
await preprocess?.();
await testCase({
container: document.getElementById('container')!,
width: 500,
height: 500,
canvas,
});
await postprocess?.();
await expect(canvas).toMatchSVGSnapshot(`${__dirname}/snapshots/static`, name);
} finally {
canvas.destroy();
await sleep(50);
}
});
}
});

View File

@ -0,0 +1,29 @@
import { resetEntityCounter } from '@antv/g';
import { Plugin as DragAndDropPlugin } from '@antv/g-plugin-dragndrop';
import { Renderer as SVGRenderer } from '@antv/g-svg';
import { Canvas } from '../../../src/runtime/canvas';
import { OffscreenCanvasContext } from './offscreen-canvas-context';
export function createNodeGCanvas(dom?: HTMLDivElement, width = 500, height = 500) {
const container = dom || document.createElement('div');
container.style.width = `${width}px`;
container.style.height = `${height}px`;
resetEntityCounter();
const offscreenNodeCanvas = {
getContext: () => context,
} as unknown as HTMLCanvasElement;
const context = new OffscreenCanvasContext(offscreenNodeCanvas);
const renderer = new SVGRenderer();
renderer.registerPlugin(new DragAndDropPlugin({ dragstartDistanceThreshold: 10 }));
return new Canvas({
container,
width,
height,
renderer: () => new SVGRenderer(),
// @ts-expect-error offscreenCanvas is not in the type definition
document: container.ownerDocument,
offscreenCanvas: offscreenNodeCanvas,
});
}

View File

@ -0,0 +1,12 @@
import type { TestCase } from '../../demo/types';
export function getCases(module: Record<string, TestCase>) {
const cases = Object.entries(module);
const only = cases.filter(([, { only = false }]) => only);
if (only.length !== 0 && process.env.CI === 'true') throw new Error('Cannot run only tests in CI');
return (only.length !== 0 ? only : cases)
.filter(([, { skip = false }]) => !skip)
.map(([name, testCase]) => [kebabCase(name), testCase] as [string, TestCase]);
}
const kebabCase = (str: string) => str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);

View File

@ -0,0 +1,128 @@
// Computed as round(measureText(text).width * 10) at 10px system-ui. For
// characters that are not represented in this map, wed ideally want to use a
// weighted average of what we expect to see. But since we dont really know
// what that is, using “e” seems reasonable.
const defaultWidthMap = {
a: 56,
b: 63,
c: 57,
d: 63,
e: 58,
f: 37,
g: 62,
h: 60,
i: 26,
j: 26,
k: 55,
l: 26,
m: 88,
n: 60,
o: 60,
p: 62,
q: 62,
r: 39,
s: 54,
t: 38,
u: 60,
v: 55,
w: 79,
x: 54,
y: 55,
z: 55,
A: 69,
B: 67,
C: 73,
D: 74,
E: 61,
F: 58,
G: 76,
H: 75,
I: 28,
J: 55,
K: 67,
L: 58,
M: 89,
N: 75,
O: 78,
P: 65,
Q: 78,
R: 67,
S: 65,
T: 65,
U: 75,
V: 69,
W: 98,
X: 69,
Y: 67,
Z: 67,
0: 64,
1: 48,
2: 62,
3: 64,
4: 66,
5: 63,
6: 65,
7: 58,
8: 65,
9: 65,
' ': 29,
'!': 32,
'"': 49,
"'": 31,
'(': 39,
')': 39,
',': 31,
'-': 48,
'.': 31,
'/': 32,
':': 31,
';': 31,
'?': 52,
'': 31,
'': 31,
'“': 47,
'”': 47,
'…': 82,
};
export function measureText(text: string, fontSize: number) {
let sum = 0;
for (let i = 0; i < text.length; i++) {
sum += ((defaultWidthMap[text[i]] ?? 100) * fontSize) / 100;
}
return sum;
}
export class OffscreenCanvasContext {
private fontSize: number;
constructor(public canvas: HTMLCanvasElement) {}
set font(font: string) {
// `${fontStyle} ${fontVariant} ${fontWeight} ${fontSizeString}
const [, , , fontSizeString] = font.split(' ');
const fontSize = parseFloat(fontSizeString.replace('px', ''));
this.fontSize = fontSize;
}
fillRect() {}
fillText() {}
getImageData(sx: number, sy: number, sw: number, sh: number) {
return {
// ignore ascent and descent
data: new Uint8ClampedArray(sw * sh * 4).fill(0),
};
}
measureText(text: string): TextMetrics {
return {
width: measureText(text, this.fontSize),
actualBoundingBoxAscent: 0,
actualBoundingBoxDescent: 0,
actualBoundingBoxLeft: 0,
actualBoundingBoxRight: 0,
fontBoundingBoxAscent: 0,
fontBoundingBoxDescent: 0,
};
}
}

View File

@ -0,0 +1,5 @@
export function sleep(n: number) {
return new Promise((resolve) => {
setTimeout(resolve, n);
});
}

View File

@ -0,0 +1,97 @@
import { Canvas } from '@antv/g';
import * as fs from 'fs';
import * as path from 'path';
import { format } from 'prettier';
import xmlserializer from 'xmlserializer';
import { sleep } from './sleep';
export type ToMatchSVGSnapshotOptions = {
fileFormat?: string;
keepSVGElementId?: boolean;
};
const formatSVG = (svg: string, keepSVGElementId: boolean) => {
return (keepSVGElementId ? svg : svg.replace(/id="[^"]*"/g, '').replace(/clip-path="[^"]*"/g, '')).replace(
'\r\n',
'\n',
);
};
// @see https://jestjs.io/docs/26.x/expect#expectextendmatchers
export async function toMatchSVGSnapshot(
gCanvas: Canvas | Canvas[],
dir: string,
name: string,
options: ToMatchSVGSnapshotOptions = {},
): Promise<{ message: () => string; pass: boolean }> {
await sleep(300);
const { fileFormat = 'svg', keepSVGElementId = true } = options;
const namePath = path.join(dir, name);
const actualPath = path.join(dir, `${name}-actual.${fileFormat}`);
const expectedPath = path.join(dir, `${name}.${fileFormat}`);
const gCanvases = Array.isArray(gCanvas) ? gCanvas : [gCanvas];
let actual: string = '';
// Clone <svg>
const svg = (gCanvases[0].getContextService().getDomElement() as unknown as SVGElement).cloneNode(true) as SVGElement;
const gRoot = svg.querySelector('#g-root');
gCanvases.slice(1).forEach((gCanvas) => {
const dom = (gCanvas.getContextService().getDomElement() as unknown as SVGElement).cloneNode(true) as SVGElement;
gRoot?.append(...(dom.querySelector('#g-root')?.childNodes || []));
});
actual += svg
? formatSVG(
await format(xmlserializer.serializeToString(svg as any), {
parser: 'babel',
}),
keepSVGElementId,
)
: 'null';
// Remove ';' after format by babel.
if (actual !== 'null') actual = actual.slice(0, -2);
try {
console.log('actual', dir, fs.existsSync(dir));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(expectedPath)) {
if (process.env.CI === 'true') {
throw new Error(`Please generate golden image for ${namePath}`);
}
console.warn(`! generate ${namePath}`);
fs.writeFileSync(expectedPath, actual);
return {
message: () => `generate ${namePath}`,
pass: true,
};
} else {
const expected = fs.readFileSync(expectedPath, {
encoding: 'utf8',
flag: 'r',
});
if (actual === expected) {
if (fs.existsSync(actualPath)) fs.unlinkSync(actualPath);
return {
message: () => `match ${namePath}`,
pass: true,
};
}
// Perverse actual file.
if (actual) fs.writeFileSync(actualPath, actual);
return {
message: () => `mismatch ${namePath}`,
pass: false,
};
}
} catch (e) {
return {
message: () => `${e}`,
pass: false,
};
}
}

View File

@ -0,0 +1,14 @@
import { toMatchSVGSnapshot, ToMatchSVGSnapshotOptions } from './to-match-svg-snapshot';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toMatchSVGSnapshot(dir: string, name: string, options?: ToMatchSVGSnapshotOptions): Promise<R>;
}
}
}
expect.extend({
toMatchSVGSnapshot,
});

View File

@ -63,8 +63,10 @@
"@antv/g-plugin-control": "^1.9.17",
"@antv/g-svg": "^1.10.21",
"@antv/g-webgl": "^1.9.29",
"@types/xmlserializer": "^0.6.6",
"stats.js": "^0.17.0",
"vite": "^5.0.10"
"vite": "^5.0.10",
"xmlserializer": "^0.6.1"
},
"limit-size": [
{

View File

@ -0,0 +1,216 @@
import type {
Cursor,
DataURLOptions,
DisplayObject,
CanvasConfig as GCanvasConfig,
IRenderer,
PointLike,
} from '@antv/g';
import { Canvas as GCanvas } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Plugin as DragNDropPlugin } from '@antv/g-plugin-dragndrop';
import { createDOM, isFunction } from '@antv/util';
import type { CanvasOptions } from '../spec/canvas';
import type { CanvasLayer } from '../types/canvas';
export interface CanvasConfig
extends Pick<GCanvasConfig, 'container' | 'devicePixelRatio' | 'width' | 'height' | 'background' | 'cursor'> {
renderer?: CanvasOptions['renderer'];
}
/**
* @deprecated this canvas will be replace by layered canvas
*/
export class Canvas {
protected config: CanvasConfig;
public background: GCanvas;
public main: GCanvas;
public label: GCanvas;
public transient: GCanvas;
public transientLabel: GCanvas;
public get canvas() {
return {
main: this.main,
label: this.label,
transient: this.transient,
transientLabel: this.transientLabel,
background: this.background,
};
}
public renderers: Record<CanvasLayer, IRenderer>;
constructor(config: CanvasConfig) {
this.config = config;
const { renderer: getRenderer, ...restConfig } = config;
const names: CanvasLayer[] = ['main', 'label', 'transient', 'transientLabel', 'background'];
const renderers = names.map((name) => {
const renderer = isFunction(getRenderer) ? getRenderer?.(name) : new CanvasRenderer();
renderer.registerPlugin(
new DragNDropPlugin({
isDocumentDraggable: true,
isDocumentDroppable: true,
dragstartDistanceThreshold: 10,
dragstartTimeThreshold: 100,
}),
);
if (name !== 'main') {
renderer.unregisterPlugin(renderer.getPlugin('dom-interaction'));
}
this[name] = new GCanvas({
renderer,
supportsMutipleCanvasesInOneContainer: true,
...restConfig,
});
return [name, renderer];
});
this.renderers = Object.fromEntries(renderers);
this.init().then(() => {
Object.entries(this.canvas).forEach(([name, canvas]) => {
const domElement = canvas.getContextService().getDomElement() as unknown as HTMLElement;
domElement.style.position = 'absolute';
domElement.style.outline = 'none';
domElement.tabIndex = 1;
if (name !== 'main') domElement.style.pointerEvents = 'none';
});
});
}
public init() {
return Promise.all(Object.values(this.canvas).map((canvas) => canvas.ready));
}
public getRendererType(layer: CanvasLayer = 'main') {
const plugins = this.renderers[layer].getPlugins();
for (const plugin of plugins) {
if (plugin.name === 'canvas-renderer') return 'canvas';
if (plugin.name === 'svg-renderer') return 'svg';
if (plugin.name === 'device-renderer') return 'gpu';
}
return 'unknown';
}
public get context() {
return this.main.context;
}
public getDevice() {
// @ts-expect-error deviceRendererPlugin is private
return this.main.context?.deviceRendererPlugin?.getDevice();
}
public getConfig() {
return this.config;
}
public setCursor(cursor: Cursor) {
Object.values(this.canvas).forEach((canvas) => {
canvas.setCursor(cursor);
});
}
public resize(width: number, height: number) {
Object.values(this.canvas).forEach((canvas) => {
canvas.resize(width, height);
});
}
public getCamera() {
return this.main.getCamera();
}
public appendChild<T extends DisplayObject>(child: T): T {
const layer = child.style?.$layer || 'main';
return this[layer].appendChild(child);
}
public getContextService() {
return this.main.getContextService();
}
public viewport2Client(viewport: PointLike) {
return this.main.viewport2Client(viewport);
}
public viewport2Canvas(viewport: PointLike) {
return this.main.viewport2Canvas(viewport);
}
public client2Viewport(client: PointLike) {
return this.main.client2Viewport(client);
}
public canvas2Viewport(canvas: PointLike) {
return this.main.canvas2Viewport(canvas);
}
public async toDataURL(options?: Partial<DataURLOptions>) {
const devicePixelRatio = window.devicePixelRatio || 1;
const { width, height, renderer: getRenderer } = this.config;
const container: HTMLElement = createDOM('<div id="virtual-image"></div>');
const offscreenCanvas = new GCanvas({
width,
height,
renderer: getRenderer('main'),
devicePixelRatio,
container,
background: this.background.getConfig().background,
});
await offscreenCanvas.ready;
offscreenCanvas.appendChild(this.background.getRoot().cloneNode(true));
offscreenCanvas.appendChild(this.main.getRoot().cloneNode(true));
// Handle label canvas
const label = this.label.getRoot().cloneNode(true);
const originCanvasPosition = offscreenCanvas.viewport2Canvas({ x: 0, y: 0 });
const currentCanvasPosition = this.main.viewport2Canvas({ x: 0, y: 0 });
label.translate([
currentCanvasPosition.x - originCanvasPosition.x,
currentCanvasPosition.y - originCanvasPosition.y,
]);
label.scale(1 / this.main.getCamera().getZoom());
offscreenCanvas.appendChild(label);
offscreenCanvas.appendChild(this.transient.getRoot().cloneNode(true));
const camera = this.main.getCamera();
const offscreenCamera = offscreenCanvas.getCamera();
offscreenCamera.setZoom(camera.getZoom());
offscreenCamera.setPosition(camera.getPosition());
offscreenCamera.setFocalPoint(camera.getFocalPoint());
const contextService = offscreenCanvas.getContextService();
return contextService.toDataURL(options);
}
public destroy() {
Object.values(this.canvas).forEach((canvas) => {
const camera = canvas.getCamera();
// @ts-expect-error landmark is private
if (camera.landmarks?.length) {
camera.cancelLandmarkAnimation();
}
canvas.destroy();
});
}
}

View File

@ -1,7 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: './tests',
root: './__tests__',
server: {
port: 8080,
open: '/',