import { beforeEach, describe, expect, it, vi } from 'vitest' import { closeDraft, compose, holdDraft, openDraft } from './compose.svelte.js' import { buildFixtureSpace } from './fixture.js' import { onKeydown, registerHere, registerPrimary, registerQuickFind, SHORTCUTS, type KeyPress, } from './keys.js' import { session } from './session.svelte.js' import { ui } from './ui.svelte.js' // The map is a table rather than four components each listening on the window, and this is why: the // rules only make sense together. Escape means one thing inside a field and another outside it, and // outside it means whichever of five layers is on top. const field = (tagName: string) => ({ tagName, blur: vi.fn() }) const press = (key: string, extra: Partial = {}) => { const preventDefault = vi.fn() const event: KeyPress = { key, target: { tagName: 'DIV' }, ...extra, preventDefault } return { event, preventDefault } } const quickFind = { tagName: 'INPUT', focus: vi.fn(), blur: vi.fn() } beforeEach(() => { quickFind.focus.mockClear() quickFind.blur.mockClear() ui.query = '' ui.accountOpen = false ui.menuOpen = false ui.helpOpen = false ui.railOpen = false session.picking = false // `n` writes a goal into the space that is open, so the table asks whether there is one. The // fixture is the cheapest honest answer — the same one every other test here draws from. session.space = buildFixtureSpace() compose.draft = undefined // Whether the open card is holding anything is module state beside the draft, so it is reset with it. holdDraft(false) registerQuickFind(quickFind) registerPrimary(() => {}) // A smart list until a test says otherwise: the view names no project, which is the case every // assertion below about a bare `{ kind: 'goal' }` is standing on. registerHere(() => undefined) }) describe('/', () => { it('puts the caret in quick find', () => { const { event, preventDefault } = press('/') onKeydown(event) expect(quickFind.focus).toHaveBeenCalled() expect(preventDefault).toHaveBeenCalled() }) it('is a slash when a slash is being typed', () => { for (const tag of ['INPUT', 'TEXTAREA', 'SELECT']) { onKeydown(press('/', { target: field(tag) }).event) } onKeydown(press('/', { target: { tagName: 'DIV', isContentEditable: true } }).event) expect(quickFind.focus).not.toHaveBeenCalled() }) }) describe('⌘N', () => { it('is whatever the ⊕ of this view does', () => { const primary = vi.fn() registerPrimary(primary) onKeydown(press('n', { metaKey: true }).event) onKeydown(press('N', { ctrlKey: true }).event) expect(primary).toHaveBeenCalledTimes(2) }) it('does nothing where no ⊕ is mounted — the picker, before there is a space', () => { const primary = vi.fn() const forget = registerPrimary(primary) forget() const { event, preventDefault } = press('n', { metaKey: true }) onKeydown(event) expect(primary).not.toHaveBeenCalled() expect(preventDefault).not.toHaveBeenCalled() }) it('is not a shortcut while a brief is being written', () => { const primary = vi.fn() registerPrimary(primary) onKeydown(press('n', { metaKey: true, target: field('TEXTAREA') }).event) expect(primary).not.toHaveBeenCalled() }) it('leaves an unmodified n to the goal composer rather than to this view’s ⊕', () => { const primary = vi.fn() registerPrimary(primary) onKeydown(press('n').event) expect(primary).not.toHaveBeenCalled() }) }) describe('n', () => { it('writes a goal from anywhere — no modifier, no ⊕ required', () => { const { event, preventDefault } = press('n') onKeydown(event) expect(compose.draft).toEqual({ kind: 'goal' }) expect(preventDefault).toHaveBeenCalled() }) // The route has already answered "which project", on a project's own list and on every goal inside // it, so the card opens on it rather than asking again — the same context the ⊕ standing on that // view hands to the item it opens. it('starts the card on the project the view is of', () => { const project = 'at://did:plc:alice/com.disnetdev.radial.project/ng' registerHere(() => project) onKeydown(press('n').event) expect(compose.draft).toEqual({ kind: 'goal', project }) }) // Asked at the moment it is pressed rather than remembered from when the view registered: the card // opened on the second route must not land in the project of the first. it('asks the view it is pressed on, not the one that was open before', () => { let where = 'at://did:plc:alice/com.disnetdev.radial.project/ng' registerHere(() => where) where = 'at://did:plc:alice/com.disnetdev.radial.project/site' onKeydown(press('n').event) expect(compose.draft).toEqual({ kind: 'goal', project: where }) }) it('names no project on a view that has none — the card asks, as it did before', () => { const forget = registerHere(() => 'at://did:plc:alice/com.disnetdev.radial.project/ng') forget() onKeydown(press('n').event) expect(compose.draft).toEqual({ kind: 'goal' }) }) it('is a letter when a letter is being typed', () => { for (const tag of ['INPUT', 'TEXTAREA', 'SELECT']) { onKeydown(press('n', { target: field(tag) }).event) } onKeydown(press('n', { target: { tagName: 'DIV', isContentEditable: true } }).event) expect(compose.draft).toBeUndefined() }) it('leaves a capital N alone: somebody typing one is not asking for a card', () => { onKeydown(press('N').event) expect(compose.draft).toBeUndefined() }) // The menu goes with it, so Escape still unwinds exactly one thing per press — the card, and then // nothing, rather than the card and a menu still open behind it. it('takes the ⊕ menu with it', () => { ui.menuOpen = true onKeydown(press('n').event) expect(ui.menuOpen).toBe(false) expect(compose.draft).toEqual({ kind: 'goal' }) }) it('does nothing before there is a space to write into', () => { session.space = undefined const { event, preventDefault } = press('n') onKeydown(event) expect(compose.draft).toBeUndefined() expect(preventDefault).not.toHaveBeenCalled() }) it('is behind the map, like everything else', () => { ui.helpOpen = true onKeydown(press('n').event) expect(compose.draft).toBeUndefined() }) // The picker covers the whole shell, so a card opened under it is a form the reader cannot see and // two Escapes to get back from — the first of which looks like it did nothing. it('is inert while the space picker is up', () => { session.picking = true const { event, preventDefault } = press('n') onKeydown(event) expect(compose.draft).toBeUndefined() expect(preventDefault).not.toHaveBeenCalled() }) it('takes the account popover with it, the way it takes the menu', () => { ui.accountOpen = true onKeydown(press('n').event) expect(ui.accountOpen).toBe(false) expect(compose.draft).toEqual({ kind: 'goal' }) }) // The sequence the map itself teaches: type a brief, press Escape once to get the page back — which // deliberately keeps the draft — and then press a letter. Replacing the card there would discard a // brief nobody asked to throw away. it('does not replace a card that has something in it', () => { openDraft({ kind: 'request', target: 'at://goal', scope: 'goal', type: 'plan' }) holdDraft(true) const { event, preventDefault } = press('n') onKeydown(event) expect(compose.draft?.kind).toBe('request') expect(preventDefault).not.toHaveBeenCalled() }) it('replaces an empty one, which is nothing to lose', () => { openDraft({ kind: 'request', target: 'at://goal', scope: 'goal', type: 'plan' }) onKeydown(press('n').event) expect(compose.draft).toEqual({ kind: 'goal' }) }) it('starts every card empty, whatever the last one was holding', () => { openDraft({ kind: 'request', target: 'at://goal', scope: 'goal', type: 'plan' }) holdDraft(true) closeDraft() onKeydown(press('n').event) expect(compose.draft).toEqual({ kind: 'goal' }) }) }) describe('?', () => { it('shows the map, and shows it away again', () => { onKeydown(press('?').event) expect(ui.helpOpen).toBe(true) onKeydown(press('?').event) expect(ui.helpOpen).toBe(false) }) it('is a question mark when a question is being asked', () => { onKeydown(press('?', { target: field('TEXTAREA') }).event) expect(ui.helpOpen).toBe(false) }) it('leaves ⌘? to the browser', () => { const { event, preventDefault } = press('?', { metaKey: true }) onKeydown(event) expect(ui.helpOpen).toBe(false) expect(preventDefault).not.toHaveBeenCalled() }) it('holds the keyboard while it is up — nothing behind it answers', () => { const primary = vi.fn() registerPrimary(primary) ui.helpOpen = true onKeydown(press('/').event) onKeydown(press('n', { metaKey: true }).event) expect(quickFind.focus).not.toHaveBeenCalled() expect(primary).not.toHaveBeenCalled() }) it('lists the keys the map actually answers to', () => { const listed = SHORTCUTS.flatMap((group) => group.items).flatMap((item) => item.keys) for (const chord of ['/', 'n', '⌘N', '?', 'Esc']) expect(listed).toContain(chord) // Every keycap in the table is the character that works, which for the goal is the lowercase one: // the handler ignores `N`, so advertising it would be a row that does nothing when pressed. expect(listed).not.toContain('N') }) }) describe('Escape', () => { it('leaves the field rather than throwing away what is in it', () => { const brief = field('TEXTAREA') openDraft({ kind: 'goal', project: 'at://did:plc:alice/com.disnetdev.radial.project/p' }) onKeydown(press('Escape', { target: brief }).event) expect(brief.blur).toHaveBeenCalled() expect(compose.draft).toBeDefined() // …and the second press, now that focus is out of the card, closes it. onKeydown(press('Escape').event) expect(compose.draft).toBeUndefined() }) it('clears quick find only for quick find', () => { ui.query = 'plan' const other = field('INPUT') onKeydown(press('Escape', { target: other }).event) expect(ui.query).toBe('plan') onKeydown(press('Escape', { target: quickFind }).event) expect(ui.query).toBe('') expect(quickFind.blur).toHaveBeenCalled() }) it('closes one layer per press, topmost first', () => { ui.query = 'plan' openDraft({ kind: 'goal', project: 'at://did:plc:alice/com.disnetdev.radial.project/p' }) ui.menuOpen = true ui.accountOpen = true ui.railOpen = true session.picking = true ui.helpOpen = true onKeydown(press('Escape').event) expect(ui.helpOpen).toBe(false) expect(session.picking).toBe(true) onKeydown(press('Escape').event) expect(session.picking).toBe(false) expect(ui.accountOpen).toBe(true) // The popover paints over the drawer (both `z-index: 30`, the popover later in the document), so // it is the one a press takes away first. The ⊕ menu underneath is the other way round. onKeydown(press('Escape').event) expect(ui.accountOpen).toBe(false) expect(ui.railOpen).toBe(true) onKeydown(press('Escape').event) expect(ui.railOpen).toBe(false) expect(ui.menuOpen).toBe(true) onKeydown(press('Escape').event) expect(ui.menuOpen).toBe(false) expect(compose.draft).toBeDefined() onKeydown(press('Escape').event) expect(compose.draft).toBeUndefined() expect(ui.query).toBe('plan') onKeydown(press('Escape').event) expect(ui.query).toBe('') }) // A request draft rather than a goal one, because a goal draft and an open drawer cannot both // exist: the layout closes the rail whenever one opens (`+layout.svelte`), the goal composer being // drawn over the whole shell. A request card is composed in the pane and closes nothing, so this // is the pair a reader can actually get into — and the half-written brief in it is what must // survive the press that takes the drawer away. it('closes the rail without disturbing a request draft underneath it', () => { openDraft({ kind: 'request', target: 'at://did:plc:alice/com.disnetdev.radial.goal/g', scope: 'goal', type: 'plan', }) ui.railOpen = true onKeydown(press('Escape').event) expect(ui.railOpen).toBe(false) expect(compose.draft).toBeDefined() }) it('is not swallowed when there is nothing open', () => { const { event, preventDefault } = press('Escape') onKeydown(event) expect(preventDefault).not.toHaveBeenCalled() }) })