feat: initial release v0.3.0
This commit is contained in:
58
tests/unit/helpers/api-fetch.test.ts
Normal file
58
tests/unit/helpers/api-fetch.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
|
||||
describe('apiFetch locale header injection', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('injects Accept-Language for internal /api requests', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await apiFetch('/api/tasks?status=running', { method: 'GET' })
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('Accept-Language')).toBe('zh')
|
||||
})
|
||||
|
||||
it('uses pathname locale and does not override explicit Accept-Language', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
pathname: '/en/workspace',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await apiFetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': 'ja',
|
||||
},
|
||||
body: JSON.stringify({ ok: true }),
|
||||
})
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('Accept-Language')).toBe('ja')
|
||||
})
|
||||
|
||||
it('does not inject locale header for non-internal URLs', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await apiFetch('https://example.com/health', { method: 'GET' })
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.has('Accept-Language')).toBe(false)
|
||||
})
|
||||
})
|
||||
185
tests/unit/helpers/json-repair.test.ts
Normal file
185
tests/unit/helpers/json-repair.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { safeParseJson, safeParseJsonObject, safeParseJsonArray } from '@/lib/json-repair'
|
||||
|
||||
// ─── safeParseJson ───────────────────────────────────────────────────
|
||||
|
||||
describe('safeParseJson', () => {
|
||||
it('正常 JSON 字符串 -> 直接解析成功', () => {
|
||||
const result = safeParseJson('{"name":"孙悟空","age":500}')
|
||||
expect(result).toEqual({ name: '孙悟空', age: 500 })
|
||||
})
|
||||
|
||||
it('包含 markdown 代码块 -> 剥离后解析成功', () => {
|
||||
const input = '```json\n{"key":"value"}\n```'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it('包含大写 JSON 标记的 markdown 代码块 -> 剥离后解析成功', () => {
|
||||
const input = '```JSON\n{"key":"value"}\n```'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it('尾部逗号 -> jsonrepair 修复后解析成功', () => {
|
||||
const input = '{"a":1,"b":2,}'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('单引号包裹字符串 -> jsonrepair 修复后解析成功', () => {
|
||||
const input = "{'name':'张三','age':25}"
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ name: '张三', age: 25 })
|
||||
})
|
||||
|
||||
it('JSON 前后有多余文字 -> jsonrepair 修复后解析成功', () => {
|
||||
const input = '以下是分析结果:\n{"result":"success"}\n以上是所有内容。'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ result: 'success' })
|
||||
})
|
||||
|
||||
it('完全无效内容(无任何 JSON 结构字符)-> jsonrepair 将其视为字符串', () => {
|
||||
// jsonrepair 会把纯文本修复为 JSON 字符串
|
||||
const result = safeParseJson('这不是JSON')
|
||||
expect(result).toBe('这不是JSON')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── safeParseJsonObject ─────────────────────────────────────────────
|
||||
|
||||
describe('safeParseJsonObject', () => {
|
||||
it('正常 JSON 对象 -> 返回对象', () => {
|
||||
const result = safeParseJsonObject('{"characters":[],"locations":[]}')
|
||||
expect(result).toEqual({ characters: [], locations: [] })
|
||||
})
|
||||
|
||||
it('markdown 包裹的 JSON 对象 -> 剥离后返回对象', () => {
|
||||
const input = '```json\n{"episodes":[{"number":1}]}\n```'
|
||||
const result = safeParseJsonObject(input)
|
||||
expect(result).toHaveProperty('episodes')
|
||||
expect((result.episodes as unknown[])[0]).toEqual({ number: 1 })
|
||||
})
|
||||
|
||||
it('包含中文角引号「」的内容 -> 正常解析保留', () => {
|
||||
const input = '{"lines":"孙悟空怒道,「一个冒牌货,也敢拦你孙爷爷的路!」"}'
|
||||
const result = safeParseJsonObject(input)
|
||||
expect(result.lines).toBe('孙悟空怒道,「一个冒牌货,也敢拦你孙爷爷的路!」')
|
||||
})
|
||||
|
||||
it('LLM 输出数组而非对象 -> 抛出 Expected JSON object 错误', () => {
|
||||
expect(() => safeParseJsonObject('[1,2,3]')).toThrow('Expected JSON object')
|
||||
})
|
||||
|
||||
it('尾部逗号 + markdown 包裹 -> 修复后返回正确对象', () => {
|
||||
const input = '```json\n{"a":1,"b":"hello",}\n```'
|
||||
const result = safeParseJsonObject(input)
|
||||
expect(result).toEqual({ a: 1, b: 'hello' })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── safeParseJsonArray ──────────────────────────────────────────────
|
||||
|
||||
describe('safeParseJsonArray', () => {
|
||||
it('正常 JSON 数组 -> 返回对象数组', () => {
|
||||
const input = '[{"id":1,"name":"角色A"},{"id":2,"name":"角色B"}]'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ id: 1, name: '角色A' })
|
||||
expect(result[1]).toEqual({ id: 2, name: '角色B' })
|
||||
})
|
||||
|
||||
it('对象包裹数组 + fallbackKey -> 提取内部数组', () => {
|
||||
const input = '{"clips":[{"id":1},{"id":2}]}'
|
||||
const result = safeParseJsonArray(input, 'clips')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
it('对象包裹数组 + 无 fallbackKey -> 自动发现第一个数组字段', () => {
|
||||
const input = '{"episodes":[{"number":1},{"number":2}]}'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ number: 1 })
|
||||
})
|
||||
|
||||
it('markdown 包裹 + 尾部逗号 -> 修复后返回正确数组', () => {
|
||||
const input = '```json\n[{"a":1},{"b":2},]\n```'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ a: 1 })
|
||||
expect(result[1]).toEqual({ b: 2 })
|
||||
})
|
||||
|
||||
it('过滤非对象元素(数字、字符串等)-> 只保留对象', () => {
|
||||
const input = '[{"valid":true}, 42, "string", null, {"also":true}]'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ valid: true })
|
||||
expect(result[1]).toEqual({ also: true })
|
||||
})
|
||||
|
||||
it('空数组 -> 返回空数组', () => {
|
||||
const result = safeParseJsonArray('[]')
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('非数组非对象 -> 抛出错误', () => {
|
||||
expect(() => safeParseJsonArray('"just a string"')).toThrow('Expected JSON array')
|
||||
})
|
||||
|
||||
it('对象不含数组字段 -> 抛出错误', () => {
|
||||
expect(() => safeParseJsonArray('{"key":"value"}')).toThrow('Expected JSON array')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 真实 LLM 畸形输出回归测试 ───────────────────────────────────────
|
||||
|
||||
describe('LLM 畸形 JSON 输出回归测试', () => {
|
||||
it('中文弯引号嵌套在 JSON 值中 -> jsonrepair 修复成功', () => {
|
||||
// 这是导致 "Invalid clip JSON format" 的典型场景
|
||||
const llmOutput = '```json\n[{"description":"孙悟空怒道,\\u201c一个冒牌货!\\u201d"}]\n```'
|
||||
const result = safeParseJsonArray(llmOutput)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].description).toContain('孙悟空')
|
||||
})
|
||||
|
||||
it('LLM 输出前后带解释文字 -> 提取并解析 JSON', () => {
|
||||
const llmOutput = `好的,以下是分析结果:
|
||||
|
||||
{"locations":[{"name":"客厅_白天","summary":"主角居住的客厅"}]}
|
||||
|
||||
以上是所有场景分析。`
|
||||
const result = safeParseJsonObject(llmOutput)
|
||||
expect(result.locations).toBeDefined()
|
||||
const locations = result.locations as unknown[]
|
||||
expect(locations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('使用「」角引号的台词内容 -> 正确解析不破坏 JSON', () => {
|
||||
// 改造后的提示词要求 LLM 用「」替代引号
|
||||
const llmOutput = '[{"speaker":"孙悟空","content":"「你竟敢拦我的路!」","emotionStrength":0.4}]'
|
||||
const result = safeParseJsonArray(llmOutput)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].speaker).toBe('孙悟空')
|
||||
expect(result[0].content).toBe('「你竟敢拦我的路!」')
|
||||
expect(result[0].emotionStrength).toBe(0.4)
|
||||
})
|
||||
|
||||
it('带控制字符的 JSON -> jsonrepair 修复成功', () => {
|
||||
// LLM 有时在字符串值中输出真实换行符
|
||||
const llmOutput = '{"text":"第一行\\n第二行","count":2}'
|
||||
const result = safeParseJsonObject(llmOutput)
|
||||
expect(result.text).toBe('第一行\n第二行')
|
||||
expect(result.count).toBe(2)
|
||||
})
|
||||
|
||||
it('clips 包裹在对象中 -> 正确提取', () => {
|
||||
// clips-build 中常见的 LLM 输出格式
|
||||
const llmOutput = '{"clips":[{"id":"clip_1","startText":"从前"},{"id":"clip_2","startText":"后来"}]}'
|
||||
const result = safeParseJsonArray(llmOutput, 'clips')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe('clip_1')
|
||||
expect(result[1].startText).toBe('后来')
|
||||
})
|
||||
})
|
||||
25
tests/unit/helpers/llm-stage-stream-card-output.test.ts
Normal file
25
tests/unit/helpers/llm-stage-stream-card-output.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitStructuredOutput } from '@/components/llm-console/LLMStageStreamCard'
|
||||
|
||||
describe('LLMStageStreamCard structured output parsing', () => {
|
||||
it('moves think-tagged text from final block into reasoning', () => {
|
||||
const parsed = splitStructuredOutput(`【思考过程】
|
||||
已有思考
|
||||
|
||||
【最终结果】
|
||||
<think>追加思考</think>
|
||||
{"locations":[]}`)
|
||||
|
||||
expect(parsed.reasoning).toContain('已有思考')
|
||||
expect(parsed.reasoning).toContain('追加思考')
|
||||
expect(parsed.finalText).toBe('{"locations":[]}')
|
||||
})
|
||||
|
||||
it('handles unmatched think opening tag during streaming', () => {
|
||||
const parsed = splitStructuredOutput(`【最终结果】
|
||||
<think>流式中的思考还没结束`)
|
||||
|
||||
expect(parsed.reasoning).toBe('流式中的思考还没结束')
|
||||
expect(parsed.finalText).toBe('')
|
||||
})
|
||||
})
|
||||
63
tests/unit/helpers/logging-core.test.ts
Normal file
63
tests/unit/helpers/logging-core.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('logging core suppression', () => {
|
||||
let originalLogLevel: string | undefined
|
||||
let originalUnifiedEnabled: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
originalLogLevel = process.env.LOG_LEVEL
|
||||
originalUnifiedEnabled = process.env.LOG_UNIFIED_ENABLED
|
||||
process.env.LOG_LEVEL = 'INFO'
|
||||
process.env.LOG_UNIFIED_ENABLED = 'true'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env.LOG_LEVEL
|
||||
} else {
|
||||
process.env.LOG_LEVEL = originalLogLevel
|
||||
}
|
||||
if (originalUnifiedEnabled === undefined) {
|
||||
delete process.env.LOG_UNIFIED_ENABLED
|
||||
} else {
|
||||
process.env.LOG_UNIFIED_ENABLED = originalUnifiedEnabled
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('suppresses worker.progress.stream logs', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { createScopedLogger } = await import('@/lib/logging/core')
|
||||
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
|
||||
|
||||
logger.info({
|
||||
action: 'worker.progress.stream',
|
||||
message: 'worker stream chunk',
|
||||
details: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps non-suppressed logs', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const { createScopedLogger } = await import('@/lib/logging/core')
|
||||
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
|
||||
|
||||
logger.info({
|
||||
action: 'worker.progress',
|
||||
message: 'worker progress update',
|
||||
})
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls[0]?.[0])) as { action?: string; message?: string }
|
||||
expect(payload.action).toBe('worker.progress')
|
||||
expect(payload.message).toBe('worker progress update')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
migrateGatewayRoutePayload,
|
||||
migrateProviderEntry,
|
||||
} from '@/lib/migrations/gateway-route-openai-compat'
|
||||
|
||||
describe('gateway-route openai-compat migration', () => {
|
||||
it('migrates openai-compatible litellm route to openai-compat', () => {
|
||||
const result = migrateProviderEntry({
|
||||
id: 'openai-compatible:oa-1',
|
||||
gatewayRoute: 'litellm',
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.next).toMatchObject({
|
||||
id: 'openai-compatible:oa-1',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)
|
||||
})
|
||||
|
||||
it('forces gemini-compatible to gemini-sdk + official route', () => {
|
||||
const result = migrateProviderEntry({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiMode: 'openai-official',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.next).toMatchObject({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiMode: 'gemini-sdk',
|
||||
gatewayRoute: 'official',
|
||||
})
|
||||
expect(result.summary.geminiApiModeCorrected).toBe(1)
|
||||
expect(result.summary.routeForcedOfficial).toBe(1)
|
||||
})
|
||||
|
||||
it('forces non-openai-compatible compat routes to official', () => {
|
||||
const result = migrateProviderEntry({
|
||||
id: 'openrouter',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.next).toMatchObject({
|
||||
id: 'openrouter',
|
||||
gatewayRoute: 'official',
|
||||
})
|
||||
expect(result.summary.routeForcedOfficial).toBe(1)
|
||||
})
|
||||
|
||||
it('returns invalid status for malformed payload json', () => {
|
||||
const result = migrateGatewayRoutePayload('{bad-json')
|
||||
expect(result.status).toBe('invalid')
|
||||
expect(result.summary.invalidPayload).toBe(true)
|
||||
})
|
||||
|
||||
it('migrates mixed provider payload and reports aggregate stats', () => {
|
||||
const result = migrateGatewayRoutePayload(JSON.stringify([
|
||||
{
|
||||
id: 'openai-compatible:oa-1',
|
||||
gatewayRoute: 'litellm',
|
||||
},
|
||||
{
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiMode: 'openai-official',
|
||||
gatewayRoute: 'openai-compat',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
gatewayRoute: 'official',
|
||||
},
|
||||
]))
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.summary.providersScanned).toBe(3)
|
||||
expect(result.summary.providersChanged).toBe(2)
|
||||
expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)
|
||||
expect(result.summary.routeForcedOfficial).toBe(1)
|
||||
expect(result.summary.geminiApiModeCorrected).toBe(1)
|
||||
|
||||
const nextPayload = JSON.parse(result.nextRaw || '[]') as Array<Record<string, unknown>>
|
||||
expect(nextPayload[0]?.gatewayRoute).toBe('openai-compat')
|
||||
expect(nextPayload[1]?.apiMode).toBe('gemini-sdk')
|
||||
expect(nextPayload[1]?.gatewayRoute).toBe('official')
|
||||
})
|
||||
})
|
||||
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addCharacterPromptSuffix,
|
||||
CHARACTER_PROMPT_SUFFIX,
|
||||
removeCharacterPromptSuffix,
|
||||
} from '@/lib/constants'
|
||||
|
||||
function countOccurrences(input: string, target: string) {
|
||||
if (!target) return 0
|
||||
return input.split(target).length - 1
|
||||
}
|
||||
|
||||
describe('character prompt suffix regression', () => {
|
||||
it('appends suffix when generating prompt', () => {
|
||||
const basePrompt = 'A brave knight in silver armor'
|
||||
const generated = addCharacterPromptSuffix(basePrompt)
|
||||
|
||||
expect(generated).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(generated, CHARACTER_PROMPT_SUFFIX)).toBe(1)
|
||||
})
|
||||
|
||||
it('removes suffix text from prompt', () => {
|
||||
const basePrompt = 'A calm detective with short black hair'
|
||||
const withSuffix = addCharacterPromptSuffix(basePrompt)
|
||||
const removed = removeCharacterPromptSuffix(withSuffix)
|
||||
|
||||
expect(removed).not.toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removed).toContain(basePrompt)
|
||||
})
|
||||
|
||||
it('uses suffix as full prompt when base prompt is empty', () => {
|
||||
expect(addCharacterPromptSuffix('')).toBe(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removeCharacterPromptSuffix('')).toBe('')
|
||||
})
|
||||
})
|
||||
278
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
278
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => payload,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCondition(condition: () => boolean, timeoutMs = 1000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (condition()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('condition not met before timeout')
|
||||
}
|
||||
|
||||
describe('recovered run subscription', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
if (originalFetch) {
|
||||
globalThis.fetch = originalFetch
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
}
|
||||
})
|
||||
|
||||
it('replays run events and keeps recovering when no terminal event is present', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'step.start',
|
||||
stepKey: 'clip_1_phase1',
|
||||
attempt: 1,
|
||||
payload: {
|
||||
stepTitle: '分镜规划',
|
||||
stepIndex: 1,
|
||||
stepTotal: 4,
|
||||
message: 'running',
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:01.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const cleanup = subscribeRecoveredRun({
|
||||
runId: 'run-1',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runs/run-1/events?afterSeq=0&limit=500',
|
||||
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
|
||||
)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'step.start',
|
||||
runId: 'run-1',
|
||||
stepId: 'clip_1_phase1',
|
||||
}))
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('settles recovery when replay hits terminal run event', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.error',
|
||||
payload: {
|
||||
message: 'exception TypeError: fetch failed sending request',
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:02.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-1',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => onSettled.mock.calls.length === 1 && applyAndCapture.mock.calls.length > 0)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'run-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('replays step.chunk output so refresh keeps prior text', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'step.chunk',
|
||||
stepKey: 'clip_1_phase1',
|
||||
payload: {
|
||||
stream: {
|
||||
kind: 'text',
|
||||
lane: 'main',
|
||||
seq: 1,
|
||||
delta: '旧输出',
|
||||
},
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const cleanup = subscribeRecoveredRun({
|
||||
runId: 'run-1',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'step.chunk',
|
||||
runId: 'run-1',
|
||||
stepId: 'clip_1_phase1',
|
||||
textDelta: '旧输出',
|
||||
}))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('emits run.error and settles when idle timeout is reached', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-timeout',
|
||||
taskStreamTimeoutMs: 3_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_200)
|
||||
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'run-timeout',
|
||||
message: 'run stream timeout: run-timeout',
|
||||
}))
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resets idle timeout when a new event arrives during recovery', async () => {
|
||||
vi.useFakeTimers()
|
||||
let eventFetchCount = 0
|
||||
const fetchMock = vi.fn().mockImplementation(async () => {
|
||||
eventFetchCount += 1
|
||||
if (eventFetchCount === 2) {
|
||||
return jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.start',
|
||||
payload: { message: 'resumed' },
|
||||
createdAt: '2026-02-28T00:00:01.500Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-recover',
|
||||
taskStreamTimeoutMs: 3_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_200)
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.start',
|
||||
runId: 'run-recover',
|
||||
}))
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reconciles run snapshot to failed when event polling stays empty', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/api/runs/run-reconcile/events')) {
|
||||
return jsonResponse({ events: [] })
|
||||
}
|
||||
if (url === '/api/runs/run-reconcile') {
|
||||
return jsonResponse({
|
||||
run: {
|
||||
id: 'run-reconcile',
|
||||
status: 'failed',
|
||||
errorMessage: 'Ark Responses 调用失败',
|
||||
},
|
||||
})
|
||||
}
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-reconcile',
|
||||
taskStreamTimeoutMs: 20_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_500)
|
||||
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runs/run-reconcile',
|
||||
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
|
||||
)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'run-reconcile',
|
||||
message: 'Ark Responses 调用失败',
|
||||
}))
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseReferenceImages, readBoolean, readString } from '@/lib/workers/handlers/reference-to-character-helpers'
|
||||
|
||||
describe('reference-to-character helpers', () => {
|
||||
it('parses and trims single reference image', () => {
|
||||
expect(parseReferenceImages({ referenceImageUrl: ' https://x/a.png ' })).toEqual(['https://x/a.png'])
|
||||
})
|
||||
|
||||
it('parses multi reference images and truncates to max 5', () => {
|
||||
expect(
|
||||
parseReferenceImages({
|
||||
referenceImageUrls: [
|
||||
'https://x/1.png',
|
||||
'https://x/2.png',
|
||||
'https://x/3.png',
|
||||
'https://x/4.png',
|
||||
'https://x/5.png',
|
||||
'https://x/6.png',
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
'https://x/1.png',
|
||||
'https://x/2.png',
|
||||
'https://x/3.png',
|
||||
'https://x/4.png',
|
||||
'https://x/5.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters empty values', () => {
|
||||
expect(
|
||||
parseReferenceImages({
|
||||
referenceImageUrls: [' ', '\n', 'https://x/ok.png'],
|
||||
}),
|
||||
).toEqual(['https://x/ok.png'])
|
||||
})
|
||||
|
||||
it('readString trims and normalizes invalid values', () => {
|
||||
expect(readString(' abc ')).toBe('abc')
|
||||
expect(readString(1)).toBe('')
|
||||
expect(readString(null)).toBe('')
|
||||
})
|
||||
|
||||
it('readBoolean supports boolean/number/string flags', () => {
|
||||
expect(readBoolean(true)).toBe(true)
|
||||
expect(readBoolean(1)).toBe(true)
|
||||
expect(readBoolean('true')).toBe(true)
|
||||
expect(readBoolean('YES')).toBe(true)
|
||||
expect(readBoolean('on')).toBe(true)
|
||||
expect(readBoolean('0')).toBe(false)
|
||||
expect(readBoolean(false)).toBe(false)
|
||||
expect(readBoolean(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
import {
|
||||
parseSyncFlag,
|
||||
resolveDisplayMode,
|
||||
resolvePositiveInteger,
|
||||
shouldRunSyncTask,
|
||||
} from '@/lib/llm-observe/route-task'
|
||||
|
||||
function buildRequest(path: string, headers?: Record<string, string>) {
|
||||
return new NextRequest(new URL(path, 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: headers || {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('route-task helpers', () => {
|
||||
it('parseSyncFlag supports boolean-like values', () => {
|
||||
expect(parseSyncFlag(true)).toBe(true)
|
||||
expect(parseSyncFlag(1)).toBe(true)
|
||||
expect(parseSyncFlag('1')).toBe(true)
|
||||
expect(parseSyncFlag('true')).toBe(true)
|
||||
expect(parseSyncFlag('yes')).toBe(true)
|
||||
expect(parseSyncFlag('on')).toBe(true)
|
||||
expect(parseSyncFlag('false')).toBe(false)
|
||||
expect(parseSyncFlag(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when internal task header exists', () => {
|
||||
const req = buildRequest('/api/test', { 'x-internal-task-id': 'task-1' })
|
||||
expect(shouldRunSyncTask(req, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when body sync flag exists', () => {
|
||||
const req = buildRequest('/api/test')
|
||||
expect(shouldRunSyncTask(req, { sync: 'true' })).toBe(true)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when query sync flag exists', () => {
|
||||
const req = buildRequest('/api/test?sync=1')
|
||||
expect(shouldRunSyncTask(req, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveDisplayMode falls back to default on invalid value', () => {
|
||||
expect(resolveDisplayMode('detail', 'loading')).toBe('detail')
|
||||
expect(resolveDisplayMode('loading', 'detail')).toBe('loading')
|
||||
expect(resolveDisplayMode('invalid', 'loading')).toBe('loading')
|
||||
})
|
||||
|
||||
it('resolvePositiveInteger returns safe integer fallback', () => {
|
||||
expect(resolvePositiveInteger(2.9, 1)).toBe(2)
|
||||
expect(resolvePositiveInteger('9', 1)).toBe(9)
|
||||
expect(resolvePositiveInteger('0', 7)).toBe(7)
|
||||
expect(resolvePositiveInteger('abc', 7)).toBe(7)
|
||||
})
|
||||
})
|
||||
278
tests/unit/helpers/run-request-executor.run-events.test.ts
Normal file
278
tests/unit/helpers/run-request-executor.run-events.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { executeRunRequest } from '@/lib/query/hooks/run-stream/run-request-executor'
|
||||
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('run-request-executor run events path', () => {
|
||||
it('uses /api/runs/:runId/events when async response includes runId', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
runId: 'run_1',
|
||||
afterSeq: 0,
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.start',
|
||||
payload: { message: 'started' },
|
||||
createdAt: '2026-02-28T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
eventType: 'step.start',
|
||||
stepKey: 'step_a',
|
||||
attempt: 1,
|
||||
payload: {
|
||||
stepTitle: 'Step A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:01.000Z',
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
eventType: 'step.chunk',
|
||||
stepKey: 'step_a',
|
||||
attempt: 1,
|
||||
lane: 'text',
|
||||
payload: {
|
||||
stream: {
|
||||
delta: 'hello',
|
||||
seq: 1,
|
||||
},
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:01.100Z',
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
eventType: 'step.complete',
|
||||
stepKey: 'step_a',
|
||||
attempt: 1,
|
||||
payload: {
|
||||
text: 'hello',
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:02.000Z',
|
||||
},
|
||||
{
|
||||
seq: 5,
|
||||
eventType: 'run.complete',
|
||||
payload: {
|
||||
summary: { ok: true },
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const captured: RunStreamEvent[] = []
|
||||
const controller = new AbortController()
|
||||
const result = await executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 30_000,
|
||||
applyAndCapture: (event) => {
|
||||
captured.push(event)
|
||||
},
|
||||
finalResultRef: { current: null },
|
||||
})
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.runId).toBe('run_1')
|
||||
expect(captured.some((event) => event.event === 'step.chunk' && event.textDelta === 'hello')).toBe(true)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/runs/run_1/events?afterSeq=0&limit=500')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces run-events fetch errors instead of swallowing them', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
error: {
|
||||
message: 'events backend unavailable',
|
||||
},
|
||||
}, 503))
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
await expect(executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 30_000,
|
||||
applyAndCapture: () => undefined,
|
||||
finalResultRef: { current: null },
|
||||
})).rejects.toThrow('run events fetch failed (HTTP 503): events backend unavailable')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('uses idle timeout and resets the timer when new events arrive', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
let eventsRequestCount = 0
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/story-to-script-stream')) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/runs/run_1') {
|
||||
return jsonResponse({
|
||||
run: {
|
||||
id: 'run_1',
|
||||
status: 'running',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!url.includes('/api/runs/run_1/events')) {
|
||||
return jsonResponse({ events: [] })
|
||||
}
|
||||
|
||||
eventsRequestCount += 1
|
||||
if (eventsRequestCount === 3) {
|
||||
return jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.start',
|
||||
payload: { message: 'started' },
|
||||
createdAt: '2026-02-28T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
let settled = false
|
||||
const request = executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 3_000,
|
||||
applyAndCapture: () => undefined,
|
||||
finalResultRef: { current: null },
|
||||
}).finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await expect(request).resolves.toEqual(expect.objectContaining({
|
||||
runId: 'run_1',
|
||||
status: 'failed',
|
||||
errorMessage: 'run stream timeout: run_1',
|
||||
}))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles terminal failed run status when events stream has no new rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/story-to-script-stream')) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_2',
|
||||
runId: 'run_2',
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/runs/run_2/events')) {
|
||||
return jsonResponse({ events: [] })
|
||||
}
|
||||
if (url === '/api/runs/run_2') {
|
||||
return jsonResponse({
|
||||
run: {
|
||||
id: 'run_2',
|
||||
status: 'failed',
|
||||
errorMessage: 'Ark Responses 调用失败',
|
||||
},
|
||||
})
|
||||
}
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const captured: RunStreamEvent[] = []
|
||||
const controller = new AbortController()
|
||||
const request = executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 30_000,
|
||||
applyAndCapture: (event) => {
|
||||
captured.push(event)
|
||||
},
|
||||
finalResultRef: { current: null },
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_500)
|
||||
await expect(request).resolves.toEqual(expect.objectContaining({
|
||||
runId: 'run_2',
|
||||
status: 'failed',
|
||||
errorMessage: 'Ark Responses 调用失败',
|
||||
}))
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runs/run_2',
|
||||
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
|
||||
)
|
||||
expect(captured.some((event) => event.event === 'run.error' && event.message === 'Ark Responses 调用失败')).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
370
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
370
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
||||
import { applyRunStreamEvent, getStageOutput } from '@/lib/query/hooks/run-stream/state-machine'
|
||||
|
||||
function applySequence(events: RunStreamEvent[]) {
|
||||
let state = null
|
||||
for (const event of events) {
|
||||
state = applyRunStreamEvent(state, event)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
describe('run stream state-machine', () => {
|
||||
it('marks unfinished steps as failed when run.error arrives', () => {
|
||||
const runId = 'run-1'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-a',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-b',
|
||||
stepTitle: 'B',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
text: 'ok',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'run.error',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'failed',
|
||||
message: 'exception TypeError: fetch failed sending request',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.status).toBe('failed')
|
||||
expect(state?.stepsById['step-a']?.status).toBe('failed')
|
||||
expect(state?.stepsById['step-a']?.errorMessage).toContain('fetch failed')
|
||||
expect(state?.stepsById['step-b']?.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns readable error output for failed step without stream text', () => {
|
||||
const output = getStageOutput({
|
||||
id: 'step-failed',
|
||||
attempt: 1,
|
||||
title: 'failed',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
status: 'failed',
|
||||
dependsOn: [],
|
||||
blockedBy: [],
|
||||
groupId: null,
|
||||
parallelKey: null,
|
||||
retryable: true,
|
||||
textOutput: '',
|
||||
reasoningOutput: '',
|
||||
textLength: 0,
|
||||
reasoningLength: 0,
|
||||
message: '',
|
||||
errorMessage: 'exception TypeError: fetch failed sending request',
|
||||
updatedAt: Date.now(),
|
||||
seqByLane: {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(output).toContain('【错误】')
|
||||
expect(output).toContain('fetch failed sending request')
|
||||
})
|
||||
|
||||
it('merges retry attempts into one step instead of duplicating stage entries', () => {
|
||||
const runId = 'run-2'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'first-attempt',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1_r2',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1_r2',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'retry-output',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepOrder).toEqual(['clip_x_phase1'])
|
||||
expect(state?.stepsById['clip_x_phase1']?.attempt).toBe(2)
|
||||
expect(state?.stepsById['clip_x_phase1']?.textOutput).toBe('retry-output')
|
||||
})
|
||||
|
||||
it('resets step output when a higher stepAttempt starts and ignores stale lower attempt chunks', () => {
|
||||
const runId = 'run-3'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'old-output',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 2,
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
lane: 'text',
|
||||
seq: 2,
|
||||
textDelta: 'should-be-ignored',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.200Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 2,
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'new-output',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['clip_y_phase1']?.attempt).toBe(2)
|
||||
expect(state?.stepsById['clip_y_phase1']?.textOutput).toBe('new-output')
|
||||
})
|
||||
|
||||
it('reopens completed step when late chunk arrives, then finalizes on run.complete', () => {
|
||||
const runId = 'run-4'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_characters',
|
||||
stepTitle: 'characters',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'analyze_characters',
|
||||
stepTitle: 'characters',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
text: 'partial',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_characters',
|
||||
lane: 'text',
|
||||
seq: 2,
|
||||
textDelta: '-tail',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'run.complete',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'completed',
|
||||
payload: { ok: true },
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.status).toBe('completed')
|
||||
expect(state?.stepsById['analyze_characters']?.status).toBe('completed')
|
||||
expect(state?.stepsById['analyze_characters']?.textOutput).toBe('partial-tail')
|
||||
})
|
||||
|
||||
it('moves activeStepId to the latest step when no step is running', () => {
|
||||
const runId = 'run-5'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
text: 'a',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-2',
|
||||
stepTitle: 'step 2',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
text: 'b',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.activeStepId).toBe('step-2')
|
||||
})
|
||||
|
||||
it('marks step as blocked when blockedBy is present', () => {
|
||||
const runId = 'run-6'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-b',
|
||||
stepTitle: 'B',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
blockedBy: ['step-a'],
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['step-b']?.status).toBe('blocked')
|
||||
expect(state?.stepsById['step-b']?.blockedBy).toEqual(['step-a'])
|
||||
})
|
||||
|
||||
it('auto-follows active step when selected step was not manually pinned', () => {
|
||||
const runId = 'run-7'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-2',
|
||||
stepTitle: 'step 2',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.activeStepId).toBe('step-2')
|
||||
expect(state?.selectedStepId).toBe('step-2')
|
||||
})
|
||||
|
||||
it('moves think-tagged text chunks into reasoning output', () => {
|
||||
const runId = 'run-8'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_locations',
|
||||
stepTitle: 'locations',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.200Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_locations',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: '<think>先分析文本</think>{"locations":[]}',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['analyze_locations']?.reasoningOutput).toBe('先分析文本')
|
||||
expect(state?.stepsById['analyze_locations']?.textOutput).toBe('{"locations":[]}')
|
||||
})
|
||||
})
|
||||
174
tests/unit/helpers/run-stream-view.test.ts
Normal file
174
tests/unit/helpers/run-stream-view.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveRunStreamView } from '@/lib/query/hooks/run-stream/run-stream-view'
|
||||
import type { RunState, RunStepState } from '@/lib/query/hooks/run-stream/types'
|
||||
|
||||
function buildStep(overrides: Partial<RunStepState> = {}): RunStepState {
|
||||
return {
|
||||
id: 'step-1',
|
||||
attempt: 1,
|
||||
title: 'step',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
status: 'running',
|
||||
dependsOn: [],
|
||||
blockedBy: [],
|
||||
groupId: null,
|
||||
parallelKey: null,
|
||||
retryable: true,
|
||||
textOutput: '',
|
||||
reasoningOutput: '',
|
||||
textLength: 0,
|
||||
reasoningLength: 0,
|
||||
message: '',
|
||||
errorMessage: '',
|
||||
updatedAt: Date.now(),
|
||||
seqByLane: {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRunState(overrides: Partial<RunState> = {}): RunState {
|
||||
const baseStep = buildStep()
|
||||
return {
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
terminalAt: null,
|
||||
errorMessage: '',
|
||||
summary: null,
|
||||
payload: null,
|
||||
stepsById: {
|
||||
[baseStep.id]: baseStep,
|
||||
},
|
||||
stepOrder: [baseStep.id],
|
||||
activeStepId: baseStep.id,
|
||||
selectedStepId: baseStep.id,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('run stream view', () => {
|
||||
it('keeps console visible for recovered running state', () => {
|
||||
const state = buildRunState({
|
||||
status: 'running',
|
||||
terminalAt: null,
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('shows run error in output when run failed and selected step has no output', () => {
|
||||
const state = buildRunState({
|
||||
status: 'failed',
|
||||
errorMessage: 'exception TypeError: fetch failed sending request',
|
||||
stepsById: {
|
||||
'step-1': buildStep({ status: 'running' }),
|
||||
},
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.outputText).toContain('【错误】')
|
||||
expect(view.outputText).toContain('fetch failed sending request')
|
||||
})
|
||||
|
||||
it('shows run error in output when run failed before any step starts', () => {
|
||||
const state = buildRunState({
|
||||
status: 'failed',
|
||||
errorMessage: 'NETWORK_ERROR',
|
||||
stepsById: {},
|
||||
stepOrder: [],
|
||||
activeStepId: null,
|
||||
selectedStepId: null,
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.outputText).toBe('【错误】\nNETWORK_ERROR')
|
||||
})
|
||||
|
||||
it('keeps failed run visible until user reset', () => {
|
||||
const state = buildRunState({
|
||||
status: 'failed',
|
||||
terminalAt: Date.now() - 60_000,
|
||||
errorMessage: 'failed',
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('hides completed run console after stream settles', () => {
|
||||
const state = buildRunState({
|
||||
status: 'completed',
|
||||
terminalAt: Date.now() - 30_000,
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.isVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses active step message instead of selected completed step message', () => {
|
||||
const completedStep = buildStep({
|
||||
id: 'step-1',
|
||||
title: 'step 1',
|
||||
status: 'completed',
|
||||
message: 'progress.runtime.llm.completed',
|
||||
updatedAt: Date.now() - 1000,
|
||||
})
|
||||
const runningStep = buildStep({
|
||||
id: 'step-2',
|
||||
title: 'step 2',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
status: 'running',
|
||||
message: 'progress.runtime.stage.llmStreaming',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
const state = buildRunState({
|
||||
stepsById: {
|
||||
'step-1': completedStep,
|
||||
'step-2': runningStep,
|
||||
},
|
||||
stepOrder: ['step-1', 'step-2'],
|
||||
activeStepId: 'step-2',
|
||||
selectedStepId: 'step-1',
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.activeMessage).toBe('progress.runtime.stage.llmStreaming')
|
||||
})
|
||||
})
|
||||
83
tests/unit/helpers/task-state-service.test.ts
Normal file
83
tests/unit/helpers/task-state-service.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
asBoolean,
|
||||
asNonEmptyString,
|
||||
asObject,
|
||||
buildIdleState,
|
||||
pairKey,
|
||||
resolveTargetState,
|
||||
toProgress,
|
||||
} from '@/lib/task/state-service'
|
||||
|
||||
describe('task state service helpers', () => {
|
||||
it('normalizes primitive parsing helpers', () => {
|
||||
expect(pairKey('A', 'B')).toBe('A:B')
|
||||
expect(asObject({ ok: true })).toEqual({ ok: true })
|
||||
expect(asObject(['x'])).toBeNull()
|
||||
expect(asNonEmptyString(' x ')).toBe('x')
|
||||
expect(asNonEmptyString(' ')).toBeNull()
|
||||
expect(asBoolean(true)).toBe(true)
|
||||
expect(asBoolean('true')).toBeNull()
|
||||
expect(toProgress(101)).toBe(100)
|
||||
expect(toProgress(-5)).toBe(0)
|
||||
expect(toProgress(Number.NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('builds idle state when no tasks found', () => {
|
||||
const idle = buildIdleState({ targetType: 'GlobalCharacter', targetId: 'c1' })
|
||||
expect(idle.phase).toBe('idle')
|
||||
expect(idle.runningTaskId).toBeNull()
|
||||
expect(idle.lastError).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves processing state from active task', () => {
|
||||
const state = resolveTargetState(
|
||||
{ targetType: 'GlobalCharacter', targetId: 'c1' },
|
||||
[
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'asset_hub_image',
|
||||
status: 'processing',
|
||||
progress: 42,
|
||||
payload: {
|
||||
stage: 'image_generating',
|
||||
stageLabel: 'Generating',
|
||||
ui: { intent: 'create', hasOutputAtStart: false },
|
||||
},
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date('2026-02-25T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(state.phase).toBe('processing')
|
||||
expect(state.runningTaskId).toBe('task-1')
|
||||
expect(state.progress).toBe(42)
|
||||
expect(state.stage).toBe('image_generating')
|
||||
expect(state.stageLabel).toBe('Generating')
|
||||
})
|
||||
|
||||
it('resolves failed state and normalizes error', () => {
|
||||
const state = resolveTargetState(
|
||||
{ targetType: 'GlobalCharacter', targetId: 'c1' },
|
||||
[
|
||||
{
|
||||
id: 'task-2',
|
||||
type: 'asset_hub_image',
|
||||
status: 'failed',
|
||||
progress: 100,
|
||||
payload: { ui: { intent: 'modify', hasOutputAtStart: true } },
|
||||
errorCode: 'INVALID_PARAMS',
|
||||
errorMessage: 'bad input',
|
||||
updatedAt: new Date('2026-02-25T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(state.phase).toBe('failed')
|
||||
expect(state.runningTaskId).toBeNull()
|
||||
expect(state.lastError?.code).toBe('INVALID_PARAMS')
|
||||
expect(state.lastError?.message).toBe('bad input')
|
||||
})
|
||||
})
|
||||
59
tests/unit/helpers/task-submitter-helpers.test.ts
Normal file
59
tests/unit/helpers/task-submitter-helpers.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { getTaskFlowMeta } from '@/lib/llm-observe/stage-pipeline'
|
||||
import { normalizeTaskPayload } from '@/lib/task/submitter'
|
||||
|
||||
describe('task submitter helpers', () => {
|
||||
it('fills default flow metadata when payload misses flow fields', () => {
|
||||
const type = TASK_TYPE.AI_CREATE_CHARACTER
|
||||
const flow = getTaskFlowMeta(type)
|
||||
const normalized = normalizeTaskPayload(type, {})
|
||||
|
||||
expect(normalized.flowId).toBe(flow.flowId)
|
||||
expect(normalized.flowStageIndex).toBe(flow.flowStageIndex)
|
||||
expect(normalized.flowStageTotal).toBe(flow.flowStageTotal)
|
||||
expect(normalized.flowStageTitle).toBe(flow.flowStageTitle)
|
||||
expect(normalized.meta).toMatchObject({
|
||||
flowId: flow.flowId,
|
||||
flowStageIndex: flow.flowStageIndex,
|
||||
flowStageTotal: flow.flowStageTotal,
|
||||
flowStageTitle: flow.flowStageTitle,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes negative stage values', () => {
|
||||
const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {
|
||||
flowId: 'flow-a',
|
||||
flowStageIndex: -9,
|
||||
flowStageTotal: -1,
|
||||
flowStageTitle: ' title ',
|
||||
meta: {},
|
||||
})
|
||||
|
||||
expect(normalized.flowId).toBe('flow-a')
|
||||
expect(normalized.flowStageIndex).toBeGreaterThanOrEqual(1)
|
||||
expect(normalized.flowStageTotal).toBeGreaterThanOrEqual(normalized.flowStageIndex)
|
||||
expect(normalized.flowStageTitle).toBe('title')
|
||||
})
|
||||
|
||||
it('prefers payload meta flow values when valid', () => {
|
||||
const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {
|
||||
flowId: 'outer-flow',
|
||||
flowStageIndex: 1,
|
||||
flowStageTotal: 2,
|
||||
flowStageTitle: 'Outer',
|
||||
meta: {
|
||||
flowId: 'meta-flow',
|
||||
flowStageIndex: 3,
|
||||
flowStageTotal: 7,
|
||||
flowStageTitle: 'Meta',
|
||||
},
|
||||
})
|
||||
|
||||
const meta = normalized.meta as Record<string, unknown>
|
||||
expect(meta.flowId).toBe('meta-flow')
|
||||
expect(meta.flowStageIndex).toBe(3)
|
||||
expect(meta.flowStageTotal).toBe(7)
|
||||
expect(meta.flowStageTitle).toBe('Meta')
|
||||
})
|
||||
})
|
||||
122
tests/unit/helpers/update-check.test.ts
Normal file
122
tests/unit/helpers/update-check.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkGithubReleaseUpdate,
|
||||
compareSemver,
|
||||
normalizeSemverTag,
|
||||
shouldPulseUpdate,
|
||||
} from '@/lib/update-check'
|
||||
|
||||
describe('update-check semver helpers', () => {
|
||||
it('normalizes semver tag with v prefix', () => {
|
||||
expect(normalizeSemverTag('v0.3.0')).toBe('0.3.0')
|
||||
})
|
||||
|
||||
it('supports prerelease suffix while comparing base semver', () => {
|
||||
expect(normalizeSemverTag('v0.3.0-rc.1')).toBe('0.3.0')
|
||||
expect(compareSemver('0.3.0-rc.1', '0.2.9')).toBe(1)
|
||||
})
|
||||
|
||||
it('throws for malformed semver', () => {
|
||||
expect(() => normalizeSemverTag('0.3')).toThrowError('Invalid semver tag: 0.3')
|
||||
})
|
||||
|
||||
it('compares semver in numeric order', () => {
|
||||
expect(compareSemver('0.3.0', '0.2.9')).toBe(1)
|
||||
expect(compareSemver('0.2.0', '0.2.0')).toBe(0)
|
||||
expect(compareSemver('0.1.9', '0.2.0')).toBe(-1)
|
||||
})
|
||||
|
||||
it('pulses only when this version was not muted', () => {
|
||||
expect(shouldPulseUpdate('0.3.0', null)).toBe(true)
|
||||
expect(shouldPulseUpdate('0.3.0', '0.2.9')).toBe(true)
|
||||
expect(shouldPulseUpdate('0.3.0', '0.3.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkGithubReleaseUpdate', () => {
|
||||
it('returns no-release when GitHub has no releases yet', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 404 }))
|
||||
|
||||
const result = await checkGithubReleaseUpdate({
|
||||
repository: 'owner/repo',
|
||||
currentVersion: '0.2.0',
|
||||
fetchImpl: fetchMock,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ kind: 'no-release' })
|
||||
})
|
||||
|
||||
it('returns update-available when latest release is newer', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(
|
||||
JSON.stringify({
|
||||
tag_name: 'v0.3.0',
|
||||
html_url: 'https://github.com/owner/repo/releases/tag/v0.3.0',
|
||||
name: 'v0.3.0',
|
||||
published_at: '2026-03-03T10:00:00Z',
|
||||
}),
|
||||
{ status: 200 },
|
||||
))
|
||||
|
||||
const result = await checkGithubReleaseUpdate({
|
||||
repository: 'owner/repo',
|
||||
currentVersion: '0.2.0',
|
||||
fetchImpl: fetchMock,
|
||||
})
|
||||
|
||||
expect(result.kind).toBe('update-available')
|
||||
if (result.kind !== 'update-available') {
|
||||
throw new Error('expected update-available result')
|
||||
}
|
||||
|
||||
expect(result.latestVersion).toBe('0.3.0')
|
||||
expect(result.release.tagName).toBe('v0.3.0')
|
||||
})
|
||||
|
||||
it('returns no-update when latest release equals current version', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(
|
||||
JSON.stringify({
|
||||
tag_name: 'v0.2.0',
|
||||
html_url: 'https://github.com/owner/repo/releases/tag/v0.2.0',
|
||||
name: 'v0.2.0',
|
||||
published_at: '2026-03-03T10:00:00Z',
|
||||
}),
|
||||
{ status: 200 },
|
||||
))
|
||||
|
||||
const result = await checkGithubReleaseUpdate({
|
||||
repository: 'owner/repo',
|
||||
currentVersion: '0.2.0',
|
||||
fetchImpl: fetchMock,
|
||||
})
|
||||
|
||||
expect(result.kind).toBe('no-update')
|
||||
if (result.kind !== 'no-update') {
|
||||
throw new Error('expected no-update result')
|
||||
}
|
||||
|
||||
expect(result.latestVersion).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('returns error when release tag is not valid semver', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(
|
||||
JSON.stringify({
|
||||
tag_name: 'release-2026-03-03',
|
||||
html_url: 'https://github.com/owner/repo/releases/tag/release-2026-03-03',
|
||||
}),
|
||||
{ status: 200 },
|
||||
))
|
||||
|
||||
const result = await checkGithubReleaseUpdate({
|
||||
repository: 'owner/repo',
|
||||
currentVersion: '0.2.0',
|
||||
fetchImpl: fetchMock,
|
||||
})
|
||||
|
||||
expect(result.kind).toBe('error')
|
||||
if (result.kind !== 'error') {
|
||||
throw new Error('expected error result')
|
||||
}
|
||||
|
||||
expect(result.reason).toBe('invalid-version')
|
||||
})
|
||||
})
|
||||
36
tests/unit/helpers/workspace-model-setup.test.ts
Normal file
36
tests/unit/helpers/workspace-model-setup.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasConfiguredAnalysisModel, readConfiguredAnalysisModel, shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
|
||||
|
||||
describe('workspace model setup guidance', () => {
|
||||
it('有 analysisModel -> 不需要引导设置', () => {
|
||||
const payload = {
|
||||
preference: {
|
||||
analysisModel: 'openai::gpt-4.1',
|
||||
},
|
||||
}
|
||||
|
||||
expect(hasConfiguredAnalysisModel(payload)).toBe(true)
|
||||
expect(readConfiguredAnalysisModel(payload)).toBe('openai::gpt-4.1')
|
||||
expect(shouldGuideToModelSetup(payload)).toBe(false)
|
||||
})
|
||||
|
||||
it('analysisModel 为空 -> 需要引导设置', () => {
|
||||
const payload = {
|
||||
preference: {
|
||||
analysisModel: ' ',
|
||||
},
|
||||
}
|
||||
|
||||
expect(hasConfiguredAnalysisModel(payload)).toBe(false)
|
||||
expect(readConfiguredAnalysisModel(payload)).toBeNull()
|
||||
expect(shouldGuideToModelSetup(payload)).toBe(true)
|
||||
})
|
||||
|
||||
it('payload 非法 -> 需要引导设置', () => {
|
||||
expect(hasConfiguredAnalysisModel(null)).toBe(false)
|
||||
expect(readConfiguredAnalysisModel(null)).toBeNull()
|
||||
expect(hasConfiguredAnalysisModel({})).toBe(false)
|
||||
expect(readConfiguredAnalysisModel({})).toBeNull()
|
||||
expect(shouldGuideToModelSetup({})).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user