Alonso Torres 3ee5b50007
🐛 Fix problems with padding multiple values in plugins and UI (#10602)
* 🐛 Fix problem with padding types in plugins

* 🐛 Fix problem with multiple selection paddings
2026-07-10 09:13:20 +02:00

638 lines
22 KiB
TypeScript

import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Layout (flex & grid).
// Layouts are created on boards via addFlexLayout/addGridLayout. Child and cell
// properties are reached through a shape that lives inside the laid-out board.
// Each group keeps its happy-path round-trips together with the related edge
// cases: "throws" tests assert invalid input is rejected (a red test surfaces a
// missing-validation bug) and the remaining ones pin non-trivial valid behaviour.
// Note: track insertion indices are 0-based (addRowAtIndex/removeRow/setRow);
// appendChild cell coordinates and layoutCell.row/column are 1-based.
function board(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
describe('Layout', () => {
describe('Flex', () => {
test('addFlexLayout adds a flex layout to the board', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
expect(flex).toBeDefined();
expect(b.flex).toBeDefined();
});
test('direction and wrap round-trip', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.dir = 'column';
flex.wrap = 'wrap';
expect(flex.dir).toBe('column');
expect(flex.wrap).toBe('wrap');
});
test('alignment round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.alignItems = 'center';
flex.alignContent = 'space-between';
flex.justifyItems = 'center';
flex.justifyContent = 'space-around';
expect(flex.alignItems).toBe('center');
expect(flex.alignContent).toBe('space-between');
expect(flex.justifyItems).toBe('center');
expect(flex.justifyContent).toBe('space-around');
});
test('gaps and padding round-trip', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.rowGap = 5;
flex.columnGap = 10;
flex.verticalPadding = 4;
flex.horizontalPadding = 8;
flex.topPadding = 1;
flex.rightPadding = 2;
flex.bottomPadding = 3;
flex.leftPadding = 4;
expect(flex.rowGap).toBeCloseTo(5, 0);
expect(flex.columnGap).toBeCloseTo(10, 0);
expect(flex.topPadding).toBeCloseTo(1, 0);
expect(flex.rightPadding).toBeCloseTo(2, 0);
expect(flex.bottomPadding).toBeCloseTo(3, 0);
expect(flex.leftPadding).toBeCloseTo(4, 0);
});
// Gap and padding setters accept fractional numbers, not just integers.
test('gaps and padding accept fractional values', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.rowGap = 5.5;
flex.columnGap = 10.25;
flex.topPadding = 1.5;
flex.rightPadding = 2.25;
flex.bottomPadding = 3.75;
flex.leftPadding = 4.5;
expect(flex.rowGap).toBeCloseTo(5.5, 2);
expect(flex.columnGap).toBeCloseTo(10.25, 2);
expect(flex.topPadding).toBeCloseTo(1.5, 2);
expect(flex.rightPadding).toBeCloseTo(2.25, 2);
expect(flex.bottomPadding).toBeCloseTo(3.75, 2);
expect(flex.leftPadding).toBeCloseTo(4.5, 2);
});
// paddingType is "simple" (sides mirrored) or "multiple" (each side independent).
test('paddingType round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
expect(flex.paddingType).toBe('simple');
flex.paddingType = 'multiple';
expect(flex.paddingType).toBe('multiple');
flex.paddingType = 'simple';
expect(flex.paddingType).toBe('simple');
});
// Issue #10278: a single asymmetric side must switch paddingType to "multiple" so it paints.
test('setting an individual padding side switches paddingType to multiple', (ctx) => {
const flex = board(ctx).addFlexLayout();
expect(flex.paddingType).toBe('simple');
flex.leftPadding = 40;
expect(flex.leftPadding).toBeCloseTo(40, 0);
expect(flex.paddingType).toBe('multiple');
});
// Re-derived from the sides: once symmetric again, the type collapses to "simple".
test('padding collapsing back to symmetric restores simple type', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.leftPadding = 40;
expect(flex.paddingType).toBe('multiple');
flex.leftPadding = 0;
expect(flex.paddingType).toBe('simple');
});
test('sizing round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
flex.horizontalSizing = 'fix';
flex.verticalSizing = 'auto';
expect(flex.horizontalSizing).toBe('fix');
expect(flex.verticalSizing).toBe('auto');
});
test('appendChild adds a child to the flex layout', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
expect(b.children.length).toBeGreaterThan(0);
});
test('remove deletes the flex layout', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
flex.remove();
expect(b.flex).toBeFalsy();
});
});
describe('Grid', () => {
test('addGridLayout adds a grid layout to the board', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
expect(grid).toBeDefined();
expect(b.grid).toBeDefined();
});
test('direction round-trips', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.dir = 'row';
expect(grid.dir).toBe('row');
});
test('rows and columns can be added and read as tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('percent', 50);
expect(grid.rows.length).toBeGreaterThan(0);
expect(grid.columns.length).toBeGreaterThan(0);
expect(grid.rows[0].type).toBe('flex');
expect(grid.columns[0].type).toBe('percent');
expect(grid.columns[0].value).toBeCloseTo(50, 0);
});
test('addRowAtIndex inserts a row at an index', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRowAtIndex(0, 'fixed', 100);
expect(grid.rows[0].type).toBe('fixed');
});
test('addColumnAtIndex inserts a column at an index', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
grid.addColumnAtIndex(0, 'fixed', 100);
expect(grid.columns[0].type).toBe('fixed');
});
test('setRow and setColumn update tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.setRow(0, 'fixed', 80);
grid.setColumn(0, 'percent', 25);
expect(grid.rows[0].type).toBe('fixed');
expect(grid.rows[0].value).toBeCloseTo(80, 0);
expect(grid.columns[0].type).toBe('percent');
});
test('removeRow and removeColumn drop tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
const rowsBefore = grid.rows.length;
const colsBefore = grid.columns.length;
grid.removeRow(0);
grid.removeColumn(0);
expect(grid.rows.length).toBe(rowsBefore - 1);
expect(grid.columns.length).toBe(colsBefore - 1);
});
test('appendChild places a child into a cell', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
const rect = ctx.penpot.createRectangle();
grid.appendChild(rect, 1, 1);
expect(b.children.length).toBeGreaterThan(0);
});
test('alignment and gaps round-trip', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.alignItems = 'center';
grid.justifyItems = 'start';
grid.rowGap = 7;
grid.columnGap = 9;
expect(grid.alignItems).toBe('center');
expect(grid.justifyItems).toBe('start');
expect(grid.rowGap).toBeCloseTo(7, 0);
expect(grid.columnGap).toBeCloseTo(9, 0);
});
// Gap and padding setters accept fractional numbers, not just integers.
test('gaps and padding accept fractional values', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.rowGap = 7.5;
grid.columnGap = 9.25;
grid.topPadding = 1.5;
grid.rightPadding = 2.75;
grid.bottomPadding = 3.25;
grid.leftPadding = 4.5;
expect(grid.rowGap).toBeCloseTo(7.5, 2);
expect(grid.columnGap).toBeCloseTo(9.25, 2);
expect(grid.topPadding).toBeCloseTo(1.5, 2);
expect(grid.rightPadding).toBeCloseTo(2.75, 2);
expect(grid.bottomPadding).toBeCloseTo(3.25, 2);
expect(grid.leftPadding).toBeCloseTo(4.5, 2);
});
// paddingType behaves the same as on flex layouts (see issue #10278).
test('paddingType round-trips', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(grid.paddingType).toBe('simple');
grid.paddingType = 'multiple';
expect(grid.paddingType).toBe('multiple');
});
test('setting an individual padding side switches paddingType to multiple', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(grid.paddingType).toBe('simple');
grid.leftPadding = 40;
expect(grid.leftPadding).toBeCloseTo(40, 0);
expect(grid.paddingType).toBe('multiple');
});
// Index boundaries — invalid indices must be rejected.
test('addRowAtIndex with a negative index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.addRowAtIndex(-1, 'fixed', 100)).toThrow();
});
test('addRowAtIndex past the end throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.addRowAtIndex(5, 'fixed', 100)).toThrow();
});
test('addColumnAtIndex with a negative index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
expect(() => grid.addColumnAtIndex(-1, 'fixed', 100)).toThrow();
});
test('removeRow on an empty grid throws', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() => grid.removeRow(0)).toThrow();
});
test('removeRow with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.removeRow(5)).toThrow();
});
test('removeColumn with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
expect(() => grid.removeColumn(5)).toThrow();
});
test('setRow with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
expect(() => grid.setRow(5, 'fixed', 80)).toThrow();
});
test('setColumn with an out-of-range index throws', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addColumn('flex', 1);
expect(() => grid.setColumn(5, 'fixed', 80)).toThrow();
});
// Track type — invalid track types must be rejected.
test('addRow with an invalid track type throws', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() => grid.addRow('not-a-type' as unknown as 'flex', 1)).toThrow();
});
test('addColumn with an invalid track type throws', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() =>
grid.addColumn('not-a-type' as unknown as 'flex', 1),
).toThrow();
});
// Success edges — non-trivial valid behaviour.
test('addRowAtIndex inserts at the position and shifts the rest', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('fixed', 10);
grid.addRow('percent', 20);
grid.addRowAtIndex(1, 'flex', 1);
expect(grid.rows.length).toBe(3);
expect(grid.rows[0].type).toBe('fixed');
expect(grid.rows[1].type).toBe('flex');
expect(grid.rows[2].type).toBe('percent');
});
test('setRow updates a track in place without changing the count', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.setRow(1, 'fixed', 80);
expect(grid.rows.length).toBe(3);
expect(grid.rows[0].type).toBe('flex');
expect(grid.rows[1].type).toBe('fixed');
expect(grid.rows[1].value).toBeCloseTo(80, 0);
expect(grid.rows[2].type).toBe('flex');
});
test('mixed track types coexist and read back', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
grid.addRow('fixed', 50);
grid.addRow('percent', 25);
grid.addRow('auto');
expect(grid.rows.map((r) => r.type)).toEqual([
'flex',
'fixed',
'percent',
'auto',
]);
});
test('appendChild places children into the cells requested', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
const a = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
grid.appendChild(a, 1, 1);
grid.appendChild(c, 2, 2);
const cellA = a.layoutCell;
const cellC = c.layoutCell;
expect(cellA).toBeDefined();
expect(cellC).toBeDefined();
if (cellA && cellC) {
expect(cellA.row).toBeCloseTo(1, 0);
expect(cellA.column).toBeCloseTo(1, 0);
expect(cellC.row).toBeCloseTo(2, 0);
expect(cellC.column).toBeCloseTo(2, 0);
}
});
test('a grid board can nest a flex board as a child', (ctx) => {
const outer = board(ctx);
const grid = outer.addGridLayout();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
const inner = ctx.penpot.createBoard();
const flex = inner.addFlexLayout();
flex.dir = 'column';
grid.appendChild(inner, 1, 1);
expect(outer.children.length).toBeGreaterThan(0);
expect(inner.flex).toBeDefined();
expect(inner.flex && inner.flex.dir).toBe('column');
});
});
describe('Child', () => {
test('layout child properties round-trip', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
const child = rect.layoutChild;
expect(child).toBeDefined();
if (child) {
child.absolute = true;
child.zIndex = 3;
child.horizontalSizing = 'fill';
child.verticalSizing = 'fix';
child.alignSelf = 'center';
child.horizontalMargin = 2;
child.verticalMargin = 4;
child.topMargin = 1;
child.rightMargin = 2;
child.bottomMargin = 3;
child.leftMargin = 4;
child.maxWidth = 200;
child.maxHeight = 150;
child.minWidth = 10;
child.minHeight = 20;
expect(child.absolute).toBe(true);
expect(child.zIndex).toBeCloseTo(3, 0);
expect(child.horizontalSizing).toBe('fill');
expect(child.verticalSizing).toBe('fix');
expect(child.alignSelf).toBe('center');
expect(child.topMargin).toBeCloseTo(1, 0);
expect(child.maxWidth).toBeCloseTo(200, 0);
expect(child.minHeight).toBeCloseTo(20, 0);
}
});
// marginType is the child-margin counterpart of a layout's paddingType.
test('marginType round-trips', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
const child = rect.layoutChild;
expect(child).toBeDefined();
if (child) {
expect(child.marginType).toBe('simple');
child.marginType = 'multiple';
expect(child.marginType).toBe('multiple');
child.marginType = 'simple';
expect(child.marginType).toBe('simple');
}
});
// Issue #10278 (margins): a single asymmetric side must switch marginType to "multiple".
test('setting an individual margin side switches marginType to multiple', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
const child = rect.layoutChild;
expect(child).toBeDefined();
if (child) {
expect(child.marginType).toBe('simple');
child.leftMargin = 12;
expect(child.leftMargin).toBeCloseTo(12, 0);
expect(child.marginType).toBe('multiple');
}
});
// Symmetric margins collapse the child back to "simple", mirroring padding.
test('margin collapsing back to symmetric restores simple type', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
const child = rect.layoutChild;
expect(child).toBeDefined();
if (child) {
child.leftMargin = 12;
expect(child.marginType).toBe('multiple');
child.leftMargin = 0;
expect(child.marginType).toBe('simple');
}
});
// Community report (forum #10700, issue #12): layoutChild was said to be
// null for children appended to a flex board that is re-found (fresh
// proxy) instead of using the creation-time reference. Did not reproduce;
// kept as a regression pin.
test('layoutChild is available on children of a re-found flex board', (ctx) => {
const b = board(ctx);
b.resize(300, 200);
b.addFlexLayout();
const found = ctx.penpot.currentPage.getShapeById(b.id) as Board;
expect(found).not.toBeNull();
const child = ctx.penpot.createRectangle();
found.appendChild(child);
expect(child.layoutChild).toBeDefined();
if (child.layoutChild) {
child.layoutChild.horizontalSizing = 'fill';
expect(child.layoutChild.horizontalSizing).toBe('fill');
}
});
});
describe('Cell', () => {
test('layout cell properties round-trip', (ctx) => {
const b = board(ctx);
const grid = b.addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
const rect = ctx.penpot.createRectangle();
grid.appendChild(rect, 1, 1);
const cell = rect.layoutCell;
expect(cell).toBeDefined();
if (cell) {
cell.row = 1;
cell.column = 1;
cell.rowSpan = 1;
cell.columnSpan = 2;
expect(cell.row).toBeCloseTo(1, 0);
expect(cell.column).toBeCloseTo(1, 0);
expect(cell.columnSpan).toBeCloseTo(2, 0);
}
});
// Community report (forum #10700, issue #2): columnSpan was said to expand
// the grid's column count and scramble the other cells' positions. Did not
// reproduce; kept as a regression pin.
test('columnSpan spans a cell without changing column count or other cells', (ctx) => {
const b = board(ctx);
b.resize(600, 400);
const grid = b.addGridLayout();
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
grid.addRow('flex', 1);
grid.addRow('flex', 1);
const a = ctx.penpot.createRectangle();
const c = ctx.penpot.createRectangle();
const d = ctx.penpot.createRectangle();
grid.appendChild(a, 1, 1);
grid.appendChild(c, 1, 3);
grid.appendChild(d, 2, 2);
const cellA = a.layoutCell;
expect(cellA).toBeDefined();
if (cellA) {
cellA.columnSpan = 2;
}
expect(grid.columns.length).toBe(3);
expect(grid.rows.length).toBe(2);
if (cellA) {
expect(cellA.row).toBeCloseTo(1, 0);
expect(cellA.column).toBeCloseTo(1, 0);
expect(cellA.columnSpan).toBeCloseTo(2, 0);
}
const cellC = c.layoutCell;
const cellD = d.layoutCell;
expect(cellC).toBeDefined();
expect(cellD).toBeDefined();
if (cellC && cellD) {
expect(cellC.row).toBeCloseTo(1, 0);
expect(cellC.column).toBeCloseTo(3, 0);
expect(cellD.row).toBeCloseTo(2, 0);
expect(cellD.column).toBeCloseTo(2, 0);
}
});
});
describe('Sizing', () => {
// Community report (forum #10700, feature request C): resize on a board
// whose layout hugs content (auto sizing) must behave like the UI — switch
// the sizing to fixed and apply the requested dimensions. This once snapped
// back to the hugged content (flex) or left the sizing 'auto' (grid). Fixed;
// kept as a regression pin.
test('resize on an auto-sized flex board switches sizing to fixed', (ctx) => {
const b = board(ctx);
b.resize(300, 200);
const flex = b.addFlexLayout();
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
const child = ctx.penpot.createRectangle();
child.resize(50, 50);
b.appendChild(child);
b.resize(500, 400);
expect(b.width).toBeCloseTo(500, 0);
expect(b.height).toBeCloseTo(400, 0);
expect(flex.horizontalSizing).toBe('fix');
expect(flex.verticalSizing).toBe('fix');
});
test('resize on an auto-sized grid board switches sizing to fixed', (ctx) => {
const b = board(ctx);
b.resize(300, 200);
const grid = b.addGridLayout();
grid.addColumn('auto');
grid.addRow('auto');
grid.horizontalSizing = 'auto';
grid.verticalSizing = 'auto';
const child = ctx.penpot.createRectangle();
child.resize(50, 50);
grid.appendChild(child, 1, 1);
b.resize(500, 400);
expect(b.width).toBeCloseTo(500, 0);
expect(b.height).toBeCloseTo(400, 0);
expect(grid.horizontalSizing).toBe('fix');
expect(grid.verticalSizing).toBe('fix');
});
});
describe('Switching type', () => {
// addFlexLayout/addGridLayout do not reject a board that already has a
// layout; they create the requested layout (switching the board's type).
// These pin that behaviour.
test('adding a grid layout to a board that already has a flex layout switches to grid', (ctx) => {
const b = board(ctx);
b.addFlexLayout();
expect(() => b.addGridLayout()).not.toThrow();
expect(b.grid).toBeDefined();
});
test('adding a flex layout to a board that already has a grid layout switches to flex', (ctx) => {
const b = board(ctx);
b.addGridLayout();
expect(() => b.addFlexLayout()).not.toThrow();
expect(b.flex).toBeDefined();
});
});
});