Files
paseo/vendor/react-native-gpui/packages/core/contextMenu.test.js
T

1013 lines
33 KiB
JavaScript
Vendored

/**
* Unit tests for the custom context menu's pure modules.
*
* The repo's vitest include list only picks up `*.test.js` under `packages/core`,
* so this file lives at the package root rather than next to the sources. The
* sources it imports are TypeScript, and the GPUI vitest preset resolves those
* with the `.desktop`/`.native` extension order and the same `react-native`
* alias the rest of `packages/core`'s tests use, so importing the modules
* directly works without a build step.
*
* @format
*/
import { describe, expect, test } from 'vitest';
import {
DEFAULT_MENU_MARGIN,
measurableSize,
placeRootMenu,
placeSubmenu,
} from './src-gpui/src/private/desktop/contextMenu/placement';
import {
placeLevels,
rowRect as rowRectOf,
} from './src-gpui/src/private/desktop/contextMenu/levels';
import {
EMPTY_NAVIGATION,
TYPEAHEAD_TIMEOUT_MS,
firstEnabledIndex,
lastEnabledIndex,
navigate,
nextEnabledIndex,
typeaheadIndex,
} from './src-gpui/src/private/desktop/contextMenu/navigation';
import {
dark,
light,
mergeTheme,
themeForScheme,
} from './src-gpui/src/private/desktop/contextMenu/theme';
import { formatAccelerator } from './src-gpui/src/private/desktop/contextMenu/accelerator';
import {
isInSafeTriangle,
safeTriangleFor,
} from './src-gpui/src/private/desktop/contextMenu/safeTriangle';
import {
contextMenuConfig,
setContextMenuConfig,
setContextMenuRenderers,
contextMenuRenderers,
contextMenuTheme,
setContextMenuPresentation,
contextMenuPresentation,
setContextMenuTheme,
subscribeContextMenuConfig,
} from './src-gpui/src/private/desktop/contextMenu/config';
import {
contextMenuHostFor,
contextMenuWindowIds,
hasContextMenuHost,
registerContextMenuHost,
unregisterContextMenuHost,
} from './src-gpui/src/private/desktop/contextMenu/registry';
import {
entryId,
entryLabel,
entryTestId,
isChecked,
isEnabled,
isSubmenu,
humanizeRole,
} from './src-gpui/src/private/desktop/contextMenu/types';
const windowSize = { width: 1000, height: 800 };
describe('placeRootMenu', () => {
test('places the menu at the anchor when it fits', () => {
expect(
placeRootMenu({
anchor: { x: 100, y: 120 },
menuSize: { width: 200, height: 150 },
windowSize,
}),
).toEqual({ x: 100, y: 120 });
});
test('flips left at the right edge and stays inside', () => {
// 900 + 200 > 996 (the window width less the margin), so it opens leftwards.
const point = placeRootMenu({
anchor: { x: 900, y: 100 },
menuSize: { width: 200, height: 150 },
windowSize,
});
expect(point.x).toBe(700);
expect(point.x + 200).toBeLessThanOrEqual(windowSize.width);
});
test('flips up at the bottom edge and stays inside', () => {
const point = placeRootMenu({
anchor: { x: 100, y: 700 },
menuSize: { width: 200, height: 150 },
windowSize,
});
expect(point.y).toBe(550);
expect(point.y + 150).toBeLessThanOrEqual(windowSize.height);
});
test('flips in both directions in the bottom-right corner', () => {
expect(
placeRootMenu({
anchor: { x: 980, y: 780 },
menuSize: { width: 200, height: 150 },
windowSize,
}),
).toEqual({ x: 780, y: 630 });
});
test('clamps to the margin when the anchor is outside the window', () => {
expect(
placeRootMenu({
anchor: { x: -50, y: -80 },
menuSize: { width: 200, height: 150 },
windowSize,
}),
).toEqual({ x: DEFAULT_MENU_MARGIN, y: DEFAULT_MENU_MARGIN });
});
test('clamps past the far edge rather than overflowing', () => {
const point = placeRootMenu({
anchor: { x: 5000, y: 5000 },
menuSize: { width: 200, height: 150 },
windowSize,
});
expect(point).toEqual({
x: windowSize.width - DEFAULT_MENU_MARGIN - 200,
y: windowSize.height - DEFAULT_MENU_MARGIN - 150,
});
});
test('keeps a menu bigger than the window against the near margin', () => {
const point = placeRootMenu({
anchor: { x: 400, y: 400 },
menuSize: { width: 1400, height: 1200 },
windowSize,
});
expect(point).toEqual({ x: DEFAULT_MENU_MARGIN, y: DEFAULT_MENU_MARGIN });
expect(point.x).toBeGreaterThanOrEqual(0);
expect(point.y).toBeGreaterThanOrEqual(0);
});
test('honours a wider margin', () => {
expect(
placeRootMenu({
anchor: { x: 0, y: 0 },
menuSize: { width: 100, height: 100 },
windowSize,
margin: 20,
}),
).toEqual({ x: 20, y: 20 });
});
test('treats a negative margin as no margin and a non-finite one as the default', () => {
expect(
placeRootMenu({
anchor: { x: 0, y: 0 },
menuSize: { width: 100, height: 100 },
windowSize,
margin: -10,
}),
).toEqual({ x: 0, y: 0 });
for (const margin of [Number.NaN, Number.POSITIVE_INFINITY]) {
expect(
placeRootMenu({
anchor: { x: 0, y: 0 },
menuSize: { width: 100, height: 100 },
windowSize,
margin,
}),
).toEqual({ x: DEFAULT_MENU_MARGIN, y: DEFAULT_MENU_MARGIN });
}
});
test('is deterministic', () => {
const input = {
anchor: { x: 950, y: 780 },
menuSize: { width: 240, height: 180 },
windowSize,
};
expect(placeRootMenu(input)).toEqual(placeRootMenu(input));
});
});
describe('placeSubmenu', () => {
const rowRect = { x: 200, y: 300, width: 180, height: 26 };
test('opens to the right of the row when there is room', () => {
const placed = placeSubmenu({
rowRect,
menuSize: { width: 200, height: 100 },
windowSize,
});
expect(placed).toEqual({ x: 380, y: 300, side: 'right' });
});
test('opens to the left when the right edge is too close', () => {
const placed = placeSubmenu({
rowRect: { x: 800, y: 300, width: 180, height: 26 },
menuSize: { width: 200, height: 100 },
windowSize,
});
expect(placed.side).toBe('left');
expect(placed.x).toBe(600);
});
test('aligns vertically with the row and clamps near the bottom', () => {
const placed = placeSubmenu({
rowRect: { x: 200, y: 760, width: 180, height: 26 },
menuSize: { width: 200, height: 100 },
windowSize,
});
expect(placed.y).toBe(windowSize.height - DEFAULT_MENU_MARGIN - 100);
expect(placed.y + 100).toBeLessThanOrEqual(windowSize.height);
});
test('clamps a submenu wider than the window to the near margin', () => {
const placed = placeSubmenu({
rowRect,
menuSize: { width: 1200, height: 100 },
windowSize,
});
expect(placed.x).toBe(DEFAULT_MENU_MARGIN);
expect(placed.side).toBe('left');
});
test('still reports a side when clamped on both axes', () => {
const placed = placeSubmenu({
rowRect: { x: 990, y: 790, width: 10, height: 10 },
menuSize: { width: 900, height: 900 },
windowSize,
});
expect(placed.side).toBe('left');
expect(placed.x).toBeGreaterThanOrEqual(0);
expect(placed.y).toBeGreaterThanOrEqual(0);
});
});
describe('measurableSize', () => {
test('accepts a measured window', () => {
expect(measurableSize({ width: 800, height: 600 })).toEqual({
width: 800,
height: 600,
});
});
test('rejects a zero or non-finite layout', () => {
expect(measurableSize({ width: 0, height: 600 })).toBeNull();
expect(measurableSize({ width: 800, height: 0 })).toBeNull();
expect(measurableSize({ width: Number.NaN, height: 600 })).toBeNull();
});
});
const items = [
{ id: 'new', label: 'New' },
{ type: 'separator' },
{ id: 'open', label: 'Open' },
{ id: 'save', label: 'Save', enabled: false },
{ type: 'submenu', id: 'export', label: 'Export', items: [] },
{ id: 'edit', label: 'Edit' },
];
describe('nextEnabledIndex', () => {
test('skips separators and disabled rows', () => {
expect(nextEnabledIndex(items, -1, 1)).toBe(0);
expect(nextEnabledIndex(items, 0, 1)).toBe(2);
expect(nextEnabledIndex(items, 2, 1)).toBe(4);
});
test('wraps at both ends', () => {
expect(nextEnabledIndex(items, 5, 1)).toBe(0);
expect(nextEnabledIndex(items, 0, -1)).toBe(5);
expect(nextEnabledIndex(items, -1, -1)).toBe(5);
});
test('returns -1 for a level with nothing selectable', () => {
expect(
nextEnabledIndex([{ type: 'separator' }, { type: 'separator' }], -1, 1),
).toBe(-1);
});
test('first and last go to a selectable row', () => {
expect(firstEnabledIndex(items)).toBe(0);
expect(lastEnabledIndex(items)).toBe(5);
});
});
describe('typeaheadIndex', () => {
test('finds a row by its label prefix', () => {
expect(typeaheadIndex(items, 'op', -1, false)).toBe(2);
expect(typeaheadIndex(items, 'ex', -1, false)).toBe(4);
});
test('is case insensitive', () => {
expect(typeaheadIndex(items, 'NEW', -1, false)).toBe(0);
});
test('skips a disabled match', () => {
// "Save" is disabled and is the only S row, so nothing matches.
expect(typeaheadIndex(items, 's', -1, false)).toBe(-1);
});
test('keeps the current row while the buffer grows', () => {
expect(typeaheadIndex(items, 'ed', 5, true)).toBe(5);
});
test('cycles to the next match when the buffer restarts', () => {
const rows = [
{ id: 'a', label: 'Edit' },
{ id: 'b', label: 'Export' },
{ id: 'c', label: 'Extend' },
];
expect(typeaheadIndex(rows, 'e', -1, false)).toBe(0);
expect(typeaheadIndex(rows, 'e', 0, false)).toBe(1);
expect(typeaheadIndex(rows, 'e', 1, false)).toBe(2);
expect(typeaheadIndex(rows, 'e', 2, false)).toBe(0);
});
test('returns the current index when nothing matches', () => {
expect(typeaheadIndex(items, 'zz', 2, false)).toBe(2);
});
});
describe('navigate', () => {
const state = index => ({ index, typed: '', typedAt: 0 });
test('Down moves to the next selectable row and wraps', () => {
expect(navigate({ items, key: 'down', state: state(-1) })).toEqual({
state: state(0),
action: 'none',
});
expect(navigate({ items, key: 'down', state: state(5) }).state.index).toBe(
0,
);
});
test('Up moves backwards and wraps', () => {
expect(navigate({ items, key: 'up', state: state(0) }).state.index).toBe(5);
expect(navigate({ items, key: 'up', state: state(-1) }).state.index).toBe(
5,
);
});
test('Home and End go to the first and last selectable rows', () => {
expect(navigate({ items, key: 'home', state: state(4) }).state.index).toBe(
0,
);
expect(navigate({ items, key: 'end', state: state(0) }).state.index).toBe(
5,
);
});
test('Right opens a submenu only on a submenu row', () => {
expect(navigate({ items, key: 'right', state: state(4) }).action).toBe(
'openSubmenu',
);
expect(navigate({ items, key: 'right', state: state(2) }).action).toBe(
'none',
);
expect(navigate({ items, key: 'right', state: state(-1) }).action).toBe(
'none',
);
});
test('Left and Escape close a level', () => {
expect(navigate({ items, key: 'left', state: state(2) }).action).toBe(
'closeLevel',
);
expect(navigate({ items, key: 'escape', state: state(2) }).action).toBe(
'closeLevel',
);
expect(navigate({ items, key: 'Esc', state: state(2) }).action).toBe(
'closeLevel',
);
});
test('Enter activates a row and opens a submenu', () => {
expect(navigate({ items, key: 'enter', state: state(2) }).action).toBe(
'activate',
);
expect(navigate({ items, key: 'enter', state: state(4) }).action).toBe(
'openSubmenu',
);
expect(navigate({ items, key: 'enter', state: state(-1) }).action).toBe(
'none',
);
expect(navigate({ items, key: 'enter', state: state(3) }).action).toBe(
'none',
);
});
test('Space activates like Enter', () => {
expect(navigate({ items, key: ' ', state: state(2) }).action).toBe(
'activate',
);
});
test('type-to-select moves the highlight and accumulates', () => {
const first = navigate({
items,
key: 'o',
state: state(-1),
now: 1000,
});
expect(first.state.index).toBe(2);
expect(first.state.typed).toBe('o');
const second = navigate({
items,
key: 'p',
state: first.state,
now: 1100,
});
expect(second.state.typed).toBe('op');
expect(second.state.index).toBe(2);
});
test('the buffer expires after the timeout', () => {
const first = navigate({ items, key: 'e', state: state(-1), now: 1000 });
expect(first.state.typed).toBe('e');
const later = navigate({
items,
key: 'n',
state: first.state,
now: 1000 + TYPEAHEAD_TIMEOUT_MS + 1,
});
// The old buffer is gone, so the new key starts one of its own.
expect(later.state.typed).toBe('n');
expect(later.state.index).toBe(0);
});
test('a modifier turns a character into a shortcut, not type-ahead', () => {
const result = navigate({
items,
key: 'k',
state: EMPTY_NAVIGATION,
now: 1000,
modifiers: { meta: true },
});
expect(result.state).toEqual(EMPTY_NAVIGATION);
expect(result.action).toBe('none');
});
test('unknown keys leave the state alone', () => {
const before = state(2);
expect(navigate({ items, key: 'f13', state: before })).toEqual({
state: before,
action: 'none',
});
});
});
describe('theme', () => {
test('the built-in themes have every token', () => {
for (const theme of [light, dark]) {
for (const token of [
'background',
'foreground',
'mutedForeground',
'separator',
'border',
'highlightBackground',
'highlightForeground',
'disabledForeground',
'shadow',
'fontSize',
'rowHeight',
'paddingX',
'paddingY',
'radius',
'minWidth',
'maxWidth',
]) {
expect(theme[token]).toBeDefined();
}
expect(theme.minWidth).toBeLessThan(theme.maxWidth);
}
expect(light.background).not.toBe(dark.background);
});
test('merging applies the override', () => {
const merged = mergeTheme(light, {
background: '#000000',
fontSize: 16,
});
expect(merged.background).toBe('#000000');
expect(merged.fontSize).toBe(16);
expect(merged.foreground).toBe(light.foreground);
});
test('an undefined field does not erase the base value', () => {
const merged = mergeTheme(light, {
background: undefined,
fontFamily: undefined,
});
expect(merged.background).toBe(light.background);
expect(merged.fontFamily).toBe(light.fontFamily);
});
test('merging does not mutate the base theme', () => {
const before = { ...light };
mergeTheme(light, { fontSize: 20 });
expect(light).toEqual(before);
});
test('a missing override returns a copy', () => {
const copy = mergeTheme(light);
expect(copy).toEqual(light);
expect(copy).not.toBe(light);
});
test('a scheme picks the matching built-in theme', () => {
expect(themeForScheme('dark').background).toBe(dark.background);
expect(themeForScheme('light').background).toBe(light.background);
// An unknown scheme (RN reports null before the platform answers) uses light.
expect(themeForScheme(null).background).toBe(light.background);
expect(themeForScheme(undefined).background).toBe(light.background);
});
test('overrides only apply to their own scheme', () => {
const overrides = { dark: { background: '#101010' } };
expect(themeForScheme('dark', overrides).background).toBe('#101010');
expect(themeForScheme('light', overrides).background).toBe(
light.background,
);
});
});
describe('formatAccelerator', () => {
test('spells a shortcut with the macOS symbols', () => {
expect(formatAccelerator('CmdOrCtrl+Shift+K', 'macos')).toBe(
'\u2318\u21e7K',
);
});
test('spells the same shortcut in words elsewhere', () => {
expect(formatAccelerator('CmdOrCtrl+Shift+K', 'windows')).toBe(
'Ctrl+Shift+K',
);
expect(formatAccelerator('CmdOrCtrl+Shift+K', 'linux')).toBe(
'Ctrl+Shift+K',
);
});
test('keeps a single character key as written', () => {
expect(formatAccelerator('Cmd+Z', 'macos')).toBe('\u2318Z');
expect(formatAccelerator('Ctrl+z', 'linux')).toBe('Ctrl+z');
});
test('names special keys', () => {
expect(formatAccelerator('Cmd+Enter', 'macos')).toBe('\u2318\u21a9');
expect(formatAccelerator('Ctrl+Enter', 'windows')).toBe('Ctrl+Enter');
});
test('returns an empty string for a missing or empty spec', () => {
expect(formatAccelerator(undefined, 'macos')).toBe('');
expect(formatAccelerator(' ', 'macos')).toBe('');
});
});
describe('safe triangle', () => {
const submenuRect = { x: 400, y: 200, width: 200, height: 160 };
test('a right-opening submenu uses its left edge', () => {
const triangle = safeTriangleFor({ x: 200, y: 260 }, submenuRect, 'right');
expect(triangle.nearTop).toEqual({ x: 400, y: 200 });
expect(triangle.nearBottom).toEqual({ x: 400, y: 360 });
});
test('a left-opening submenu uses its right edge', () => {
const triangle = safeTriangleFor({ x: 800, y: 260 }, submenuRect, 'left');
expect(triangle.nearTop).toEqual({ x: 600, y: 200 });
expect(triangle.nearBottom).toEqual({ x: 600, y: 360 });
});
test('a diagonal move towards the submenu stays inside', () => {
const triangle = safeTriangleFor({ x: 200, y: 210 }, submenuRect, 'right');
expect(isInSafeTriangle({ x: 300, y: 240 }, triangle)).toBe(true);
});
test('a move far above the triangle is outside', () => {
const triangle = safeTriangleFor({ x: 200, y: 260 }, submenuRect, 'right');
expect(isInSafeTriangle({ x: 380, y: 100 }, triangle)).toBe(false);
});
test('a move below the origin is outside', () => {
const triangle = safeTriangleFor({ x: 200, y: 260 }, submenuRect, 'right');
expect(isInSafeTriangle({ x: 300, y: 500 }, triangle)).toBe(false);
});
test('the near edge counts as inside', () => {
const triangle = safeTriangleFor({ x: 200, y: 260 }, submenuRect, 'right');
expect(isInSafeTriangle({ x: 400, y: 280 }, triangle)).toBe(true);
expect(isInSafeTriangle({ x: 400, y: 200 }, triangle)).toBe(true);
expect(isInSafeTriangle({ x: 400, y: 360 }, triangle)).toBe(true);
});
});
describe('entry helpers', () => {
test('a separator is never enabled', () => {
expect(isEnabled({ type: 'separator' })).toBe(false);
expect(isEnabled({ id: 'a', label: 'A' })).toBe(true);
expect(isEnabled({ id: 'a', label: 'A', enabled: false })).toBe(false);
});
test('checked only means something for a plain item', () => {
expect(isChecked({ id: 'a', label: 'A', checked: true })).toBe(true);
expect(isChecked({ id: 'a', label: 'A' })).toBe(false);
expect(isChecked({ type: 'submenu', label: 'S', items: [] })).toBe(false);
});
test('a submenu is recognised', () => {
expect(isSubmenu({ type: 'submenu', label: 'S', items: [] })).toBe(true);
expect(isSubmenu({ id: 'a', label: 'A' })).toBe(false);
});
test('a role item resolves with its role and gets a label', () => {
expect(entryId({ type: 'role', role: 'selectAll' })).toBe('selectAll');
expect(entryLabel({ type: 'role', role: 'selectAll' })).toBe('Select All');
expect(entryLabel({ type: 'role', role: 'copy', label: 'Copy text' })).toBe(
'Copy text',
);
expect(humanizeRole('hideOthers')).toBe('Hide Others');
});
test('test ids use the id, or a slug of the label', () => {
expect(entryTestId({ id: 'open', label: 'Open' })).toBe('open');
expect(entryTestId({ type: 'role', role: 'selectAll' })).toBe('selectAll');
expect(
entryTestId({ type: 'submenu', label: 'More Options', items: [] }),
).toBe('more-options');
});
});
describe('config store', () => {
test('notifies subscribers and can be reset', () => {
let calls = 0;
const unsubscribe = subscribeContextMenuConfig(() => {
calls += 1;
});
setContextMenuTheme({ dark: { background: '#000000' } });
expect(calls).toBe(1);
expect(contextMenuConfig().theme.dark.background).toBe('#000000');
unsubscribe();
setContextMenuConfig({});
expect(calls).toBe(1);
expect(contextMenuConfig().theme).toBeUndefined();
});
});
describe('renderers store', () => {
const Row = () => null;
const Surface = () => null;
test('replacing one slot keeps the others and the theme', () => {
setContextMenuConfig({ theme: { dark: { background: '#000000' } } });
setContextMenuRenderers({ Row });
expect(contextMenuRenderers().Row).toBe(Row);
// Setting renderers must not drop unrelated config: the two setters share a
// store, and a replacement that silently reset the theme would be a mystery.
expect(contextMenuTheme().dark.background).toBe('#000000');
setContextMenuRenderers({ Row, Surface });
expect(contextMenuRenderers().Row).toBe(Row);
expect(contextMenuRenderers().Surface).toBe(Surface);
setContextMenuRenderers(undefined);
expect(contextMenuRenderers()).toBeUndefined();
expect(contextMenuTheme().dark.background).toBe('#000000');
setContextMenuConfig({});
});
test('an explicit undefined slot means the built-in', () => {
setContextMenuRenderers({ Row, Surface: undefined });
expect(contextMenuRenderers().Row).toBe(Row);
// `undefined` is the documented way back to the built-in for one slot, so the
// host's `?? MenuSurface` fallback has to see it rather than an empty object.
expect(contextMenuRenderers().Surface).toBeUndefined();
setContextMenuConfig({});
});
});
describe('presentation resolution', () => {
test('the app default applies only to default, never to an explicit choice', () => {
setContextMenuPresentation('custom');
expect(contextMenuPresentation()).toBe('custom');
// An explicit 'native' must win over the app default, otherwise an app that
// set a custom default could never ask for the platform's menu.
setContextMenuPresentation('native');
expect(contextMenuPresentation()).toBe('native');
});
test('unset means default, which the platform decides', () => {
setContextMenuPresentation(undefined);
expect(contextMenuPresentation()).toBe('default');
});
});
describe('registry', () => {
const handle = { open: () => Promise.resolve(null), close: () => {} };
test('registers, looks up and unregisters by window id', () => {
const remove = registerContextMenuHost(7, handle);
expect(hasContextMenuHost(7)).toBe(true);
expect(contextMenuHostFor(7)).toBe(handle);
expect(contextMenuWindowIds()).toContain(7);
remove();
expect(contextMenuHostFor(7)).toBeNull();
});
test('a second registration for one window wins', () => {
const first = { open: () => Promise.resolve(null), close: () => {} };
const second = { open: () => Promise.resolve(null), close: () => {} };
const removeFirst = registerContextMenuHost(8, first);
registerContextMenuHost(8, second);
expect(contextMenuHostFor(8)).toBe(second);
// The stale unmount must not remove the replacement.
removeFirst();
expect(contextMenuHostFor(8)).toBe(second);
unregisterContextMenuHost(8);
expect(contextMenuHostFor(8)).toBeNull();
});
test('unregistering a specific host leaves another one alone', () => {
const other = { open: () => Promise.resolve(null), close: () => {} };
registerContextMenuHost(9, handle);
unregisterContextMenuHost(9, other);
expect(contextMenuHostFor(9)).toBe(handle);
unregisterContextMenuHost(9, handle);
expect(contextMenuHostFor(9)).toBeNull();
});
test('an unknown window has no host', () => {
expect(contextMenuHostFor(12345)).toBeNull();
expect(hasContextMenuHost(12345)).toBe(false);
});
});
/**
* A level with one 26px row per entry, starting at `y` inside the panel. The
* rows are what the host learns from each row's `onLayout`; the size is what the
* panel reports about itself.
*/
function level({ size, parentIndex = 0, rows, openedAt = null }) {
return {
items: [],
highlightedIndex: -1,
size,
rows,
parentIndex,
openedAt,
};
}
/** Rows laid out top to bottom with the given height, plus the panel's padding. */
function rowsOf(count, height = 26, paddingTop = 4) {
const rows = {};
for (let index = 0; index < count; index += 1) {
rows[index] = { y: paddingTop + index * height, height };
}
return rows;
}
describe('placeLevels chaining', () => {
const bigWindow = { width: 1000, height: 800 };
test('a three-level chain attaches each level to its own parent row', () => {
// Root at (100, 100), 200x150, rows 26px tall from y=4.
// Row 1 of the root is therefore at window y = 100 + 4 + 26 = 130.
const root = level({
size: { width: 200, height: 150 },
rows: rowsOf(5),
});
// The submenu opens beside the root's row 1 at x = 300, y = 130; its own
// row 2 is at y = 130 + 4 + 2*26 = 190.
const submenu = level({
size: { width: 180, height: 120 },
parentIndex: 1,
rows: rowsOf(4),
});
// The third level must use the submenu's row 2, not the root's row 1.
const subSubmenu = level({
size: { width: 160, height: 90 },
parentIndex: 2,
rows: rowsOf(3),
});
const placed = placeLevels(
[root, submenu, subSubmenu],
{ x: 100, y: 100 },
bigWindow,
);
expect(placed[0]).toEqual({ point: { x: 100, y: 100 }, side: null });
expect(placed[1]).toEqual({ point: { x: 300, y: 130 }, side: 'right' });
// x = 300 + 180 = 480; y = 130 + (4 + 2*26) = 186, i.e. the submenu's own
// row 2. Using the root's placement instead would give y = 130, which is the
// chaining bug this asserts against.
expect(placed[2]).toEqual({ point: { x: 480, y: 186 }, side: 'right' });
});
test('a third level follows its parent, not the root, on every axis', () => {
// Put the root's referenced row and the submenu's referenced row at
// different y values, then check the third level matches the submenu.
const root = level({
size: { width: 200, height: 40 },
rows: rowsOf(3),
});
const submenu = level({
size: { width: 200, height: 200 },
parentIndex: 2,
rows: rowsOf(6),
});
const subSubmenu = level({
size: { width: 100, height: 100 },
parentIndex: 5,
rows: rowsOf(2),
});
const placed = placeLevels(
[root, submenu, subSubmenu],
{ x: 50, y: 50 },
bigWindow,
);
const rootRowY = 50 + 4 + 2 * 26;
const submenuRowY = rootRowY + 4 + 5 * 26;
expect(placed[1].point).toEqual({ x: 250, y: rootRowY });
expect(placed[2].point).toEqual({ x: 450, y: submenuRowY });
expect(placed[2].point.y).not.toBe(rootRowY);
});
test('an unmeasured parent yields null and propagates it downwards', () => {
const root = level({ size: { width: 200, height: 150 }, rows: rowsOf(5) });
const unmeasured = level({
size: null,
parentIndex: 0,
rows: {},
});
const below = level({
size: { width: 100, height: 100 },
parentIndex: 0,
rows: rowsOf(2),
});
let placed;
expect(() => {
placed = placeLevels(
[root, unmeasured, below],
{ x: 10, y: 10 },
bigWindow,
);
}).not.toThrow();
expect(placed[0]).toEqual({ point: { x: 10, y: 10 }, side: null });
expect(placed[1]).toBeNull();
// Its parent has no position, so it cannot be placed either; it must not
// silently fall back to the root.
expect(placed[2]).toBeNull();
});
test('a level whose parent row has not been measured yet yields null', () => {
const root = level({ size: { width: 200, height: 150 }, rows: {} });
const child = level({
size: { width: 100, height: 100 },
parentIndex: 0,
rows: rowsOf(2),
});
const placed = placeLevels([root, child], { x: 10, y: 10 }, bigWindow);
expect(placed[0]).not.toBeNull();
expect(placed[1]).toBeNull();
});
test('a missing anchor or window size places nothing', () => {
const root = level({ size: { width: 200, height: 150 }, rows: rowsOf(5) });
expect(placeLevels([root], null, bigWindow)).toEqual([null]);
expect(placeLevels([root], { x: 1, y: 1 }, null)).toEqual([null]);
expect(placeLevels([root], undefined, bigWindow)).toEqual([null]);
expect(placeLevels([root], { x: 1, y: 1 }, undefined)).toEqual([null]);
});
test('a chain running off the bottom is clamped level by level', () => {
const window = { width: 600, height: 300 };
const root = level({
size: { width: 150, height: 200 },
rows: rowsOf(8),
});
const child = level({
size: { width: 150, height: 180 },
parentIndex: 7,
rows: rowsOf(6),
});
const grandchild = level({
size: { width: 150, height: 160 },
parentIndex: 5,
rows: rowsOf(5),
});
// Anchor near the bottom so the root itself has to flip up.
const placed = placeLevels(
[root, child, grandchild],
{ x: 20, y: 280 },
window,
);
for (const entry of placed) {
expect(entry).not.toBeNull();
expect(entry.point.y).toBeGreaterThanOrEqual(0);
expect(entry.point.x).toBeGreaterThanOrEqual(0);
expect(entry.point.y).toBeLessThanOrEqual(window.height);
expect(entry.point.x).toBeLessThanOrEqual(window.width);
}
// Each level's own size still fits inside the window.
expect(placed[1].point.y + 180).toBeLessThanOrEqual(window.height);
expect(placed[2].point.y + 160).toBeLessThanOrEqual(window.height);
});
test('a submenu on the last row flips up while the root stays put', () => {
const window = { width: 800, height: 800 };
// The root fits comfortably at the anchor, so it must not move.
const root = level({
size: { width: 200, height: 240 },
rows: rowsOf(9),
});
// The child opens beside the last row, which is near the bottom, and is
// taller than the space left below it, so it has to be clamped upwards.
const child = level({
size: { width: 200, height: 200 },
parentIndex: 8,
rows: rowsOf(7),
});
const placed = placeLevels([root, child], { x: 100, y: 300 }, window);
expect(placed[0]).toEqual({ point: { x: 100, y: 300 }, side: null });
// The last row is at 300 + 4 + 8*26 = 512; a 200px menu would end at 712,
// which still fits in 800, so clamping only starts past the margin.
expect(placed[1].point.y).toBe(512);
expect(placed[1].point.y + 200).toBeLessThanOrEqual(window.height);
});
test('a submenu near the bottom is pulled up without moving the root', () => {
const window = { width: 800, height: 600 };
const root = level({
size: { width: 200, height: 240 },
rows: rowsOf(9),
});
const child = level({
size: { width: 200, height: 200 },
parentIndex: 8,
rows: rowsOf(7),
});
// Last root row at 400 + 4 + 8*26 = 612, past the bottom, so the root flips.
const placed = placeLevels([root, child], { x: 100, y: 400 }, window);
expect(placed[0].point).toEqual({ x: 100, y: 400 - 240 });
expect(placed[1].point.y + 200).toBeLessThanOrEqual(window.height);
expect(placed[1].point.y).toBeGreaterThanOrEqual(DEFAULT_MENU_MARGIN);
});
test('is deterministic and leaves its input untouched', () => {
const root = level({ size: { width: 200, height: 150 }, rows: rowsOf(5) });
const child = level({
size: { width: 180, height: 120 },
parentIndex: 1,
rows: rowsOf(4),
});
const levels = [root, child];
const snapshot = JSON.parse(JSON.stringify(levels));
const first = placeLevels(levels, { x: 100, y: 100 }, bigWindow);
const second = placeLevels(levels, { x: 100, y: 100 }, bigWindow);
expect(second).toEqual(first);
expect(levels).toEqual(snapshot);
expect(levels[0]).toBe(root);
expect(levels[0].rows).toBe(root.rows);
});
test('one level places on its own like placeRootMenu', () => {
const root = level({ size: { width: 200, height: 150 }, rows: rowsOf(5) });
const placed = placeLevels([root], { x: 950, y: 780 }, bigWindow);
expect(placed[0].point).toEqual(
placeRootMenu({
anchor: { x: 950, y: 780 },
menuSize: { width: 200, height: 150 },
windowSize: bigWindow,
}),
);
});
test('an empty chain is an empty array', () => {
expect(placeLevels([], { x: 0, y: 0 }, bigWindow)).toEqual([]);
});
});
describe('rowRect', () => {
test('adds the panel position to a row measured relative to it', () => {
const root = level({
size: { width: 200, height: 150 },
rows: rowsOf(5),
});
expect(rowRectOf(root, { x: 100, y: 100 }, 1)).toEqual({
x: 100,
y: 100 + 4 + 26,
width: 200,
height: 26,
});
});
test('returns null without a measurement', () => {
const root = level({ size: null, rows: rowsOf(2) });
expect(rowRectOf(root, { x: 0, y: 0 }, 0)).toBeNull();
const noRows = level({ size: { width: 10, height: 10 }, rows: {} });
expect(rowRectOf(noRows, { x: 0, y: 0 }, 3)).toBeNull();
});
});