Files
penpot/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts
T
Luis de Diosandalonso.torres fad083314d 🎉 Expose per-side stroke width in the plugin API (#11994)
* ✨ Add stroke side-width helpers to common types

Add set-width-to-all-sides, set-width-to-single-side and side-width to
app.common.types.stroke, mirroring the border-radius helpers in
app.common.types.shape.radius. They back the per-side stroke width API
and are covered by unit tests.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Round-trip per-side stroke widths through the plugin API

Add strokeWidthTop/Right/Bottom/Left to format-stroke and parse-stroke so
reading shape.strokes exposes the per-side widths and writing a stroke
back preserves them instead of collapsing to the uniform width.

parse-stroke prefers a live stroke proxy's hidden raw state when present,
so a commit only rewrites the attributes the plugin touched.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add per-side stroke widths to the plugin API types

Add the four per-side members to the Stroke interface and to the
borderWidth and dimension token property lists, and refresh the generated
API surface used by the plugin test suite.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Expose per-side stroke width on the stroke proxy

Add strokeWidthTop/Right/Bottom/Left to StrokeProxy. Reads fall back to
the uniform strokeWidth; strokeWidth now writes all four sides, like
borderRadius writes all four corners; each side setter writes only its
own key and the top side also updates strokeWidth.

Reject per-side writes on shapes other than boards and rectangles with a
validation error naming the property, and expose the raw stroke map
through a hidden proxy member so a commit preserves untouched attributes.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Support stroke width tokens per side in the plugin API

Token attribute expansion now accepts the uniform strokeWidth property and
expands it to the four per-side attributes, mirroring the design tab.
shape.tokens exposes the per-side keys and reports strokeWidth when all
four sides share one token, and applyToken routes through the shared
expander.

AI-assisted-by: deepseek-v4.1-flash

* 📚 Document per-side stroke width in the plugins changelog

Note the new Stroke members, the restored strokeWidth token property and
the shape-type validation in plugins/CHANGELOG.md.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix some minor issues

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-08 18:09:06 +02:00

424 lines
14 KiB
TypeScript

import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { TestContext } from '../framework/types';
import { PNG_1X1 } from './fixtures';
// Value-object property setters.
// Fills/strokes/gradients/shadows are returned as live proxies (their setters
// persist to the shape); blur and the shadow `color` are returned as plain
// snapshots (setting records the member and round-trips on the returned
// object). Either way every writable member is exercised by reading the value
// object and setting each property.
function rect(ctx: TestContext) {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Value objects', () => {
describe('Fill', () => {
test('solid fill members round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
const fills = r.fills;
if (Array.isArray(fills)) {
const fill = fills[0];
fill.fillColor = '#00ff00';
fill.fillOpacity = 0.5;
expect(fill.fillColor).toBe('#00ff00');
expect(fill.fillOpacity).toBeCloseTo(0.5, 2);
}
});
test('assigning a gradient on a live solid fill switches it', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
const fills = r.fills;
if (Array.isArray(fills)) {
fills[0].fillColorGradient = {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
};
expect(fills[0].fillColorGradient).toBeDefined();
}
});
test('fill color reference members round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
const fills = r.fills;
if (Array.isArray(fills)) {
const fill = fills[0];
fill.fillColorRefId = '00000000-0000-0000-0000-000000000001';
fill.fillColorRefFile = '00000000-0000-0000-0000-000000000002';
expect(fill.fillColorRefId).toBe(
'00000000-0000-0000-0000-000000000001',
);
expect(fill.fillColorRefFile).toBe(
'00000000-0000-0000-0000-000000000002',
);
}
});
test('fill gradient members round-trip', (ctx) => {
const r = rect(ctx);
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
const fills = r.fills;
if (Array.isArray(fills)) {
const gradient = fills[0].fillColorGradient;
expect(gradient).toBeDefined();
if (gradient) {
gradient.type = 'radial';
gradient.startX = 0.2;
gradient.startY = 0.3;
gradient.endX = 0.8;
gradient.endY = 0.9;
gradient.width = 0.5;
expect(gradient.type).toBe('radial');
expect(gradient.startX).toBeCloseTo(0.2, 2);
expect(gradient.endY).toBeCloseTo(0.9, 2);
expect(gradient.width).toBeCloseTo(0.5, 2);
expect(gradient.stops.length).toBeGreaterThan(0);
}
}
});
});
describe('Stroke', () => {
test('stroke members round-trip', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
const stroke = r.strokes[0];
stroke.strokeColor = '#112233';
stroke.strokeOpacity = 0.7;
stroke.strokeStyle = 'dotted';
stroke.strokeWidth = 4;
stroke.strokeAlignment = 'inner';
stroke.strokeCapStart = 'round';
stroke.strokeCapEnd = 'square';
expect(stroke.strokeColor).toBe('#112233');
expect(stroke.strokeOpacity).toBeCloseTo(0.7, 2);
expect(stroke.strokeStyle).toBe('dotted');
expect(stroke.strokeWidth).toBeCloseTo(4, 0);
expect(stroke.strokeAlignment).toBe('inner');
expect(stroke.strokeCapStart).toBe('round');
expect(stroke.strokeCapEnd).toBe('square');
});
test('per-side width members round-trip', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
const stroke = r.strokes[0];
stroke.strokeWidthTop = 2;
stroke.strokeWidthRight = 3;
stroke.strokeWidthBottom = 4;
stroke.strokeWidthLeft = 5;
expect(stroke.strokeWidthTop).toBeCloseTo(2, 0);
expect(stroke.strokeWidthRight).toBeCloseTo(3, 0);
expect(stroke.strokeWidthBottom).toBeCloseTo(4, 0);
expect(stroke.strokeWidthLeft).toBeCloseTo(5, 0);
});
test('stroke literals apply uniform width before side widths', (ctx) => {
const r = rect(ctx);
r.strokes = [
{
strokeColor: '#000000',
strokeWidthLeft: 8,
strokeWidthBottom: 8,
strokeWidthRight: 8,
strokeWidthTop: 8,
strokeWidth: 1,
},
];
const stroke = r.strokes[0];
expect(stroke.strokeWidthType).toBe('simple');
expect(stroke.strokeWidth).toBe(8);
expect(stroke.strokeWidthTop).toBe(8);
expect(stroke.strokeWidthRight).toBe(8);
expect(stroke.strokeWidthBottom).toBe(8);
expect(stroke.strokeWidthLeft).toBe(8);
stroke.strokeWidthRight = 12;
expect(stroke.strokeWidthType).toBe('multiple');
expect(stroke.strokeWidthTop).toBe(8);
stroke.strokeWidth = 3;
expect(stroke.strokeWidthType).toBe('simple');
expect(stroke.strokeWidthRight).toBe(3);
});
test('a top width change preserves the other uniform sides', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
r.strokes[0].strokeWidthTop = 8;
const stroke = r.strokes[0];
expect(stroke.strokeWidthType).toBe('multiple');
expect(stroke.strokeWidth).toBe(8);
expect(stroke.strokeWidthTop).toBe(8);
expect(stroke.strokeWidthRight).toBe(1);
expect(stroke.strokeWidthBottom).toBe(1);
expect(stroke.strokeWidthLeft).toBe(1);
});
test('side setters return to simple mode when all widths match', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
r.strokes[0].strokeWidthTop = 8;
expect(r.strokes[0].strokeWidthType).toBe('multiple');
r.strokes[0].strokeWidthRight = 8;
expect(r.strokes[0].strokeWidthType).toBe('multiple');
r.strokes[0].strokeWidthBottom = 8;
expect(r.strokes[0].strokeWidthType).toBe('multiple');
r.strokes[0].strokeWidthLeft = 8;
const stroke = r.strokes[0];
expect(stroke.strokeWidthType).toBe('simple');
expect(stroke.strokeWidth).toBe(8);
expect(stroke.strokeWidthTop).toBe(8);
expect(stroke.strokeWidthRight).toBe(8);
expect(stroke.strokeWidthBottom).toBe(8);
expect(stroke.strokeWidthLeft).toBe(8);
});
test('a uniform stroke reads the same width on every side', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 6 }];
const stroke = r.strokes[0];
expect(stroke.strokeWidthTop).toBeCloseTo(6, 0);
expect(stroke.strokeWidthRight).toBeCloseTo(6, 0);
expect(stroke.strokeWidthBottom).toBeCloseTo(6, 0);
expect(stroke.strokeWidthLeft).toBeCloseTo(6, 0);
});
test('setting strokeWidth flattens every side', (ctx) => {
const r = rect(ctx);
r.strokes = [
{
strokeColor: '#000000',
strokeWidth: 1,
strokeWidthTop: 2,
strokeWidthRight: 3,
strokeWidthBottom: 4,
strokeWidthLeft: 5,
},
];
const stroke = r.strokes[0];
stroke.strokeWidth = 7;
expect(stroke.strokeWidth).toBeCloseTo(7, 0);
expect(stroke.strokeWidthTop).toBeCloseTo(7, 0);
expect(stroke.strokeWidthRight).toBeCloseTo(7, 0);
expect(stroke.strokeWidthBottom).toBeCloseTo(7, 0);
expect(stroke.strokeWidthLeft).toBeCloseTo(7, 0);
});
test('stroke reference and gradient members round-trip', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
const stroke = r.strokes[0];
stroke.strokeColorRefId = '00000000-0000-0000-0000-000000000001';
stroke.strokeColorRefFile = '00000000-0000-0000-0000-000000000002';
expect(stroke.strokeColorRefId).toBe(
'00000000-0000-0000-0000-000000000001',
);
expect(stroke.strokeColorRefFile).toBe(
'00000000-0000-0000-0000-000000000002',
);
stroke.strokeColorGradient = {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
};
expect(stroke.strokeColorGradient).toBeDefined();
});
});
describe('Shadow', () => {
test('shadow members round-trip on the returned shadow', (ctx) => {
const r = rect(ctx);
r.shadows = [
{
style: 'drop-shadow',
offsetX: 1,
offsetY: 1,
blur: 2,
spread: 0,
hidden: false,
color: { color: '#000000', opacity: 1 },
},
];
const shadow = r.shadows[0];
shadow.style = 'inner-shadow';
shadow.offsetX = 5;
shadow.offsetY = 6;
shadow.blur = 7;
shadow.spread = 2;
shadow.hidden = true;
shadow.id = '00000000-0000-0000-0000-000000000003';
expect(shadow.style).toBe('inner-shadow');
expect(shadow.offsetX).toBeCloseTo(5, 0);
expect(shadow.offsetY).toBeCloseTo(6, 0);
expect(shadow.blur).toBeCloseTo(7, 0);
expect(shadow.spread).toBeCloseTo(2, 0);
expect(shadow.hidden).toBe(true);
});
// Skipped under MOCK_BACKEND: exercises uploadMediaData, which needs real
// backend media processing (ImageMagick) a mock can't reproduce.
test.skipIfMocked('shadow color members round-trip', async (ctx) => {
const image = await ctx.penpot.uploadMediaData(
'shadow-color-image',
PNG_1X1,
'image/png',
);
const r = rect(ctx);
r.shadows = [
{
style: 'drop-shadow',
offsetX: 1,
offsetY: 1,
blur: 2,
spread: 0,
hidden: false,
color: { color: '#000000', opacity: 1 },
},
];
const color = r.shadows[0].color;
expect(color).toBeDefined();
if (color) {
color.color = '#abcdef';
color.opacity = 0.4;
color.id = '00000000-0000-0000-0000-000000000004';
color.name = 'shadow-color';
color.path = 'group';
color.refId = '00000000-0000-0000-0000-000000000005';
color.refFile = '00000000-0000-0000-0000-000000000006';
color.fileId = '00000000-0000-0000-0000-000000000007';
// Color is a plain snapshot, so image set/read round-trips on it like
// the other members.
color.image = image;
expect(color.color).toBe('#abcdef');
expect(color.opacity).toBeCloseTo(0.4, 2);
expect(color.name).toBe('shadow-color');
expect(color.path).toBe('group');
expect(color.image).toBeDefined();
}
});
});
describe('Blur', () => {
test('blur members round-trip on the returned blur', (ctx) => {
const r = rect(ctx);
r.blur = { value: 5 };
const blur = r.blur;
expect(blur).toBeDefined();
if (blur) {
blur.value = 12;
blur.hidden = true;
blur.id = '00000000-0000-0000-0000-000000000008';
expect(blur.value).toBeCloseTo(12, 0);
expect(blur.hidden).toBe(true);
expect(blur.id).toBe('00000000-0000-0000-0000-000000000008');
}
});
test('negative blur value throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.blur = { value: -5 };
}).toThrow();
});
});
// ---------------------------------------------------------------------------
// Edge cases — gradients.
// ---------------------------------------------------------------------------
describe('Gradient — edge cases', () => {
test('a gradient stop offset outside 0..1 throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#0000ff', opacity: 1, offset: 1.5 },
],
},
},
];
}).toThrow();
});
test('a gradient with many stops at boundary offsets round-trips', (ctx) => {
const r = rect(ctx);
r.fills = [
{
fillColorGradient: {
type: 'linear',
startX: 0,
startY: 0,
endX: 1,
endY: 1,
width: 1,
stops: [
{ color: '#ff0000', opacity: 1, offset: 0 },
{ color: '#00ff00', opacity: 1, offset: 0.5 },
{ color: '#0000ff', opacity: 1, offset: 1 },
],
},
},
];
const fills = r.fills;
if (Array.isArray(fills)) {
const gradient = fills[0].fillColorGradient;
expect(gradient).toBeDefined();
if (gradient) {
expect(gradient.stops.length).toBe(3);
expect(gradient.stops[0].offset).toBeCloseTo(0, 2);
expect(gradient.stops[2].offset).toBeCloseTo(1, 2);
}
}
});
});
});