unreadPanel.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import { mount, flushPromises } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { createPinia } from 'pinia'
  4. import { createMemoryHistory, createRouter } from 'vue-router'
  5. import UnreadPanel from '@/components/app/UnreadPanel.vue'
  6. import { useAuthStore } from '@/stores/auth'
  7. import { useUnreadStore } from '@/stores/unread'
  8. import type { UnreadSnapshot } from '@shared/unread'
  9. const snapshot: UnreadSnapshot = {
  10. total: 5, bot: 2, chat: 3,
  11. conversations: [
  12. { mode: 'bot', id: 'bot-1', name: '开发助手', preview: '任务已完成', count: 2, updatedAt: 1, messages: [{ id: 'bot-message', seq: 1, taskId: 'task-1' }] },
  13. { mode: 'chat', id: 'chat-1', profile: 'ops', name: '运维聊天', preview: '检查结果', count: 3, updatedAt: 2, messages: [{ id: 'chat-message', seq: 2 }] },
  14. ],
  15. }
  16. const unread = useUnreadStore()
  17. async function mountPanel() {
  18. const pinia = createPinia()
  19. const router = createRouter({
  20. history: createMemoryHistory(),
  21. routes: [
  22. { path: '/conversations', component: { template: '<div />' } },
  23. { path: '/conversations/:id', component: { template: '<div />' } },
  24. { path: '/chat/:id', component: { template: '<div />' } },
  25. ],
  26. })
  27. await router.push('/conversations')
  28. await router.isReady()
  29. const selectProfile = vi.spyOn(useAuthStore(pinia), 'selectProfile').mockResolvedValue(undefined)
  30. const wrapper = mount(UnreadPanel, {
  31. attachTo: document.body,
  32. props: { anchor: { left: 372, top: 86 } },
  33. global: { plugins: [pinia, router] },
  34. })
  35. return { wrapper, router, selectProfile }
  36. }
  37. beforeEach(() => {
  38. unread.reset()
  39. unread.snapshot = structuredClone(snapshot)
  40. })
  41. afterEach(() => {
  42. vi.restoreAllMocks()
  43. unread.reset()
  44. })
  45. function button(selector: string) { return document.querySelector<HTMLButtonElement>(selector)! }
  46. describe('Unread center', () => {
  47. it('starts with all modes and marks only the selected mode as read', async () => {
  48. const read = vi.spyOn(unread, 'read').mockResolvedValue(undefined)
  49. const { wrapper } = await mountPanel()
  50. expect(document.querySelector('.unread-header p')?.textContent).toBe('5 条未读 · 2 个会话')
  51. expect(document.querySelectorAll('.unread-row')).toHaveLength(2)
  52. button('.unread-filters button:nth-child(2)').click()
  53. await flushPromises()
  54. expect(document.querySelector('.unread-header p')?.textContent).toBe('2 条未读 · 1 个会话')
  55. expect(document.querySelectorAll('.unread-row')).toHaveLength(1)
  56. button('.unread-clear').click()
  57. await flushPromises()
  58. expect(read).toHaveBeenCalledOnce()
  59. expect(read).toHaveBeenCalledWith([snapshot.conversations[0]])
  60. wrapper.unmount()
  61. })
  62. it('preserves unread message navigation without marking unseen messages as read', async () => {
  63. const read = vi.spyOn(unread, 'read').mockResolvedValue(undefined)
  64. const { wrapper, router, selectProfile } = await mountPanel()
  65. button('.unread-row').click()
  66. await flushPromises()
  67. expect(router.currentRoute.value.path).toBe('/conversations/bot-1')
  68. expect(router.currentRoute.value.query).toEqual({ taskId: 'task-1', unread: 'bot-message' })
  69. expect(wrapper.emitted('close')).toHaveLength(1)
  70. expect(read).not.toHaveBeenCalled()
  71. document.querySelectorAll<HTMLButtonElement>('.unread-row')[1]!.click()
  72. await flushPromises()
  73. expect(selectProfile).toHaveBeenCalledWith('ops')
  74. expect(router.currentRoute.value.path).toBe('/chat/chat-1')
  75. expect(router.currentRoute.value.query).toEqual({ profile: 'ops', unread: 'chat-message' })
  76. expect(read).not.toHaveBeenCalled()
  77. wrapper.unmount()
  78. })
  79. it('keeps keyboard focus inside the dialog', async () => {
  80. const { wrapper } = await mountPanel()
  81. const panel = document.querySelector<HTMLElement>('#unread-center')!
  82. expect(document.activeElement).toBe(panel)
  83. panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
  84. const last = [...panel.querySelectorAll('button')].at(-1)!
  85. expect(document.activeElement).toBe(last)
  86. last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
  87. expect(document.activeElement).toBe(button('.unread-close'))
  88. wrapper.unmount()
  89. })
  90. it('prevents duplicate bulk reads and keeps errors visible for retry', async () => {
  91. let reject!: (reason: Error) => void
  92. const read = vi.spyOn(unread, 'read').mockImplementationOnce(() => new Promise<void>((_, fail) => { reject = fail }))
  93. const { wrapper } = await mountPanel()
  94. button('.unread-clear').click()
  95. await flushPromises()
  96. expect(button('.unread-clear').disabled).toBe(true)
  97. expect(button('.unread-clear').textContent).toContain('正在同步')
  98. button('.unread-clear').click()
  99. expect(read).toHaveBeenCalledOnce()
  100. reject(new Error('同步失败,请重试'))
  101. await flushPromises()
  102. expect(document.querySelector('[role="alert"]')?.textContent).toBe('同步失败,请重试')
  103. expect(button('.unread-clear').disabled).toBe(false)
  104. unread.reset()
  105. await flushPromises()
  106. expect(document.querySelector('.unread-empty')?.textContent).toContain('暂无未读消息')
  107. expect(button('.unread-clear').disabled).toBe(true)
  108. wrapper.unmount()
  109. })
  110. })