From 9e31b6d682342146f0acf9f6a7d2a7c4a897384a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 19:47:21 -0600 Subject: [PATCH] test(chess/e2e): expand visual-builder Playwright suite to 11 scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
, and uses click({ position }) to land on the card header (avoiding the × / expand / grip / inspector overlays). --- .../chess/e2e/custom-modifier-visual.spec.ts | 584 +++++++++++++++--- 1 file changed, 495 insertions(+), 89 deletions(-) diff --git a/packages/chess/e2e/custom-modifier-visual.spec.ts b/packages/chess/e2e/custom-modifier-visual.spec.ts index 94b2cf2..a08c8d9 100644 --- a/packages/chess/e2e/custom-modifier-visual.spec.ts +++ b/packages/chess/e2e/custom-modifier-visual.spec.ts @@ -1,130 +1,536 @@ +/** + * 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 { await page.goto('/'); await page.evaluate( - ({ customKey, modeKey }) => { + ({ 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, }, - { customKey: CUSTOM_LIBRARY_KEY, modeKey: EDITOR_MODE_KEY }, ); await page.reload(); await page.waitForSelector('[data-testid="page-home"]'); } -test.describe('T25 — Custom modifier visual builder e2e', () => { +/** Open the Custom Modifier Editor via the standard lobby flow. */ +async function openCustomModifierEditor(page: Page): Promise { + 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 { + 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 { + 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); }); - test('visual authoring flow: create, save, load', async ({ page }) => { - // 1. Open editor - 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(); + // --------------------------------------------------------------- + // 1. Mode toggle persistence + // --------------------------------------------------------------- + test('mode toggle persists across reload', async ({ page }) => { + await openCustomModifierEditor(page); - // 2. Toggle to visual mode - await page.getByTestId('custom-modifier-editor-mode-visual').click(); - - // Assert visual mode components are visible + // 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(); - await expect(page.getByTestId('preview-pane')).toBeVisible(); - - // Give it a name - await page.getByPlaceholder('Modifier Name').fill('Visual Test Modifier'); + }); - // 3. Add on-move primitive - await page.getByTestId('palette-btn-on-move').click(); - - // It should appear in the block list - const onMoveBlock = page.getByTestId('block-card-on-move'); - await expect(onMoveBlock).toBeVisible(); - await expect(onMoveBlock.getByText('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); - // 4. Add add-to-attribute inside on-move - // Open the block to show dropzones - await onMoveBlock.click(); + // 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
, 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(); - // Fill in the add-to-attribute form fields - // Target the specific inputs inside the visual builder - const attrInput = page.locator('[data-testid="primitive-add-to-attribute-attr-input"]').first(); + // 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'); - - // The delta field is typically a generic input without a testid right now, - // so we target the first number input in the inspector panel const deltaInput = page.locator('input[type="number"]').first(); await deltaInput.fill('1'); - // 5. Verify preview narrative - const previewPane = page.getByTestId('preview-pane'); - await expect(previewPane).toContainText('When this piece moves'); - await expect(previewPane).toContainText('add 1 to Hp'); - - // Take screenshot of composed state - await page.screenshot({ path: '.sisyphus/evidence/task-25-visual-composed.png' }); - - // 6. Save to library + // Save. await page.getByTestId('custom-save').click(); - await expect(page.getByText('Custom modifier saved to library')).toBeVisible(); + await expect( + page.getByText(/saved to library|custom modifier saved/i), + ).toBeVisible({ timeout: 3000 }); - // 7. Reload page + // 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'); - // 8. Re-open editor & load - 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(); - - // Should remember visual mode preference - 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 Test Modifier').click(); + await page.getByText('Visual Round-Trip').click(); - // 9. Verify loaded structure - const loadedBlock = page.getByTestId('block-card-on-move'); - await expect(loadedBlock).toBeVisible(); - await expect(loadedBlock.getByText('On Move')).toBeVisible(); - - // Open the block to see children - await loadedBlock.click(); - await expect(page.getByText('Add To Attribute')).toBeVisible(); - await expect(page.locator('[data-testid="primitive-add-to-attribute-attr-input"]').first()).toHaveValue('Hp'); - await expect(page.locator('input[type="number"]').first()).toHaveValue('1'); + // 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. Negative: try to add beyond max depth (validator prevents save) - // Add conditional -> on-capture -> on-damaged - // The block list allows arbitrary adding, but validation fails - await page.getByTestId('palette-btn-conditional').click(); - const condBlock = page.getByTestId('block-card-conditional'); - await condBlock.click(); - - await page.getByTestId('palette-btn-on-capture').click(); - const captureBlock = page.getByTestId('block-card-on-capture'); - await captureBlock.click(); - - await page.getByTestId('palette-btn-on-damaged').click(); - - // The validator banner should show depth exceeded - await expect(page.getByText(/exceed 3|Fix errors in Form mode/)).toBeVisible(); - - // Save should be disabled + // --------------------------------------------------------------- + // 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(); - - await page.screenshot({ path: '.sisyphus/evidence/task-25-visual-validation-error.png' }); + }); + + // --------------------------------------------------------------- + // 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
 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);
   });
 });