mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
1013 lines
33 KiB
JavaScript
Vendored
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();
|
|
});
|
|
});
|