dialog.client.spec.ts 6.9 KB

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