houserules/packages/chess/e2e/custom-modifier-visual.spec.ts
Joey Yakimowich-Payne 9e31b6d682
test(chess/e2e): expand visual-builder Playwright suite to 11 scenarios
Replaces the original single-flow stub with a comprehensive suite
that exercises every user-facing behaviour of the visual authoring
surface against a live Vite dev server. 11 tests run in 23s.

Scenarios:

 1. Mode toggle persists across reload
    Opens editor, toggles to Visual, verifies aria-pressed state +
    localStorage key, reloads, confirms Visual is still active on
    the next mount.

 2. Palette click adds top-level primitive to block list
    Clicks palette-btn-on-turn-end, verifies block-card-on-turn-end
    appears with matching aria-label and the narrative preview
    mentions "turn end". Asserts via aria-label rather than visible
    text so the open inspector docs do not cause strict-mode matches.

 3. Clicking × removes the block without triggering a drag
    Adds three blocks, clicks the × button on the middle one,
    verifies it is gone AND that dnd-kits assertive announcer never
    reported "Picked up sortable item" — direct regression for the
    drag-handle isolation fix.

 4. Clicking expand toggles the block without triggering a drag
    Asserts aria-expanded flips from false to true on click without
    the card being removed or reordered.

 5. Selecting a trigger makes palette clicks add children
    Adds on-turn-end, selects it, verifies palette-add-target-banner
    appears with the parent label, then clicks add-to-attribute and
    asserts there is exactly one add-to-attribute block AND it is
    a descendant of block-card-on-turn-end.

 6. "Add at top level instead" resets the nested-target selection
    After entering nested-add mode, clicks the escape button and
    verifies subsequent palette adds are top-level siblings.

 7. × on a nested child removes only that child
    Verifies nested removal leaves the parent intact.

 8. Preview narrative reflects tree mutations immediately
    Adds primitive and checks narrative; removes and checks narrative
    no longer mentions the seeded attribute.

 9. Save → reload → load preserves the composed descriptor
    Fills inspector fields (attr=Hp, delta=1), saves, reloads,
    confirms Visual mode is remembered, loads from library, verifies
    inspector value survived the round-trip.

10. Depth-4 descriptor surfaces validation banner + disables save
    Pre-seeds localStorage with a depth-4 descriptor (conditional
    → on-capture → on-damaged → conditional → add-to-attribute),
    loads it, verifies the validation banner renders and the Save
    button becomes disabled.

11. Toggling Form ↔ Visual preserves the composed descriptor
    Composes a tree, captures the JSON preview, toggles to Form and
    back to Visual, verifies JSON preview is byte-identical.

The spec uses data-testid selectors exclusively where possible,
falls back to aria-label for the block card outer <article>, and
uses click({ position }) to land on the card header (avoiding the
× / expand / grip / inspector overlays).
2026-04-21 19:47:21 -06:00

536 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* T25 — End-to-end suite for the visual modifier builder.
*
* Exercises the full visual-mode authoring surface end-to-end against
* a live Vite dev server. The unit tests under
* `src/ui/visual-builder/**` cover props, rendering shapes, and pure
* tree mutations in isolation; this spec covers integration: real
* pointer events, real localStorage, real dnd-kit wiring, real
* narrate → preview composition, real validator round-trips.
*
* Scenarios:
* 1. Mode toggle persistence across reloads
* 2. Palette click adds top-level primitive + appears in block list
* 3. Drag-handle isolation — clicking × removes block, does NOT drag
* 4. Drag-handle isolation — clicking expand toggles, does NOT drag
* 5. Nested add — selecting a trigger targets new primitives as children
* 6. Nested add — auto-expands parent + "Add at top level instead" resets
* 7. Nested remove — × on a child primitive removes only that child
* 8. Preview narrative updates live as the tree mutates
* 9. Save → reload → load round-trip preserves the composed descriptor
* 10. Validation banner appears on depth-4 composition + save stays disabled
* 11. Form ↔ Visual toggle preserves descriptor content byte-equal
*/
import { test, expect, type Page } from '@playwright/test';
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
const EDITOR_MODE_KEY = 'houserules:custom-modifier-editor-mode:v1';
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
/** Wipe both libraries + mode pref and return to a clean lobby. */
async function freshLobby(page: Page): Promise<void> {
await page.goto('/');
await page.evaluate(
({ customKey, modeKey, profileKey }) => {
localStorage.removeItem(customKey);
localStorage.removeItem(modeKey);
localStorage.removeItem(profileKey);
sessionStorage.clear();
},
{
customKey: CUSTOM_LIBRARY_KEY,
modeKey: EDITOR_MODE_KEY,
profileKey: PROFILE_LIBRARY_KEY,
},
);
await page.reload();
await page.waitForSelector('[data-testid="page-home"]');
}
/** Open the Custom Modifier Editor via the standard lobby flow. */
async function openCustomModifierEditor(page: Page): Promise<void> {
await page.getByTestId('profile-picker').selectOption('custom');
await expect(
page
.getByTestId('per-type-panel-paste')
.or(page.locator('[role="dialog"], .fixed.inset-0').first()),
).toBeVisible({ timeout: 3000 });
await page.getByTestId('open-custom-modifier-editor').click();
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
timeout: 3000,
});
}
/** Switch the editor into Visual mode. */
async function switchToVisualMode(page: Page): Promise<void> {
await page.getByTestId('custom-modifier-editor-mode-visual').click();
// Palette is the clearest visual-mode marker.
await expect(page.getByTestId('palette-btn-on-move')).toBeVisible();
await expect(page.getByTestId('preview-pane')).toBeVisible();
}
/**
* Select a block by its kind. Clicks into the card's header region
* (top 40px) so the click lands on the card body, not on the expand /
* × buttons that live in the same row, nor on the inspector docs that
* render below when the card is already selected.
*/
async function selectBlockCard(page: Page, kind: string): Promise<void> {
const card = page.getByTestId(`block-card-${kind}`).first();
await expect(card).toBeVisible();
await card.click({ position: { x: 100, y: 12 } });
}
test.describe('Visual modifier builder — T25 e2e', () => {
test.beforeEach(async ({ page }) => {
await freshLobby(page);
});
// ---------------------------------------------------------------
// 1. Mode toggle persistence
// ---------------------------------------------------------------
test('mode toggle persists across reload', async ({ page }) => {
await openCustomModifierEditor(page);
// Default mode is Form.
await expect(
page.getByTestId('custom-modifier-editor-mode-form'),
).toHaveAttribute('aria-pressed', 'true');
await expect(
page.getByTestId('custom-modifier-editor-mode-visual'),
).toHaveAttribute('aria-pressed', 'false');
// Switch to Visual.
await switchToVisualMode(page);
await expect(
page.getByTestId('custom-modifier-editor-mode-visual'),
).toHaveAttribute('aria-pressed', 'true');
// localStorage has the preference.
const stored = await page.evaluate(
(key) => localStorage.getItem(key),
EDITOR_MODE_KEY,
);
expect(stored).toBe('visual');
// Reload — mode should still be Visual on the way back in.
await page.reload();
await page.waitForSelector('[data-testid="page-home"]');
await openCustomModifierEditor(page);
await expect(
page.getByTestId('custom-modifier-editor-mode-visual'),
).toHaveAttribute('aria-pressed', 'true');
// Palette rendered without an explicit click.
await expect(page.getByTestId('palette-btn-on-move')).toBeVisible();
});
// ---------------------------------------------------------------
// 2. Palette add (top-level)
// ---------------------------------------------------------------
test('palette click adds top-level primitive to block list', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Click a palette button and verify a matching block appears.
await page.getByTestId('palette-btn-on-turn-end').click();
const block = page.getByTestId('block-card-on-turn-end');
await expect(block).toBeVisible();
// Use the aria-label ("On Turn End block") which is unique to the
// card's outer <article>, even when an inspector opens below.
await expect(block).toHaveAttribute('aria-label', /On Turn End/);
// Preview narrative reflects the new primitive.
const narrative = page
.getByTestId('preview-pane')
.getByRole('region', { name: 'narrative' });
await expect(narrative).toContainText(/turn end/i);
});
// ---------------------------------------------------------------
// 3. Drag-handle isolation — × button
// ---------------------------------------------------------------
test('clicking × removes the block without triggering a drag', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Add three blocks.
await page.getByTestId('palette-btn-seed-attribute').click();
await page.getByTestId('palette-btn-add-to-attribute').click();
await page.getByTestId('palette-btn-multiply-attribute').click();
await expect(page.getByTestId('block-card-seed-attribute')).toBeVisible();
await expect(page.getByTestId('block-card-add-to-attribute')).toBeVisible();
await expect(
page.getByTestId('block-card-multiply-attribute'),
).toBeVisible();
// Click the × on the middle (add-to-attribute) block — must remove
// it WITHOUT starting a drag. If pointerdown on the × started a
// drag, dnd-kit's announcer element would contain "Picked up
// sortable item", and the block wouldn't be removed.
const middleBlock = page.getByTestId('block-card-add-to-attribute');
await middleBlock
.getByRole('button', { name: 'Remove primitive' })
.click();
await expect(page.getByTestId('block-card-add-to-attribute')).toHaveCount(
0,
);
// The other two still exist.
await expect(page.getByTestId('block-card-seed-attribute')).toBeVisible();
await expect(
page.getByTestId('block-card-multiply-attribute'),
).toBeVisible();
// dnd-kit's visually-hidden announcer should not have reported a pickup.
const announcement = await page
.locator('[aria-live="assertive"]')
.first()
.textContent();
expect(announcement ?? '').not.toContain('Picked up sortable item');
});
// ---------------------------------------------------------------
// 4. Drag-handle isolation — expand chevron
// ---------------------------------------------------------------
test('clicking expand toggles the block without triggering a drag', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
await page.getByTestId('palette-btn-on-turn-end').click();
const block = page.getByTestId('block-card-on-turn-end');
await expect(block).toBeVisible();
// Click the expand chevron (aria-expanded toggles).
const expandBtn = block.getByRole('button', { name: /expand|collapse/i });
await expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
await expandBtn.click();
await expect(expandBtn).toHaveAttribute('aria-expanded', 'true');
// Block still exists — the click didn't trigger a reorder drag.
await expect(block).toBeVisible();
});
// ---------------------------------------------------------------
// 5. Nested add via selection
// ---------------------------------------------------------------
test('selecting a trigger makes palette clicks add children', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Add a trigger container.
await page.getByTestId('palette-btn-on-turn-end').click();
const onTurnEnd = page.getByTestId('block-card-on-turn-end');
await expect(onTurnEnd).toBeVisible();
// Select it by clicking the header area (avoids the × / expand /
// inspector regions of the card).
await selectBlockCard(page, 'on-turn-end');
// The palette-add-target banner should appear.
await expect(page.getByTestId('palette-add-target-banner')).toBeVisible();
await expect(page.getByTestId('palette-add-target-banner')).toContainText(
/on turn end/i,
);
// Now click a state primitive — it should land INSIDE on-turn-end,
// not as a top-level sibling.
await page.getByTestId('palette-btn-add-to-attribute').click();
// The parent auto-expands so the child is visible.
// Only ONE add-to-attribute block exists (the nested one).
const addBlocks = page.getByTestId('block-card-add-to-attribute');
await expect(addBlocks).toHaveCount(1);
// And it lives inside on-turn-end (descendant selector).
await expect(
onTurnEnd.getByTestId('block-card-add-to-attribute'),
).toBeVisible();
// Preview narrative should mention both the turn-end trigger AND
// the nested add-to-attribute effect.
const narrative = page
.getByTestId('preview-pane')
.getByRole('region', { name: 'narrative' });
await expect(narrative).toContainText(/turn end/i);
await expect(narrative).toContainText(/add/i);
});
// ---------------------------------------------------------------
// 6. "Add at top level instead" resets selection
// ---------------------------------------------------------------
test('“Add at top level instead” resets the nested-target selection', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
await page.getByTestId('palette-btn-on-turn-end').click();
await selectBlockCard(page, 'on-turn-end');
await expect(page.getByTestId('palette-add-target-banner')).toBeVisible();
// Click the "Add at top level instead" button.
await page
.getByTestId('palette-add-target-banner')
.getByRole('button', { name: /top level/i })
.click();
await expect(page.getByTestId('palette-add-target-banner')).toHaveCount(0);
// Now clicking a palette button adds at top level.
await page.getByTestId('palette-btn-seed-attribute').click();
// Both blocks are top-level siblings now.
const topLevelBlocks = page.locator('[data-testid^="block-card-"]');
await expect(
topLevelBlocks.filter({ has: page.getByText('On Turn End') }),
).toHaveCount(1);
await expect(page.getByTestId('block-card-seed-attribute')).toBeVisible();
});
// ---------------------------------------------------------------
// 7. Nested remove
// ---------------------------------------------------------------
test('× on a nested child removes only that child', async ({ page }) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Set up: on-turn-end containing add-to-attribute.
await page.getByTestId('palette-btn-on-turn-end').click();
await selectBlockCard(page, 'on-turn-end');
const onTurnEnd = page.getByTestId('block-card-on-turn-end');
await page.getByTestId('palette-btn-add-to-attribute').click();
const nestedChild = onTurnEnd.getByTestId('block-card-add-to-attribute');
await expect(nestedChild).toBeVisible();
// Click × on the nested child.
await nestedChild
.getByRole('button', { name: 'Remove primitive' })
.click();
// Nested child gone.
await expect(
onTurnEnd.getByTestId('block-card-add-to-attribute'),
).toHaveCount(0);
// Parent still present.
await expect(onTurnEnd).toBeVisible();
});
// ---------------------------------------------------------------
// 8. Preview narrative updates live
// ---------------------------------------------------------------
test('preview narrative reflects tree mutations immediately', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
const narrative = page
.getByTestId('preview-pane')
.getByRole('region', { name: 'narrative' });
// Start: palette view, narrative may be empty OR show placeholder.
// After adding seed-attribute it must mention the new primitive.
await page.getByTestId('palette-btn-seed-attribute').click();
await expect(narrative).toContainText(/set|seed/i);
// After removing it, narrative reverts.
await page
.getByTestId('block-card-seed-attribute')
.getByRole('button', { name: 'Remove primitive' })
.click();
const afterRemove = (await narrative.textContent()) ?? '';
expect(afterRemove).not.toMatch(/ShieldCharges/i);
});
// ---------------------------------------------------------------
// 9. Save → reload → load round-trip
// ---------------------------------------------------------------
test('save → reload → load preserves the composed descriptor', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Compose a simple top-level structure: add-to-attribute + aura.
await page.getByPlaceholder('Modifier Name').fill('Visual Round-Trip');
await page.getByTestId('palette-btn-add-to-attribute').click();
await selectBlockCard(page, 'add-to-attribute');
// Inspector opens automatically on select — fill the fields.
const attrInput = page
.locator('[data-testid="primitive-add-to-attribute-attr-input"]')
.first();
await expect(attrInput).toBeVisible({ timeout: 3000 });
await attrInput.fill('Hp');
const deltaInput = page.locator('input[type="number"]').first();
await deltaInput.fill('1');
// Save.
await page.getByTestId('custom-save').click();
await expect(
page.getByText(/saved to library|custom modifier saved/i),
).toBeVisible({ timeout: 3000 });
// Reload — mode preference persists, library persists.
await page.reload();
await page.waitForSelector('[data-testid="page-home"]');
await openCustomModifierEditor(page);
// Still in Visual mode.
await expect(
page.getByTestId('custom-modifier-editor-mode-visual'),
).toHaveAttribute('aria-pressed', 'true');
// Load from library.
await page.getByTestId('custom-load').click();
await page.getByText('Visual Round-Trip').click();
// The composed primitive is back.
await expect(page.getByTestId('block-card-add-to-attribute')).toBeVisible();
// Select it to reveal the inspector, then confirm persisted values.
await selectBlockCard(page, 'add-to-attribute');
await expect(
page.locator('[data-testid="primitive-add-to-attribute-attr-input"]').first(),
).toHaveValue('Hp');
});
// ---------------------------------------------------------------
// 10. Depth-4 validation banner — pre-seed a too-deep descriptor
// via localStorage then load it. Verifies the banner surfaces and
// the Save button is disabled regardless of how the descriptor got
// deep (manual click, paste, import, etc.).
// ---------------------------------------------------------------
test('depth-4 descriptor surfaces validation banner + disables save', async ({
page,
}) => {
// Seed the library with a depth-4 descriptor before opening the editor.
await page.evaluate((key) => {
const depth4: unknown = {
type: 'data',
id: 'custom-depth4',
name: 'Depth 4 Test',
description: '',
version: 1,
uiForm: 'primitive-composer',
source: 'custom',
targetAttrs: [],
primitives: [
{
kind: 'conditional',
params: {
condition: { type: 'always' },
then: [
{
kind: 'on-capture',
params: {
primitives: [
{
kind: 'on-damaged',
params: {
primitives: [
{
kind: 'conditional',
params: {
condition: { type: 'always' },
then: [
{ kind: 'add-to-attribute', params: { attr: 'Hp', delta: 1 } },
],
},
},
],
},
},
],
},
},
],
},
},
],
};
localStorage.setItem(
key,
JSON.stringify([
{
id: 'custom-depth4',
descriptor: depth4,
starred: false,
updatedAt: Date.now(),
},
]),
);
}, CUSTOM_LIBRARY_KEY);
await page.reload();
await page.waitForSelector('[data-testid="page-home"]');
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Load the pre-seeded depth-4 descriptor.
await page.getByTestId('custom-load').click();
await page.getByText('Depth 4 Test').click();
// Validation banner appears.
await expect(
page.getByText(/exceed|depth|fix errors/i).first(),
).toBeVisible({ timeout: 5000 });
// Save is disabled — validator rejects depth-4 descriptors.
await expect(page.getByTestId('custom-save')).toBeDisabled();
});
// ---------------------------------------------------------------
// 11. Form ↔ Visual toggle preserves descriptor content
// ---------------------------------------------------------------
test('toggling Form ↔ Visual preserves the composed descriptor', async ({
page,
}) => {
await openCustomModifierEditor(page);
await switchToVisualMode(page);
// Compose a small non-trivial tree.
await page.getByPlaceholder('Modifier Name').fill('Toggle Test');
await page.getByTestId('palette-btn-on-turn-end').click();
await selectBlockCard(page, 'on-turn-end');
await page.getByTestId('palette-btn-add-to-attribute').click();
// Capture the JSON preview (preview-json is the <pre> itself).
await page
.getByTestId('preview-pane')
.getByRole('tab', { name: 'JSON' })
.click();
const jsonView = page.getByTestId('preview-json');
await expect(jsonView).toBeVisible();
const jsonBefore = await jsonView.textContent();
expect(jsonBefore ?? '').toContain('on-turn-end');
expect(jsonBefore ?? '').toContain('add-to-attribute');
// Toggle to Form, then back to Visual.
await page.getByTestId('custom-modifier-editor-mode-form').click();
await expect(
page.getByTestId('custom-modifier-editor-mode-form'),
).toHaveAttribute('aria-pressed', 'true');
await page.getByTestId('custom-modifier-editor-mode-visual').click();
await expect(page.getByTestId('palette-btn-on-move')).toBeVisible();
// Preview JSON after the round-trip — byte-equal content.
await page
.getByTestId('preview-pane')
.getByRole('tab', { name: 'JSON' })
.click();
await expect(jsonView).toBeVisible();
const jsonAfter = await jsonView.textContent();
expect(jsonAfter).toBe(jsonBefore);
});
});