139 lines
6.3 KiB
JavaScript
139 lines
6.3 KiB
JavaScript
// E2E: text element CRUD via the Text tab.
|
|
//
|
|
// What this guards
|
|
// ────────────────
|
|
// The most common editing flow in the app: open the Text tab, type a
|
|
// message, add it to the canvas, optionally edit or delete it.
|
|
// Vitest covers each piece (TextTab's local state, useDesignEditor's
|
|
// addElement, LayersPanel rendering) but not the whole pipeline as
|
|
// the user experiences it. This spec runs the actual integration.
|
|
//
|
|
// Why E2E rather than RTL component tests
|
|
// ───────────────────────────────────────
|
|
// TextTab is leaf-ish but composed with Sidebar, App's selectedElement
|
|
// logic, and useDesignEditor — testing it in RTL would require either
|
|
// mocking half the app (brittle) or rendering the real App with jsdom
|
|
// (which then breaks on Konva, because jsdom doesn't have a real
|
|
// canvas). Playwright drives the actual Chromium where everything
|
|
// works, including Konva.
|
|
//
|
|
// Selector strategy
|
|
// ─────────────────
|
|
// We use accessible queries throughout — getByRole, getByLabel,
|
|
// getByText. CSS class selectors would be tighter to the
|
|
// implementation and break on any styling refactor; the accessible
|
|
// roles describe what the user is looking at, which is stable across
|
|
// styling changes. The cost is occasional verbosity (`getByRole('tab',
|
|
// { name: 'Text' })` vs `.tab-text`) but the test stays valid as
|
|
// styles iterate.
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
import {
|
|
gotoFreshEditor,
|
|
addTextViaSidebar,
|
|
getElementsCount,
|
|
} from './fixtures/editor.js';
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
// Every test starts on the editor with empty state. See
|
|
// gotoFreshEditor's docblock for the full reset sequence —
|
|
// localStorage + service workers + caches all wiped.
|
|
await gotoFreshEditor(page);
|
|
});
|
|
|
|
test('starts with zero elements', async ({ page }) => {
|
|
// Empty-state check. The LayersPanel renders a banner instead of
|
|
// a title when there are no elements, so getElementsCount returns
|
|
// 0 by checking for the title's absence.
|
|
expect(await getElementsCount(page)).toBe(0);
|
|
// The empty-state message is the canonical "no layers" indicator.
|
|
// We assert it's visible to confirm we're really on an empty
|
|
// editor — a partially-loaded editor without elements would also
|
|
// have a 0 count but no empty-state banner.
|
|
await expect(page.getByText('No elements yet')).toBeVisible();
|
|
});
|
|
|
|
test('adds a text element via the sidebar and shows it in the Layers panel', async ({ page }) => {
|
|
// The headline text-editing flow. addTextViaSidebar drives the
|
|
// Text tab → fill textarea → click Add → wait for the layer count
|
|
// to increment. After it returns, we make user-observable
|
|
// assertions about what's on the canvas.
|
|
await addTextViaSidebar(page, { text: 'Hello world' });
|
|
|
|
// Count is now 1.
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
|
|
// The LayersPanel row for the new element. LayersPanel uses
|
|
// `getName(el)` which returns the text content for text elements,
|
|
// so "Hello world" should appear as the row label.
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Hello world' })).toBeVisible();
|
|
});
|
|
|
|
test('adding two text elements lists both in the Layers panel, newest on top', async ({ page }) => {
|
|
// LayersPanel docblock: "Lists all elements on the canvas in
|
|
// render order (last in array = topmost)." The panel renders
|
|
// top-down with the most recently added on top, mirroring how
|
|
// users mentally stack layers in design tools.
|
|
await addTextViaSidebar(page, { text: 'First' });
|
|
await addTextViaSidebar(page, { text: 'Second' });
|
|
|
|
expect(await getElementsCount(page)).toBe(2);
|
|
|
|
// Order check: read all row names and verify "Second" precedes
|
|
// "First". allTextContents resolves to an array in DOM order;
|
|
// for a flex column that's top-to-bottom.
|
|
const names = await page.locator('.layers-item-name').allTextContents();
|
|
expect(names.indexOf('Second')).toBeLessThan(names.indexOf('First'));
|
|
});
|
|
|
|
test('editing the textarea after selecting a text element updates the layer name', async ({ page }) => {
|
|
// Bidirectional editing: selecting an existing text element on
|
|
// the canvas (or via the layers panel, which is what we do here
|
|
// since we don't want to deal with canvas pixel coordinates)
|
|
// should put the Text tab into edit mode against that element.
|
|
// Typing into the textarea then flows back to update the element's
|
|
// text on canvas AND in the layers panel.
|
|
await addTextViaSidebar(page, { text: 'Original' });
|
|
|
|
// Select the layer's row to make it the active element.
|
|
await page.locator('.layers-item-main', { hasText: 'Original' }).click();
|
|
|
|
// The Text tab auto-switches on selection (desktop auto-tab-switch
|
|
// effect in App.jsx). The textarea should now contain the
|
|
// selected element's text.
|
|
const textarea = page.getByLabel('Your message');
|
|
await expect(textarea).toHaveValue('Original');
|
|
|
|
// Edit it. fill() clears + types, mirroring a user selecting all
|
|
// and re-typing.
|
|
await textarea.fill('Updated');
|
|
|
|
// The layer name in the panel should reflect the new text. The
|
|
// update is debounced (300ms in useDesignEditor.updateElement),
|
|
// and the layer name reads from element.text directly, so it
|
|
// shows the new value as soon as state propagates.
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Updated' })).toBeVisible();
|
|
|
|
// And the count is still 1 — editing doesn't add a new element.
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
});
|
|
|
|
test('deleting a text element from the Layers panel removes it', async ({ page }) => {
|
|
await addTextViaSidebar(page, { text: 'Doomed' });
|
|
await addTextViaSidebar(page, { text: 'Survivor' });
|
|
|
|
expect(await getElementsCount(page)).toBe(2);
|
|
|
|
// Each row has a trash-can delete button as a sibling of the
|
|
// main row button. LayersPanel labels it
|
|
// `aria-label="Delete ${getName(element)}"`, so we can target
|
|
// the specific row's delete button by accessible name.
|
|
await page.getByRole('button', { name: 'Delete Doomed' }).click();
|
|
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
// The remaining row is the one we didn't touch.
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Survivor' })).toBeVisible();
|
|
// And the deleted row is gone.
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Doomed' })).toHaveCount(0);
|
|
});
|