// Context menus: the entry model, keyboard moves, type-ahead and placement (ui/menu-logic.ts), and the rules that keep // the browser's own menu out of the window (ui/ContextMenu.tsx, main.tsx, the Windows webview settings). import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { first, isEditable, last, type MenuEntry, move, placeAt, placeBeside, tidy, typeahead } from '../src/ui/menu-logic'; const ROOT = join(import.meta.dir, '..'); const src = (f: string) => readFileSync(join(ROOT, f), 'utf8'); const item = (label: string, extra: object = {}) => ({ label, ...extra }); describe('menu entries', () => { test('tidy drops falsy entries, empty submenus and stray separators', () => { const out = tidy(['sep', item('Open'), false, null, 'sep', 'sep', item('More', { submenu: [false, 'sep'] }), item('Copy'), 'sep', undefined, '']); expect(out.map(e => (e === 'sep' ? '-' : e.label))).toEqual(['Open', '-', 'Copy']); }); test('tidy keeps a submenu with entries, tidied', () => { const [m] = tidy([item('Copy', { submenu: ['sep', item('Name'), null, 'sep'] })]); expect(m !== 'sep' && (m!.submenu as MenuEntry[]).map(e => (e === 'sep' ? '-' : e.label))).toEqual(['Name']); }); }); describe('keyboard', () => { const items: MenuEntry[] = [item('Open'), 'sep', item('Copy', { disabled: 'nothing selected' }), item('Cut'), item('Paste'), item('Remove', { danger: true })]; test('arrows skip separators and disabled items and wrap', () => { expect(first(items)).toBe(0); expect(last(items)).toBe(5); expect(move(items, 0, 1)).toBe(3); expect(move(items, 3, -1)).toBe(0); expect(move(items, 5, 1)).toBe(0); expect(move(items, 0, -1)).toBe(5); expect(move([], -1, 1)).toBe(-1); expect(move(['sep', item('x', { disabled: true })], -1, 1)).toBe(-1); }); test('type-ahead finds the next enabled match; a repeated letter cycles', () => { expect(typeahead(items, -1, 'p')).toBe(4); expect(typeahead(items, -1, 'c')).toBe(3); // Copy is disabled: Cut expect(typeahead(items, 3, 'c')).toBe(3); expect(typeahead(items, 0, 'o')).toBe(0); expect(typeahead(items, 3, 'cu')).toBe(3); expect(typeahead(items, 0, 'zz')).toBe(-1); }); }); describe('placement', () => { const view = { w: 1000, h: 700 }, box = { w: 220, h: 300 }; test('opens right of and below the cursor', () => expect(placeAt(100, 100, box, view)).toEqual({ left: 100, top: 100 })); test('flips left and up at the edges, and stays inside', () => { expect(placeAt(900, 600, box, view)).toEqual({ left: 680, top: 300 }); expect(placeAt(5, 5, { w: 2000, h: 2000 }, view)).toEqual({ left: 8, top: 8 }); }); test('submenus open beside the item, or on the other side near the edge', () => { expect(placeBeside({ left: 100, right: 320, top: 50 }, box, view)).toEqual({ left: 316, top: 45 }); expect(placeBeside({ left: 700, right: 920, top: 600 }, box, view)).toEqual({ left: 484, top: 392 }); }); }); test('text fields get the edit menu', () => { expect(isEditable({ tagName: 'INPUT', type: 'text' })).toBe(true); expect(isEditable({ tagName: 'INPUT', type: 'checkbox' })).toBe(false); expect(isEditable({ tagName: 'TEXTAREA' })).toBe(true); expect(isEditable({ tagName: 'DIV', isContentEditable: true })).toBe(true); expect(isEditable({ tagName: 'BUTTON' })).toBe(false); expect(isEditable(null)).toBe(false); }); describe('no browser menu', () => { test('the provider prevents every native contextmenu and handles Shift+F10 / the Menu key', () => { const s = src('src/ui/ContextMenu.tsx'); expect(s).toMatch(/document\.addEventListener\('contextmenu', onMenu\)/); // the fallback handler prevents the default before anything else expect(s).toMatch(/const onMenu = \(e: MouseEvent\) => \{\s*e\.preventDefault\(\);/); expect(s).toMatch(/e\.key === 'F10' && e\.shiftKey/); expect(s).toMatch(/e\.key === 'ContextMenu'/); expect(s).toMatch(/role="menu"/); expect(s).toMatch(/menuitemcheckbox/); }); test('main.tsx wraps the app in the provider', () => { expect(src('src/main.tsx')).toMatch(/ { const rs = src('src-tauri/src/lib.rs'); expect(rs).toMatch(/#\[cfg\(all\(windows, not\(debug_assertions\)\)\)\]\s*fn harden_webview/); expect(rs).toMatch(/SetAreDefaultContextMenusEnabled\(false\)/); expect(rs).toMatch(/SetAreBrowserAcceleratorKeysEnabled\(false\)/); }); test('menus use the catalog in every language', () => { for (const k of ['cm.cut', 'cm.copy', 'cm.paste', 'cm.selectAll']) { expect(src('src/i18n/en.ts')).toContain(`'${k}'`); expect(src('src/i18n/tr.ts')).toContain(`'${k}'`); } }); });