/** * CompositionWireframe — the read-only structure tree (arch 08 / arch 10 §5). * * Guards the load-bearing behavior: the affordance is hidden for a single-view * spec (nothing to schematize), the disclosed panel is an APG tree with one * roving tab stop, arrow keys walk it in document order, and activating a box * asks the editor to reveal that view's exact source range. The viewTree model * itself is covered in core (spec-view-tree.test.ts). */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useAppStore } from '../stores/AppStore'; import { usePopoverStore } from '../stores/PopoverStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { CompositionWireframe } from './CompositionWireframe'; import { markIconName } from './mark-icon'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2); const LAYERED = JSON.stringify({ layer: [{ mark: 'bar' }, { mark: 'line' }] }, null, 2); let container: HTMLDivElement; let root: Root; const setSpec = (text: string) => { useSnippetStore.getState().reset(); useSnippetStore.setState({ draftText: text }); }; /** Like `setSpec`, but with an active draft so reorder affordances are enabled. */ const setEditableSpec = (text: string) => { useSnippetStore.getState().reset(); useSnippetStore.setState({ draftText: text, activeSnippetId: 'snip', editorView: 'draft' }); }; const key = (e: KeyboardEventInit) => new KeyboardEvent('keydown', { bubbles: true, ...e }); const treeItems = () => Array.from(document.body.querySelectorAll('[role="treeitem"]')); const item = (key: string) => document.body.querySelector(`[data-key="${key}"]`)!; async function renderOpen() { await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { usePopoverStore.getState().show('composition-wireframe'); await Promise.resolve(); }); } beforeEach(() => { usePopoverStore.setState({ openId: null }); useAppStore.setState({ revealTarget: null, composeRequest: null }); setSpec(COMPOSED); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); usePopoverStore.setState({ openId: null }); useAppStore.setState({ revealTarget: null, composeRequest: null }); useSnippetStore.getState().reset(); }); describe('CompositionWireframe', () => { test('renders nothing for a single-view spec', () => { setSpec('{"mark":"point"}'); act(() => root.render()); expect(container.querySelector('button')).toBeNull(); }); test('shows a structure trigger for a composed spec', () => { act(() => root.render()); expect(container.querySelector('button[aria-controls="composition-wireframe"]')).not.toBeNull(); }); test('discloses an APG tree: the vconcat root and its two leaf views', async () => { await renderOpen(); expect(document.body.querySelector('[role="tree"]')).not.toBeNull(); expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'vconcat|0', 'vconcat|1']); }); test('roving tabindex: exactly one treeitem is in the tab order', async () => { await renderOpen(); expect(treeItems().filter((el) => el.tabIndex === 0)).toHaveLength(1); expect(item('root').tabIndex).toBe(0); // the root holds it on open }); test('ArrowDown moves the roving tab stop in document order', async () => { await renderOpen(); act(() => { item('root').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); }); expect(item('root').tabIndex).toBe(-1); expect(item('vconcat|0').tabIndex).toBe(0); }); test('activating a box asks the editor to reveal that view’s source range', async () => { await renderOpen(); act(() => item('vconcat|1').click()); const target = useAppStore.getState().revealTarget!; expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"'); }); test('a layer discloses its marks as reorderable treeitems (z-order)', async () => { // The layer renders as one frame of mark glyphs, but each mark stays a treeitem // so selection and Alt+arrow z-order reorder keep working. setEditableSpec(LAYERED); await renderOpen(); expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'layer|0', 'layer|1']); act(() => { item('root').dispatchEvent(key({ key: 'ArrowDown' })); }); act(() => { item('layer|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true })); }); expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'move', arrayPath: ['layer'], from: 0, to: 1, }); }); test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => { setEditableSpec(COMPOSED); await renderOpen(); // Move the roving focus onto the first leaf, then reorder it down. act(() => { item('root').dispatchEvent(key({ key: 'ArrowDown' })); }); act(() => { item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true })); }); const req = useAppStore.getState().composeRequest!; expect(req).toMatchObject({ arrayPath: ['vconcat'], from: 0, to: 1 }); }); test('Alt+ArrowUp at the start does not reorder', async () => { setEditableSpec(COMPOSED); await renderOpen(); act(() => { item('root').dispatchEvent(key({ key: 'ArrowDown' })); }); act(() => { item('vconcat|0').dispatchEvent(key({ key: 'ArrowUp', altKey: true })); }); expect(useAppStore.getState().composeRequest).toBeNull(); }); test('reorder is inert on a read-only (non-draft) view', async () => { // Default COMPOSED is set with no active snippet → not editable. await renderOpen(); act(() => { item('root').dispatchEvent(key({ key: 'ArrowDown' })); }); act(() => { item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true })); }); expect(useAppStore.getState().composeRequest).toBeNull(); }); }); describe('CompositionWireframe — drag to restructure', () => { // happy-dom has no layout, so the drop hit-test (which reads box rects) needs them // stubbed. A vertical stack of two equal halves, keyed by the box's data-key. const RECTS: Record = { root: { x: 0, y: 0, w: 200, h: 200 }, 'vconcat|0': { x: 0, y: 0, w: 200, h: 100 }, 'vconcat|1': { x: 0, y: 100, w: 200, h: 100 }, }; beforeEach(() => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: HTMLElement, ): DOMRect { const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 }; return { left: r.x, top: r.y, right: r.x + r.w, bottom: r.y + r.h, width: r.w, height: r.h, x: r.x, y: r.y, toJSON: () => ({}), }; }); }); afterEach(() => { vi.restoreAllMocks(); }); const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => { act(() => { item(fromKey).dispatchEvent( new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }), ); }); act(() => { window.dispatchEvent( new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }), ); }); act(() => { window.dispatchEvent(new MouseEvent('pointerup', {})); }); }; test('dropping onto a view’s far cross edge pairs the two into a row', async () => { setEditableSpec(COMPOSED); await renderOpen(); drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view (cross axis) expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'wrap', targetPath: ['vconcat', 0], sourcePath: ['vconcat', 1], axis: 'horizontal', side: 'after', }); }); test('dropping along the container reorders within it', async () => { setEditableSpec(COMPOSED); await renderOpen(); drag('vconcat|1', { x: 100, y: 8 }); // top edge of the top view expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'move', arrayPath: ['vconcat'], from: 1, to: 0, }); }); }); describe('CompositionWireframe — pull a view out via the frame margin', () => { const HCON = JSON.stringify({ hconcat: [{ mark: 'point' }, { mark: 'bar' }, { mark: 'line' }] }); // A row of three boxes inset inside the root frame, leaving a margin (the pull-out // zone) all around. happy-dom has no layout, so the hit-test rects are stubbed. const RECTS: Record = { root: { x: 0, y: 0, w: 300, h: 120 }, 'hconcat|0': { x: 24, y: 24, w: 80, h: 72 }, 'hconcat|1': { x: 110, y: 24, w: 80, h: 72 }, 'hconcat|2': { x: 196, y: 24, w: 80, h: 72 }, }; beforeEach(() => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: HTMLElement, ): DOMRect { const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 }; const box = { left: r.x, top: r.y, right: r.x + r.w, bottom: r.y + r.h, width: r.w, height: r.h, x: r.x, y: r.y, }; return { ...box, toJSON: () => ({}) }; }); }); afterEach(() => vi.restoreAllMocks()); const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => { act(() => { item(fromKey).dispatchEvent( new MouseEvent('pointerdown', { bubbles: true, clientX: 60, clientY: 60 }), ); }); act(() => { window.dispatchEvent( new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }), ); }); act(() => { window.dispatchEvent(new MouseEvent('pointerup', {})); }); }; test('dropping in the cross-axis margin pulls the view into a new full-span row', async () => { setEditableSpec(HCON); await renderOpen(); drag('hconcat|0', { x: 150, y: 8 }); // top margin of the row — across its axis expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'wrap-container', containerPath: [], sourcePath: ['hconcat', 0], axis: 'vertical', side: 'before', }); }); test('a with-axis margin reorders to that end of the row, not a pull', async () => { setEditableSpec(HCON); await renderOpen(); drag('hconcat|1', { x: 8, y: 60 }); // left margin (along the axis), before every box expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'move', arrayPath: ['hconcat'], from: 1, to: 0, }); }); test('dropping over a sibling’s central band reorders past it', async () => { setEditableSpec(HCON); await renderOpen(); drag('hconcat|2', { x: 50, y: 70 }); // central band of the first box → before it expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'move', arrayPath: ['hconcat'], from: 2, to: 0, }); }); test('dropping on a sibling’s top edge stacks the two into a column', async () => { setEditableSpec(HCON); await renderOpen(); drag('hconcat|2', { x: 130, y: 30 }); // top edge of the middle box (cross axis) expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'wrap', targetPath: ['hconcat', 1], sourcePath: ['hconcat', 2], axis: 'vertical', side: 'before', }); }); }); describe('CompositionWireframe — simplify redundant wrappers', () => { const simplifyButton = () => Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent === 'Simplify'); test('offers Simplify for a single-child composition and dispatches it', async () => { setEditableSpec(JSON.stringify({ hconcat: [{ mark: 'point' }] })); await renderOpen(); const btn = simplifyButton(); expect(btn).toBeTruthy(); act(() => btn!.click()); expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'simplify' }); }); test('no Simplify prompt when every composition has multiple views', async () => { setEditableSpec(COMPOSED); // a vconcat of two await renderOpen(); expect(simplifyButton()).toBeUndefined(); }); }); describe('markIconName', () => { test('maps marks to glyphs, collapsing synonyms', () => { expect(markIconName('bar')).toBe('mark-bar'); expect(markIconName('circle')).toBe('mark-point'); expect(markIconName('square')).toBe('mark-point'); expect(markIconName('trail')).toBe('mark-line'); }); test('falls back to a generic glyph for an unknown or absent mark', () => { expect(markIconName('boxplot')).toBe('mark-generic'); expect(markIconName(undefined)).toBe('mark-generic'); }); });