release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
21
tests/unit/task/async-poll-external-id.test.ts
Normal file
21
tests/unit/task/async-poll-external-id.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatExternalId, parseExternalId } from '@/lib/async-poll'
|
||||
|
||||
describe('async poll externalId contract', () => {
|
||||
it('parses standard FAL externalId with endpoint', () => {
|
||||
const parsed = parseExternalId('FAL:VIDEO:fal-ai/wan/v2.6/image-to-video:req_123')
|
||||
expect(parsed.provider).toBe('FAL')
|
||||
expect(parsed.type).toBe('VIDEO')
|
||||
expect(parsed.endpoint).toBe('fal-ai/wan/v2.6/image-to-video')
|
||||
expect(parsed.requestId).toBe('req_123')
|
||||
})
|
||||
|
||||
it('rejects legacy non-standard externalId formats', () => {
|
||||
expect(() => parseExternalId('FAL:fal-ai/wan/v2.6/image-to-video:req_123')).toThrow(/无效 FAL externalId/)
|
||||
expect(() => parseExternalId('batches/legacy')).toThrow(/无法识别的 externalId 格式/)
|
||||
})
|
||||
|
||||
it('requires endpoint when formatting FAL externalId', () => {
|
||||
expect(() => formatExternalId('FAL', 'VIDEO', 'req_123')).toThrow(/requires endpoint/)
|
||||
})
|
||||
})
|
||||
60
tests/unit/task/error-message.test.ts
Normal file
60
tests/unit/task/error-message.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTaskErrorMessage, resolveTaskErrorSummary } from '@/lib/task/error-message'
|
||||
|
||||
describe('task error message normalization', () => {
|
||||
it('maps TASK_CANCELLED to unified cancelled message', () => {
|
||||
const summary = resolveTaskErrorSummary({
|
||||
errorCode: 'TASK_CANCELLED',
|
||||
errorMessage: 'whatever',
|
||||
})
|
||||
expect(summary.cancelled).toBe(true)
|
||||
expect(summary.code).toBe('CONFLICT')
|
||||
expect(summary.message).toBe('Task cancelled by user')
|
||||
})
|
||||
|
||||
it('keeps cancelled semantics from normalized task error details', () => {
|
||||
const summary = resolveTaskErrorSummary({
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: 'Task cancelled by user',
|
||||
details: { cancelled: true, originalCode: 'TASK_CANCELLED' },
|
||||
},
|
||||
})
|
||||
expect(summary.cancelled).toBe(true)
|
||||
expect(summary.code).toBe('CONFLICT')
|
||||
expect(summary.message).toBe('Task cancelled by user')
|
||||
})
|
||||
|
||||
it('extracts nested error message from payload', () => {
|
||||
const message = resolveTaskErrorMessage({
|
||||
error: {
|
||||
details: {
|
||||
message: 'provider failed',
|
||||
},
|
||||
},
|
||||
}, 'fallback')
|
||||
expect(message).toBe('provider failed')
|
||||
})
|
||||
|
||||
it('supports flat error/details string payload', () => {
|
||||
expect(resolveTaskErrorMessage({
|
||||
error: 'provider failed',
|
||||
}, 'fallback')).toBe('provider failed')
|
||||
|
||||
expect(resolveTaskErrorMessage({
|
||||
details: 'provider failed',
|
||||
}, 'fallback')).toBe('provider failed')
|
||||
})
|
||||
|
||||
it('uses fallback when payload has no structured error', () => {
|
||||
expect(resolveTaskErrorMessage({}, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('recognizes cancelled semantics from message-only payload', () => {
|
||||
const summary = resolveTaskErrorSummary({
|
||||
message: 'Task cancelled by user',
|
||||
})
|
||||
expect(summary.cancelled).toBe(true)
|
||||
expect(summary.message).toBe('Task cancelled by user')
|
||||
})
|
||||
})
|
||||
23
tests/unit/task/intent.test.ts
Normal file
23
tests/unit/task/intent.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { resolveTaskIntent } from '@/lib/task/intent'
|
||||
|
||||
describe('resolveTaskIntent', () => {
|
||||
it('maps generate task types', () => {
|
||||
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')
|
||||
})
|
||||
|
||||
it('maps regenerate and modify task types', () => {
|
||||
expect(resolveTaskIntent(TASK_TYPE.REGENERATE_GROUP)).toBe('regenerate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.PANEL_VARIANT)).toBe('regenerate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.MODIFY_ASSET_IMAGE)).toBe('modify')
|
||||
})
|
||||
|
||||
it('falls back to process for unknown types', () => {
|
||||
expect(resolveTaskIntent('unknown_type')).toBe('process')
|
||||
expect(resolveTaskIntent(null)).toBe('process')
|
||||
expect(resolveTaskIntent(undefined)).toBe('process')
|
||||
})
|
||||
})
|
||||
65
tests/unit/task/llm-observe-contract.test.ts
Normal file
65
tests/unit/task/llm-observe-contract.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getTaskFlowMeta, getTaskPipeline } from '@/lib/llm-observe/stage-pipeline'
|
||||
import { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
|
||||
describe('llm observe task contract', () => {
|
||||
it('maps AI_CREATE tasks to standard llm policy', () => {
|
||||
const characterPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_CHARACTER)
|
||||
const locationPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_LOCATION)
|
||||
|
||||
expect(characterPolicy.consoleEnabled).toBe(true)
|
||||
expect(characterPolicy.displayMode).toBe('loading')
|
||||
expect(characterPolicy.captureReasoning).toBe(true)
|
||||
|
||||
expect(locationPolicy.consoleEnabled).toBe(true)
|
||||
expect(locationPolicy.displayMode).toBe('loading')
|
||||
expect(locationPolicy.captureReasoning).toBe(true)
|
||||
})
|
||||
|
||||
it('maps story/script run tasks to long-flow stage metadata', () => {
|
||||
const storyMeta = getTaskFlowMeta(TASK_TYPE.STORY_TO_SCRIPT_RUN)
|
||||
const scriptMeta = getTaskFlowMeta(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
|
||||
|
||||
expect(storyMeta.flowId).toBe('novel_promotion_generation')
|
||||
expect(storyMeta.flowStageIndex).toBe(1)
|
||||
expect(storyMeta.flowStageTotal).toBe(2)
|
||||
|
||||
expect(scriptMeta.flowId).toBe('novel_promotion_generation')
|
||||
expect(scriptMeta.flowStageIndex).toBe(2)
|
||||
expect(scriptMeta.flowStageTotal).toBe(2)
|
||||
})
|
||||
|
||||
it('maps AI_CREATE tasks to dedicated single-stage flows', () => {
|
||||
const characterMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_CHARACTER)
|
||||
const locationMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_LOCATION)
|
||||
|
||||
expect(characterMeta.flowId).toBe('novel_promotion_ai_create_character')
|
||||
expect(characterMeta.flowStageIndex).toBe(1)
|
||||
expect(characterMeta.flowStageTotal).toBe(1)
|
||||
|
||||
expect(locationMeta.flowId).toBe('novel_promotion_ai_create_location')
|
||||
expect(locationMeta.flowStageIndex).toBe(1)
|
||||
expect(locationMeta.flowStageTotal).toBe(1)
|
||||
})
|
||||
|
||||
it('returns a stable two-stage pipeline for story/script flow', () => {
|
||||
const pipeline = getTaskPipeline(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
|
||||
const stageTaskTypes = pipeline.stages.map((stage) => stage.taskType)
|
||||
expect(stageTaskTypes).toEqual([
|
||||
TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to single-stage metadata for unknown task type', () => {
|
||||
const meta = getTaskFlowMeta('unknown_task_type')
|
||||
const pipeline = getTaskPipeline('unknown_task_type')
|
||||
|
||||
expect(meta.flowId).toBe('single:unknown_task_type')
|
||||
expect(meta.flowStageIndex).toBe(1)
|
||||
expect(meta.flowStageTotal).toBe(1)
|
||||
expect(pipeline.stages).toHaveLength(1)
|
||||
expect(pipeline.stages[0]?.taskType).toBe('unknown_task_type')
|
||||
})
|
||||
})
|
||||
22
tests/unit/task/normalize-error.test.ts
Normal file
22
tests/unit/task/normalize-error.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeAnyError } from '@/lib/errors/normalize'
|
||||
|
||||
describe('normalizeAnyError network termination mapping', () => {
|
||||
it('maps undici terminated TypeError to NETWORK_ERROR', () => {
|
||||
const normalized = normalizeAnyError(new TypeError('terminated'))
|
||||
expect(normalized.code).toBe('NETWORK_ERROR')
|
||||
expect(normalized.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('maps socket hang up TypeError to NETWORK_ERROR', () => {
|
||||
const normalized = normalizeAnyError(new TypeError('socket hang up'))
|
||||
expect(normalized.code).toBe('NETWORK_ERROR')
|
||||
expect(normalized.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('maps wrapped terminated message to NETWORK_ERROR', () => {
|
||||
const normalized = normalizeAnyError(new Error('exception TypeError: terminated'))
|
||||
expect(normalized.code).toBe('NETWORK_ERROR')
|
||||
expect(normalized.retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
38
tests/unit/task/presentation.test.ts
Normal file
38
tests/unit/task/presentation.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
|
||||
describe('resolveTaskPresentationState', () => {
|
||||
it('uses overlay mode when running and has output', () => {
|
||||
const state = resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'regenerate',
|
||||
resource: 'image',
|
||||
hasOutput: true,
|
||||
})
|
||||
expect(state.isRunning).toBe(true)
|
||||
expect(state.mode).toBe('overlay')
|
||||
expect(state.labelKey).toBe('taskStatus.intent.regenerate.running.image')
|
||||
})
|
||||
|
||||
it('uses placeholder mode when running and no output', () => {
|
||||
const state = resolveTaskPresentationState({
|
||||
phase: 'queued',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
expect(state.mode).toBe('placeholder')
|
||||
expect(state.labelKey).toBe('taskStatus.intent.generate.running.image')
|
||||
})
|
||||
|
||||
it('maps failed state to failed label', () => {
|
||||
const state = resolveTaskPresentationState({
|
||||
phase: 'failed',
|
||||
intent: 'modify',
|
||||
resource: 'video',
|
||||
hasOutput: true,
|
||||
})
|
||||
expect(state.isError).toBe(true)
|
||||
expect(state.labelKey).toBe('taskStatus.failed.video')
|
||||
})
|
||||
})
|
||||
207
tests/unit/task/publisher.replay.test.ts
Normal file
207
tests/unit/task/publisher.replay.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const taskEventFindManyMock = vi.hoisted(() => vi.fn(async () => []))
|
||||
const taskEventCreateMock = vi.hoisted(() => vi.fn(async () => null))
|
||||
const taskFindManyMock = vi.hoisted(() => vi.fn(async () => []))
|
||||
const redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
taskEvent: {
|
||||
findMany: taskEventFindManyMock,
|
||||
create: taskEventCreateMock,
|
||||
},
|
||||
task: {
|
||||
findMany: taskFindManyMock,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
redis: {
|
||||
publish: redisPublishMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { listEventsAfter, listTaskLifecycleEvents, publishTaskStreamEvent } from '@/lib/task/publisher'
|
||||
|
||||
describe('task publisher replay', () => {
|
||||
beforeEach(() => {
|
||||
taskEventFindManyMock.mockReset()
|
||||
taskEventCreateMock.mockReset()
|
||||
taskFindManyMock.mockReset()
|
||||
redisPublishMock.mockReset()
|
||||
})
|
||||
|
||||
it('replays persisted lifecycle + stream rows in chronological order', async () => {
|
||||
taskEventFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 12,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.stream',
|
||||
payload: {
|
||||
stepId: 'step-1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 2,
|
||||
lane: 'main',
|
||||
delta: 'world',
|
||||
},
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:02.000Z'),
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.processing',
|
||||
payload: {
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'step-1',
|
||||
stepTitle: '阶段1',
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:01.000Z'),
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.ignored',
|
||||
payload: {},
|
||||
createdAt: new Date('2026-02-27T00:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
taskFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'script_to_storyboard_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
},
|
||||
])
|
||||
|
||||
const events = await listTaskLifecycleEvents('task-1', 50)
|
||||
|
||||
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { taskId: 'task-1' },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 50,
|
||||
}))
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((event) => event.id)).toEqual(['11', '12'])
|
||||
expect(events.map((event) => event.type)).toEqual(['task.lifecycle', 'task.stream'])
|
||||
expect((events[1]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('world')
|
||||
})
|
||||
|
||||
it('persists stream rows when persist=true', async () => {
|
||||
taskEventCreateMock.mockResolvedValueOnce({
|
||||
id: 99,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.stream',
|
||||
payload: {
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
delta: 'hello',
|
||||
},
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:03.000Z'),
|
||||
})
|
||||
redisPublishMock.mockResolvedValueOnce(1)
|
||||
|
||||
const message = await publishTaskStreamEvent({
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
taskType: 'story_to_script_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
payload: {
|
||||
stepId: 'step-1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
delta: 'hello',
|
||||
},
|
||||
},
|
||||
persist: true,
|
||||
})
|
||||
|
||||
expect(taskEventCreateMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
taskId: 'task-1',
|
||||
eventType: 'task.stream',
|
||||
}),
|
||||
}))
|
||||
expect(redisPublishMock).toHaveBeenCalledTimes(1)
|
||||
expect(message?.id).toBe('99')
|
||||
expect(message?.type).toBe('task.stream')
|
||||
})
|
||||
|
||||
it('replays lifecycle + stream rows in listEventsAfter', async () => {
|
||||
taskEventFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 101,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.stream',
|
||||
payload: {
|
||||
stepId: 'step-1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 3,
|
||||
lane: 'main',
|
||||
delta: 'chunk',
|
||||
},
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:03.000Z'),
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.processing',
|
||||
payload: {
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'step-1',
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:04.000Z'),
|
||||
},
|
||||
])
|
||||
taskFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'story_to_script_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
},
|
||||
])
|
||||
|
||||
const events = await listEventsAfter('project-1', 100, 20)
|
||||
|
||||
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: {
|
||||
projectId: 'project-1',
|
||||
id: { gt: 100 },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}))
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((event) => event.id)).toEqual(['101', '102'])
|
||||
expect(events.map((event) => event.type)).toEqual(['task.stream', 'task.lifecycle'])
|
||||
expect((events[0]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('chunk')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user