feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View 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)
})
})

View 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('后来')
})
})

View 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('')
})
})

View 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')
})
})

View File

@@ -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')
})
})

View 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('')
})
})

View 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()
})
})

View 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)
})
})

View 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)
})
})

View 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
}
})
})

View 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":[]}')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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)
})
})