|
|
@@ -252,3 +252,147 @@ describe('ModelSelect reasoning effort', () => {
|
|
|
expect(load).not.toHaveBeenCalled()
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+describe('ModelSelect keyboard walk', () => {
|
|
|
+ function mountOpen() {
|
|
|
+ const select = vi.fn().mockResolvedValue(true)
|
|
|
+ render(<ModelSelect
|
|
|
+ locked={false}
|
|
|
+ available
|
|
|
+ directory={createSnapshotStore(state())}
|
|
|
+ load={vi.fn()}
|
|
|
+ select={select}
|
|
|
+ t={t}
|
|
|
+ />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
|
|
+ return select
|
|
|
+ }
|
|
|
+
|
|
|
+ it('↑↓ walk the rows of the shown pane, wrapping, and stay open', () => {
|
|
|
+ mountOpen()
|
|
|
+ // The trigger holds focus while the menu opens: the first forward step
|
|
|
+ // enters at the first cell instead of skipping it. false = preventDefault ran.
|
|
|
+ const cells = screen.getAllByRole('menuitem')
|
|
|
+ expect(fireEvent.keyDown(cells[0]!, { key: 'ArrowDown' })).toBe(false)
|
|
|
+ expect(document.activeElement).toBe(cells[0])
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ expect(rows.map(row => row.textContent)).toEqual(['Off', 'High', 'Max'])
|
|
|
+ // The pane opens on its checked row, so walking starts from High.
|
|
|
+ fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })
|
|
|
+ expect(document.activeElement).toBe(rows[2])
|
|
|
+ fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' }) // wraps to the top
|
|
|
+ expect(document.activeElement).toBe(rows[0])
|
|
|
+ fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' }) // wraps to the bottom
|
|
|
+ expect(document.activeElement).toBe(rows[2])
|
|
|
+ expect(screen.getByRole('menu')).toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('Tab settles the focused row like Enter and closes the menu', async () => {
|
|
|
+ const select = mountOpen()
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' }) // High → Max
|
|
|
+ expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false)
|
|
|
+ expect(select).toHaveBeenCalledWith({
|
|
|
+ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max',
|
|
|
+ })
|
|
|
+ await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('Shift+Tab leaves a drilled pane and then closes, like Escape', () => {
|
|
|
+ mountOpen()
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ expect(fireEvent.keyDown(rows[0]!, { key: 'Tab', shiftKey: true })).toBe(false)
|
|
|
+ // Back on the drilled cell, then closed on the second press.
|
|
|
+ const cells = screen.getAllByRole('menuitem')
|
|
|
+ expect(document.activeElement).toBe(cells[1])
|
|
|
+ expect(screen.getByRole('menu')).toBeTruthy()
|
|
|
+ fireEvent.keyDown(cells[1]!, { key: 'Tab', shiftKey: true })
|
|
|
+ expect(screen.queryByRole('menu')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('Tab with the keyboard still on the trigger enters the menu at the value in use', () => {
|
|
|
+ mountOpen()
|
|
|
+ const trigger = screen.getByRole('button', { name: /选择模型/ })
|
|
|
+ expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(false)
|
|
|
+ // The root pane's first cell carries the current selection.
|
|
|
+ const cells = screen.getAllByRole('menuitem')
|
|
|
+ expect(document.activeElement).toBe(cells[0])
|
|
|
+ expect(screen.getByRole('menu')).toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('a backward step from outside the list enters at the last row, and a closed menu leaves Tab native', () => {
|
|
|
+ mountOpen()
|
|
|
+ const [modelRow, effortRow] = screen.getAllByRole('menuitem')
|
|
|
+ expect(fireEvent.keyDown(modelRow!, { key: 'ArrowUp' })).toBe(false)
|
|
|
+ expect(document.activeElement).toBe(effortRow)
|
|
|
+ const trigger = screen.getByRole('button', { name: /选择模型/ })
|
|
|
+ fireEvent.keyDown(trigger, { key: 'Escape' })
|
|
|
+ expect(screen.queryByRole('menu')).toBeNull()
|
|
|
+ expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('hands a drilled pane the focus its unmounted cell left behind, on the value in use', () => {
|
|
|
+ mountOpen()
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ // The fixture's model defaults to the High effort: the checked row is where
|
|
|
+ // the keyboard lands, not the top of the list.
|
|
|
+ expect(rows[1]!.getAttribute('aria-checked')).toBe('true')
|
|
|
+ expect(document.activeElement).toBe(rows[1])
|
|
|
+ // The walk continues from there.
|
|
|
+ fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })
|
|
|
+ expect(document.activeElement).toBe(rows[2])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drills into the model list on the selected model', () => {
|
|
|
+ mountOpen()
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ expect(rows[0]!.getAttribute('aria-checked')).toBe('true')
|
|
|
+ expect(document.activeElement).toBe(rows[0])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => {
|
|
|
+ mountOpen()
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ fireEvent.keyDown(rows[0]!, { key: 'Escape' })
|
|
|
+ // The root pane is back with its two cells.
|
|
|
+ const cells = screen.getAllByRole('menuitem')
|
|
|
+ // Back on the drilled cell, so the next keystroke still reaches the menu.
|
|
|
+ expect(document.activeElement).toBe(cells[1])
|
|
|
+ expect(screen.getByRole('menu')).toBeTruthy()
|
|
|
+ // A second Escape closes back to the trigger.
|
|
|
+ fireEvent.keyDown(cells[1]!, { key: 'Escape' })
|
|
|
+ expect(screen.queryByRole('menu')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('Escape from the model list lands back on the model cell', () => {
|
|
|
+ mountOpen()
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
|
|
+ fireEvent.keyDown(screen.getAllByRole('menuitemradio')[0]!, { key: 'Escape' })
|
|
|
+ const cells = screen.getAllByRole('menuitem')
|
|
|
+ expect(document.activeElement).toBe(cells[0])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('a pane whose rows mark no current value opens on its first row', () => {
|
|
|
+ // The session runs a model the catalog no longer lists: no row is checked.
|
|
|
+ render(<ModelSelect
|
|
|
+ locked={false}
|
|
|
+ available
|
|
|
+ directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
|
|
|
+ load={vi.fn()}
|
|
|
+ select={vi.fn().mockResolvedValue(true)}
|
|
|
+ t={t}
|
|
|
+ />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
|
|
|
+ const rows = screen.getAllByRole('menuitemradio')
|
|
|
+ expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
|
|
|
+ expect(document.activeElement).toBe(rows[0])
|
|
|
+ })
|
|
|
+})
|