mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Session candidates labelled from projection checkpoints instead of a full log fold per keystroke, with the uncheckpointed remainder folded once and memoized while its log stays cold. The file index keeps answering while an invalidated traversal rebuilds behind the caret, and its default exclusions now cover build outputs so deep sources stay reachable. Rows carry only what distinguishes them: a file names its parent directory, a session names its workspace only when that workspace is not the current one, and a drilled listing names none because its new breadcrumb does. Resolves #3180 Related to #3154
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* MenuView rendering spec, props-direct: closed store
|
|
* renders null, groups render in roster order under localized title rows
|
|
* (unknown sources fall back to the raw name) with pending rows as skeleton
|
|
* placeholders, pointer picks route (source, index) back without stealing
|
|
* focus, the highlight is exposed through aria-activedescendant +
|
|
* aria-selected, and the list height clamps to the space above the composer.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
|
import { zh } from '../src/client/locales.ts'
|
|
import type {
|
|
InputTriggerCrumb, MenuState, TriggerHit,
|
|
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
import { MenuView } from '../src/client/MenuView.tsx'
|
|
|
|
const hit: TriggerHit = {
|
|
trigger: '/',
|
|
query: 'g',
|
|
quoted: false,
|
|
position: 'leading',
|
|
span: { start: 0, end: 2, draftRev: 1 },
|
|
}
|
|
|
|
const CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
|
|
|
|
function openState(partial?: Partial<MenuState>): MenuState {
|
|
return {
|
|
open: true,
|
|
hit,
|
|
generation: 1,
|
|
groups: [
|
|
{ source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: 'file' }, { name: 'plan' }] },
|
|
{ source: 'skill', status: 'pending', items: [] },
|
|
],
|
|
highlight: { source: 'command', index: 0 },
|
|
...partial,
|
|
}
|
|
}
|
|
|
|
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
|
|
const scrollIntoView = vi.fn()
|
|
beforeEach(() => {
|
|
Element.prototype.scrollIntoView = scrollIntoView
|
|
scrollIntoView.mockClear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
// The framework-injected t seat, stubbed over the zh dictionaries (the
|
|
// default locale); the stub mirrors the LocaleRuntime key fallback, so an
|
|
// unknown source comes back verbatim (its raw name).
|
|
const t = makeTranslate(zh, commonZh)
|
|
|
|
function mount(state: MenuState, crumbs: ReadonlyMap<string, readonly InputTriggerCrumb[]> = new Map()) {
|
|
const menu = createSnapshotStore<MenuState>(state)
|
|
const headers = createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(crumbs)
|
|
const onPick = vi.fn()
|
|
const onCrumb = vi.fn()
|
|
const onHover = vi.fn()
|
|
const onDismiss = vi.fn()
|
|
const view = render(
|
|
<MenuView
|
|
menu={menu}
|
|
headers={headers}
|
|
onPick={onPick}
|
|
onCrumb={onCrumb}
|
|
onHover={onHover}
|
|
onDismiss={onDismiss}
|
|
t={t}
|
|
/>,
|
|
)
|
|
return { menu, headers, onPick, onCrumb, onHover, onDismiss, view }
|
|
}
|
|
|
|
/** The bounded menu shell: it owns the height clamp, the listbox scrolls inside it. */
|
|
function menuShell(): HTMLElement {
|
|
const shell = document.querySelector('[data-trigger-menu]')
|
|
if (!(shell instanceof HTMLElement)) throw new Error('menu shell is not rendered')
|
|
return shell
|
|
}
|
|
|
|
/** The non-interactive group title rows (role=presentation), in document order. */
|
|
function titles(container: HTMLElement): string[] {
|
|
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
|
|
.map(el => el.textContent ?? '')
|
|
}
|
|
|
|
describe('MenuView', () => {
|
|
it('renders null while closed and appears when the store opens', () => {
|
|
const { menu, view } = mount(CLOSED)
|
|
expect(view.container.childElementCount).toBe(0)
|
|
act(() => { menu.set(openState()) })
|
|
expect(screen.queryByRole('listbox')).not.toBeNull()
|
|
act(() => { menu.set(CLOSED) })
|
|
expect(view.container.childElementCount).toBe(0)
|
|
})
|
|
|
|
it('renders ready groups as option rows and pending groups as two skeleton rows', () => {
|
|
mount(openState())
|
|
const options = screen.getAllByRole('option')
|
|
expect(options.map(o => o.textContent)).toEqual(['goalSet up a goal', 'plan'])
|
|
// The icon token renders as an SVG glyph, not text.
|
|
expect(options[0]?.querySelector('svg')).not.toBeNull()
|
|
expect(options[1]?.querySelector('svg')).toBeNull()
|
|
const status = screen.getByRole('status', { name: '正在加载…' })
|
|
expect(status.children).toHaveLength(2)
|
|
})
|
|
|
|
it('keeps an opted-out source title hidden while its candidates are pending', () => {
|
|
mount(openState({
|
|
groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }],
|
|
highlight: null,
|
|
}))
|
|
expect(screen.queryByText('reference')).toBeNull()
|
|
expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy()
|
|
})
|
|
|
|
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
|
|
const { view } = mount(openState({
|
|
groups: [
|
|
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
|
|
{ source: 'hollow', status: 'ready', items: [] },
|
|
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
|
|
{ source: 'skill', status: 'pending', items: [] },
|
|
],
|
|
}))
|
|
expect(titles(view.container)).toEqual(['指令', 'mystery', '技能'])
|
|
})
|
|
|
|
it('renders contiguous candidate sections once without changing option indexes', () => {
|
|
const { onPick } = mount(openState({
|
|
groups: [{
|
|
source: 'reference',
|
|
status: 'ready',
|
|
items: [
|
|
{ name: 'Folder · src/', section: '文件与文件夹' },
|
|
{ name: 'File · README.md', section: '文件与文件夹' },
|
|
{ name: 'Session · Research', section: '对话' },
|
|
],
|
|
}],
|
|
highlight: { source: 'reference', index: 0 },
|
|
}))
|
|
expect(screen.queryByText('reference')).toBeNull()
|
|
expect(screen.getAllByText('文件与文件夹')).toHaveLength(1)
|
|
expect(screen.getAllByText('对话')).toHaveLength(1)
|
|
const options = screen.getAllByRole('option')
|
|
expect(options.map(option => option.textContent)).toEqual([
|
|
'Folder · src/',
|
|
'File · README.md',
|
|
'Session · Research',
|
|
])
|
|
fireEvent.mouseDown(options[2]!)
|
|
expect(onPick).toHaveBeenCalledWith('reference', 2)
|
|
})
|
|
|
|
it('renders the drill chevron only on drillable rows and routes its own action', () => {
|
|
const { onPick } = mount(openState({
|
|
groups: [{
|
|
source: 'reference',
|
|
status: 'ready',
|
|
items: [
|
|
{ name: 'Folder · src/', drill: true },
|
|
{ name: 'File · README.md' },
|
|
],
|
|
}],
|
|
highlight: { source: 'reference', index: 0 },
|
|
}))
|
|
const chevrons = screen.getAllByRole('button', { name: '进入目录' })
|
|
expect(chevrons).toHaveLength(1)
|
|
// The chevron drills; the row body still settles the pick untouched.
|
|
fireEvent.mouseDown(chevrons[0]!)
|
|
expect(onPick).toHaveBeenCalledWith('reference', 0, 'drill')
|
|
fireEvent.mouseDown(screen.getAllByRole('option')[0]!)
|
|
expect(onPick).toHaveBeenCalledWith('reference', 0)
|
|
})
|
|
|
|
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
|
|
mount(openState({ highlight: { source: 'command', index: 1 } }))
|
|
const listbox = screen.getByRole('listbox')
|
|
const options = screen.getAllByRole('option')
|
|
expect(options[1]!.id).toBeTruthy()
|
|
expect(listbox.getAttribute('aria-activedescendant')).toBe(options[1]!.id)
|
|
expect(options[1]!.getAttribute('aria-selected')).toBe('true')
|
|
expect(options[0]!.getAttribute('aria-selected')).toBe('false')
|
|
})
|
|
|
|
it('omits aria-activedescendant without a highlight', () => {
|
|
mount(openState({ highlight: null }))
|
|
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
|
|
})
|
|
|
|
it('scrolls the highlighted option into view when the highlight moves', () => {
|
|
const { menu } = mount(openState())
|
|
scrollIntoView.mockClear()
|
|
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
|
|
const options = screen.getAllByRole('option')
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
|
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
|
})
|
|
|
|
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
|
mount(openState())
|
|
expect(menuShell().style.maxHeight).toBe('320px')
|
|
})
|
|
|
|
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
|
mount(openState())
|
|
expect(menuShell().style.maxHeight).toBe('188px')
|
|
})
|
|
|
|
it('re-fits the height when the window resizes', () => {
|
|
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
|
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
|
mount(openState())
|
|
expect(menuShell().style.maxHeight).toBe('320px')
|
|
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
|
act(() => { window.dispatchEvent(new Event('resize')) })
|
|
expect(menuShell().style.maxHeight).toBe('88px')
|
|
})
|
|
|
|
it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
|
|
const { onDismiss } = mount(openState())
|
|
fireEvent.pointerDown(document.body)
|
|
expect(onDismiss).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('pointerdown inside the list does not dismiss', () => {
|
|
const { onDismiss } = mount(openState())
|
|
fireEvent.pointerDown(screen.getAllByRole('option')[0]!)
|
|
expect(onDismiss).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('pointerdown inside the surrounding composer card does not dismiss; outside it does', () => {
|
|
const menu = createSnapshotStore<MenuState>(openState())
|
|
const onDismiss = vi.fn()
|
|
render(
|
|
<div data-composer-card="">
|
|
<MenuView
|
|
menu={menu}
|
|
headers={createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(new Map())}
|
|
onPick={vi.fn()}
|
|
onCrumb={vi.fn()}
|
|
onHover={vi.fn()}
|
|
onDismiss={onDismiss}
|
|
t={t}
|
|
/>
|
|
<button type="button" data-testid="composer-button" />
|
|
</div>,
|
|
)
|
|
fireEvent.pointerDown(screen.getByTestId('composer-button'))
|
|
expect(onDismiss).not.toHaveBeenCalled()
|
|
fireEvent.pointerDown(document.body)
|
|
expect(onDismiss).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('ignores a pointerdown whose target is not a DOM node', () => {
|
|
const { onDismiss } = mount(openState())
|
|
const ev = new Event('pointerdown', { bubbles: true })
|
|
Object.defineProperty(ev, 'target', { value: {} })
|
|
document.dispatchEvent(ev)
|
|
expect(onDismiss).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('closing the menu removes the dismiss listener', () => {
|
|
const { menu, onDismiss } = mount(openState())
|
|
act(() => { menu.set(CLOSED) })
|
|
fireEvent.pointerDown(document.body)
|
|
expect(onDismiss).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
|
|
const { onPick } = mount(openState())
|
|
const options = screen.getAllByRole('option')
|
|
const notPrevented = fireEvent.mouseDown(options[1]!)
|
|
// fireEvent returns false when preventDefault was called.
|
|
expect(notPrevented).toBe(false)
|
|
expect(onPick).toHaveBeenCalledWith('command', 1)
|
|
})
|
|
|
|
it('pointer motion over a row routes hover; the highlighted row stays silent', () => {
|
|
const { onHover } = mount(openState())
|
|
const options = screen.getAllByRole('option')
|
|
fireEvent.mouseMove(options[1]!)
|
|
expect(onHover).toHaveBeenCalledWith('command', 1)
|
|
onHover.mockClear()
|
|
// Index 0 already holds the highlight: no hover round-trip.
|
|
fireEvent.mouseMove(options[0]!)
|
|
expect(onHover).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders a source header as a breadcrumb above the list, current step last', () => {
|
|
mount(openState(), new Map([['command', [
|
|
{ label: 'Workspace', value: 'root' },
|
|
{ label: 'src', value: 'src' },
|
|
{ label: 'module1', value: 'module1', current: true },
|
|
]]]))
|
|
const nav = screen.getByRole('navigation', { name: '目录导航' })
|
|
expect([...nav.querySelectorAll('button')].map(button => button.textContent))
|
|
.toEqual(['Workspace', 'src', 'module1'])
|
|
// The listbox holds options alone; the header is its sibling, not a row.
|
|
expect(screen.getByRole('listbox').contains(nav)).toBe(false)
|
|
})
|
|
|
|
it('mousedown on a crumb routes (source, index) without stealing focus; the current step is inert', () => {
|
|
const { onCrumb } = mount(openState(), new Map([['command', [
|
|
{ label: 'Workspace', value: 'root' },
|
|
{ label: 'src', value: 'src', current: true },
|
|
]]]))
|
|
const crumbs = screen.getByRole('navigation', { name: '目录导航' }).querySelectorAll('button')
|
|
expect(fireEvent.mouseDown(crumbs[0]!)).toBe(false)
|
|
expect(onCrumb).toHaveBeenCalledWith('command', 0)
|
|
onCrumb.mockClear()
|
|
expect(crumbs[1]!.disabled).toBe(true)
|
|
fireEvent.mouseDown(crumbs[1]!)
|
|
expect(onCrumb).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders no header for a source that published no crumbs', () => {
|
|
mount(openState())
|
|
expect(screen.queryByRole('navigation')).toBeNull()
|
|
})
|
|
})
|