// The app menu's model (frame/menuModel.ts): which rows an app gets (its groups, the built-in rows // and what gates them), how the keyboard moves through them, the language list, the zoom steps, // how shortcuts are written and where the menu's words come from. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { setShellI18n, type ShellI18n } from '../src/frame/i18n.ts'; import { DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, ZOOM_MAX, ZOOM_MIN, ZOOM_STEPS, builtinAvailable, findLabel, findSpec, joinGroups, languageChoice, languageRows, menuText, menuWith, menuWithout, moveStop, planMenu, shortcutText, specRows, submenuKeys, submenuLeft, typeahead, updateRow, zoomPercent, zoomStep, type MenuEnv, type MenuGroups, type MenuItemRow, type MenuItemSpec, type MenuLanguage, type MenuRow, } from '../src/frame/menuModel.ts'; import { buildItems } from '../src/frame/paletteModel.ts'; const here = dirname(fileURLToPath(import.meta.url)); const ALL: MenuEnv = { decks: true, languages: true, themes: true, modes: true, zoom: true, find: true, safeMode: true, store: true, workspaces: true, developer: true, updates: true }; const NONE: MenuEnv = { decks: false, languages: false, themes: false, modes: false, zoom: false, find: false, safeMode: false, store: false, workspaces: false, developer: false, updates: false }; const ids = (groups: ReturnType) => groups.map(group => group.map(entry => (typeof entry === 'string' ? entry : entry.id))); const items = (rows: MenuRow[]) => rows.filter((row): row is MenuItemRow => row.kind === 'item'); test('the default menu lists every built-in row once, and an app that serves everything gets them all', () => { assert.deepEqual([...DEFAULT_MENU.flat()].sort(), [...MENU_BUILTINS].sort()); assert.deepEqual(ids(planMenu(DEFAULT_MENU, ALL)), DEFAULT_MENU.map(group => [...group])); }); test('a row the app cannot serve is left out, and a group left empty takes its rule with it', () => { // An app that serves none of the optional parts: no decks, languages, themes, zoom, find, Store, workspaces, developer mode or updater. assert.deepEqual(ids(planMenu(DEFAULT_MENU, NONE)), [ ['new-tab', 'split-right', 'split-down'], ['plugins'], ['shortcuts', 'palette'], ['report', 'about', 'settings'], ]); // Each gate is its own. const gates: Array<[keyof MenuEnv, string]> = [ ['decks', 'new-column'], ['languages', 'language'], ['themes', 'theme'], ['modes', 'mode'], ['zoom', 'zoom'], ['find', 'find'], ['safeMode', 'safe-mode'], ['store', 'store'], ['workspaces', 'workspaces'], ['developer', 'developer'], ['updates', 'updates'], ]; for (const [feature, row] of gates) { const only = ids(planMenu(DEFAULT_MENU, { ...NONE, [feature]: true })).flat(); assert.ok(only.includes(row), `${feature} shows ${row}`); assert.equal(ids(planMenu(DEFAULT_MENU, { ...ALL, [feature]: false })).flat().includes(row), false, `without ${feature} there is no ${row}`); assert.equal(builtinAvailable(row as typeof MENU_BUILTINS[number], NONE), false); } for (const row of ['new-tab', 'split-right', 'split-down', 'plugins', 'shortcuts', 'palette', 'report', 'about', 'settings'] as const) assert.ok(builtinAvailable(row, NONE), row); // The rows that remain are separated once, never twice. const rows = joinGroups(planMenu(DEFAULT_MENU, NONE).map(group => group.map((entry): MenuRow => ({ kind: 'item', id: String(entry), label: String(entry) })))); assert.equal(rows.filter(row => row.kind === 'separator').length, 3); assert.ok(rows.every((row, index) => row.kind !== 'separator' || (index > 0 && index < rows.length - 1 && rows[index - 1]!.kind !== 'separator'))); }); test('an app orders, removes and adds rows; its own rows follow their `when`', () => { let drafts = true; const own: MenuItemSpec = { id: 'drafts', label: () => 'Drafts…', icon: 'draft', when: () => drafts, run() {} }; const groups = menuWith(DEFAULT_MENU, 'workspaces', [own]); assert.deepEqual(ids(planMenu(groups, ALL))[3], ['plugins', 'store', 'workspaces', 'drafts']); assert.deepEqual(ids(planMenu(menuWith(DEFAULT_MENU, 'plugins', [own], 'before'), ALL))[3], ['drafts', 'plugins', 'store', 'workspaces']); // Without its anchor the app's rows form a last group. assert.deepEqual(ids(planMenu(menuWith(DEFAULT_MENU, 'nothing', [own]), ALL)).at(-1), ['drafts']); drafts = false; assert.equal(ids(planMenu(groups, ALL)).flat().includes('drafts'), false); drafts = true; assert.deepEqual(ids(planMenu(menuWithout(groups, 'store', 'zoom', 'drafts'), ALL)), [ ['new-tab', 'split-right', 'split-down', 'new-column'], ['language', 'theme', 'mode'], ['plugins', 'workspaces'], ['find', 'shortcuts', 'palette'], ['developer', 'updates', 'safe-mode', 'report', 'about', 'settings'], ]); // Explicit order; a row listed twice shows once; an unknown name and an own row named like a built-in are ignored. const custom: MenuGroups = [['settings', 'new-tab', 'settings', 'nope' as never], [{ id: 'zoom', label: () => 'Fake zoom' }, own]]; assert.deepEqual(ids(planMenu(custom, ALL)), [['settings', 'new-tab'], ['drafts']]); }); test("the app's rows become menu rows: toggles, hints, submenus and groups inside a submenu", () => { let on = false; const specs: MenuItemSpec[] = [ { id: 'a', label: () => 'Alpha', shortcut: 'Ctrl+Shift+A', run() {} }, { id: 'b', label: () => 'Beta', checked: () => on, keepOpen: true, run() { on = !on; } }, { id: 'c', label: () => 'Gamma', hint: () => '3', separated: true, submenu: () => [{ id: 'c1', label: () => 'Inner', submenu: () => [{ id: 'c2', label: () => 'Deep' }] }] }, { id: 'd', label: () => 'Hidden', when: () => false }, ]; const rows = specRows(specs); assert.deepEqual(rows.map(row => (row.kind === 'item' ? row.id : row.kind)), ['a', 'b', 'separator', 'c']); const [a, b, c] = items(rows); assert.equal(a!.shortcut, 'Ctrl+Shift+A'); assert.deepEqual([b!.check, b!.checked, b!.keepOpen], ['checkbox', false, true]); assert.deepEqual([c!.submenu, c!.hint, a!.submenu, a!.check], [true, '3', false, undefined]); (b!.run as () => void)(); assert.equal(items(specRows(specs))[1]!.checked, true, 'a toggle shows its state when the menu is drawn again'); // Submenus are found by id at any depth. assert.equal(findSpec([['new-tab', ...specs]], 'c2')?.label(), 'Deep'); assert.equal(findSpec([['new-tab', ...specs]], 'c')?.submenu?.().length, 1); assert.equal(findSpec([['new-tab']], 'c'), null); }); test('arrows wrap, Home and End jump, and with nothing focused an arrow enters at the near end', () => { assert.equal(moveStop(0, 'ArrowDown', 4), 1); assert.equal(moveStop(3, 'ArrowDown', 4), 0); assert.equal(moveStop(0, 'ArrowUp', 4), 3); assert.equal(moveStop(2, 'Home', 4), 0); assert.equal(moveStop(1, 'End', 4), 3); assert.equal(moveStop(-1, 'ArrowDown', 4), 0); assert.equal(moveStop(-1, 'ArrowUp', 4), 3); assert.equal(moveStop(2, 'a', 4), 2); assert.equal(moveStop(0, 'ArrowDown', 0), -1); // Submenus open along the reading direction and close against it. assert.deepEqual(submenuKeys(false), { open: 'ArrowRight', close: 'ArrowLeft' }); assert.deepEqual(submenuKeys(true), { open: 'ArrowLeft', close: 'ArrowRight' }); }); test('a submenu opens toward the middle of the window and never leaves it', () => { // Under the button at the window's end: to the left of the menu. assert.equal(submenuLeft({ left: 990, right: 1294 }, 250, 1440), 990 - 250 + 3); // Under the logo at the window's start: to the right of the menu. assert.equal(submenuLeft({ left: 8, right: 312 }, 250, 1440), 312 - 3); // No room toward the middle: the other side. assert.equal(submenuLeft({ left: 100, right: 404 }, 250, 700), 404 - 3); // No room on either side (a narrow window): over the menu, inside the window. assert.equal(submenuLeft({ left: 11, right: 315 }, 250, 420), 162); assert.equal(submenuLeft({ left: 108, right: 412 }, 250, 420), 8); for (const [left, width, viewport] of [[0, 300, 320], [40, 310, 330], [500, 260, 1000], [900, 320, 1000]] as const) { const at = submenuLeft({ left, right: left + 304 }, width, viewport); assert.ok(at >= 8 && (at + width <= viewport - 8 || at === 8), `${left}/${width}/${viewport}: ${at}`); } }); test('type-ahead finds the next row that starts with what was typed', () => { const labels = ['New tab', 'Split right', 'Split down', 'Language', 'Theme', 'Settings…', 'Écran', 'İstanbul']; assert.equal(typeahead(labels, -1, 's'), 1); assert.equal(typeahead(labels, 1, 's'), 2, 'the same letter again moves on'); assert.equal(typeahead(labels, 2, 'ss'), 5, 'a repeated letter cycles'); assert.equal(typeahead(labels, 5, 's'), 1, 'and wraps'); assert.equal(typeahead(labels, 1, 'se'), 5, 'a typed word narrows'); assert.equal(typeahead(labels, 5, 'set'), 5, 'and stays on the row that still matches'); assert.equal(typeahead(labels, 0, 'T'), 4, 'case is ignored'); assert.equal(typeahead(labels, 0, 'e'), 6, 'accents are ignored'); assert.equal(typeahead(labels, 0, 'i'), 7); assert.equal(typeahead(labels, 0, 'x'), -1); assert.equal(typeahead(labels, 0, ''), -1); assert.equal(typeahead([], 0, 'a'), -1); }); test('shortcuts are written the way the platform writes them', () => { assert.equal(shortcutText('Ctrl+T', false), 'Ctrl+T'); assert.equal(shortcutText('Ctrl+Shift+\\', false), 'Ctrl+Shift+\\'); assert.equal(shortcutText('Ctrl+T', true), '⌘T'); assert.equal(shortcutText('Ctrl+Shift+\\', true), '⇧⌘\\'); assert.equal(shortcutText('Ctrl+Alt+N', true), '⌥⌘N'); assert.equal(shortcutText('F10', true), 'F10'); }); const LANGUAGES: MenuLanguage[] = [ { code: 'en', name: 'English', englishName: 'English', dir: 'ltr', tier: 'supported' }, { code: 'tr', name: 'Türkçe', englishName: 'Turkish', dir: 'ltr', tier: 'supported' }, { code: 'ar', name: 'العربية', englishName: 'Arabic', dir: 'rtl', tier: 'supported' }, { code: 'sv', name: 'Svenska', englishName: 'Swedish', dir: 'ltr', tier: 'community' }, ]; const WORDS = { site: 'Follow X', system: 'Follow system', more: 'More languages…' }; test('the Language submenu: follow the site or the system, the supported languages by their own names, then the full list', () => { const rows = languageRows(LANGUAGES, { preference: 'site', siteLang: 'tr', locale: 'tr' }, WORDS); assert.deepEqual(rows.map(row => (row.kind === 'item' ? row.id : row.kind)), ['lang:site', 'lang:system', 'separator', 'lang:en', 'lang:tr', 'lang:ar', 'separator', 'lang:more']); const [site, system, en, tr, ar, more] = items(rows); assert.deepEqual([site!.label, site!.checked, site!.hint], ['Follow X', true, 'Türkçe'], 'following the site says which language that is'); assert.deepEqual([system!.checked, system!.hint], [false, undefined]); assert.deepEqual([tr!.label, tr!.hint, tr!.checked, tr!.check], ['Türkçe', 'Turkish', false, 'radio'], 'a language reads in its own name, English beside it'); assert.equal(en!.hint, undefined, 'no English name beside English'); assert.deepEqual([ar!.lang, ar!.dir], ['ar', 'rtl']); assert.deepEqual([more!.label, more!.check], ['More languages…', undefined]); assert.equal(items(rows).filter(row => row.checked).length, 1, 'exactly one is checked'); }); test('the Language submenu checks what was picked, a community language included', () => { const picked = items(languageRows(LANGUAGES, { preference: 'ar', siteLang: 'tr', locale: 'ar' }, WORDS)); assert.deepEqual(picked.filter(row => row.checked).map(row => row.id), ['lang:ar']); assert.equal(picked[0]!.hint, 'Türkçe', 'the site row still says what following it would give'); const system = items(languageRows(LANGUAGES, { preference: 'system', siteLang: null, locale: 'en' }, WORDS)); assert.deepEqual([system[0]!.hint, system[1]!.hint, system[1]!.checked], [undefined, 'English', true]); // Community languages are in Settings; one that is in use is listed, so the check is never missing. assert.equal(picked.some(row => row.id === 'lang:sv'), false); const community = items(languageRows(LANGUAGES, { preference: 'sv', siteLang: null, locale: 'sv' }, WORDS)); assert.deepEqual(community.filter(row => row.checked).map(row => row.id), ['lang:sv']); // A row stands for the preference the language state takes. assert.deepEqual(['lang:site', 'lang:system', 'lang:zh-Hant', 'lang:more', 'theme:x'].map(languageChoice), ['site', 'system', 'zh-Hant', null, null]); }); test('zoom moves through a browser\'s steps and stays inside the limits native code enforces', () => { assert.equal(zoomStep(1, 1), 1.1); assert.equal(zoomStep(1, -1), 0.9); assert.equal(zoomStep(1.1, 1), 1.25); assert.equal(zoomStep(1.2, 1), 1.25, 'from between two steps to the next one'); assert.equal(zoomStep(1.2, -1), 1.1); assert.equal(zoomStep(2.5, 0), 1); assert.equal(zoomStep(ZOOM_MAX, 1), ZOOM_MAX); assert.equal(zoomStep(ZOOM_MIN, -1), ZOOM_MIN); assert.equal(zoomStep(Number.NaN, 1), 1); assert.deepEqual([ZOOM_STEPS[0], ZOOM_STEPS.at(-1)], [ZOOM_MIN, ZOOM_MAX]); assert.ok(ZOOM_STEPS.every((step, index) => index === 0 || step > ZOOM_STEPS[index - 1]!)); // Up through every step and down again: no step is skipped or repeated. let factor: number = ZOOM_MIN; const up: number[] = []; while (factor < ZOOM_MAX) { factor = zoomStep(factor, 1); up.push(factor); } assert.deepEqual(up, ZOOM_STEPS.slice(1)); assert.deepEqual([zoomPercent(1.1), zoomPercent(0.67), zoomPercent(0.33)], [110, 67, 33]); // The limits are the host's (zoom.rs). const rust = readFileSync(join(here, '../../../crates/waybetter-host/src/zoom.rs'), 'utf8'); assert.match(rust, new RegExp(`pub const MIN: f64 = ${ZOOM_MIN};`)); assert.match(rust, new RegExp(`pub const MAX: f64 = ${ZOOM_MAX.toFixed(1)};`)); }); test("the menu's words: English from menuMessages.en.json until a catalog has them", () => { // Every key is a wb.menu.* key with plain text (no markup), as the catalogs require. for (const [key, value] of Object.entries(MENU_MESSAGES)) { assert.match(key, /^wb\.menu\.[A-Za-z]+$/, key); assert.ok(value.trim() && !/[<>&]|javascript:/i.test(value), key); } // Unbound (no i18n instance) and bound to a catalog without the key: the English file speaks. assert.equal(menuText('label'), 'Settings and more'); assert.equal(menuText('about', { product: 'wayBetter for X' }), 'About wayBetter for X'); assert.equal(menuText('section', { section: 'Plugins' }), 'Plugins…'); const catalog: Record = { 'wb.menu.label': 'Ayarlar ve daha fazlası', 'wb.menu.about': '{product} hakkında' }; const bound: ShellI18n = { locale: 'tr', tx: (key, params, fallback) => (key in catalog ? catalog[key]!.replace('{product}', String(params?.product ?? '')) : fallback ?? key), onChange: () => () => {}, number: String, relative: String, date: String, }; setShellI18n(bound); assert.equal(menuText('label'), 'Ayarlar ve daha fazlası', 'a catalog that has the key wins'); assert.equal(menuText('about', { product: 'wayBetter for X' }), 'wayBetter for X hakkında'); assert.equal(menuText('zoom'), 'Zoom', 'a key the catalog lacks still reads in English'); // The component and the palette name their words through menuText, never as raw keys. const used = new Set(); for (const file of ['AppMenu.tsx', 'TitlebarExtras.tsx', 'AccountSwitcher.tsx', 'paletteModel.ts', 'FindBar.tsx', 'menuModel.ts', 'ReportDialog.tsx']) { for (const match of readFileSync(join(here, '../src/frame', file), 'utf8').matchAll(/menuText\('([A-Za-z]+)'/g)) used.add(`wb.menu.${match[1]}`); } for (const key of used) assert.ok(key in MENU_MESSAGES, `${key} has English`); for (const key of Object.keys(MENU_MESSAGES)) assert.ok(used.has(key), `${key} is used`); }); test('the Updates row: a real check that says what came of it, calmly', () => { assert.deepEqual(updateRow({ state: 'idle' }, false, '1.3.2'), { label: 'Check for updates', hint: '1.3.2', action: 'check' }); assert.deepEqual(updateRow({ state: 'checking' }, true, '1.3.2'), { label: 'Check for updates', hint: 'Checking…', action: 'wait' }); assert.deepEqual(updateRow({ state: 'upToDate' }, true, '1.3.2'), { label: 'Check for updates', hint: 'Up to date · 1.3.2', action: 'check' }); assert.equal(updateRow({ state: 'upToDate' }, true, '').hint, 'Up to date'); assert.deepEqual(updateRow({ state: 'downloading', version: '1.4.0' }, true, '1.3.2'), { label: 'Check for updates', hint: 'Downloading 1.4.0…', action: 'wait' }); assert.deepEqual(updateRow({ state: 'ready', version: '1.4.0', notes: '' }, false, '1.3.2'), { label: 'Restart to update', hint: '1.4.0', action: 'restart' }); assert.equal(updateRow({ state: 'installing', version: '1.4.0' }, false, '1.3.2').action, 'wait'); assert.equal(updateRow({ state: 'problem', downloadUrl: 'https://example.com/download' }, false, '1.3.2').action, 'open'); // A check that did not get through: a short state and a plain sentence, never the error text; and only // when the owner asked (the timer's own failures stay quiet). const failed = updateRow({ state: 'error', message: 'UPDATE: error sending request for url (https://updates.example/x): dns error' }, true, '1.3.2'); assert.deepEqual([failed.label, failed.hint, failed.action], ['Check for updates', 'Could not check', 'check']); assert.ok(failed.title && !/UPDATE:|https?:|dns|[Ee]rror/.test(`${failed.hint} ${failed.title}`), 'no error dump'); assert.deepEqual(updateRow({ state: 'error', message: 'UPDATE: offline' }, false, '1.3.2'), { label: 'Check for updates', hint: '1.3.2', action: 'check' }); }); test("the find bar says which match of how many, in the reader's numbers", () => { assert.equal(findLabel({ count: 17, index: 2 }), '3/17'); assert.equal(findLabel({ count: 1, index: 0 }), '1/1'); assert.equal(findLabel({ count: 0, index: -1 }), 'No matches'); assert.equal(findLabel({ count: 5, index: -1 }), '5 matches', 'a count without an active match'); assert.equal(findLabel({ count: 1, index: -1 }), '1 match'); assert.equal(findLabel({ count: 12, index: 3 }, value => `<${value}>`), '<4>/<12>'); }); test('the palette opens the menu', () => { const all = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false }); const entry = all.find(item => item.id === 'app-menu'); assert.deepEqual([entry?.action, entry?.group], [{ type: 'menu' }, 'App']); // Find in page is there unless the app has no find. assert.deepEqual(all.find(item => item.id === 'find')?.action, { type: 'find' }); const without = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false, find: false }); assert.equal(without.some(item => item.id === 'find'), false); });