penpot/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts
Alonso Torres 7655f854a4
Improved api test suite (#10533)
* 🐛 Fix plugins api issues

* 🐛 Fix problem with text weight

* 🐛 Fix api problems

*  Updated plugins test suite

* 🐛 Referential integrity tests

*  Add resetOverrides method

* 🐛 Fix plugins components structure poluting
2026-07-07 16:12:12 +02:00

329 lines
11 KiB
TypeScript

import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
// Shapes & geometry.
// Exercises the Context shape factories and the context-level structural
// operations (group/ungroup/flatten, align/distribute). Everything created is
// appended to the scratch board `ctx.board` so the user's canvas stays clean.
// Each group keeps its happy-path tests together with the related edge cases:
// "fail" tests assert the documented null returns / rejections for degenerate
// input; the remaining ones pin non-trivial valid construction (clone
// independence, group order, boolean tree, svg tree).
describe('Shapes', () => {
describe('Factories', () => {
test('createRectangle returns a rectangle', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(rect.type).toBe('rectangle');
});
test('createEllipse returns an ellipse', (ctx) => {
const ellipse = ctx.penpot.createEllipse();
ctx.board.appendChild(ellipse);
expect(ellipse.type).toBe('ellipse');
});
test('createBoard returns a board', (ctx) => {
const board = ctx.penpot.createBoard();
ctx.board.appendChild(board);
expect(board.type).toBe('board');
});
test('createPath returns a path', (ctx) => {
const path = ctx.penpot.createPath();
ctx.board.appendChild(path);
expect(path.type).toBe('path');
});
test('createText returns a text shape with the given content', (ctx) => {
const text = ctx.penpot.createText('Hello Penpot');
expect(text).not.toBeNull();
if (text) {
ctx.board.appendChild(text);
expect(text.type).toBe('text');
expect(text.characters).toContain('Hello');
}
});
test('createBoolean unions two shapes', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
b.x = 50;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(bool.type).toBe('boolean');
}
});
test('createShapeFromSvg returns a group', (ctx) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">' +
'<rect width="10" height="10" fill="#ff0000"/></svg>';
const group = ctx.penpot.createShapeFromSvg(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
expect(group.type).toBe('group');
}
});
test('createShapeFromSvgWithImages resolves to a group', async (ctx) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">' +
'<rect width="10" height="10" fill="#00ff00"/></svg>';
const group = await ctx.penpot.createShapeFromSvgWithImages(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
expect(group.type).toBe('group');
}
});
// Degenerate input — documented null returns / rejections.
test('createText with an empty string returns null', (ctx) => {
// The d.ts documents: "Returns null if an empty string is provided".
const t = ctx.penpot.createText('');
expect(t).toBeNull();
});
test('group of an empty array returns null', (ctx) => {
const g = ctx.penpot.group([]);
expect(g).toBeNull();
});
test('createBoolean with an empty shapes array is rejected', (ctx) => {
// createBoolean validates a non-empty shapes array, so with
// throwValidationErrors enabled it throws rather than returning null.
expect(() => ctx.penpot.createBoolean('union', [])).toThrow();
});
test('createBoolean supports difference, exclude and intersection', (ctx) => {
// Only `union` is exercised elsewhere; cover the remaining boolean ops so a
// typology-specific regression in any single operation is caught.
for (const op of ['difference', 'exclude', 'intersection'] as const) {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
b.x = 10;
b.y = 10;
const bool = ctx.penpot.createBoolean(op, [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(typeof bool.d).toBe('string');
}
}
});
test('createShapeFromSvg rejects unparseable markup', (ctx) => {
// Malformed SVG is rejected up front rather than failing asynchronously
// inside the import pipeline (which would surface as an error toast).
expect(() => ctx.penpot.createShapeFromSvg('not svg at all')).toThrow();
});
// Success edges — non-trivial valid construction.
test('clone produces an independent copy', (ctx) => {
const r = ctx.penpot.createRectangle();
r.name = 'original';
ctx.board.appendChild(r);
const copy = r.clone();
ctx.board.appendChild(copy);
copy.name = 'copy';
expect(copy.id).not.toBe(r.id);
expect(copy.name).toBe('copy');
// Mutating the copy must not affect the original.
expect(r.name).toBe('original');
});
test('group preserves child count and order', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
const c = ctx.penpot.createRectangle();
a.name = 'a';
b.name = 'b';
c.name = 'c';
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.board.appendChild(c);
const group = ctx.penpot.group([a, b, c]);
expect(group).not.toBeNull();
if (group) {
expect(group.children).toHaveLength(3);
expect(group.children.map((s) => s.name).sort()).toEqual([
'a',
'b',
'c',
]);
}
});
test('a boolean keeps its two operands as children', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
b.x = 50;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const bool = ctx.penpot.createBoolean('union', [a, b]);
expect(bool).not.toBeNull();
if (bool) {
ctx.board.appendChild(bool);
expect(bool.type).toBe('boolean');
expect(bool.children).toHaveLength(2);
}
});
test('createShapeFromSvg builds a group with children', (ctx) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">' +
'<rect width="10" height="10" fill="#ff0000"/>' +
'<circle cx="15" cy="15" r="5" fill="#0000ff"/></svg>';
const group = ctx.penpot.createShapeFromSvg(svg);
expect(group).not.toBeNull();
if (group) {
ctx.board.appendChild(group);
expect(group.type).toBe('group');
expect(group.children.length).toBeGreaterThan(0);
}
});
});
describe('Grouping', () => {
test('group wraps shapes in a group', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
expect(group.type).toBe('group');
expect(group.children).toHaveLength(2);
}
});
test('ungroup dissolves a group', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createEllipse();
ctx.board.appendChild(a);
ctx.board.appendChild(b);
const group = ctx.penpot.group([a, b]);
expect(group).not.toBeNull();
if (group) {
const before = ctx.board.children.length;
ctx.penpot.ungroup(group);
// The group dissolves into its two children (net +1 on the board) and
// the group itself is gone.
expect(ctx.board.children.length).toBe(before + 1);
expect(ctx.board.children.some((c) => c.id === group.id)).toBe(false);
}
});
test('flatten converts shapes into paths', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const paths = ctx.penpot.flatten([rect]);
expect(paths).toHaveLength(1);
expect(paths[0].type).toBe('path');
});
});
describe('Align & distribute', () => {
test('alignHorizontal moves shapes to a shared edge', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
a.x = 0;
b.x = 200;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.penpot.alignHorizontal([a, b], 'left');
expect(a.x).toBeCloseTo(b.x, 0);
});
test('alignVertical moves shapes to a shared edge', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
a.y = 0;
b.y = 200;
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.penpot.alignVertical([a, b], 'top');
expect(a.y).toBeCloseTo(b.y, 0);
});
test('distributeHorizontal spaces the shapes evenly', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
a.x = 0;
b.x = 50;
c.x = 300;
a.resize(20, 20);
b.resize(20, 20);
c.resize(20, 20);
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.board.appendChild(c);
ctx.penpot.distributeHorizontal([a, b, c]);
// The outer shapes stay in place and the middle one lands so the gaps
// between consecutive shapes are equal.
const xs = [a.x, b.x, c.x].sort((p, q) => p - q);
expect(xs[1] - xs[0]).toBeCloseTo(xs[2] - xs[1], 0);
expect(xs[0]).toBeCloseTo(0, 0);
expect(xs[2]).toBeCloseTo(300, 0);
});
test('distributeVertical spaces the shapes evenly', (ctx) => {
const a = ctx.penpot.createRectangle();
const b = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
a.y = 0;
b.y = 50;
c.y = 300;
a.resize(20, 20);
b.resize(20, 20);
c.resize(20, 20);
ctx.board.appendChild(a);
ctx.board.appendChild(b);
ctx.board.appendChild(c);
ctx.penpot.distributeVertical([a, b, c]);
const ys = [a.y, b.y, c.y].sort((p, q) => p - q);
expect(ys[1] - ys[0]).toBeCloseTo(ys[2] - ys[1], 0);
expect(ys[0]).toBeCloseTo(0, 0);
expect(ys[2]).toBeCloseTo(300, 0);
});
// Edge cases.
test('aligning an empty array is a no-op (not rejected)', (ctx) => {
// align/distribute do not validate the shape list; an empty array is
// simply a no-op rather than an error.
expect(() => ctx.penpot.alignHorizontal([], 'left')).not.toThrow();
});
test('distributing a single shape leaves it in place', (ctx) => {
const a = ctx.penpot.createRectangle();
a.x = 10;
ctx.board.appendChild(a);
ctx.penpot.distributeHorizontal([a]);
expect(a.x).toBeCloseTo(10, 0);
});
});
});