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

190 lines
8.3 KiB
JavaScript

// E2E: undo/redo flow.
//
// What this guards
// ────────────────
// The history subsystem is the most bug-bitten area of the codebase
// (see the May 22 / May 23 Refinements notes). useDesignEditor.test.js
// covers the hook-level behaviour exhaustively, but the keyboard
// shortcuts (Cmd+Z / Cmd+Shift+Z) are attached to the window via a
// global useEffect in App.jsx — that wiring isn't exercised by Vitest.
// This spec drives the actual keystrokes and verifies the right
// things happen.
//
// Two scenarios in particular are worth catching at this layer:
//
// 1. The keyboard handler's input/textarea exclusion. The handler
// bails out early when the focused element is an <input> or
// <textarea>, so the user can type a `z` into the text field
// without accidentally undoing. Vitest can't test this because
// it doesn't render real form elements with focus state.
//
// 2. The HistoryControls buttons disabling/enabling correctly. The
// pills have `disabled` attributes wired to canUndo/canRedo,
// and the visual state of those buttons is what tells the
// user "you can / can't go back further." A bug where the
// buttons stay enabled past the history floor would only be
// visible at the DOM-attribute level.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
pressUndo,
pressRedo,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('Cmd/Ctrl+Z removes the most recently added element', async ({ page }) => {
// The headline undo test. Add an element, undo, verify it's gone.
// This single keystroke covers the whole pipeline: the keyboard
// handler in App.jsx fires undo(), useDesignEditor walks the
// history pointer back, setElements applies the previous snapshot,
// LayersPanel re-renders.
await addTextViaSidebar(page, { text: 'Will be undone' });
expect(await getElementsCount(page)).toBe(1);
await pressUndo(page);
// The deleted element is gone; count drops to 0.
expect(await getElementsCount(page)).toBe(0);
});
test('Cmd/Ctrl+Shift+Z restores an undone element', async ({ page }) => {
// Redo after undo. The post-redo state must match the original
// (same text on the same element) — a regression where redo
// restored a different snapshot than what was undone would fail
// the layer-name assertion.
await addTextViaSidebar(page, { text: 'Resurrected' });
await pressUndo(page);
expect(await getElementsCount(page)).toBe(0);
await pressRedo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'Resurrected' })).toBeVisible();
});
test('typing `z` in the text field does NOT undo (keyboard handler input gate)', async ({ page }) => {
// The keyboard handler in App.jsx has an explicit early-return:
// if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Without this, typing a `z` into the textarea while holding a
// modifier would hijack the keystroke. (Just typing `z` without
// a modifier doesn't trigger the undo branch anyway, but Cmd+Z
// is the danger — except even Cmd+Z is suppressed because the
// user's cursor is in a field where Cmd+Z is the OS-native
// "undo last typed character" shortcut, which the browser
// handles.)
//
// We test the simpler case here: pressing Cmd+Z while focused
// on the textarea should NOT remove the element. The OS-native
// textarea undo may or may not fire — we don't care; we care
// that OUR keyboard handler doesn't fire and remove an element
// from the canvas.
await addTextViaSidebar(page, { text: 'Safe' });
expect(await getElementsCount(page)).toBe(1);
// Focus the textarea by clicking it explicitly. addTextViaSidebar
// leaves focus elsewhere by the time it returns; we have to
// re-focus deterministically.
const textarea = page.getByLabel('Your message');
await textarea.click();
await expect(textarea).toBeFocused();
// Now press Cmd+Z. The app's handler should bail out at the
// tagName check. The element on the canvas stays.
await pressUndo(page);
// Verification: element count is still 1.
expect(await getElementsCount(page)).toBe(1);
});
test('Undo and Redo buttons in the bottom bar reflect history state', async ({ page }) => {
// HistoryControls renders two buttons with aria-label "Undo" /
// "Redo" and a `disabled` attribute wired to canUndo / canRedo.
// The button states are how users (and screen readers) see
// whether stepping further is available.
//
// History initialization model (confirmed by running the suite):
// a fresh editor's history is seeded with the empty snapshot at
// idx=0. canUndo = `idx > 0`, so a fresh editor has canUndo false.
// The FIRST add pushes a new snapshot and advances idx to 1 — at
// which point canUndo flips to true because the user can step
// back to the empty floor. This contradicted my initial mental
// model ("first add gives idx=0") but matches what the app
// actually does; the May 22 cleanup arc relies on that empty
// floor being there as a restoration target.
const undoButton = page.getByRole('button', { name: 'Undo' });
const redoButton = page.getByRole('button', { name: 'Redo' });
// Fresh editor: history=[empty], idx=0.
// canUndo = 0 > 0 = false (we're at the floor)
// canRedo = 0 < length-1=0 = false (nothing to redo)
await expect(undoButton).toBeDisabled();
await expect(redoButton).toBeDisabled();
// After one add: history=[empty, [A]], idx=1.
// canUndo = 1 > 0 = true (can step back to empty)
// canRedo = 1 < length-1=1 = false (we're at the head)
await addTextViaSidebar(page, { text: 'First' });
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// After two adds: history=[empty, [A], [A,B]], idx=2.
// canUndo = 2 > 0 = true
// canRedo = 2 < length-1=2 = false
await addTextViaSidebar(page, { text: 'Second' });
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// Click Undo: idx 2 → 1. Now we're mid-history with both
// directions available.
// canUndo = 1 > 0 = true
// canRedo = 1 < length-1=2 = true (can re-apply the second add)
await undoButton.click();
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeEnabled();
// Click Redo: idx 1 → 2. Back to post-two-adds state.
await redoButton.click();
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// Finally, undo twice to reach the floor and confirm undo
// disables at idx=0. This is the assertion the first-add test
// ABOVE used to (incorrectly) make against a one-add history;
// the only way to actually get there is to wind all the way
// back to the seeded empty floor.
await undoButton.click(); // idx 2 → 1
await undoButton.click(); // idx 1 → 0 (the floor)
await expect(undoButton).toBeDisabled();
await expect(redoButton).toBeEnabled();
});
test('a new action after undo discards the redo stack', async ({ page }) => {
// Standard editor behaviour: after undo, redo is available. But
// a new edit branches history forward from the current idx,
// discarding anything that was previously to the right of it.
// This is the redo-stack truncation tested in
// useDesignEditor.test.js at the hook level; here we confirm
// it works end-to-end through the real keyboard handler.
await addTextViaSidebar(page, { text: 'A' });
await addTextViaSidebar(page, { text: 'B' });
await pressUndo(page);
// Now redo would restore "B".
await expect(page.getByRole('button', { name: 'Redo' })).toBeEnabled();
// New branch: add a different element. This should truncate the
// redo stack — "B" is gone from history.
await addTextViaSidebar(page, { text: 'C' });
await expect(page.getByRole('button', { name: 'Redo' })).toBeDisabled();
// Undoing now should land on the "just A" state, NOT "A + B".
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'A' })).toBeVisible();
// "B" should not be findable — it was truncated when we added C.
await expect(page.locator('.layers-item-name', { hasText: 'B' })).toHaveCount(0);
});