dialog.client.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /**
  2. * FeedbackDialogController: one draft per open, submission routed by target,
  3. * success closes and acknowledges, failure keeps the draft with its code, a
  4. * settlement from a superseded open closes nothing, and the toast sequence
  5. * retires only the toast the view finished showing.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
  9. import type { MessageFeedbackActionResult } from '../src/client/controller.ts'
  10. import { FeedbackDialogController, type FeedbackSubmit } from '../src/client/dialog.ts'
  11. const MSG = 'm-1' as MessageId
  12. const MESSAGE_TARGET = { kind: 'message', messageId: MSG, rating: 'positive' } as const
  13. function bench(result: () => Promise<MessageFeedbackActionResult> = () => Promise.resolve({ ok: true })) {
  14. const submit = vi.fn<FeedbackSubmit>(() => result())
  15. return { submit, controller: new FeedbackDialogController(submit) }
  16. }
  17. describe('FeedbackDialogController', () => {
  18. it('starts closed with no toast', () => {
  19. const { controller } = bench()
  20. expect(controller.state.getSnapshot()).toEqual({
  21. target: null, category: null, text: '', submitting: false, failure: null, toast: 0,
  22. })
  23. })
  24. it('opens with an empty draft and drops the draft on dismiss', () => {
  25. const { controller } = bench()
  26. controller.open({ kind: 'session' })
  27. controller.edit({ category: 'task-result' })
  28. controller.edit({ text: 'slow' })
  29. expect(controller.state.getSnapshot()).toMatchObject({
  30. target: { kind: 'session' }, category: 'task-result', text: 'slow',
  31. })
  32. controller.dismiss()
  33. controller.open(MESSAGE_TARGET)
  34. expect(controller.state.getSnapshot()).toMatchObject({
  35. target: MESSAGE_TARGET, category: null, text: '',
  36. })
  37. })
  38. it('ignores draft edits and submits while closed', async () => {
  39. const { controller, submit } = bench()
  40. controller.edit({ category: 'other' })
  41. controller.edit({ text: 'x' })
  42. await controller.submitDraft()
  43. expect(controller.state.getSnapshot()).toMatchObject({ category: null, text: '', toast: 0 })
  44. expect(submit).not.toHaveBeenCalled()
  45. })
  46. it('submits the Session target with the trimmed text and the category', async () => {
  47. const { controller, submit } = bench()
  48. controller.open({ kind: 'session' })
  49. controller.edit({ category: 'service-stability' })
  50. controller.edit({ text: ' timed out twice ' })
  51. await controller.submitDraft()
  52. expect(submit).toHaveBeenCalledWith({ kind: 'session' }, { text: 'timed out twice', category: 'service-stability' })
  53. expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
  54. })
  55. it('submits an empty draft as an entry with neither text nor category', async () => {
  56. const { controller, submit } = bench()
  57. controller.open({ kind: 'session' })
  58. controller.edit({ text: ' ' })
  59. await controller.submitDraft()
  60. expect(submit).toHaveBeenCalledWith({ kind: 'session' }, {})
  61. })
  62. it('submits the message target with the entry', async () => {
  63. const { controller, submit } = bench()
  64. controller.open(MESSAGE_TARGET)
  65. controller.edit({ category: 'task-result' })
  66. controller.edit({ text: 'wrong file' })
  67. await controller.submitDraft()
  68. expect(submit).toHaveBeenCalledWith(MESSAGE_TARGET, { text: 'wrong file', category: 'task-result' })
  69. expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
  70. })
  71. it('keeps the draft open with the failure code when the submission is rejected', async () => {
  72. const { controller } = bench(() => Promise.resolve({ ok: false, error: { code: 'version-conflict', message: 'changed' } }))
  73. controller.open(MESSAGE_TARGET)
  74. controller.edit({ text: 'draft' })
  75. await controller.submitDraft()
  76. expect(controller.state.getSnapshot()).toMatchObject({
  77. target: MESSAGE_TARGET, text: 'draft', submitting: false,
  78. failure: 'version-conflict', toast: 0,
  79. })
  80. })
  81. it('retires the failure toast without closing its draft', async () => {
  82. const { controller } = bench(() => Promise.resolve({ ok: false, error: { code: 'version-conflict', message: 'changed' } }))
  83. controller.open(MESSAGE_TARGET)
  84. await controller.submitDraft()
  85. controller.dismissFailure()
  86. expect(controller.state.getSnapshot().failure).toBeNull()
  87. expect(controller.state.getSnapshot().target).toEqual(MESSAGE_TARGET)
  88. })
  89. it('freezes the draft and refuses a second submit while one is in flight', async () => {
  90. let release = (): void => {}
  91. const gate = new Promise<MessageFeedbackActionResult>((resolve) => { release = () => { resolve({ ok: true }) } })
  92. const { controller, submit } = bench(() => gate)
  93. controller.open({ kind: 'session' })
  94. controller.edit({ text: 'first' })
  95. const first = controller.submitDraft()
  96. controller.edit({ text: 'second' })
  97. controller.edit({ category: 'other' })
  98. await controller.submitDraft()
  99. expect(controller.state.getSnapshot()).toMatchObject({ submitting: true, text: 'first', category: null })
  100. release()
  101. await first
  102. expect(submit).toHaveBeenCalledTimes(1)
  103. expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
  104. })
  105. it('acknowledges a success that lands after a reopen without closing the new draft', async () => {
  106. let release = (): void => {}
  107. const gate = new Promise<MessageFeedbackActionResult>((resolve) => { release = () => { resolve({ ok: true }) } })
  108. const { controller } = bench(() => gate)
  109. controller.open({ kind: 'session' })
  110. const pending = controller.submitDraft()
  111. controller.open(MESSAGE_TARGET)
  112. controller.edit({ text: 'new draft' })
  113. release()
  114. await pending
  115. expect(controller.state.getSnapshot()).toMatchObject({
  116. target: MESSAGE_TARGET, text: 'new draft', toast: 1,
  117. })
  118. })
  119. it('drops a failure that lands after the draft was dismissed', async () => {
  120. let release = (): void => {}
  121. const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
  122. release = () => { resolve({ ok: false, error: { code: 'gateway/internal', message: 'boom' } }) }
  123. })
  124. const { controller } = bench(() => gate)
  125. controller.open({ kind: 'session' })
  126. const pending = controller.submitDraft()
  127. controller.dismiss()
  128. release()
  129. await pending
  130. expect(controller.state.getSnapshot()).toMatchObject({ target: null, failure: null, submitting: false, toast: 0 })
  131. })
  132. it('retires only the toast the view finished showing', async () => {
  133. const { controller } = bench()
  134. controller.open({ kind: 'session' })
  135. await controller.submitDraft()
  136. controller.open({ kind: 'session' })
  137. await controller.submitDraft()
  138. expect(controller.state.getSnapshot().toast).toBe(2)
  139. controller.dismissToast(1)
  140. expect(controller.state.getSnapshot().toast).toBe(2)
  141. controller.dismissToast(2)
  142. expect(controller.state.getSnapshot().toast).toBe(0)
  143. })
  144. it('keeps a toast on screen across a dismiss and drops everything on dispose', async () => {
  145. const { controller } = bench()
  146. controller.open({ kind: 'session' })
  147. await controller.submitDraft()
  148. controller.open({ kind: 'session' })
  149. controller.dismiss()
  150. expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
  151. controller.open({ kind: 'session' })
  152. controller.dispose()
  153. expect(controller.state.getSnapshot()).toEqual({
  154. target: null, category: null, text: '', submitting: false, failure: null, toast: 0,
  155. })
  156. })
  157. })