menu-view.spec.tsx 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. // @vitest-environment jsdom
  2. /**
  3. * MenuView rendering spec, props-direct (slot-parity doctrine): closed store
  4. * renders null, groups render in roster order with pending rows as loading,
  5. * pointer picks route (source, index) back without stealing focus, and the
  6. * highlight is exposed through aria-activedescendant + aria-selected.
  7. */
  8. import { afterEach, describe, expect, it, vi } from 'vitest'
  9. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  10. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  11. import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-slash/client'
  12. import { MenuView } from '../src/client/MenuView.tsx'
  13. const hit: TriggerHit = {
  14. trigger: '/',
  15. query: 'g',
  16. position: 'leading',
  17. span: { start: 0, end: 2, draftRev: 1 },
  18. }
  19. const CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
  20. function openState(partial?: Partial<MenuState>): MenuState {
  21. return {
  22. open: true,
  23. hit,
  24. generation: 1,
  25. groups: [
  26. { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] },
  27. { source: 'skill', status: 'pending', items: [] },
  28. ],
  29. highlight: { source: 'command', index: 0 },
  30. ...partial,
  31. }
  32. }
  33. afterEach(cleanup)
  34. function mount(state: MenuState) {
  35. const menu = createSnapshotStore<MenuState>(state)
  36. const onPick = vi.fn()
  37. const view = render(<MenuView menu={menu} onPick={onPick} />)
  38. return { menu, onPick, view }
  39. }
  40. describe('MenuView', () => {
  41. it('renders null while closed and appears when the store opens', () => {
  42. const { menu, view } = mount(CLOSED)
  43. expect(view.container.childElementCount).toBe(0)
  44. act(() => { menu.set(openState()) })
  45. expect(screen.queryByRole('listbox')).not.toBeNull()
  46. act(() => { menu.set(CLOSED) })
  47. expect(view.container.childElementCount).toBe(0)
  48. })
  49. it('renders ready groups as option rows and pending groups as loading rows', () => {
  50. mount(openState())
  51. const options = screen.getAllByRole('option')
  52. expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
  53. expect(screen.queryByText('Loading skill…')).not.toBeNull()
  54. })
  55. it('exposes the highlight via aria-activedescendant and aria-selected', () => {
  56. mount(openState({ highlight: { source: 'command', index: 1 } }))
  57. const listbox = screen.getByRole('listbox')
  58. const options = screen.getAllByRole('option')
  59. expect(options[1]!.id).toBeTruthy()
  60. expect(listbox.getAttribute('aria-activedescendant')).toBe(options[1]!.id)
  61. expect(options[1]!.getAttribute('aria-selected')).toBe('true')
  62. expect(options[0]!.getAttribute('aria-selected')).toBe('false')
  63. })
  64. it('omits aria-activedescendant without a highlight', () => {
  65. mount(openState({ highlight: null }))
  66. expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
  67. })
  68. it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
  69. const { onPick } = mount(openState())
  70. const options = screen.getAllByRole('option')
  71. const notPrevented = fireEvent.mouseDown(options[1]!)
  72. // fireEvent returns false when preventDefault was called.
  73. expect(notPrevented).toBe(false)
  74. expect(onPick).toHaveBeenCalledWith('command', 1)
  75. })
  76. })