Files
wehub-resource-sync d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

153 lines
4.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
IllegalTransitionError,
isLegalTransition,
LEGAL_TRANSITIONS,
transition
} from '../src/fsm'
import type { TaskOutput, TaskStatus } from '../src/types'
import { TERMINAL_STATUSES } from '../src/types'
import { makeTask } from './_fixtures'
const ALL_STATUSES: TaskStatus[] = [
'queued',
'running',
'processing',
'paused',
'retry-scheduled',
'completed',
'failed',
'cancelled'
]
describe('TaskFSM transition table (design §3.1)', () => {
it('legality table matches the design doc one-for-one', () => {
const expected: Record<TaskStatus, ReadonlyArray<TaskStatus>> = {
queued: ['running', 'paused', 'cancelled'],
running: [
'processing',
'completed',
'retry-scheduled',
'failed',
'paused',
'cancelled'
],
processing: [
'completed',
'retry-scheduled',
'failed',
'paused',
'cancelled'
],
paused: ['queued', 'cancelled', 'failed'],
'retry-scheduled': ['queued', 'paused', 'cancelled'],
failed: ['queued'],
cancelled: ['queued'],
completed: []
}
for (const [from, tos] of Object.entries(expected)) {
const set = LEGAL_TRANSITIONS[from as TaskStatus]
expect([...set].sort()).toEqual([...tos].sort())
}
})
it('every illegal From→To combo throws IllegalTransitionError', () => {
for (const from of ALL_STATUSES) {
for (const to of ALL_STATUSES) {
if (from === to) continue
if (isLegalTransition(from, to)) continue
const task = makeTask({ status: from })
expect(() =>
transition(task, to, { trigger: 'fail' })
).toThrowError(IllegalTransitionError)
}
}
})
it('terminal `completed` has no outgoing transitions', () => {
const t = makeTask({ status: 'completed' })
for (const to of ALL_STATUSES) {
if (to === 'completed') continue
expect(() => transition(t, to, { trigger: 'fail' })).toThrow()
}
})
it('completed requires output.size > 0 (design §3.1 guard)', () => {
const t = makeTask({ status: 'running' })
const zero: TaskOutput = {
filePath: '/tmp/x.mp4',
size: 0,
durationMs: null,
sha256: null
}
expect(() =>
transition(t, 'completed', { trigger: 'finalize-success', output: zero })
).toThrow(IllegalTransitionError)
const ok: TaskOutput = { ...zero, size: 1024 }
const next = transition(t, 'completed', {
trigger: 'finalize-success',
output: ok
})
expect(next.status).toBe('completed')
expect(next.output).toEqual(ok)
expect(next.pid).toBeNull()
})
it('queued → running on the first dispatch sets attempt to 1', () => {
const t = makeTask({ status: 'queued', attempt: 0 })
const next = transition(t, 'running', { trigger: 'dispatch' })
expect(next.status).toBe('running')
expect(next.attempt).toBe(1)
})
it('retry-scheduled → queued via tick increments attempt', () => {
const t = makeTask({
status: 'retry-scheduled',
attempt: 2,
nextRetryAt: 1
})
const next = transition(t, 'queued', { trigger: 'retry-tick' })
expect(next.attempt).toBe(3)
expect(next.nextRetryAt).toBeNull()
})
it('failed → queued via manual retry resets attempt to 0', () => {
const t = makeTask({ status: 'failed', attempt: 3 })
const next = transition(t, 'queued', { trigger: 'retry-manual' })
expect(next.attempt).toBe(0)
expect(next.lastError).toBeNull()
})
it('retry-scheduled → retry-scheduled requires nextRetryAt', () => {
const t = makeTask({ status: 'running' })
expect(() =>
transition(t, 'retry-scheduled', {
trigger: 'finalize-error'
})
).toThrow()
const next = transition(t, 'retry-scheduled', {
trigger: 'finalize-error',
nextRetryAt: 1_700_000_010_000
})
expect(next.status).toBe('retry-scheduled')
expect(next.nextRetryAt).toBe(1_700_000_010_000)
})
it('terminal statuses block writes (assert no leak through transition)', () => {
for (const term of TERMINAL_STATUSES) {
const t = makeTask({ status: term })
// Self-transition is illegal except via the explicit failed→queued/
// cancelled→queued paths (already covered by legality table).
for (const to of ALL_STATUSES) {
if (isLegalTransition(term, to)) continue
if (to === term) continue
expect(() =>
transition(t, to, { trigger: 'cancel' })
).toThrow(IllegalTransitionError)
}
}
})
})