Bläddra i källkod

test(baseline): 修复清理后失效的回归用例

移除 test 与 test helpers 的全局忽略规则,将隐藏回归测试重新纳入版本控制。

改用公开 selector 和 resolver 行为验证,避免依赖测试专用导出。
darknessomi 2 veckor sedan
förälder
incheckning
ad5f3f66ca

+ 2 - 22
.gitignore

@@ -24,28 +24,8 @@ yarn-error.log*
 # === 更新日志(仅本地)===
 /GenxinLOG/
 
-# === 测试文件 ===
-tests/
-src/test-helpers/*
-!src/test-helpers/
-!src/test-helpers/load-test-env.ts
-!src/test-helpers/fs-temp.ts
-**/*.test.ts
-**/*.test.tsx
-!src/lib/chat-copy-content.test.ts
-!src/lib/chapter-formatting.test.ts
-!src/lib/dashboard-issue-actions.test.ts
-!src/lib/tauri-fetch.test.ts
-!src/lib/novel/start-review-run.test.ts
-!src/lib/web-fs.spec.ts
-!src/lib/graph-relevance.spec.ts
-!src/lib/chat-request-budget.test.ts
-!src/lib/context-budget.test.ts
-!src/lib/ingest.prompt.test.ts
-!src/components/layout/chat-layout.test.ts
-!src/i18n/i18n-parity.test.ts
-!src/lib/hardcoded-ui-chinese-i18n.test.ts
-!src/lib/project-store.integration.test.ts
+# === 测试源码 ===
+# 测试与 test helpers 必须纳入版本控制,不在此忽略。
 
 # === 根目录文档(仅本地保留)===
 /docs/

+ 56 - 0
src/components/chat/chapter-plan-confirm-dialog.test.ts

@@ -0,0 +1,56 @@
+import { expect, test } from "vitest"
+import {
+  CHAPTER_PLAN_MARKER_END,
+  CHAPTER_PLAN_MARKER_START,
+  extractChapterPlan,
+} from "./chapter-plan-confirm-dialog"
+
+test("extracts plan wrapped in chapter_plan markers", () => {
+  const fullContent = [
+    "下面是本章计划:",
+    CHAPTER_PLAN_MARKER_START,
+    "### 1. 本章目标",
+    "- 推进主线",
+    CHAPTER_PLAN_MARKER_END,
+    "请确认。",
+  ].join("\n")
+
+  const extracted = extractChapterPlan(fullContent)
+  expect(extracted).not.toBeNull()
+  expect(extracted!.plan).toContain("本章目标")
+  expect(extracted!.plan).not.toContain(CHAPTER_PLAN_MARKER_START)
+  expect(extracted!.body).toContain("下面是本章计划:")
+  expect(extracted!.body).toContain("请确认。")
+})
+
+test("tolerates a missing end marker (truncated output)", () => {
+  const fullContent = [
+    CHAPTER_PLAN_MARKER_START,
+    "### 1. 本章目标",
+    "- 推进主线,但输出在这里被截断了",
+  ].join("\n")
+
+  const extracted = extractChapterPlan(fullContent)
+  expect(extracted).not.toBeNull()
+  expect(extracted!.plan).toContain("本章目标")
+  expect(extracted!.plan).toContain("被截断了")
+})
+
+test("falls back to keyword sections when markers are absent", () => {
+  const fullContent = [
+    "### 本章目标",
+    "- 推进主线",
+    "",
+    "### 分场景执行计划",
+    "- S1:开场冲突",
+  ].join("\n")
+
+  const extracted = extractChapterPlan(fullContent)
+  expect(extracted).not.toBeNull()
+  expect(extracted!.plan).toContain("分场景执行计划")
+})
+
+test("returns null for content that is neither marked nor plan-shaped", () => {
+  expect(extractChapterPlan("好的,我明白了。")).toBe(null)
+  expect(extractChapterPlan("")).toBe(null)
+})

+ 34 - 0
src/components/settings/context-size-selector.spec.tsx

@@ -0,0 +1,34 @@
+import { renderToStaticMarkup } from "react-dom/server"
+import { describe, expect, it, vi } from "vitest"
+import { ContextSizeSelector } from "./context-size-selector"
+
+vi.mock("react-i18next", () => ({
+  initReactI18next: {
+    type: "3rdParty",
+    init: vi.fn(),
+  },
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}))
+
+describe("ContextSizeSelector", () => {
+  it("offers every supported context tier through the public selector", () => {
+    const presets = [
+      { value: 204_800, label: "200K" },
+      { value: 262_144, label: "256K" },
+      { value: 307_200, label: "300K" },
+      { value: 409_600, label: "400K" },
+      { value: 524_288, label: "512K" },
+      { value: 1_000_000, label: "1M" },
+    ]
+
+    for (const [index, preset] of presets.entries()) {
+      const html = renderToStaticMarkup(
+        <ContextSizeSelector value={preset.value} onChange={vi.fn()} />,
+      )
+      expect(html).toContain(`value="${index}"`)
+      expect(html).toContain(`>${preset.label}</button>`)
+    }
+  })
+})

+ 47 - 0
src/lib/llm-context-size.test.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest"
+import { LLM_PRESETS } from "@/components/settings/llm-presets"
+import {
+  MIN_USER_LLM_CONTEXT_SIZE,
+  normalizeProviderConfigs,
+  normalizeUserLlmContextSize,
+} from "./llm-context-size"
+
+describe("user LLM context size", () => {
+  it.each([undefined, 0, 4_096, 128_000, 200_000])(
+    "raises legacy value %s to 204800",
+    (value) => {
+      expect(normalizeUserLlmContextSize(value)).toBe(MIN_USER_LLM_CONTEXT_SIZE)
+    },
+  )
+
+  it.each([204_800, 262_144, 307_200, 409_600, 524_288, 1_000_000])(
+    "keeps supported value %i",
+    (value) => {
+      expect(normalizeUserLlmContextSize(value)).toBe(value)
+    },
+  )
+
+  it("normalizes provider overrides without changing unrelated fields", () => {
+    const configs = normalizeProviderConfigs({
+      custom: {
+        apiKey: "secret",
+        model: "model-a",
+        maxContextSize: 32_768,
+        enabled: false,
+      },
+    })
+    expect(configs.custom).toEqual({
+      apiKey: "secret",
+      model: "model-a",
+      maxContextSize: 204_800,
+      enabled: false,
+    })
+  })
+
+  it("exports no built-in preset below the minimum", () => {
+    for (const preset of LLM_PRESETS) {
+      if (preset.suggestedContextSize === undefined) continue
+      expect(preset.suggestedContextSize).toBeGreaterThanOrEqual(204_800)
+    }
+  })
+})

+ 219 - 0
src/lib/novel/model-resolver.test.ts

@@ -0,0 +1,219 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  type LlmConfig,
+  type NovelConfig,
+  type ProviderConfigs,
+} from "@/stores/wiki-store"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import {
+  resolveDefaultModel,
+  resolveNovelModel,
+} from "./model-resolver"
+
+const SAVED_AT = 1
+
+function savedModel(id: string, name: string, model: string) {
+  return { id, name, model, createdAt: SAVED_AT }
+}
+
+const baseLlmConfig: LlmConfig = {
+  provider: "claude-code",
+  apiKey: "",
+  model: "claude-sonnet",
+  ollamaUrl: "http://localhost:11434",
+  customEndpoint: "",
+  maxContextSize: 204800,
+  reasoning: { mode: "auto" },
+  localCliIsolation: false,
+}
+
+const openAiProviderConfigs: ProviderConfigs = {
+  openai: {
+    enabled: true,
+    apiKey: "sk-test",
+    savedModels: [savedModel("1", "GPT-4o", "gpt-4o")],
+  },
+}
+
+const dualProviderConfigs: ProviderConfigs = {
+  ...openAiProviderConfigs,
+  deepseek: {
+    enabled: true,
+    apiKey: "ds-test",
+    savedModels: [savedModel("2", "DeepSeek V3", "deepseek-chat")],
+  },
+}
+
+const cliProviderConfigs: ProviderConfigs = {
+  "claude-code-cli": {
+    enabled: true,
+    savedModels: [savedModel("3", "Claude Sonnet", "claude-sonnet-4")],
+  },
+}
+
+const storeState = vi.hoisted(() => ({
+  llmConfig: {
+    provider: "claude-code" as const,
+    apiKey: "",
+    model: "claude-sonnet",
+    ollamaUrl: "http://localhost:11434",
+    customEndpoint: "",
+    maxContextSize: 204800,
+    reasoning: { mode: "auto" as const },
+    localCliIsolation: false,
+  } as LlmConfig,
+  defaultLlmModel: "",
+  aiChatModel: "",
+  providerConfigs: {
+    openai: {
+      enabled: true,
+      apiKey: "sk-test",
+      savedModels: [{ id: "1", name: "GPT-4o", model: "gpt-4o", createdAt: 1 }],
+    },
+  } as ProviderConfigs,
+  novelConfig: {
+    contextTokenBudget: 0,
+    recentSummaryWindow: 8,
+    searchTopK: 5,
+    chapterTargetChars: 3000,
+    autoIngestOnSave: true,
+    autoExtractOnImport: true,
+    reviewBeforeSave: false,
+    deepPreviousChaptersAnalysis: false,
+    deepChapterReview: true,
+    reviewReasoningEffort: "high" as const,
+    defaultLlmModel: "",
+    writingModel: "",
+    reviewModel: "",
+    summaryModel: "",
+    extractModel: "",
+    deAiModel: "",
+    communitySummaryEnabled: true,
+    communitySummaryInterval: 5,
+    communitySummaryAsync: true,
+    autoGenerateChapterTitle: true,
+  } as NovelConfig,
+}))
+
+vi.mock("@/stores/wiki-store", () => ({
+  useWikiStore: {
+    getState: () => storeState,
+  },
+}))
+
+describe("resolveDefaultModel", () => {
+  beforeEach(() => {
+    storeState.llmConfig = { ...baseLlmConfig }
+    storeState.defaultLlmModel = ""
+    storeState.aiChatModel = ""
+    storeState.providerConfigs = { ...openAiProviderConfigs }
+    storeState.novelConfig.defaultLlmModel = ""
+  })
+
+  it("returns the configured default model when registered and usable", () => {
+    storeState.novelConfig.defaultLlmModel = "openai/gpt-4o"
+
+    const cfg = resolveDefaultModel(storeState.llmConfig)
+
+    expect(cfg.provider).toBe("openai")
+    expect(cfg.apiKey).toBe("sk-test")
+    expect(cfg.model).toBe("gpt-4o")
+  })
+
+  it("falls back to legacy global defaultLlmModel when novelConfig is empty", () => {
+    storeState.defaultLlmModel = "openai/gpt-4o"
+
+    const cfg = resolveDefaultModel(storeState.llmConfig)
+
+    expect(cfg.model).toBe("gpt-4o")
+  })
+
+  it("falls back to aiChatModel when defaultLlmModel is empty", () => {
+    storeState.aiChatModel = "openai/gpt-4o"
+
+    const cfg = resolveDefaultModel(storeState.llmConfig)
+
+    expect(cfg.model).toBe("gpt-4o")
+    expect(cfg.provider).toBe("openai")
+  })
+
+  it("skips stale defaultLlmModel and uses aiChatModel instead of baseConfig", () => {
+    storeState.llmConfig = { ...baseLlmConfig }
+    storeState.providerConfigs = { ...cliProviderConfigs }
+    storeState.novelConfig.defaultLlmModel = "nonexistent/stale-model"
+    storeState.aiChatModel = "claude-code-cli/claude-sonnet-4"
+
+    const cfg = resolveDefaultModel(storeState.llmConfig)
+
+    expect(cfg.provider).toBe("claude-code")
+    expect(cfg.model).toBe("claude-sonnet-4")
+    expect(hasUsableLlm(cfg, cliProviderConfigs)).toBe(true)
+  })
+
+  it("returns unusable config when no registered model is available", () => {
+    storeState.llmConfig = { ...baseLlmConfig }
+    storeState.providerConfigs = { ...cliProviderConfigs }
+
+    const cfg = resolveDefaultModel(storeState.llmConfig)
+
+    expect(hasUsableLlm(cfg, cliProviderConfigs)).toBe(false)
+    expect(cfg.model).toBe("")
+    expect(cfg.apiKey).toBe("")
+  })
+})
+
+describe("resolveNovelModel", () => {
+  beforeEach(() => {
+    storeState.llmConfig = {
+      provider: "openai",
+      apiKey: "sk-test",
+      model: "gpt-4o",
+      ollamaUrl: "http://localhost:11434",
+      customEndpoint: "",
+      maxContextSize: 204800,
+      reasoning: { mode: "auto" },
+      localCliIsolation: false,
+    }
+    storeState.defaultLlmModel = ""
+    storeState.aiChatModel = ""
+    storeState.providerConfigs = { ...dualProviderConfigs }
+    storeState.novelConfig = {
+      ...storeState.novelConfig,
+      defaultLlmModel: "",
+      reviewModel: "",
+      summaryModel: "",
+      extractModel: "",
+    }
+  })
+
+  it("prefers defaultLlmModel over aiChatModel for extract when task model is empty", () => {
+    storeState.aiChatModel = "openai/gpt-4o"
+    storeState.novelConfig.defaultLlmModel = "deepseek/deepseek-chat"
+
+    const cfg = resolveNovelModel(storeState.llmConfig, storeState.novelConfig, "extract")
+
+    expect(cfg.model).toBe("deepseek-chat")
+    expect(cfg.apiKey).toBe("ds-test")
+  })
+
+  it("falls back to defaultLlmModel when aiChatModel is empty", () => {
+    storeState.novelConfig.defaultLlmModel = "deepseek/deepseek-chat"
+
+    const cfg = resolveNovelModel(storeState.llmConfig, storeState.novelConfig, "extract")
+
+    expect(cfg.model).toBe("deepseek-chat")
+    expect(cfg.apiKey).toBe("ds-test")
+  })
+
+  it("uses the configured task model when present", () => {
+    storeState.novelConfig = {
+      ...storeState.novelConfig,
+      extractModel: "deepseek/deepseek-chat",
+    }
+    storeState.aiChatModel = "openai/gpt-4o"
+
+    const cfg = resolveNovelModel(storeState.llmConfig, storeState.novelConfig, "extract")
+
+    expect(cfg.model).toBe("deepseek-chat")
+  })
+})