kanbanView.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import { createPinia } from 'pinia'
  2. import { mount } from '@vue/test-utils'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { createMemoryHistory, createRouter } from 'vue-router'
  5. import { defineComponent } from 'vue'
  6. import KanbanCard from '@/components/kanban/KanbanCard.vue'
  7. import KanbanColumn from '@/components/kanban/KanbanColumn.vue'
  8. import KanbanTaskDrawer from '@/components/kanban/KanbanTaskDrawer.vue'
  9. import KanbanView from '@/views/KanbanView.vue'
  10. import { setApiCsrfToken } from '@/api/client'
  11. import { useAuthStore } from '@/stores/auth'
  12. import { useKanbanStore } from '@/stores/kanban'
  13. function deferred<T>() {
  14. let resolve!: (value: T) => void
  15. const promise = new Promise<T>(resolvePromise => { resolve = resolvePromise })
  16. return { promise, resolve }
  17. }
  18. afterEach(() => {
  19. vi.restoreAllMocks()
  20. vi.unstubAllGlobals()
  21. setApiCsrfToken('')
  22. document.body.innerHTML = ''
  23. })
  24. describe('Kanban components', () => {
  25. it('offers a mobile-safe status menu and emits an allowed move', async () => {
  26. const wrapper = mount(KanbanCard, {
  27. props: {
  28. task: { id: 'task-a', title: '移动端状态', status: 'todo', assignee: 'yaoyao' },
  29. columns: ['todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done'],
  30. canEdit: true,
  31. },
  32. global: { stubs: { AppIcon: true } },
  33. })
  34. const options = wrapper.findAll('option').map(option => option.attributes('value'))
  35. expect(options).toEqual(['todo', 'ready', 'blocked', 'done'])
  36. await wrapper.get('select').setValue('ready')
  37. expect(wrapper.emitted('move')).toEqual([[
  38. expect.objectContaining({ id: 'task-a' }),
  39. 'ready',
  40. ]])
  41. })
  42. it('accepts desktop drops only on operator-owned status columns', async () => {
  43. const transfer = { dropEffect: '', getData: () => 'task-a' }
  44. const wrapper = mount(KanbanColumn, {
  45. props: {
  46. column: { name: 'ready', tasks: [] },
  47. columns: ['todo', 'ready', 'running'],
  48. canEdit: true,
  49. },
  50. global: { stubs: { AppIcon: true, KanbanCard: true } },
  51. })
  52. await wrapper.get('.kanban-column').trigger('dragover', { dataTransfer: transfer })
  53. await wrapper.get('.kanban-column').trigger('drop', { dataTransfer: transfer })
  54. expect(wrapper.emitted('dropTask')).toEqual([['task-a', 'ready']])
  55. await wrapper.setProps({ column: { name: 'running', tasks: [] } })
  56. await wrapper.get('.kanban-column').trigger('drop', { dataTransfer: transfer })
  57. expect(wrapper.emitted('dropTask')).toHaveLength(1)
  58. })
  59. it('renders task comments, runs, events, and a genuine read-only state', () => {
  60. const wrapper = mount(KanbanTaskDrawer, {
  61. props: {
  62. canEdit: false,
  63. columns: ['todo', 'ready', 'running', 'done'],
  64. profiles: [{ name: 'yaoyao', is_default: true }],
  65. detail: {
  66. task: { id: 'task-a', title: '发布核对', body: '检查全部端', status: 'running', assignee: 'yaoyao' },
  67. comments: [{ id: 1, author: 'admin', body: '请补充验收', created_at: 1 }],
  68. events: [{ id: 1, kind: 'claimed', created_at: 2 }],
  69. links: { parents: [], children: [] },
  70. runs: [{ id: 1, profile: 'yaoyao', status: 'running', summary: '正在执行' }],
  71. },
  72. },
  73. global: { stubs: { AppIcon: true } },
  74. })
  75. expect(wrapper.text()).toContain('编辑仅限管理员')
  76. expect(wrapper.text()).toContain('请补充验收')
  77. expect(wrapper.text()).toContain('正在执行')
  78. expect(wrapper.text()).toContain('Agent 开始处理')
  79. expect(wrapper.findAll('input:disabled, textarea:disabled, select:disabled').length).toBeGreaterThan(0)
  80. expect(wrapper.find('.kanban-comment-form').exists()).toBe(false)
  81. expect(wrapper.find('.kanban-task-form__actions').exists()).toBe(false)
  82. })
  83. it('patches only edited fields so a running task can be renamed safely', async () => {
  84. const detail = {
  85. task: { id: 'task-running', title: '旧标题', body: '描述', status: 'running', assignee: 'yaoyao', priority: 2 },
  86. comments: [], events: [], links: { parents: [], children: [] }, runs: [],
  87. }
  88. const wrapper = mount(KanbanTaskDrawer, {
  89. props: { canEdit: true, columns: ['todo', 'ready', 'running', 'done'], profiles: [{ name: 'yaoyao', is_default: true }], detail },
  90. global: { stubs: { AppIcon: true } },
  91. })
  92. await wrapper.get('.kanban-task-form input[required]').setValue('新标题')
  93. await wrapper.get('.kanban-task-form').trigger('submit')
  94. expect(wrapper.emitted('save')).toEqual([[{ title: '新标题' }]])
  95. await wrapper.setProps({ detail: { ...detail, task: { ...detail.task, status: 'done', latest_summary: '远端轮询更新' } } })
  96. expect((wrapper.get('.kanban-task-form input[required]').element as HTMLInputElement).value).toBe('新标题')
  97. expect((wrapper.get('.kanban-task-form select').element as HTMLSelectElement).value).toBe('done')
  98. await wrapper.get('.kanban-task-form').trigger('submit')
  99. expect(wrapper.emitted('save')?.at(-1)).toEqual([{ title: '新标题' }])
  100. await wrapper.setProps({ detail: { ...detail, task: { ...detail.task, title: '新标题', status: 'done' } } })
  101. expect(wrapper.get<HTMLButtonElement>('.kanban-task-form .primary-button').element.disabled).toBe(true)
  102. })
  103. })
  104. describe('Kanban view', () => {
  105. it('loads the official snapshot and presents the board in the shared workspace shell', async () => {
  106. vi.stubGlobal('fetch', vi.fn(async (path: string) => {
  107. if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
  108. if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', name: '默认看板', total: 1 }, { slug: 'mobile', name: '移动看板', total: 1 }], current: 'default' })
  109. if (path.endsWith('/profiles')) return Response.json({ profiles: [{ name: 'yaoyao', is_default: true }] })
  110. if (path.includes('/board?')) return Response.json({
  111. columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: 'Web 看板验收', status: 'todo', assignee: 'yaoyao' }] }, { name: 'ready', tasks: [] }],
  112. tenants: [], assignees: ['yaoyao'], latest_event_id: 1, now: 2,
  113. })
  114. return Response.json({ ok: true })
  115. }))
  116. setApiCsrfToken('csrf-view')
  117. const pinia = createPinia()
  118. const router = createRouter({
  119. history: createMemoryHistory(),
  120. routes: [{ path: '/kanban/:boardSlug?', component: KanbanView }],
  121. })
  122. await router.push('/kanban')
  123. await router.isReady()
  124. const auth = useAuthStore(pinia)
  125. auth.status = 'authenticated'
  126. auth.user = { id: 'admin', username: 'admin', role: 'admin' }
  127. const WorkspaceStub = defineComponent({
  128. emits: ['closeInspector'],
  129. template: '<div class="workspace-stub"><slot name="sidebar-action"/><slot name="sidebar"/><slot/><slot name="inspector"/></div>',
  130. })
  131. const wrapper = mount(KanbanView, {
  132. attachTo: document.body,
  133. global: {
  134. plugins: [pinia, router],
  135. stubs: {
  136. WorkspaceView: WorkspaceStub,
  137. KanbanTaskDialog: true,
  138. KanbanBoardDialog: true,
  139. AppIcon: true,
  140. YaoYaoSidebarIcon: true,
  141. },
  142. },
  143. })
  144. await vi.waitFor(() => expect(wrapper.text()).toContain('Web 看板验收'))
  145. expect(wrapper.text()).toContain('默认看板')
  146. expect(wrapper.text()).toContain('待办')
  147. expect(wrapper.get('.kanban-columns').attributes('aria-label')).toBe('任务看板')
  148. await router.push('/kanban/mobile')
  149. await vi.waitFor(() => expect(wrapper.text()).toContain('移动看板'))
  150. expect(useKanbanStore(pinia).selectedBoardSlug).toBe('mobile')
  151. router.back()
  152. await vi.waitFor(() => expect(useKanbanStore(pinia).selectedBoardSlug).toBe('default'))
  153. wrapper.unmount()
  154. })
  155. it('does not start polling when the view unmounts during initialization', async () => {
  156. const boardResponse = deferred<Response>()
  157. let boardReads = 0
  158. vi.stubGlobal('fetch', vi.fn(async (path: string) => {
  159. if (path.endsWith('/status')) return Response.json({ available: true })
  160. if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 0 }], current: 'default' })
  161. if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
  162. if (path.includes('/board?')) {
  163. boardReads += 1
  164. return boardResponse.promise
  165. }
  166. return Response.json({ ok: true })
  167. }))
  168. const interval = vi.spyOn(window, 'setInterval')
  169. const pinia = createPinia()
  170. const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/kanban/:boardSlug?', component: KanbanView }] })
  171. await router.push('/kanban')
  172. await router.isReady()
  173. const auth = useAuthStore(pinia)
  174. auth.status = 'authenticated'
  175. auth.user = { id: 'admin-unmount', username: 'admin', role: 'admin' }
  176. const WorkspaceStub = defineComponent({ template: '<div><slot name="sidebar-action"/><slot name="sidebar"/><slot/><slot name="inspector"/></div>' })
  177. const wrapper = mount(KanbanView, {
  178. global: {
  179. plugins: [pinia, router],
  180. stubs: { WorkspaceView: WorkspaceStub, KanbanTaskDialog: true, KanbanBoardDialog: true, AppIcon: true, YaoYaoSidebarIcon: true },
  181. },
  182. })
  183. await vi.waitFor(() => expect(boardReads).toBe(1))
  184. wrapper.unmount()
  185. boardResponse.resolve(Response.json({ columns: [], tenants: [], assignees: [], latest_event_id: 0, now: 1 }))
  186. await new Promise(resolve => window.setTimeout(resolve, 15))
  187. expect(interval).not.toHaveBeenCalled()
  188. interval.mockRestore()
  189. })
  190. })