Files
waoowaoo/tests/unit/helpers/run-stream-state-machine.test.ts

275 lines
7.3 KiB
TypeScript

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