mirror of
https://github.com/penpot/penpot.git
synced 2026-11-01 15:40:41 +00:00
* ✨ 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>
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import { expect } from '../framework/expect';
|
|
import { describe, test } from '../framework/registry';
|
|
import type { TestContext } from '../framework/types';
|
|
|
|
// Fills & strokes.
|
|
// Fills/strokes are assigned as whole arrays of plain objects and read back
|
|
// through the shape proxy, so the Fill/Stroke getters are what coverage records
|
|
// (the per-property setters are not individually settable at runtime).
|
|
//
|
|
// Each group bundles its happy-path round-trips together with the related edge
|
|
// cases: "throws" tests assert invalid input is rejected, the "(currently
|
|
// unvalidated)" tests pin lenient behaviour, and the remaining ones cover
|
|
// non-trivial valid behaviour (ordering, type switching, multiple strokes,
|
|
// clearing).
|
|
|
|
function rect(ctx: TestContext) {
|
|
const r = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(r);
|
|
return r;
|
|
}
|
|
|
|
describe('Fills & strokes', () => {
|
|
describe('Fills', () => {
|
|
test('solid fill color and opacity round-trip', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.fills = [{ fillColor: '#ff0000', fillOpacity: 0.5 }];
|
|
|
|
const fills = r.fills;
|
|
expect(fills).toHaveLength(1);
|
|
if (Array.isArray(fills)) {
|
|
expect(fills[0].fillColor).toBe('#ff0000');
|
|
expect(fills[0].fillOpacity).toBeCloseTo(0.5, 2);
|
|
}
|
|
});
|
|
|
|
test('gradient fill is preserved', (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();
|
|
expect(gradient && gradient.type).toBe('linear');
|
|
}
|
|
});
|
|
|
|
test('multiple fills can be stacked', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.fills = [
|
|
{ fillColor: '#ff0000', fillOpacity: 0.5 },
|
|
{ fillColor: '#0000ff', fillOpacity: 0.5 },
|
|
];
|
|
expect(r.fills).toHaveLength(2);
|
|
});
|
|
|
|
test('multiple fills preserve their order', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.fills = [
|
|
{ fillColor: '#ff0000', fillOpacity: 1 },
|
|
{ fillColor: '#00ff00', fillOpacity: 1 },
|
|
{ fillColor: '#0000ff', fillOpacity: 1 },
|
|
];
|
|
const fills = r.fills;
|
|
expect(fills).toHaveLength(3);
|
|
if (Array.isArray(fills)) {
|
|
expect(fills.map((f) => f.fillColor)).toEqual([
|
|
'#ff0000',
|
|
'#00ff00',
|
|
'#0000ff',
|
|
]);
|
|
}
|
|
});
|
|
|
|
test('a fill can switch solid -> gradient -> solid', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
|
|
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 },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
let fills = r.fills;
|
|
if (Array.isArray(fills)) {
|
|
expect(fills[0].fillColorGradient).toBeDefined();
|
|
}
|
|
r.fills = [{ fillColor: '#00ff00', fillOpacity: 1 }];
|
|
fills = r.fills;
|
|
if (Array.isArray(fills)) {
|
|
expect(fills[0].fillColor).toBe('#00ff00');
|
|
// Switching back to a solid fill clears the gradient (read back as null).
|
|
expect(fills[0].fillColorGradient).toBeFalsy();
|
|
}
|
|
});
|
|
|
|
test('a gradient-only fill reads back no solid fillColor', (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)) {
|
|
expect(fills[0].fillColorGradient).toBeDefined();
|
|
// A gradient fill carries no solid color.
|
|
expect(fills[0].fillColor).toBeFalsy();
|
|
}
|
|
});
|
|
|
|
test('fillOpacity above 1 throws', (ctx) => {
|
|
const r = rect(ctx);
|
|
expect(() => {
|
|
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1.5 }];
|
|
}).toThrow();
|
|
});
|
|
|
|
test('fillOpacity below 0 throws', (ctx) => {
|
|
const r = rect(ctx);
|
|
expect(() => {
|
|
r.fills = [{ fillColor: '#ff0000', fillOpacity: -0.5 }];
|
|
}).toThrow();
|
|
});
|
|
|
|
test('setting fills on a group is accepted (currently unvalidated)', (ctx) => {
|
|
// The plugin API does not block fills on groups, so the assignment is
|
|
// accepted rather than rejected. This pins the current (lenient) behaviour.
|
|
const a = ctx.penpot.createRectangle();
|
|
const b = ctx.penpot.createRectangle();
|
|
ctx.board.appendChild(a);
|
|
ctx.board.appendChild(b);
|
|
const group = ctx.penpot.group([a, b]);
|
|
expect(group).not.toBeNull();
|
|
if (group) {
|
|
expect(() => {
|
|
group.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
|
|
}).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test('assigning empty arrays clears fills and strokes', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
|
|
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
|
|
r.fills = [];
|
|
r.strokes = [];
|
|
expect(r.fills).toHaveLength(0);
|
|
expect(r.strokes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Strokes', () => {
|
|
test('stroke properties round-trip', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.strokes = [
|
|
{
|
|
strokeColor: '#0000ff',
|
|
strokeOpacity: 1,
|
|
strokeStyle: 'solid',
|
|
strokeWidth: 3,
|
|
strokeAlignment: 'center',
|
|
},
|
|
];
|
|
|
|
expect(r.strokes).toHaveLength(1);
|
|
const stroke = r.strokes[0];
|
|
expect(stroke.strokeColor).toBe('#0000ff');
|
|
expect(stroke.strokeOpacity).toBeCloseTo(1, 2);
|
|
expect(stroke.strokeStyle).toBe('solid');
|
|
expect(stroke.strokeWidth).toBeCloseTo(3, 0);
|
|
expect(stroke.strokeAlignment).toBe('center');
|
|
});
|
|
|
|
test('stroke caps round-trip on an open path', (ctx) => {
|
|
const path = ctx.penpot.createPath();
|
|
ctx.board.appendChild(path);
|
|
path.d = 'M0 0 L40 0';
|
|
path.strokes = [
|
|
{
|
|
strokeColor: '#000000',
|
|
strokeWidth: 4,
|
|
strokeCapStart: 'round',
|
|
strokeCapEnd: 'triangle-arrow',
|
|
},
|
|
];
|
|
|
|
const stroke = path.strokes[0];
|
|
expect(stroke.strokeCapStart).toBe('round');
|
|
expect(stroke.strokeCapEnd).toBe('triangle-arrow');
|
|
});
|
|
|
|
test('per-side widths survive an unrelated write', (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.strokeColor = '#ff0000';
|
|
|
|
const updated = r.strokes[0];
|
|
expect(updated.strokeColor).toBe('#ff0000');
|
|
expect(updated.strokeWidthTop).toBeCloseTo(2, 0);
|
|
expect(updated.strokeWidthRight).toBeCloseTo(3, 0);
|
|
expect(updated.strokeWidthBottom).toBeCloseTo(4, 0);
|
|
expect(updated.strokeWidthLeft).toBeCloseTo(5, 0);
|
|
});
|
|
|
|
test('a literal without per-side keys reads uniform', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.strokes = [{ strokeColor: '#000000', strokeWidth: 6 }];
|
|
expect(r.strokes[0].strokeWidth).toBeCloseTo(6, 0);
|
|
expect(r.strokes[0].strokeWidthTop).toBeCloseTo(6, 0);
|
|
expect(r.strokes[0].strokeWidthLeft).toBeCloseTo(6, 0);
|
|
});
|
|
|
|
test('a literal side width preserves the other uniform widths', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.strokes = [
|
|
{ strokeColor: '#000000', strokeWidthTop: 9, strokeWidth: 5 },
|
|
];
|
|
|
|
const stroke = r.strokes[0];
|
|
expect(stroke.strokeWidthType).toBe('multiple');
|
|
expect(stroke.strokeWidth).toBe(9);
|
|
expect(stroke.strokeWidthTop).toBe(9);
|
|
expect(stroke.strokeWidthRight).toBe(5);
|
|
expect(stroke.strokeWidthBottom).toBe(5);
|
|
expect(stroke.strokeWidthLeft).toBe(5);
|
|
});
|
|
|
|
test('dashed stroke style is preserved', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.strokes = [
|
|
{ strokeColor: '#00ff00', strokeWidth: 2, strokeStyle: 'dashed' },
|
|
];
|
|
expect(r.strokes[0].strokeStyle).toBe('dashed');
|
|
});
|
|
|
|
test('dotted stroke style is preserved', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.strokes = [
|
|
{ strokeColor: '#0000ff', strokeWidth: 2, strokeStyle: 'dotted' },
|
|
];
|
|
expect(r.strokes[0].strokeStyle).toBe('dotted');
|
|
});
|
|
|
|
test("stroke style 'none' is rejected at runtime (d.ts lists it)", (ctx) => {
|
|
// The d.ts allows strokeStyle 'none', but the runtime rejects it as an
|
|
// invalid value ("Value not valid"), so with throwValidationErrors it
|
|
// throws. Pins the current d.ts/runtime mismatch.
|
|
const r = rect(ctx);
|
|
expect(() => {
|
|
r.strokes = [
|
|
{ strokeColor: '#0000ff', strokeWidth: 2, strokeStyle: 'none' },
|
|
];
|
|
}).toThrow();
|
|
});
|
|
|
|
test('two strokes with different alignment coexist', (ctx) => {
|
|
const r = rect(ctx);
|
|
r.strokes = [
|
|
{ strokeColor: '#000000', strokeWidth: 2, strokeAlignment: 'inner' },
|
|
{ strokeColor: '#ffffff', strokeWidth: 1, strokeAlignment: 'outer' },
|
|
];
|
|
expect(r.strokes).toHaveLength(2);
|
|
expect(r.strokes.map((s) => s.strokeAlignment).sort()).toEqual([
|
|
'inner',
|
|
'outer',
|
|
]);
|
|
});
|
|
|
|
test('negative strokeWidth throws', (ctx) => {
|
|
const r = rect(ctx);
|
|
expect(() => {
|
|
r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }];
|
|
}).toThrow();
|
|
|
|
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
|
|
expect(() => {
|
|
r.strokes[0].strokeWidth = -1;
|
|
}).toThrow();
|
|
});
|
|
|
|
test('invalid strokeStyle throws', (ctx) => {
|
|
const r = rect(ctx);
|
|
expect(() => {
|
|
r.strokes = [
|
|
{
|
|
strokeColor: '#000000',
|
|
strokeWidth: 1,
|
|
strokeStyle: 'wavy' as unknown as 'solid',
|
|
},
|
|
];
|
|
}).toThrow();
|
|
});
|
|
|
|
test('invalid strokeAlignment throws', (ctx) => {
|
|
const r = rect(ctx);
|
|
expect(() => {
|
|
r.strokes = [
|
|
{
|
|
strokeColor: '#000000',
|
|
strokeWidth: 1,
|
|
strokeAlignment: 'middle' as unknown as 'center',
|
|
},
|
|
];
|
|
}).toThrow();
|
|
});
|
|
});
|
|
});
|