// The tray menu panels' page (src/traymenu/): the tree from Rust, paths by stable keys, the row split, filtering, // keyboard moves, a live update that keeps the highlight, and the page's wiring (its own bundle, dev demo only in dev). import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { filterNodes, findPath, isTearOff, keepActive, type MenuNode, nextIndex, rows, split } from '../src/traymenu/model'; const ROOT = join(import.meta.dir, '..'); const src = (f: string) => readFileSync(join(ROOT, f), 'utf8'); const item = (id: string, label: string, enabled = true): MenuNode => ({ kind: 'item', id, label, enabled }); const tree = (state: string): MenuNode[] => [ item('tray-open', 'Open storebound'), { kind: 'sep' }, { kind: 'sub', key: 'apps', label: 'Apps', items: [item('tray-app:k1', `First · ${state}`), item('tray-app:k2', 'Second')] }, item('tray-none', 'No apps yet', false), item('tray-quit', 'Quit storebound'), ]; describe('tray menu model', () => { test('a row splits into its text and the live value after the middle dot', () => { expect(split('First · App Store: In review · Play: Live')).toEqual({ text: 'First', value: 'App Store: In review · Play: Live' }); expect(split('Settings')).toEqual({ text: 'Settings' }); expect(split('Odd · ')).toEqual({ text: 'Odd' }); }); test('paths follow submenu keys; a gone submenu gives null', () => { expect(findPath(tree('x'), [])!.label).toBeNull(); expect(findPath(tree('x'), ['apps'])!.items).toHaveLength(2); expect(findPath(tree('x'), ['apps'])!.label).toBe('Apps'); expect(findPath(tree('x'), ['gone'])).toBeNull(); }); test('typing filters rows by every word, separators drop out', () => { const r = filterNodes(tree('x'), 'stor quit'); expect(r.map(n => (n.kind === 'sep' ? '-' : n.label))).toEqual(['Quit storebound']); expect(filterNodes(tree('x'), ' ')).toHaveLength(5); }); test('keyboard moves skip disabled rows and wrap', () => { const en = rows(tree('x')).map(n => n.kind === 'sub' || n.enabled); expect(en).toEqual([true, true, false, true]); expect(nextIndex(1, 1, en)).toBe(3); expect(nextIndex(3, 1, en)).toBe(0); expect(nextIndex(-1, 1, en)).toBe(0); expect(nextIndex(-1, -1, en)).toBe(3); expect(nextIndex(0, 1, [])).toBe(-1); }); test('a live update keeps the highlight on the same row', () => { const before = rows(findPath(tree('In review'), ['apps'])!.items); const after = rows(findPath([...tree('Live')], ['apps'])!.items).slice().reverse(); expect(keepActive(before, after, 0)).toBe(1); // First moved to index 1 expect(keepActive(before, after.slice(0, 1), 0)).toBe(0); // gone: the same place, clamped expect(keepActive(before, after, -1)).toBe(-1); expect(keepActive(before, [], 0)).toBe(-1); }); test('Ctrl / Cmd + click or a middle click tears a submenu off', () => { expect(isTearOff({ button: 1, ctrlKey: false, metaKey: false })).toBe(true); expect(isTearOff({ button: 0, ctrlKey: true, metaKey: false })).toBe(true); expect(isTearOff({ button: 0, ctrlKey: false, metaKey: true })).toBe(true); expect(isTearOff({ button: 0, ctrlKey: false, metaKey: false })).toBe(false); }); }); describe('tray menu page', () => { test('its own page in the build, with the demo only behind import.meta.env.DEV', () => { expect(src('vite.config.ts')).toMatch(/menu: 'tray-menu\.html'/); expect(src('tray-menu.html')).toMatch(/src="\/src\/traymenu\/main\.tsx"/); const main = src('src/traymenu/main.tsx'); expect(main).toMatch(/!inTauri\(\) && import\.meta\.env\.DEV/); expect(main).toMatch(/await import\('\.\/demo'\)/); expect(main).not.toMatch(/^import .*\.\/demo/m); }); test('the page never polls: values come from menu-model pushes', () => { const main = src('src/traymenu/main.tsx'); expect(src('src/traymenu/menu-api.ts')).toMatch(/sub\('menu-model'/); expect(main).toMatch(/onModel\(setNodes\)/); expect(main).not.toMatch(/setInterval/); expect(src('src/traymenu/TrayMenu.tsx')).not.toMatch(/setInterval|requestAnimationFrame/); }); test('every command the page calls is a menu command Rust grants to menu-* windows', () => { const api = src('src/traymenu/menu-api.ts'); const called = [...api.matchAll(/call\('(\w+)'/g), ...api.matchAll(/invoke<[^>]*>\('(\w+)'/g)].map(m => m[1]); const list = src('src-tauri/src/commands_list.rs'); const menu = list.slice(list.indexOf('MENU_COMMANDS')); expect(called.length).toBeGreaterThan(5); for (const c of called) expect(menu, c).toContain(`"${c}"`); }); });