release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

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,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,210 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'
type MockEvent = {
id: string
type: string
taskId: string
projectId: string
userId: string
ts: string
taskType: string
targetType: string
targetId: string
episodeId: string | null
payload: Record<string, unknown>
}
function buildLifecycleEvent(payload: Record<string, unknown>): MockEvent {
return {
id: '1',
type: 'task.lifecycle',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
payload,
}
}
function buildStreamEvent(payload: Record<string, unknown>): MockEvent {
return {
id: 'stream-1',
type: 'task.stream',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
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()
if (originalFetch) {
globalThis.fetch = originalFetch
} else {
Reflect.deleteProperty(globalThis, 'fetch')
}
})
it('replays task lifecycle events for external mode to recover stage steps', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
events: [
buildLifecycleEvent({
lifecycleType: 'task.processing',
stepId: 'clip_1_phase1',
stepTitle: '分镜规划',
stepIndex: 1,
stepTotal: 4,
message: 'running',
}),
],
}),
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const pollTaskTerminalState = vi.fn(async () => null)
const onSettled = vi.fn()
const cleanup = subscribeRecoveredRun({
projectId: 'project-1',
storageScopeKey: 'episode-1',
taskId: 'task-1',
eventSourceMode: 'external',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
pollTaskTerminalState,
onSettled,
})
await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)
expect(fetchMock).toHaveBeenCalledWith(
'/api/tasks/task-1?includeEvents=1&eventsLimit=5000',
expect.objectContaining({
method: 'GET',
cache: 'no-store',
}),
)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'step.start',
runId: 'task-1',
stepId: 'clip_1_phase1',
}))
expect(onSettled).not.toHaveBeenCalled()
cleanup()
})
it('settles external recovery when replay hits terminal lifecycle event', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
events: [
buildLifecycleEvent({
lifecycleType: 'task.failed',
message: 'exception TypeError: fetch failed sending request',
}),
],
}),
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const pollTaskTerminalState = vi.fn(async () => null)
const onSettled = vi.fn()
subscribeRecoveredRun({
projectId: 'project-1',
storageScopeKey: 'episode-1',
taskId: 'task-1',
eventSourceMode: 'external',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
pollTaskTerminalState,
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: 'task-1',
}))
})
it('replays persisted stream events so refresh keeps prior output', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
events: [
buildLifecycleEvent({
lifecycleType: 'task.processing',
stepId: 'clip_1_phase1',
stepTitle: '分镜规划',
stepIndex: 1,
stepTotal: 1,
message: 'running',
}),
buildStreamEvent({
stepId: 'clip_1_phase1',
stream: {
kind: 'text',
lane: 'main',
seq: 1,
delta: '旧输出',
},
}),
],
}),
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const pollTaskTerminalState = vi.fn(async () => null)
const onSettled = vi.fn()
const cleanup = subscribeRecoveredRun({
projectId: 'project-1',
storageScopeKey: 'episode-1',
taskId: 'task-1',
eventSourceMode: 'external',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
pollTaskTerminalState,
onSettled,
})
await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'step.chunk',
runId: 'task-1',
stepId: 'clip_1_phase1',
textDelta: '旧输出',
}))
cleanup()
})
})

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

View File

@@ -0,0 +1,102 @@
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',
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')
})
})

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