feedback-dialog.client.spec.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. // @vitest-environment jsdom
  2. /**
  3. * FeedbackDialog rendering: the modal shows the seven category chips, the
  4. * detail box, and the hint while a target is open; a chip toggles the
  5. * category through the injected verb; Submit routes to the controller and
  6. * stays enabled with an empty draft; a failure code renders a warning toast;
  7. * and the acknowledgement toast mounts from the toast sequence and retires
  8. * through dismissToast once its fade completes.
  9. */
  10. import { useSyncExternalStore } from 'react'
  11. import { afterEach, describe, expect, it, vi } from 'vitest'
  12. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  13. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  14. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  15. import { FEEDBACK_CATEGORIES } from '@deepseek-ai/dsh-command-feedback'
  16. import { FeedbackDialog } from '../src/client/FeedbackDialog.tsx'
  17. import type { FeedbackDialogState } from '../src/client/dialog.ts'
  18. import { en, zh } from '../src/client/locales.ts'
  19. afterEach(cleanup)
  20. const t = makeTranslate(zh, commonZh)
  21. /** Render the entry over a fixed state and recording verbs. */
  22. function mount(overrides: Partial<FeedbackDialogState> = {}) {
  23. const state: FeedbackDialogState = {
  24. target: { kind: 'session' }, category: null, text: '', submitting: false, failure: null, toast: 0,
  25. ...overrides,
  26. }
  27. const verbs = {
  28. edit: vi.fn(),
  29. submit: vi.fn(() => Promise.resolve()),
  30. dismiss: vi.fn(),
  31. dismissFailure: vi.fn(),
  32. dismissToast: vi.fn(),
  33. }
  34. const useDialog = (<T,>(select: (v: FeedbackDialogState) => T): T =>
  35. useSyncExternalStore(() => () => {}, () => select(state))) as never
  36. const props = { useDialog, ...verbs, t } as unknown as Parameters<typeof FeedbackDialog>[0]
  37. return { ...render(<FeedbackDialog {...props} />), ...verbs }
  38. }
  39. describe('FeedbackDialog', () => {
  40. it('owns the conversation-log disclosure and stability category in both supported locales', () => {
  41. expect(zh['dialog.hint']).toBe('填写详情以帮助我们改进体验,提交内容会包括当前对话的日志')
  42. expect(en['dialog.hint']).toBe('Add details to help us improve. Your submission will include the current conversation log.')
  43. expect(zh['category.service-stability']).toBe('稳定性和速度')
  44. expect(en['category.service-stability']).toBe('Stability and speed')
  45. })
  46. it('renders nothing but the probe while closed with no toast', () => {
  47. const ui = mount({ target: null })
  48. expect(ui.queryByRole('dialog')).toBeNull()
  49. expect(ui.queryByRole('alert')).toBeNull()
  50. })
  51. it('shows every category, the detail box with the hint, and an enabled Submit for an empty draft', () => {
  52. const ui = mount()
  53. const dialog = ui.getByRole('dialog', { name: zh['dialog.title'] })
  54. expect(dialog).toBeTruthy()
  55. const chips = ui.getByRole('group', { name: zh['dialog.categories'] }).querySelectorAll('button')
  56. expect([...chips].map(chip => chip.textContent)).toEqual(
  57. FEEDBACK_CATEGORIES.map(category => zh[`category.${category}`]),
  58. )
  59. expect(ui.getByLabelText(zh['dialog.detail']).getAttribute('placeholder')).toBe(zh['dialog.hint'])
  60. expect(ui.getByRole('button', { name: commonZh.submit }).hasAttribute('disabled')).toBe(false)
  61. })
  62. it('selects a chip, and clears it when the selected chip is clicked again', () => {
  63. const ui = mount({ category: 'task-result' })
  64. const chips = ui.getByRole('group', { name: zh['dialog.categories'] }).querySelectorAll('button')
  65. expect(chips[0]?.getAttribute('aria-pressed')).toBe('true')
  66. expect(chips[1]?.getAttribute('aria-pressed')).toBe('false')
  67. fireEvent.click(chips[1]!)
  68. expect(ui.edit).toHaveBeenLastCalledWith({ category: 'instruction-following' })
  69. fireEvent.click(chips[0]!)
  70. expect(ui.edit).toHaveBeenLastCalledWith({ category: null })
  71. })
  72. it('forwards typing, submits, and closes through the injected verbs', () => {
  73. const ui = mount({ text: 'draft' })
  74. fireEvent.change(ui.getByLabelText(zh['dialog.detail']), { target: { value: 'draft more' } })
  75. expect(ui.edit).toHaveBeenCalledWith({ text: 'draft more' })
  76. fireEvent.click(ui.getByRole('button', { name: commonZh.submit }))
  77. expect(ui.submit).toHaveBeenCalledTimes(1)
  78. fireEvent.click(ui.getByRole('button', { name: commonZh.close }))
  79. expect(ui.dismiss).toHaveBeenCalledTimes(1)
  80. })
  81. it('disables Submit and the chips while a submission is in flight', () => {
  82. const ui = mount({ submitting: true })
  83. expect(ui.getByRole('button', { name: commonZh.submitting }).hasAttribute('disabled')).toBe(true)
  84. const chips = ui.getByRole('group', { name: zh['dialog.categories'] }).querySelectorAll('button')
  85. expect([...chips].every(chip => chip.hasAttribute('disabled'))).toBe(true)
  86. })
  87. it('renders submission failures as toasts outside the dialog', () => {
  88. const conflict = mount({ failure: 'version-conflict' })
  89. const conflictToast = conflict.getByRole('alert')
  90. expect(conflictToast.textContent).toBe(zh['error.conflict'])
  91. expect(conflict.getByRole('dialog').contains(conflictToast)).toBe(false)
  92. cleanup()
  93. const oversized = mount({ failure: 'note-too-large' })
  94. expect(oversized.getByRole('alert').textContent).toBe(zh['error.noteTooLarge'])
  95. cleanup()
  96. const other = mount({ failure: 'session-not-found' })
  97. expect(other.getByRole('alert').textContent).toBe(zh['error.generic'])
  98. })
  99. it('retires a submission-failure toast after its extended hold', () => {
  100. vi.useFakeTimers()
  101. try {
  102. const ui = mount({ failure: 'version-conflict' })
  103. act(() => { vi.advanceTimersByTime(7000) })
  104. expect(ui.dismissFailure).toHaveBeenCalledTimes(1)
  105. } finally {
  106. vi.useRealTimers()
  107. }
  108. })
  109. it('retires the toast when the entry unmounts, so a Session switch does not replay it', () => {
  110. const ui = mount({ target: null, toast: 3 })
  111. ui.unmount()
  112. expect(ui.dismissToast).toHaveBeenCalledWith(3)
  113. })
  114. it('shows the acknowledgement toast and retires it after the fade', () => {
  115. vi.useFakeTimers()
  116. try {
  117. const ui = mount({ target: null, toast: 3 })
  118. expect(ui.getByRole('alert').textContent).toBe(zh['toast.recorded'])
  119. act(() => { vi.advanceTimersByTime(4000) })
  120. expect(ui.dismissToast).toHaveBeenCalledWith(3)
  121. } finally {
  122. vi.useRealTimers()
  123. }
  124. })
  125. })