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 = board(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 = board(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 = board(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 = board(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); }); // Removing an interaction shifts the ones after it, so draining a shape from // a single read of the array must reach every interaction it returned. Both // removal entry points are covered. test('every interaction can be removed from one read of the array', async (ctx) => { const r = rect(ctx); r.addInteraction('click', { type: 'open-url', url: 'https://a.example' }); await ctx.penpot.waitForLayoutUpdate(); r.addInteraction('mouse-enter', { type: 'open-url', url: 'https://b.example', }); await ctx.penpot.waitForLayoutUpdate(); expect(r.interactions).toHaveLength(2); for (const interaction of r.interactions) { interaction.remove(); await ctx.penpot.waitForLayoutUpdate(); } expect(r.interactions).toHaveLength(0); }); test('removeInteraction can drain a shape from one read of the array', async (ctx) => { const r = rect(ctx); r.addInteraction('click', { type: 'open-url', url: 'https://a.example' }); await ctx.penpot.waitForLayoutUpdate(); r.addInteraction('mouse-enter', { type: 'open-url', url: 'https://b.example', }); await ctx.penpot.waitForLayoutUpdate(); expect(r.interactions).toHaveLength(2); for (const interaction of r.interactions) { r.removeInteraction(interaction); await ctx.penpot.waitForLayoutUpdate(); } expect(r.interactions).toHaveLength(0); }); // A held interaction addresses itself rather than a position, so a write // reaches it even once an earlier interaction has shifted it. test('an interaction still writes to itself after an earlier one is removed', async (ctx) => { const r = rect(ctx); for (const trigger of ['click', 'mouse-enter', 'mouse-leave'] as const) { r.addInteraction(trigger, { type: 'open-url', url: `https://${trigger}.example`, }); await ctx.penpot.waitForLayoutUpdate(); } const [first, , last] = r.interactions; first.remove(); await ctx.penpot.waitForLayoutUpdate(); last.delay = 500; await ctx.penpot.waitForLayoutUpdate(); expect(r.interactions.map((i) => i.trigger)).toEqual([ 'mouse-enter', 'mouse-leave', ]); expect(r.interactions.map((i) => i.delay)).toEqual([null, 500]); }); // A proxy whose own interaction is gone resolves to no position, so the write // has nothing to address and the API rejects it. test('a write through a removed interaction is rejected', async (ctx) => { const r = rect(ctx); r.addInteraction('click', { type: 'open-url', url: 'https://example.com', }); await ctx.penpot.waitForLayoutUpdate(); const [only] = r.interactions; only.remove(); await ctx.penpot.waitForLayoutUpdate(); expect(r.interactions).toHaveLength(0); expect(() => { only.delay = 500; }).toThrow('The interaction is not part of the shape anymore'); await ctx.penpot.waitForLayoutUpdate(); expect(r.interactions).toHaveLength(0); }); 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'); }); test('unknown interaction triggers are rejected', (ctx) => { const dest = board(ctx); const r = rect(ctx); expect(() => r.addInteraction('unknown-trigger' as unknown as 'click', { type: 'navigate-to', destination: dest, }), ).toThrow(); const interaction = r.addInteraction('click', { type: 'navigate-to', destination: dest, }); expect(() => { interaction.trigger = 'unknown-trigger' as unknown as 'click'; }).toThrow(); }); // --------------------------------------------------------------------------- // Edge cases. "fail" tests assert invalid interaction input is // rejected; the "success" test checks several triggers coexisting. // --------------------------------------------------------------------------- test('navigate-to a removed board throws', (ctx) => { const dest = board(ctx); const r = rect(ctx); dest.remove(); expect(() => r.addInteraction('click', { type: 'navigate-to', destination: dest }), ).toThrow(); }); test('open-url rejects invalid input and normalizes a bare hostname', (ctx) => { const r = rect(ctx); expect(() => r.addInteraction('click', { type: 'open-url', url: 'not a valid url', }), ).toThrow(); expect(() => r.addInteraction('click', { type: 'open-url', url: 'ftp://example.com/file', }), ).toThrow(); const interaction = r.addInteraction('click', { type: 'open-url', url: 'example.com/path', }); expect(interaction.action.type).toBe('open-url'); if (interaction.action.type === 'open-url') { expect(interaction.action.url).toBe('http://example.com/path'); } }); test('after-delay is board-only and initializes its default delay', (ctx) => { const dest = board(ctx); const r = rect(ctx); expect(() => r.addInteraction('after-delay', { type: 'navigate-to', destination: dest, }), ).toThrow(); const source = board(ctx); const interaction = source.addInteraction('click', { type: 'navigate-to', destination: dest, }); interaction.trigger = 'after-delay'; expect(interaction.delay).toBeCloseTo(600, 0); expect(() => { const invalid = r.addInteraction('click', { type: 'navigate-to', destination: dest, }); invalid.trigger = 'after-delay'; }).toThrow(); }); test('after-delay creation rejects invalid delays', (ctx) => { const dest = board(ctx); const source = board(ctx); for (const delay of ['bad', 1.5, -1]) { expect(() => source.addInteraction( 'after-delay', { type: 'navigate-to', destination: dest }, delay as unknown as number, ), ).toThrow(); } const interaction = source.addInteraction( 'after-delay', { type: 'navigate-to', destination: dest }, 10, ); for (const delay of ['bad', 1.5, -1]) { expect(() => { interaction.delay = delay as unknown as number; }).toThrow(); } }); test('navigation destinations must be eligible boards', (ctx) => { const source = board(ctx); const child = rect(ctx); const rectangle = rect(ctx); expect(() => source.addInteraction('click', { type: 'navigate-to', destination: source, }), ).toThrow(); expect(() => child.addInteraction('click', { type: 'navigate-to', destination: ctx.board, }), ).toThrow(); expect(() => source.addInteraction('click', { type: 'navigate-to', destination: rectangle as unknown as Board, }), ).toThrow(); }); test('interaction destinations must belong to the current page', async (ctx) => { const original = ctx.penpot.currentPage; expect(original).not.toBeNull(); if (!original) return; const source = rect(ctx); const localDestination = board(ctx); const interaction = source.addInteraction('click', { type: 'navigate-to', destination: localDestination, }); const otherPage = ctx.penpot.createPage(); try { await ctx.penpot.openPage(otherPage); const otherBoard = ctx.penpot.createBoard(); (otherPage.root as Board).appendChild(otherBoard); await ctx.penpot.openPage(original); expect(() => source.addInteraction('click', { type: 'navigate-to', destination: otherBoard, }), ).toThrow(); expect(() => { interaction.action = { type: 'navigate-to', destination: otherBoard, }; }).toThrow(); } finally { if (ctx.penpot.currentPage?.id !== original.id) { await ctx.penpot.openPage(original); } otherPage.remove(); } }); test('push animation is rejected for overlay actions and replacements', (ctx) => { const overlay = board(ctx); const r = rect(ctx); const push = { type: 'push' as const, direction: 'left' as const, duration: 300, easing: 'linear' as const, }; expect(() => r.addInteraction('click', { type: 'open-overlay', destination: overlay, animation: push, }), ).toThrow(); const interaction = r.addInteraction('click', { type: 'navigate-to', destination: overlay, }); expect(() => { interaction.action = { type: 'open-overlay', destination: overlay, animation: push, }; }).toThrow(); }); 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', ]); }); });