chat-panel.mount.spec.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. // @vitest-environment jsdom
  2. import { act } from "react"
  3. import { describe, expect, it, vi } from "vitest"
  4. import { renderChatPanel } from "@/test/chat-panel-mount"
  5. import { clearTaskBreakpoint, loadTaskBreakpoint } from "@/lib/agent/task-breakpoint"
  6. import type { Conversation } from "@/stores/chat-store"
  7. import { useChatStore } from "@/stores/chat-store"
  8. import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
  9. const breakpoint = {
  10. taskId: "task_resume_1",
  11. taskGoal: "生成第19章",
  12. completedStages: ["agent_round_4"],
  13. currentStage: "agent_round_4",
  14. usedSkills: [],
  15. usedTools: ["run_chapter_workflow"],
  16. searches: [],
  17. mcpCalls: [],
  18. createdAt: 100,
  19. updatedAt: 200,
  20. }
  21. async function flushEffects() {
  22. await act(async () => {
  23. await Promise.resolve()
  24. })
  25. }
  26. function conversation(id: string, title: string, updatedAt: number, inputDraft = ""): Conversation {
  27. return { id, title, createdAt: updatedAt, updatedAt, deAiMode: false, inputDraft }
  28. }
  29. async function click(element: Element | null) {
  30. expect(element).not.toBeNull()
  31. await act(async () => {
  32. element?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
  33. })
  34. }
  35. describe("ChatPanel mount 基础设施", () => {
  36. it("根据当前会话是否已发送用户消息控制新建对话按钮", async () => {
  37. const first = await renderChatPanel()
  38. expect((first.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement).disabled).toBe(false)
  39. await first.unmount()
  40. const empty = await renderChatPanel({ activeConversationId: "empty" })
  41. const emptyButton = empty.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement
  42. expect(emptyButton.disabled).toBe(true)
  43. expect(emptyButton.title).toBe("请先发送当前会话内容,再新建对话。")
  44. const reasonId = emptyButton.getAttribute("aria-describedby")
  45. expect(reasonId).toBeTruthy()
  46. expect(empty.container.querySelector(`#${reasonId}`)?.textContent).toBe(
  47. "请先发送当前会话内容,再新建对话。",
  48. )
  49. await empty.unmount()
  50. const draft = await renderChatPanel({
  51. activeConversationId: "draft",
  52. conversations: [conversation("draft", "草稿", 100, "仅填写未发送")],
  53. })
  54. expect((draft.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement).disabled).toBe(true)
  55. await draft.unmount()
  56. const sent = await renderChatPanel({
  57. activeConversationId: "sent",
  58. messages: [{ id: "user-1", role: "user", content: "已发送内容", timestamp: 1, conversationId: "sent" }],
  59. })
  60. expect((sent.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement).disabled).toBe(false)
  61. await sent.unmount()
  62. })
  63. it("基础渲染:chat-panel mount 成功并显示空会话入口", async () => {
  64. const view = await renderChatPanel()
  65. expect(view.container.textContent).toContain("novel.chat.startNewConversation")
  66. await view.unmount()
  67. })
  68. it("Agent skill 配置为空时仍可打开 ChatPanel", async () => {
  69. const view = await renderChatPanel({ agentSkillConfig: null })
  70. expect(view.container.textContent).toContain("novel.chat.startNewConversation")
  71. await view.unmount()
  72. })
  73. it("输入工具栏显示模式下拉按钮,不再显示深度模式按钮", async () => {
  74. const view = await renderChatPanel({ activeConversation: true })
  75. expect(view.container.textContent).toContain("标准")
  76. expect(view.container.querySelector('[aria-label="开启深度模式"]')).toBeNull()
  77. expect(view.container.querySelector('[aria-label="关闭深度模式"]')).toBeNull()
  78. await view.unmount()
  79. })
  80. it("输入工具栏单独显示计划执行开关,可与模式下拉按钮并列使用", async () => {
  81. const view = await renderChatPanel({ activeConversation: true })
  82. expect(view.container.textContent).toContain("标准")
  83. expect(view.container.textContent).toContain("计划执行")
  84. expect(view.container.querySelector('[aria-label="开启计划执行模式"]')).not.toBeNull()
  85. await view.unmount()
  86. })
  87. it("存在旧断点时不再自动弹出继续未完成任务对话框", async () => {
  88. const loadMock = vi.mocked(loadTaskBreakpoint)
  89. const clearMock = vi.mocked(clearTaskBreakpoint)
  90. loadMock.mockClear()
  91. clearMock.mockClear()
  92. const view = await renderChatPanel({ taskBreakpoint: breakpoint })
  93. await flushEffects()
  94. expect(loadMock).not.toHaveBeenCalled()
  95. expect(clearMock).not.toHaveBeenCalled()
  96. expect(view.container.textContent).not.toContain("继续未完成任务")
  97. expect(view.container.textContent).not.toContain("确认后将继续执行断点任务")
  98. expect(view.container.textContent).not.toContain("恢复提示词")
  99. await view.unmount()
  100. })
  101. it("在顶部会话标签显示逐会话运行状态", async () => {
  102. const view = await renderChatPanel({
  103. activeConversationId: "conv-active",
  104. conversations: [
  105. conversation("conv-active", "当前会话", 400),
  106. conversation("conv-running", "生成中会话", 300),
  107. ],
  108. runStates: {
  109. "conv-running": { status: "running", updatedAt: 500, runId: "run-running" },
  110. },
  111. })
  112. expect(view.container.querySelector('[aria-label="正在生成"]')).not.toBeNull()
  113. await view.unmount()
  114. })
  115. it("普通会话达到三任务并发上限时只禁止发送", async () => {
  116. const view = await renderChatPanel({
  117. activeConversationId: "conv-draft",
  118. conversations: [
  119. conversation("conv-a", "任务 A", 400),
  120. conversation("conv-b", "任务 B", 300),
  121. conversation("conv-c", "任务 C", 200),
  122. conversation("conv-draft", "草稿会话", 100, "仍可编辑的草稿"),
  123. ],
  124. runStates: {
  125. "conv-a": { status: "running", updatedAt: 500, runId: "run-a" },
  126. "conv-b": { status: "running", updatedAt: 501, runId: "run-b" },
  127. "conv-c": { status: "running", updatedAt: 502, runId: "run-c" },
  128. },
  129. })
  130. const input = view.container.querySelector('[aria-label="引用输入框"]') as HTMLTextAreaElement | null
  131. const send = view.container.querySelector('[aria-label="发送消息"]') as HTMLButtonElement | null
  132. expect(input?.disabled).toBe(false)
  133. expect(send?.disabled).toBe(true)
  134. expect(send?.title).toBe("普通 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。")
  135. await view.unmount()
  136. })
  137. it("停止当前会话不会中止其他正在运行的会话", async () => {
  138. const currentController = new AbortController()
  139. const backgroundController = new AbortController()
  140. chatConversationRunRegistry.register("conv-current", currentController)
  141. chatConversationRunRegistry.register("conv-background", backgroundController)
  142. const view = await renderChatPanel({
  143. activeConversationId: "conv-current",
  144. conversations: [
  145. conversation("conv-current", "当前任务", 200),
  146. conversation("conv-background", "后台任务", 100),
  147. ],
  148. runStates: {
  149. "conv-current": { status: "running", updatedAt: 300, runId: "run-current" },
  150. "conv-background": { status: "running", updatedAt: 301, runId: "run-background" },
  151. },
  152. })
  153. await act(async () => {
  154. useChatStore.setState({
  155. streamingContents: { "conv-current": "部分内容", "conv-background": "后台内容" },
  156. messages: [{
  157. id: "assistant-running",
  158. role: "assistant",
  159. content: "部分内容",
  160. timestamp: 300,
  161. conversationId: "conv-current",
  162. isAgentRunning: true,
  163. }],
  164. })
  165. })
  166. await click(view.container.querySelector('[aria-label="停止生成"]'))
  167. expect(currentController.signal.aborted).toBe(true)
  168. expect(backgroundController.signal.aborted).toBe(false)
  169. expect(useChatStore.getState().runStates["conv-current"]?.status).toBe("idle")
  170. expect(useChatStore.getState().runStates["conv-background"]?.status).toBe("running")
  171. const currentMessages = useChatStore.getState().messages.filter((message) => message.conversationId === "conv-current")
  172. expect(currentMessages).toHaveLength(1)
  173. expect(currentMessages[0]).toMatchObject({ isAgentRunning: false })
  174. expect(currentMessages[0].content).toContain("已停止生成。")
  175. expect(currentMessages[0].content).not.toContain("出错")
  176. chatConversationRunRegistry.abort("conv-background")
  177. await view.unmount()
  178. })
  179. it("删除运行中会话时先显示中文确认,取消后不删除", async () => {
  180. const view = await renderChatPanel({
  181. activeConversationId: "conv-running",
  182. conversations: [conversation("conv-running", "运行中会话", 100)],
  183. runStates: {
  184. "conv-running": { status: "running", updatedAt: 200, runId: "run-delete" },
  185. },
  186. })
  187. const chip = view.container.querySelector('button[title="运行中会话"]')
  188. await act(async () => {
  189. chip?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }))
  190. })
  191. await click(view.container.querySelector('[aria-label="删除会话"]'))
  192. expect(document.body.textContent).toContain("停止并删除会话?")
  193. await click(Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent === "取消") ?? null)
  194. expect(useChatStore.getState().conversations.some((item) => item.id === "conv-running")).toBe(true)
  195. await view.unmount()
  196. })
  197. it.todo("PrePlugin 链触发:发送消息后 pipeline 执行")
  198. it.todo("Stage C 对话框:standard 模式 chapter_plan 触发对话框")
  199. it.todo("Stage C 跳过:fast 模式直接生成正文")
  200. it.todo("Stage D 自检:章节写完后 PostWriteCheck 写入 trace")
  201. it.todo("Stage D 降级:无模型时降级到规则检查")
  202. it.todo("断点保存:失败时记录调试信息但不自动弹窗")
  203. })