Forráskód Böngészése

test(stage-c): add chapter plan confirm dialog tests; fix modify button visibility without onModify

Mochocyang 3 hónapja
szülő
commit
1f044bd3de

+ 190 - 0
src/components/chat/chapter-plan-confirm-dialog.spec.tsx

@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  extractChapterPlan,
+  buildPlanConfirmMessage,
+  buildPlanSkipMessage,
+  CHAPTER_PLAN_MARKER_START,
+  CHAPTER_PLAN_MARKER_END,
+  ChapterPlanConfirmDialog,
+} from "./chapter-plan-confirm-dialog"
+
+// React 19 需要 IS_REACT_ACT_ENVIRONMENT 才能让 act(...) 正常工作且无警告
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+describe("chapter-plan-confirm-dialog 纯函数", () => {
+  describe("extractChapterPlan", () => {
+    it("无标记时返回 null", () => {
+      expect(extractChapterPlan("普通正文")).toBeNull()
+    })
+
+    it("只有开始标记时返回 null", () => {
+      expect(extractChapterPlan(`${CHAPTER_PLAN_MARKER_START}计划内容`)).toBeNull()
+    })
+
+    it("完整标记时正确提取计划", () => {
+      const content = `前文${CHAPTER_PLAN_MARKER_START}这是计划${CHAPTER_PLAN_MARKER_END}后文`
+      const result = extractChapterPlan(content)
+      expect(result).not.toBeNull()
+      expect(result!.plan).toBe("这是计划")
+      expect(result!.body).toBe("前文\n后文")
+    })
+
+    it("标记前后无额外内容时 body 为空字符串", () => {
+      const content = `${CHAPTER_PLAN_MARKER_START}计划${CHAPTER_PLAN_MARKER_END}`
+      const result = extractChapterPlan(content)
+      expect(result!.plan).toBe("计划")
+      expect(result!.body).toBe("")
+    })
+
+    it("计划内容含换行和空白时保留原样(仅首尾 trim)", () => {
+      const content = `${CHAPTER_PLAN_MARKER_START}  第一行\n第二行  ${CHAPTER_PLAN_MARKER_END}`
+      const result = extractChapterPlan(content)
+      expect(result!.plan).toBe("第一行\n第二行")
+    })
+  })
+
+  describe("buildPlanConfirmMessage", () => {
+    it("包含已确认计划标识", () => {
+      const msg = buildPlanConfirmMessage("我的计划")
+      expect(msg).toContain("已确认")
+      expect(msg).toContain("我的计划")
+    })
+
+    it("指示不要再次输出计划", () => {
+      const msg = buildPlanConfirmMessage("计划")
+      expect(msg).toContain("不要再次输出计划")
+    })
+  })
+
+  describe("buildPlanSkipMessage", () => {
+    it("指示直接写正文", () => {
+      const msg = buildPlanSkipMessage()
+      expect(msg).toContain("直接写正文")
+      expect(msg).toContain("不要输出计划")
+    })
+  })
+})
+
+describe("ChapterPlanConfirmDialog 组件", () => {
+  const baseProps = {
+    open: true,
+    planContent: "本章目标:主角发现秘密\n核心冲突:与反派对峙",
+    aiWorkflowMode: "standard" as const,
+    onConfirm: () => {},
+    onSkip: () => {},
+    onCancel: () => {},
+  }
+
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+  })
+
+  afterEach(() => {
+    act(() => {
+      root.unmount()
+    })
+    host.remove()
+  })
+
+  // 模拟 @testing-library/screen.getByText 的精确匹配语义:
+  // 只匹配元素自身的“直接文本节点”(忽略子元素聚合的 textContent),
+  // 避免父容器因仅含一个文本子元素而被误匹配。
+  function queryByText(text: string): HTMLElement | null {
+    const els = Array.from(host.querySelectorAll("*")) as HTMLElement[]
+    return (
+      els.find((el) => {
+        const directText = Array.from(el.childNodes)
+          .filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
+          .map((node) => node.textContent ?? "")
+          .join("")
+          .replace(/\s+/g, " ")
+          .trim()
+        return directText === text
+      }) ?? null
+    )
+  }
+
+  it("open=false 时不渲染", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} open={false} />)
+    })
+    expect(host.firstChild).toBeNull()
+  })
+
+  it("open=true 时显示计划内容和按钮", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} />)
+    })
+    expect(queryByText("章节创作计划")).not.toBeNull()
+    expect(host.textContent).toContain("本章目标:主角发现秘密")
+    expect(queryByText("确认,按此计划写正文")).not.toBeNull()
+    expect(queryByText("跳过计划,直接写")).not.toBeNull()
+    expect(queryByText("取消生成")).not.toBeNull()
+  })
+
+  it("点击确认按钮触发 onConfirm", async () => {
+    const onConfirm = vi.fn()
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onConfirm={onConfirm} />)
+    })
+    const btn = queryByText("确认,按此计划写正文") as HTMLButtonElement
+    await act(async () => {
+      btn.click()
+    })
+    expect(onConfirm).toHaveBeenCalledOnce()
+  })
+
+  it("点击跳过按钮触发 onSkip", async () => {
+    const onSkip = vi.fn()
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onSkip={onSkip} />)
+    })
+    const btn = queryByText("跳过计划,直接写") as HTMLButtonElement
+    await act(async () => {
+      btn.click()
+    })
+    expect(onSkip).toHaveBeenCalledOnce()
+  })
+
+  it("点击取消按钮触发 onCancel", async () => {
+    const onCancel = vi.fn()
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onCancel={onCancel} />)
+    })
+    const btn = queryByText("取消生成") as HTMLButtonElement
+    await act(async () => {
+      btn.click()
+    })
+    expect(onCancel).toHaveBeenCalledOnce()
+  })
+
+  it("提供 onModify 时显示修改计划按钮", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onModify={() => {}} />)
+    })
+    expect(queryByText("修改计划")).not.toBeNull()
+  })
+
+  it("未提供 onModify 时不显示修改计划按钮", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} />)
+    })
+    expect(queryByText("修改计划")).toBeNull()
+  })
+
+  it("严格模式显示严格模式标识", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} aiWorkflowMode="strict" />)
+    })
+    expect(host.textContent).toContain("严格模式")
+  })
+})

+ 143 - 0
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -0,0 +1,143 @@
+import { useState, useCallback } from "react"
+import { X, Check, SkipForward, Edit3, ListChecks } from "lucide-react"
+
+export const CHAPTER_PLAN_MARKER_START = "<!-- chapter_plan -->"
+export const CHAPTER_PLAN_MARKER_END = "<!-- /chapter_plan -->"
+
+export function extractChapterPlan(fullContent: string): { plan: string; body: string } | null {
+  const startIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_START)
+  if (startIdx < 0) return null
+  const contentStart = startIdx + CHAPTER_PLAN_MARKER_START.length
+  const endIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_END, contentStart)
+  if (endIdx < 0) return null
+  const plan = fullContent.slice(contentStart, endIdx).trim()
+  const beforePlan = fullContent.slice(0, startIdx).trim()
+  const afterPlan = fullContent.slice(endIdx + CHAPTER_PLAN_MARKER_END.length).trim()
+  const body = [beforePlan, afterPlan].filter(Boolean).join("\n").trim()
+  return { plan, body }
+}
+
+export function buildPlanConfirmMessage(plan: string): string {
+  return `章节计划已确认,请按以下计划写正文。不要再次输出计划,直接输出正文。\n\n=== 已确认的章节计划 ===\n${plan}`
+}
+
+export function buildPlanSkipMessage(): string {
+  return "请直接写正文,不要输出计划,不要输出分析过程。"
+}
+
+interface ChapterPlanConfirmDialogProps {
+  open: boolean
+  planContent: string
+  aiWorkflowMode: "fast" | "standard" | "strict"
+  onConfirm: () => void
+  onSkip: () => void
+  onModify?: (modifiedPlan: string) => void
+  onCancel: () => void
+}
+
+export function ChapterPlanConfirmDialog({
+  open,
+  planContent,
+  aiWorkflowMode,
+  onConfirm,
+  onSkip,
+  onModify,
+  onCancel,
+}: ChapterPlanConfirmDialogProps) {
+  const [editing, setEditing] = useState(false)
+  const [editedContent, setEditedContent] = useState(planContent)
+
+  const handleStartEdit = useCallback(() => {
+    setEditedContent(planContent)
+    setEditing(true)
+  }, [planContent])
+
+  const handleSubmitEdit = useCallback(() => {
+    if (!onModify) return;
+    onModify(editedContent)
+    setEditing(false)
+  }, [editedContent, onModify])
+
+  if (!open) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex h-[75vh] w-full max-w-4xl flex-col rounded-lg border bg-background shadow-2xl">
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <ListChecks className="h-5 w-5 text-blue-500" />
+            <div>
+              <h3 className="font-semibold">章节创作计划</h3>
+              <p className="text-xs text-muted-foreground">
+                {aiWorkflowMode === "strict" ? "严格模式" : "标准模式"} · 请确认 AI 的创作计划
+              </p>
+            </div>
+          </div>
+          <button onClick={onCancel} className="rounded-md p-1 hover:bg-accent" aria-label="关闭">
+            <X className="h-4 w-4" />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-auto p-4">
+          {editing ? (
+            <textarea
+              value={editedContent}
+              onChange={(e) => setEditedContent(e.target.value)}
+              className="h-full w-full resize-none rounded-md border bg-muted/20 p-3 font-mono text-xs leading-relaxed focus:outline-none focus:ring-1 focus:ring-primary"
+            />
+          ) : (
+            <div className="whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-relaxed">
+              {planContent}
+            </div>
+          )}
+        </div>
+
+        <div className="flex items-center justify-between border-t px-4 py-3">
+          <div className="flex gap-2">
+            <button
+              onClick={onCancel}
+              className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+            >
+              取消生成
+            </button>
+            {onModify && (
+              editing ? (
+                <button
+                  onClick={handleSubmitEdit}
+                  className="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
+                >
+                  <Check className="h-4 w-4" />
+                  按修改后的计划写正文
+                </button>
+              ) : (
+                <button
+                  onClick={handleStartEdit}
+                  className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+                >
+                  <Edit3 className="h-4 w-4" />
+                  修改计划
+                </button>
+              )
+            )}
+          </div>
+          <div className="flex gap-2">
+            <button
+              onClick={onSkip}
+              className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
+            >
+              <SkipForward className="h-4 w-4" />
+              跳过计划,直接写
+            </button>
+            <button
+              onClick={onConfirm}
+              className="flex items-center gap-1.5 rounded-md bg-green-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-green-700"
+            >
+              <Check className="h-4 w-4" />
+              确认,按此计划写正文
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}