Browse Source

fix(test): 修复完整测试回归与配置漂移

恢复结构化大纲 Markdown 收尾并统一模型供应商停用语义。\n\n补齐 Function Calling 翻译与 3.1.x 更新日志,同步已废弃质量拦截、拆书后台调度和异步下拉框的测试契约。
darknessomi 1 month ago
parent
commit
a0453e0e91

+ 3 - 5
src/components/chat/chat-model-selector.tsx

@@ -5,7 +5,7 @@ import { createPortal } from "react-dom"
 import { Button } from "@/components/ui/button"
 import { useWikiStore, type SavedModel } from "@/stores/wiki-store"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
-import { getEffectiveSavedModels } from "@/lib/llm-model-keys"
+import { getEffectiveSavedModels, isProviderAvailable } from "@/lib/llm-model-keys"
 
 interface ChatModelSelectorProps {
   value: string
@@ -82,9 +82,7 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
     const builtinKeys = Object.keys(providerConfigs).filter((k) => !k.startsWith("custom-"))
     for (const key of builtinKeys) {
       const config = providerConfigs[key]
-      const hasConfig = config.enabled === true
-        || ((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length))
-      if (!hasConfig) continue
+      if (!isProviderAvailable(key, config)) continue
       const models = getEffectiveSavedModels(config)
       if (models.length > 0) {
         const preset = LLM_PRESETS.find((p) => p.id === key)
@@ -99,7 +97,7 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
     const customKeys = Object.keys(providerConfigs).filter((k) => k.startsWith("custom-"))
     for (const key of customKeys) {
       const config = providerConfigs[key]
-      if (config.enabled === false) continue
+      if (!isProviderAvailable(key, config)) continue
       const models = getEffectiveSavedModels(config)
       if (models.length > 0) {
         groups.push({

+ 3 - 3
src/components/novel/book-analysis-view.spec.ts

@@ -404,14 +404,14 @@ afterEach(async () => {
 afterAll(() => restoreActEnvironment())
 
 describe("BookAnalysisView 批量导入运行时接线", () => {
-  it("挂载和项目切换时初始化对应项目,并在切换和卸载时异步释放", async () => {
+  it("挂载和项目切换时初始化对应项目,切换页面时保留后台调度器", async () => {
     await renderView()
     expect(mocks.initializeProject).toHaveBeenCalledWith("E:/项目甲")
 
     mocks.wikiState.project = { id: "project-b", name: "项目乙", path: "F:/项目乙" }
     await rerenderView()
 
-    expect(mocks.dispose).toHaveBeenCalledTimes(1)
+    expect(mocks.dispose).not.toHaveBeenCalled()
     expect(mocks.initializeProject).toHaveBeenLastCalledWith("F:/项目乙")
 
     await act(async () => {
@@ -419,7 +419,7 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
       await Promise.resolve()
     })
     mounted = false
-    expect(mocks.dispose).toHaveBeenCalledTimes(2)
+    expect(mocks.dispose).not.toHaveBeenCalled()
   })
 
   it("弹窗通过同名批量入口原样提交完整候选列表,并在成功后关闭", async () => {

+ 9 - 15
src/components/sources/outline-chat-panel.spec.tsx

@@ -678,9 +678,6 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("classification.targetFolder")
     expect(source).toContain("classification.fileName")
     expect(source).toContain("保存大纲文件")
-    expect(source).toContain("summarizeChapterOutlineQuality")
-    expect(source).toContain("formatChapterOutlineQualityReport")
-    expect(source).toContain("includeWarnings: true")
   })
 
   it("parses structured AI outline save requests and requires user confirmation before writing", () => {
@@ -694,14 +691,6 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("AI 大纲输出协议")
   })
 
-  it("生成后对可保存大纲内容输出质量检查反馈并支持继续修订", () => {
-    expect(source).toContain("buildOutlineGenerationQualityFeedback")
-    expect(source).toContain("qualityFeedback")
-    expect(source).toContain("生成后质量检查")
-    expect(source).toContain("修订质量问题")
-    expect(source).toContain("repairPrompt")
-  })
-
   it("uses folder save confirm dialog for classified outline saves", () => {
     expect(source).toContain("OutlineSaveConfirmDialog")
     expect(source).toContain("OutlineSaveConfirmPayload")
@@ -758,11 +747,16 @@ describe("OutlineChatPanel controls", () => {
     expect(trigger).toBeDefined()
     await act(async () => {
       trigger?.click()
-      await new Promise((resolve) => setTimeout(resolve, 20))
     })
-    const option = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button !== trigger && button.textContent?.includes(label),
-    ) as HTMLButtonElement | undefined
+    let option: HTMLButtonElement | undefined
+    for (let attempt = 0; attempt < 50 && !option; attempt += 1) {
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 10))
+      })
+      option = Array.from(document.body.querySelectorAll("button")).find((button) =>
+        button !== trigger && button.textContent?.includes(label),
+      ) as HTMLButtonElement | undefined
+    }
     expect(option).toBeDefined()
     await act(async () => {
       option?.click()

+ 39 - 20
src/components/sources/outline-chat-panel.tsx

@@ -113,7 +113,7 @@ import {
   resolveNovelModel,
   resolveUsableModelKey,
 } from "@/lib/novel/model-resolver";
-import { getEffectiveSavedModels } from "@/lib/llm-model-keys";
+import { hasAvailableModels as hasConfiguredModels } from "@/lib/llm-model-keys";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
@@ -204,6 +204,8 @@ import {
 } from "@/lib/conversation-create-guard";
 import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry";
 import { toast } from "@/lib/toast";
+import { finalizeStructuredMarkdownMessage } from "@/lib/novel/markdown-quality-finalizer";
+import { repairMarkdownFormatWithAi } from "@/lib/novel/markdown-quality-ai-repair";
 import {
   type OutlineWorkflowStage,
   canTransitionOutlineWorkflow,
@@ -1194,23 +1196,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   );
   const historyCount = historyConversations.length;
 
-  const hasAvailableModels = useMemo(() => {
-    for (const key of Object.keys(providerConfigs)) {
-      const config = providerConfigs[key];
-      if (key.startsWith("custom-")) {
-        if (config.enabled === false) continue;
-      } else {
-        // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
-        const hasConfig = config.enabled === true
-          || Boolean((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length));
-        if (!hasConfig) continue;
-      }
-      if (getEffectiveSavedModels(config).length > 0) {
-        return true;
-      }
-    }
-    return false;
-  }, [providerConfigs]);
+  const hasAvailableModels = useMemo(
+    () => hasConfiguredModels(providerConfigs),
+    [providerConfigs],
+  );
 
   const defaultOutlineLlmConfig = useMemo(
     () => resolveNovelModel(llmConfig, novelConfig, "writing"),
@@ -2524,7 +2513,26 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           completedModule: intentContextsRef.current[capturedConvId]?.title || "当前模块",
         });
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const finalContent = cleanFinalContent;
+        const structuredMarkdownEnabled = options.intentPhase === "generation"
+          || options.novelGenerationRequest !== undefined;
+        const finalContent = await finalizeStructuredMarkdownMessage(
+          cleanFinalContent,
+          {
+            enabled: structuredMarkdownEnabled,
+            repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
+              content,
+              llmConfig: effectiveLlmConfig,
+              signal: controller.signal,
+              maxTokens,
+            }),
+            onFailure: () => {
+              if (!isCurrentRun()) return;
+              toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
+                dedupeKey: "outline-markdown-quality-incomplete",
+              });
+            },
+          },
+        );
         if (!isCurrentRun()) return { started: true, sent: false };
         // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
         setStreamingContent(capturedConvId, finalContent);
@@ -3441,7 +3449,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           { allowFallback: true, completedModule: "当前模块" },
         );
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const finalContent = cleanFinalContent;
+        const finalContent = await finalizeStructuredMarkdownMessage(cleanFinalContent, {
+          enabled: regenerationInput.structuredGeneration,
+          repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
+            content,
+            llmConfig: effectiveLlmConfig,
+            signal: controller.signal,
+            maxTokens,
+          }),
+          onFailure: () => toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
+            dedupeKey: "outline-markdown-quality-incomplete",
+          }),
+        });
         if (!isCurrentRun()) return;
         // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
         setStreamingContent(capturedConvId, finalContent);

+ 6 - 0
src/i18n/en.json

@@ -798,6 +798,12 @@
         "emptyValue": "(empty)",
         "endpointPreviewWillUse": "Will use",
         "endpointPreviewAutoApply": "(auto-applies on blur)",
+        "functionCalling": {
+          "label": "Enable Function Calling",
+          "hint": "When disabled, requests through this provider omit tools and tool_choice, including built-in and MCP tools, for compatibility with relays or local models that do not support tool calling.",
+          "stateOn": "ON",
+          "stateOff": "OFF"
+        },
         "cliStatus": {
           "title": "CLI Status",
           "checking": "Checking…",

+ 6 - 0
src/i18n/zh.json

@@ -505,6 +505,12 @@
         "emptyValue": "未填写",
         "endpointPreviewWillUse": "将使用",
         "endpointPreviewAutoApply": ",失焦后自动应用",
+        "functionCalling": {
+          "label": "启用 Function Calling",
+          "hint": "关闭后,使用该供应商时请求不携带 tools/tool_choice(含内置工具与 MCP),用于兼容不支持工具调用的中转或本地模型。",
+          "stateOn": "ON",
+          "stateOff": "OFF"
+        },
         "cliStatus": {
           "title": "CLI 状态",
           "checking": "检查中...",

+ 7 - 34
src/lib/changelog.spec.ts

@@ -2,43 +2,16 @@ import { describe, expect, it } from "vitest"
 import { allChangelog, currentVersionChangelog } from "./changelog"
 
 describe("changelog", () => {
-  it("shows the latest visible 2.2 releases before earlier releases", () => {
+  it("shows current releases first and keeps intentionally retained history", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 30)).toEqual([
-      "2.2.37",
-      "2.2.36",
-      "2.2.35",
-      "2.2.33",
-      "2.2.32",
-      "2.2.31",
-      "2.2.30",
-      "2.2.29",
-      "2.2.27",
-      "2.2.26",
-      "2.2.25",
-      "2.2.24",
-      "2.2.23",
-      "2.2.22",
-      "2.2.21",
-      "2.2.20",
-      "2.2.19",
-      "2.2.18",
-      "2.2.17",
-      "2.2.16",
-      "2.2.14",
-      "2.2.13",
-      "2.2.12",
-      "2.2.11",
-      "2.2.10",
-      "2.2.9",
-      "2.2.8",
-      "2.2.7",
-      "2.2.0",
-      "2.1.0",
-    ])
-    expect(versions[30]).toBe("2.0.0")
+    expect(versions.slice(0, 3)).toEqual(["3.1.2", "3.1.1", "3.1.0"])
+    expect(versions).toContain("3.0.9")
+    expect(versions).toContain("2.2.37")
+    expect(versions).toContain("2.1.0")
+    expect(versions).toContain("2.0.0")
+    expect(new Set(versions).size).toBe(versions.length)
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)

+ 40 - 2
src/lib/changelog.ts

@@ -20,6 +20,34 @@ const THREE_POINT_ONE_TWO_CHANGELOG: ChangelogEntry = {
   },
 };
 
+const THREE_POINT_ONE_ONE_CHANGELOG: ChangelogEntry = {
+  version: "3.1.1",
+  date: "2026-08-06",
+  highlights: {
+    en: [
+      "[Outline Save Queue Fix] Pending outline save confirmations are now merged and queued without being overwritten by later generation rounds; normal and character save batches drain in order.",
+      "[Runtime Alignment] Updated frontend and Rust dependencies and aligned local and CI builds with Node.js 24 LTS.",
+    ],
+    zh: [
+      "【大纲保存队列修复】后续生成轮次不再覆盖尚未确认的大纲保存请求;普通大纲批次合并去重,普通与人物批次按顺序排队处理",
+      "【运行环境对齐】更新前端与 Rust 依赖,并将本地和 CI 构建环境统一到 Node.js 24 LTS",
+    ],
+  },
+};
+
+const THREE_POINT_ONE_ZERO_CHANGELOG: ChangelogEntry = {
+  version: "3.1.0",
+  date: "2026-08-06",
+  highlights: {
+    en: [
+      "[Save Flow Simplification] Removed automatic review interception before saving chapters and outlines; manual review in the Review Center remains available.",
+    ],
+    zh: [
+      "【保存流程简化】移除章节和大纲保存前的自动审稿拦截;审稿中心的手动审稿功能保持不变",
+    ],
+  },
+};
+
 const THREE_POINT_ZERO_NINE_CHANGELOG: ChangelogEntry = {
   version: "3.0.9",
   date: "2026-08-04",
@@ -1091,6 +1119,10 @@ export const CHANGELOG: ChangelogEntry[] = [
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
   if (version === THREE_POINT_ONE_TWO_CHANGELOG.version)
     return [THREE_POINT_ONE_TWO_CHANGELOG];
+  if (version === THREE_POINT_ONE_ONE_CHANGELOG.version)
+    return [THREE_POINT_ONE_ONE_CHANGELOG];
+  if (version === THREE_POINT_ONE_ZERO_CHANGELOG.version)
+    return [THREE_POINT_ONE_ZERO_CHANGELOG];
   if (version === THREE_POINT_ZERO_NINE_CHANGELOG.version)
     return [THREE_POINT_ZERO_NINE_CHANGELOG];
   if (version === THREE_POINT_ZERO_EIGHT_CHANGELOG.version)
@@ -1180,6 +1212,9 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    THREE_POINT_ONE_TWO_CHANGELOG,
+    THREE_POINT_ONE_ONE_CHANGELOG,
+    THREE_POINT_ONE_ZERO_CHANGELOG,
     THREE_POINT_ZERO_NINE_CHANGELOG,
     THREE_POINT_ZERO_EIGHT_CHANGELOG,
     THREE_POINT_ZERO_SEVEN_CHANGELOG,
@@ -1220,7 +1255,10 @@ export function allChangelog(): ChangelogEntry[] {
     TWO_POINT_TWO_ZERO_CHANGELOG,
     TWO_POINT_ONE_ZERO_CHANGELOG,
     TWO_POINT_ZERO_CHANGELOG,
-    ...CHANGELOG.filter((entry) => !isMergedOnePointRelease(entry.version)),
+    ...CHANGELOG.filter(
+      (entry) =>
+        entry !== THREE_POINT_ONE_TWO_CHANGELOG &&
+        !isMergedOnePointRelease(entry.version),
+    ),
   ];
 }
-

+ 1 - 1
src/lib/hardcoded-ui-chinese-i18n.test.ts

@@ -171,7 +171,7 @@ describe("user-facing hardcoded chinese strings", () => {
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.title", { defaultValue: "维护工具" })')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.description", {')
     expect(maintenanceSection).toContain(
-      'defaultValue:\n              "用于清理资料库的工具:检测并合并那些在多次重新摄取后被大模型以不同名称创建出来的重复实体或概念。",'
+      'defaultValue:\n              "用于清理资料库的工具:检测并合并重复实体/概念,以及清理伏笔追踪器中的重复、噪声与失效条目。",'
     )
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.title", {')
     expect(maintenanceSection).toContain('defaultValue: "检测重复实体 / 概念"')

+ 16 - 1
src/lib/llm-model-keys.spec.ts

@@ -1,6 +1,10 @@
 import { describe, expect, it } from "vitest"
 
-import { getStableAvailableModelKey } from "@/lib/llm-model-keys"
+import {
+  getStableAvailableModelKey,
+  hasAvailableModels,
+  isProviderAvailable,
+} from "@/lib/llm-model-keys"
 import type { ProviderConfigs } from "@/stores/wiki-store"
 
 function saved(id: string, name = id) {
@@ -8,6 +12,17 @@ function saved(id: string, name = id) {
 }
 
 describe("stable available model keys", () => {
+  it("hard-disables explicit false while keeping configured legacy providers usable", () => {
+    const disabled = { enabled: false, apiKey: "old-key", savedModels: [saved("disabled-model")] }
+    const legacy = { apiKey: "legacy-key", savedModels: [saved("legacy-model")] }
+    const configs: ProviderConfigs = { openai: disabled, anthropic: legacy }
+
+    expect(isProviderAvailable("openai", disabled)).toBe(false)
+    expect(isProviderAvailable("anthropic", legacy)).toBe(true)
+    expect(hasAvailableModels(configs)).toBe(true)
+    expect(getStableAvailableModelKey("legacy-model", configs)).toBe("anthropic/legacy-model")
+  })
+
   it("treats a slash-containing legacy model id as a whole when its prefix is not a provider", () => {
     const configs: ProviderConfigs = {
       openai: { enabled: true, apiKey: "key", savedModels: [saved("vendor/family/model-v1")] },

+ 3 - 2
src/lib/llm-model-keys.ts

@@ -1,8 +1,9 @@
 import type { ProviderConfigs, ProviderOverride, SavedModel } from "@/stores/wiki-store"
 
-function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
+export function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
+  if (config.enabled === false) return false
   if (providerId.startsWith("custom-")) {
-    return config.enabled !== false
+    return true
   }
   // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
   return config.enabled === true

+ 0 - 41
src/lib/novel/outline-quality-check.spec.ts

@@ -1,8 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
-  buildOutlineGenerationQualityFeedback,
   extractChapterOutlineStatus,
-  formatChapterOutlineQualityReport,
   isLikelyChapterOutline,
   runChapterOutlineQualityCheck,
   summarizeChapterOutlineQuality,
@@ -73,43 +71,4 @@ describe("章纲质量检查", () => {
     expect(extractChapterOutlineStatus(content.replace("当前状态:草稿", "当前状态:已确认"))).toBe("已确认")
   })
 
-  it("质量报告应给出可执行的章纲补全建议", () => {
-    const summary = summarizeChapterOutlineQuality("# 章纲-第001章\n\n## 核心事件\n\n- 事件1:只有一个事件")
-    const report = formatChapterOutlineQualityReport(summary, {
-      maxIssues: 3,
-      includeWarnings: true,
-    })
-
-    expect(report).toContain("章纲质量检查未通过")
-    expect(report).toContain("项错误")
-    expect(report).toContain("主要缺失")
-    expect(report).toContain("另有")
-    expect(report).toContain("请让 AI 按章纲标准补齐后重新输出完整章纲,再保存")
-  })
-
-  it("通过但存在提醒时应提示继续完善提醒项", () => {
-    const report = formatChapterOutlineQualityReport({
-      valid: true,
-      errors: [],
-      warnings: ["人物状态缺少「关键配角状态」字段"],
-      items: [],
-    })
-
-    expect(report).toBe("章纲质量检查通过,但有 1 项提醒。建议完善:人物状态缺少「关键配角状态」字段。")
-  })
-
-  it("生成后质量检查应给不完整章纲返回可修复项和修订提示", () => {
-    const feedback = buildOutlineGenerationQualityFeedback({
-      fileType: "chapter-outline",
-      fileName: "章纲-第001章.md",
-      content: "# 章纲-第001章\n\n## 核心事件\n\n- 事件1:只有一个事件",
-    })
-
-    expect(feedback).not.toBeNull()
-    expect(feedback?.status).toBe("error")
-    expect(feedback?.title).toBe("生成后质量检查")
-    expect(feedback?.summary).toContain("可修复项")
-    expect(feedback?.repairPrompt).toContain("请按章纲标准修订")
-    expect(feedback?.repairPrompt).toContain("章纲-第001章.md")
-  })
 })