// The in-app context menus' panel stack (ui/menu-stack.ts): several menus open, pinned panels that survive the next // menu and never sit on each other, tear-offs, live sources that a pinned panel re-reads, and the wiring in // ContextMenu.tsx that keeps it that way. import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { MenuEntry, MenuInput } from '../src/ui/menu-logic'; import { autoOffset, clampPos, closeMenu, closeUnpinned, LiveMenus, moveMenu, OFFSET_STEP, openMenu, pinMenu, resolvePath, type StackEntry, tearOff, unpinMenu } from '../src/ui/menu-stack'; const e = (uid: string, extra: Partial = {}): StackEntry => ({ uid, source: `src-${uid}`, path: [], pinned: false, x: 10, y: 10, ...extra }); const uids = (l: readonly StackEntry[]) => l.map(x => `${x.uid}${x.pinned ? '*' : ''}`); describe('menu stack', () => { test('opening a menu closes only the unpinned ones', () => { let l = openMenu([], e('a')); l = pinMenu(l, 'a', { x: 100, y: 100 }); l = openMenu(l, e('b')); l = openMenu(l, e('c')); expect(uids(l)).toEqual(['a*', 'c']); l = closeUnpinned(l); expect(uids(l)).toEqual(['a*']); expect(closeUnpinned(l)).toBe(l); // nothing to close: the same list (no re-render) }); test('a pinned panel steps aside from other pinned panels', () => { let l = [e('a', { pinned: true, x: 100, y: 100 }), e('b')]; l = pinMenu(l, 'b', { x: 104, y: 98 }); const b = l.find(x => x.uid === 'b')!; expect(b.pinned).toBe(true); expect(Math.abs(b.x - 100) >= OFFSET_STEP || Math.abs(b.y - 100) >= OFFSET_STEP).toBe(true); expect(autoOffset([{ x: 0, y: 0 }, { x: 24, y: 24 }], { x: 0, y: 0 })).toEqual({ x: 48, y: 48 }); expect(autoOffset([], { x: 5, y: 5 })).toEqual({ x: 5, y: 5 }); }); test('pinning the same panel again replaces the old pinned copy', () => { let l = [e('a', { pinned: true, source: 's', path: ['Copy'] }), e('b', { source: 's', path: ['Copy'] })]; l = pinMenu(l, 'b', { x: 300, y: 300 }); expect(uids(l)).toEqual(['b*']); // a menu without a live source (the edit menu) is never "the same" as another l = [e('a', { pinned: true, source: null }), e('b', { source: null })]; expect(uids(pinMenu(l, 'b', { x: 300, y: 300 }))).toEqual(['a*', 'b*']); }); test('a torn-off submenu is its own pinned panel and the menu it came from stays open', () => { let l = openMenu([], e('a')); l = tearOff(l, e('t', { path: ['Open tab'] }), { x: 50, y: 50 }); expect(uids(l)).toEqual(['a', 't*']); // the same submenu torn off again: one copy l = tearOff(l, e('t2', { path: ['Open tab'], source: 'src-t' }), { x: 50, y: 50 }); expect(uids(l)).toEqual(['a', 't2*']); }); test('unpin: a root menu becomes a normal menu again, a torn-off submenu closes', () => { let l = [e('a', { pinned: true }), e('t', { pinned: true, path: ['Copy'] })]; l = unpinMenu(l, 'a'); expect(uids(l)).toEqual(['a', 't*']); l = unpinMenu(l, 't'); expect(uids(l)).toEqual(['a']); expect(uids(closeMenu(l, 'a'))).toEqual([]); expect(closeMenu(l, 'zz')).toBe(l); }); test('a moved panel keeps the move; positions stay inside the window', () => { const l = moveMenu([e('a', { pinned: true })], 'a', { x: 400, y: 20 }); expect(l[0]).toMatchObject({ x: 400, y: 20 }); expect(clampPos({ x: 1900, y: -40 }, { w: 240, h: 300 }, { w: 1280, h: 800 })).toEqual({ x: 1280 - 240 - 8, y: 8 }); expect(clampPos({ x: 100, y: 100 }, { w: 240, h: 300 }, { w: 1280, h: 800 })).toEqual({ x: 100, y: 100 }); }); test('resolvePath walks submenu labels; a gone submenu gives null', () => { const items: MenuEntry[] = [{ label: 'Open' }, 'sep', { label: 'Copy', submenu: [{ label: 'Name' }, { label: 'More', submenu: [{ label: 'Deep' }] }] }]; expect(resolvePath(items, [])).toBe(items); expect(resolvePath(items, ['Copy', 'More'])!.map(x => (x === 'sep' ? '-' : x.label))).toEqual(['Deep']); expect(resolvePath(items, ['Open'])).toBeNull(); // not a submenu expect(resolvePath(items, ['Gone'])).toBeNull(); }); }); describe('live sources', () => { const sync = () => { const queue: (() => void)[] = []; return { live: new LiveMenus(fn => queue.push(fn)), flush: () => { while (queue.length) queue.shift()!(); } }; }; test('a pinned panel hears once per batch and re-reads the newest builder', () => { const { live, flush } = sync(); let value = 1; const build = (): MenuInput => [{ label: `Rollout ${value}%` }, false]; live.set('h#0', build); let heard = 0; const off = live.watch('h#0', () => heard++); value = 20; live.set('h#0', build); live.set('h#0', build); expect(heard).toBe(0); flush(); expect(heard).toBe(1); expect(live.get('h#0')!.map(x => (x === 'sep' ? '-' : x.label))).toEqual(['Rollout 20%']); off(); live.set('h#0', build); flush(); expect(heard).toBe(1); }); test('keys a render no longer makes are dropped and their panels hear it (gone, not closed)', () => { const { live, flush } = sync(); live.set('h#0', () => [{ label: 'A' }]); live.set('h#app:k1', () => [{ label: 'B' }]); live.set('other#0', () => [{ label: 'C' }]); let heard = 0; live.watch('h#app:k1', () => heard++); live.retain('h', new Set(['h#0'])); flush(); expect(heard).toBe(1); expect(live.get('h#app:k1')).toBeNull(); expect(live.has('h#0') && live.has('other#0')).toBe(true); live.retain('h'); expect(live.has('h#0')).toBe(false); expect(live.has('other#0')).toBe(true); }); test('a builder that throws reads as gone', () => { const { live } = sync(); live.set('x#0', () => { throw new Error('boom'); }); expect(live.get('x#0')).toBeNull(); expect(live.get('missing')).toBeNull(); }); }); describe('wiring', () => { const s = readFileSync(join(import.meta.dir, '../src/ui/ContextMenu.tsx'), 'utf8'); test('menus register live sources and pinned panels re-read them', () => { expect(s).toMatch(/live\.set\(source, build\)/); expect(s).toMatch(/live\.watch\(entry\.source, bump\)/); expect(s).toMatch(/resolvePath\(root, entry\.path\)/); }); test('an outside click closes only the normal menus; a pick in a pinned panel keeps it', () => { expect(s).toMatch(/closeUnpinned\(s\)/); expect(s).toMatch(/done=\{\(\) => closeTransient\(false\)\}/); expect(s).toMatch(/ev\.button === 1 \|\| ev\.ctrlKey \|\| ev\.metaKey/); }); test('the app menus carry the app key, so a pinned panel follows the app when rows reorder', () => { for (const f of ['src/App.tsx', 'src/screens/AppView.tsx', 'src/screens/Ignored.tsx', 'src/screens/Portfolio.tsx']) { const src = readFileSync(join(import.meta.dir, '..', f), 'utf8'); const lines = src.split('\n').filter(l => l.includes('=> appMenu(')); expect(lines.length, f).toBeGreaterThan(0); for (const l of lines) expect(l, f).toMatch(/`app:\$\{/); } }); });