mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
/**
|
||
* 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<HTMLElement>('[role="treeitem"]'));
|
||
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;
|
||
|
||
async function renderOpen() {
|
||
await act(async () => {
|
||
root.render(<CompositionWireframe />);
|
||
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(<CompositionWireframe />));
|
||
expect(container.querySelector('button')).toBeNull();
|
||
});
|
||
|
||
test('shows a structure trigger for a composed spec', () => {
|
||
act(() => root.render(<CompositionWireframe />));
|
||
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<string, { x: number; y: number; w: number; h: number }> = {
|
||
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<string, { x: number; y: number; w: number; h: number }> = {
|
||
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');
|
||
});
|
||
});
|