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

408 lines
14 KiB
TypeScript

import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Rectangle } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Interactions, overlays and animations.
// Interactions are added to a shape; navigate/overlay actions target boards, so
// destination boards are self-provisioned on the scratch board.
function board(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
function rect(ctx: TestContext): Rectangle {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
return r;
}
describe('Interactions', () => {
test('navigate-to interaction round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
expect(interaction.trigger).toBe('click');
expect(interaction.action.type).toBe('navigate-to');
if (interaction.action.type === 'navigate-to') {
expect(interaction.action.destination.id).toBe(dest.id);
}
expect(interaction.shape && interaction.shape.id).toBe(r.id);
expect(r.interactions.length).toBeGreaterThan(0);
});
test('open-url interaction round-trips', (ctx) => {
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-url',
url: 'https://example.com',
});
expect(interaction.action.type).toBe('open-url');
if (interaction.action.type === 'open-url') {
expect(interaction.action.url).toBe('https://example.com');
}
});
test('open-overlay interaction round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
position: 'manual',
manualPositionLocation: { x: 10, y: 20 },
closeWhenClickOutside: true,
addBackgroundOverlay: true,
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
expect(interaction.action.type).toBe('open-overlay');
if (interaction.action.type === 'open-overlay') {
expect(interaction.action.destination.id).toBe(overlay.id);
expect(interaction.action.position).toBe('manual');
expect(interaction.action.closeWhenClickOutside).toBe(true);
expect(interaction.action.addBackgroundOverlay).toBe(true);
}
});
test('open-overlay supports a non-manual position', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
// Per the types, manualPositionLocation is only needed for 'manual'.
const interaction = r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
position: 'center',
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
expect(interaction.action.type).toBe('open-overlay');
});
// position is optional; when omitted the overlay defaults to 'center'.
test('open-overlay without a position defaults to center', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
});
expect(interaction.action.type).toBe('open-overlay');
if (interaction.action.type === 'open-overlay') {
expect(interaction.action.destination.id).toBe(overlay.id);
expect(interaction.action.position).toBe('center');
}
});
test('toggle-overlay interaction round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'toggle-overlay',
destination: overlay,
position: 'manual',
manualPositionLocation: { x: 0, y: 0 },
animation: { type: 'dissolve', duration: 100, easing: 'linear' },
});
expect(interaction.action.type).toBe('toggle-overlay');
if (interaction.action.type === 'toggle-overlay') {
expect(interaction.action.destination.id).toBe(overlay.id);
}
});
test('close-overlay interaction round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'close-overlay',
destination: overlay,
animation: { type: 'dissolve', duration: 200, easing: 'linear' },
});
expect(interaction.action.type).toBe('close-overlay');
if (interaction.action.type === 'close-overlay') {
expect(
interaction.action.destination && interaction.action.destination.id,
).toBe(overlay.id);
expect(interaction.action.animation).toBeDefined();
}
});
// animation is optional on close-overlay; omitting it closes with no transition.
test('close-overlay without an animation round-trips', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'close-overlay',
destination: overlay,
});
expect(interaction.action.type).toBe('close-overlay');
if (interaction.action.type === 'close-overlay') {
expect(
interaction.action.destination && interaction.action.destination.id,
).toBe(overlay.id);
expect(interaction.action.animation).toBeUndefined();
}
});
test('previous-screen interaction round-trips', (ctx) => {
const r = rect(ctx);
const interaction = r.addInteraction('click', { type: 'previous-screen' });
expect(interaction.action.type).toBe('previous-screen');
});
test('after-delay trigger carries a delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
1000,
);
expect(interaction.trigger).toBe('after-delay');
expect(interaction.delay).toBeCloseTo(1000, 0);
});
// A zero delay is a valid value (fires immediately), not an error.
test('after-delay accepts a zero delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
0,
);
expect(interaction.trigger).toBe('after-delay');
expect(interaction.delay).toBeCloseTo(0, 0);
});
test('mouse-leave trigger is recorded', (ctx) => {
// click / mouse-enter / after-delay are covered above; mouse-leave is the
// remaining trigger.
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('mouse-leave', {
type: 'navigate-to',
destination: dest,
});
expect(interaction.trigger).toBe('mouse-leave');
});
// Pins persistence of the `delay` and `action` setters on an existing
// interaction (mutating after `addInteraction`). `misc.test.ts:300` exercises
// these setters' (set) coverage targets but never asserts that the new values
// stick; this fills that behavioural gap. (An older note claimed these setters
// "don't persist" — that is stale: CI confirms they do.)
test('interaction delay and action setters persist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
1000,
);
interaction.delay = 250;
interaction.action = { type: 'previous-screen' };
// Re-fetch from the shape so we prove persistence, not that the proxy
// reflects its own write.
const persisted = r.interactions[0];
expect(persisted.delay).toBeCloseTo(250, 0);
expect(persisted.action.type).toBe('previous-screen');
});
// The delay setter accepts zero (fires immediately) as a valid value.
test('delay setter accepts a zero value', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
1000,
);
interaction.delay = 0;
expect(interaction.delay).toBeCloseTo(0, 0);
});
test('removeInteraction removes an interaction from a shape', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
const before = r.interactions.length;
r.removeInteraction(interaction);
expect(r.interactions.length).toBe(before - 1);
});
describe('Animations', () => {
test('dissolve animation round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: { type: 'dissolve', duration: 300, easing: 'ease' },
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation
) {
expect(interaction.action.animation.type).toBe('dissolve');
if (interaction.action.animation.type === 'dissolve') {
expect(interaction.action.animation.duration).toBeCloseTo(300, 0);
expect(interaction.action.animation.easing).toBe('ease');
}
}
});
test('dissolve animation accepts every easing curve', (ctx) => {
// Only `linear` and `ease` are exercised elsewhere; cover the remaining
// easing curves so a single broken curve is caught.
for (const easing of ['ease-in', 'ease-out', 'ease-in-out'] as const) {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: { type: 'dissolve', duration: 200, easing },
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation &&
interaction.action.animation.type === 'dissolve'
) {
expect(interaction.action.animation.easing).toBe(easing);
}
}
});
test('slide animation round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: {
type: 'slide',
way: 'in',
direction: 'right',
duration: 300,
easing: 'linear',
},
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation
) {
expect(interaction.action.animation.type).toBe('slide');
if (interaction.action.animation.type === 'slide') {
expect(interaction.action.animation.way).toBe('in');
expect(interaction.action.animation.direction).toBe('right');
expect(interaction.action.animation.duration).toBeCloseTo(300, 0);
}
}
});
test('push animation round-trips', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
animation: {
type: 'push',
direction: 'left',
duration: 300,
easing: 'linear',
},
});
if (
interaction.action.type === 'navigate-to' &&
interaction.action.animation
) {
expect(interaction.action.animation.type).toBe('push');
if (interaction.action.animation.type === 'push') {
expect(interaction.action.animation.direction).toBe('left');
expect(interaction.action.animation.duration).toBeCloseTo(300, 0);
}
}
});
});
test('an interaction can be removed', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
const before = r.interactions.length;
interaction.remove();
expect(r.interactions.length).toBe(before - 1);
});
test('interaction trigger can be changed', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
interaction.trigger = 'mouse-enter';
expect(interaction.trigger).toBe('mouse-enter');
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert invalid interaction input is
// rejected; the "success" test checks several triggers coexisting.
// ---------------------------------------------------------------------------
// addInteraction validates the interaction's structure (schema) but not the
// liveness of a navigate destination nor the format of an open-url string,
// so both of these are accepted rather than rejected. These pin the current
// (lenient) behaviour.
test('navigate-to a removed board is accepted (dangling destination)', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
dest.remove();
expect(() =>
r.addInteraction('click', { type: 'navigate-to', destination: dest }),
).not.toThrow();
});
test('open-url accepts an arbitrary url string', (ctx) => {
const r = rect(ctx);
const interaction = r.addInteraction('click', {
type: 'open-url',
url: 'not a valid url',
});
expect(interaction.action.type).toBe('open-url');
if (interaction.action.type === 'open-url') {
expect(interaction.action.url).toBe('not a valid url');
}
});
test('several triggers on one shape coexist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
r.addInteraction('click', { type: 'navigate-to', destination: dest });
r.addInteraction('mouse-enter', {
type: 'navigate-to',
destination: dest,
});
expect(r.interactions).toHaveLength(2);
expect(r.interactions.map((i) => i.trigger).sort()).toEqual([
'click',
'mouse-enter',
]);
});
});