Files
apparel-designer/e2e/text-editing.spec.js
2026-05-23 03:28:58 -05:00

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);
});