task-95: 错误提示、重试和终态刷新的轮询状态机
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 轮询状态机(Task 95)。
|
||||
*
|
||||
* 收敛轮询 UI 的深色主题无关状态语义:错误提示(error)、重试(retry)、
|
||||
* 终态刷新(markRefreshed)的有界状态转换。
|
||||
*
|
||||
* 状态流:idle → start → polling →(fail)→ retrying →(fail…)→ failed
|
||||
* └→ succeed → done(终态;仅刷新历史,不重复计数)
|
||||
*
|
||||
* 有界语义:
|
||||
* - 重试次数有上限(maxAttempts),超过后进入 failed,不再重试;
|
||||
* - 终态幂等:重复 succeed 不覆盖状态、不重复计数;markRefreshed 只计一次;
|
||||
* - 未 start 时 fail/succeed 抛错;isTerminal 依赖抛错时调用失败但状态
|
||||
* 零变更,恢复后可继续。
|
||||
*/
|
||||
export interface PollingStateMachineOptions {
|
||||
/** 重试上限,必须为正整数,达到上限后失败不再重试 */
|
||||
maxAttempts: number
|
||||
/** 判定终态,默认 SUCCESS/FAILED;抛错时调用失败且状态不变 */
|
||||
isTerminal?: (status: string) => boolean
|
||||
}
|
||||
|
||||
export interface PollingStateMachine {
|
||||
/** idle | polling | retrying | done | failed */
|
||||
status: string
|
||||
/** 是否仍在轮询(polling 或 retrying) */
|
||||
retrying: boolean
|
||||
attempts: number
|
||||
errorCount: number
|
||||
/** 最近一次终态状态字符串(未终态为空串) */
|
||||
terminalStatus: string
|
||||
/** 最近一次错误消息 */
|
||||
lastError: string
|
||||
/** 是否已刷新历史(终态后至多一次) */
|
||||
refreshed: boolean
|
||||
refreshCount: number
|
||||
start: () => void
|
||||
/** 记录一次失败;未超上限进入 retrying,超上限进入 failed */
|
||||
fail: (message: string) => void
|
||||
/** 记录一次成功;终态后重复调用幂等 */
|
||||
succeed: (status: string) => void
|
||||
/** 标记终态历史已刷新;仅 done/failed 后首个调用生效 */
|
||||
markRefreshed: () => void
|
||||
/** 已发生的重试序号列表 */
|
||||
retries: () => number[]
|
||||
}
|
||||
|
||||
export function createPollingStateMachine(options: PollingStateMachineOptions): PollingStateMachine {
|
||||
if (typeof options !== 'object' || options == null) {
|
||||
throw new Error('options 必须是对象')
|
||||
}
|
||||
if (!(options.maxAttempts > 0)) {
|
||||
throw new Error('maxAttempts 必须为正数: ' + options.maxAttempts)
|
||||
}
|
||||
if (!Number.isInteger(options.maxAttempts)) {
|
||||
throw new Error('maxAttempts 必须为整数: ' + options.maxAttempts)
|
||||
}
|
||||
const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED')
|
||||
if (typeof isTerminal !== 'function') {
|
||||
throw new Error('isTerminal 必须是函数')
|
||||
}
|
||||
|
||||
let status = 'idle'
|
||||
let attempts = 0
|
||||
let errorCount = 0
|
||||
let terminalStatus = ''
|
||||
let lastError = ''
|
||||
let refreshCount = 0
|
||||
const retrySequence: number[] = []
|
||||
|
||||
function start() {
|
||||
if (status === 'done' || status === 'failed') return
|
||||
status = 'polling'
|
||||
attempts = 0
|
||||
}
|
||||
|
||||
function fail(message: string) {
|
||||
if (status === 'idle') {
|
||||
throw new Error('未开始轮询')
|
||||
}
|
||||
if (status === 'done' || status === 'failed') return
|
||||
attempts += 1
|
||||
errorCount += 1
|
||||
lastError = message
|
||||
if (attempts >= options.maxAttempts) {
|
||||
status = 'failed'
|
||||
} else {
|
||||
retrySequence.push(attempts)
|
||||
status = 'retrying'
|
||||
}
|
||||
}
|
||||
|
||||
function succeed(terminal: string) {
|
||||
if (status === 'idle') {
|
||||
throw new Error('未开始轮询')
|
||||
}
|
||||
if (status === 'done' || status === 'failed') return
|
||||
if (!isTerminal(terminal)) {
|
||||
// 非终态响应:按一次失败计入,沿用重试语义
|
||||
fail('非终态响应: ' + terminal)
|
||||
return
|
||||
}
|
||||
attempts += 1
|
||||
terminalStatus = terminal
|
||||
status = 'done'
|
||||
}
|
||||
|
||||
function markRefreshed() {
|
||||
if (status !== 'done' && status !== 'failed') return
|
||||
if (refreshCount > 0) return
|
||||
refreshCount = 1
|
||||
}
|
||||
|
||||
return {
|
||||
get status() {
|
||||
return status
|
||||
},
|
||||
get retrying() {
|
||||
return status === 'polling' || status === 'retrying'
|
||||
},
|
||||
get attempts() {
|
||||
return attempts
|
||||
},
|
||||
get errorCount() {
|
||||
return errorCount
|
||||
},
|
||||
get terminalStatus() {
|
||||
return terminalStatus
|
||||
},
|
||||
get lastError() {
|
||||
return lastError
|
||||
},
|
||||
get refreshed() {
|
||||
return refreshCount > 0
|
||||
},
|
||||
get refreshCount() {
|
||||
return refreshCount
|
||||
},
|
||||
start,
|
||||
fail,
|
||||
succeed,
|
||||
markRefreshed,
|
||||
retries: () => [...retrySequence],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createPollingStateMachine } from '../src/shared/polling-state-machine.ts'
|
||||
|
||||
test('test_task_095_task_normal_default_path', () => {
|
||||
const fsm = createPollingStateMachine({ maxAttempts: 3 })
|
||||
assert.equal(fsm.status, 'idle')
|
||||
fsm.start()
|
||||
assert.equal(fsm.status, 'polling')
|
||||
fsm.succeed('SUCCESS')
|
||||
assert.equal(fsm.status, 'done')
|
||||
assert.equal(fsm.terminalStatus, 'SUCCESS')
|
||||
assert.equal(fsm.attempts, 1)
|
||||
assert.equal(fsm.errorCount, 0)
|
||||
assert.deepEqual(fsm.retries(), [])
|
||||
// 终态刷新:刷新计数
|
||||
fsm.markRefreshed()
|
||||
assert.equal(fsm.refreshed, true)
|
||||
assert.equal(fsm.refreshCount, 1)
|
||||
})
|
||||
|
||||
test('test_task_095_task_normal_multiple_items', () => {
|
||||
// 多任务独立状态机:互不串扰
|
||||
const fsmA = createPollingStateMachine({ maxAttempts: 3 })
|
||||
const fsmB = createPollingStateMachine({ maxAttempts: 3 })
|
||||
fsmA.start()
|
||||
fsmB.start()
|
||||
fsmA.fail('Network Error')
|
||||
fsmB.succeed('SUCCESS')
|
||||
assert.equal(fsmA.status, 'retrying')
|
||||
assert.equal(fsmB.status, 'done')
|
||||
assert.equal(fsmA.errorCount, 1)
|
||||
assert.equal(fsmB.errorCount, 0)
|
||||
assert.equal(fsmB.terminalStatus, 'SUCCESS')
|
||||
assert.deepEqual(fsmA.retries(), [1])
|
||||
})
|
||||
|
||||
test('test_task_095_task_normal_repeated_operation_is_idempotent', () => {
|
||||
const fsm = createPollingStateMachine({ maxAttempts: 5 })
|
||||
fsm.start()
|
||||
fsm.fail('e1')
|
||||
fsm.fail('e2')
|
||||
fsm.succeed('SUCCESS')
|
||||
// 重复进入终态幂等:不重复计数、不覆盖状态
|
||||
fsm.succeed('SUCCESS')
|
||||
fsm.succeed('SUCCESS')
|
||||
assert.equal(fsm.status, 'done')
|
||||
assert.equal(fsm.attempts, 3)
|
||||
assert.equal(fsm.errorCount, 2)
|
||||
// 重复刷新幂等:refreshCount 只计一次
|
||||
fsm.markRefreshed()
|
||||
fsm.markRefreshed()
|
||||
fsm.markRefreshed()
|
||||
assert.equal(fsm.refreshed, true)
|
||||
assert.equal(fsm.refreshCount, 1)
|
||||
// 终态后 start 无效
|
||||
fsm.start()
|
||||
assert.equal(fsm.status, 'done')
|
||||
})
|
||||
|
||||
test('test_task_095_task_boundary_empty_input', () => {
|
||||
const fsm = createPollingStateMachine({ maxAttempts: 3 })
|
||||
assert.equal(fsm.status, 'idle')
|
||||
assert.equal(fsm.attempts, 0)
|
||||
assert.equal(fsm.errorCount, 0)
|
||||
assert.equal(fsm.retries().length, 0)
|
||||
assert.equal(fsm.refreshed, false)
|
||||
// idle 状态不触发任何刷新
|
||||
fsm.markRefreshed()
|
||||
assert.equal(fsm.refreshCount, 0)
|
||||
})
|
||||
|
||||
test('test_task_095_task_boundary_single_item', () => {
|
||||
const fsm = createPollingStateMachine({ maxAttempts: 1 })
|
||||
fsm.start()
|
||||
fsm.succeed('FAILED')
|
||||
assert.equal(fsm.status, 'done')
|
||||
assert.equal(fsm.terminalStatus, 'FAILED', '终态单次成功')
|
||||
assert.equal(fsm.attempts, 1)
|
||||
})
|
||||
|
||||
test('test_task_095_task_boundary_limit_and_overflow', () => {
|
||||
// 重试有界:超过 maxAttempts 后失败进入 failed,不再重试
|
||||
const fsm = createPollingStateMachine({ maxAttempts: 2 })
|
||||
fsm.start()
|
||||
fsm.fail('e1')
|
||||
assert.equal(fsm.status, 'retrying')
|
||||
assert.equal(fsm.retrying, true)
|
||||
fsm.fail('e2')
|
||||
assert.equal(fsm.status, 'failed', '达到上限后失败不再重试')
|
||||
assert.equal(fsm.retrying, false)
|
||||
assert.deepEqual(fsm.retries(), [1], '仅成功重试过 1 次,第二次失败直接进入 failed')
|
||||
// 失败状态下再次 fail:不越界
|
||||
fsm.fail('e3')
|
||||
assert.equal(fsm.attempts, 2)
|
||||
assert.equal(fsm.errorCount, 2)
|
||||
})
|
||||
|
||||
test('test_task_095_task_invalid_input_rejected', () => {
|
||||
assert.throws(() => createPollingStateMachine({} as never), /maxAttempts 必须为正数/)
|
||||
assert.throws(() => createPollingStateMachine({ maxAttempts: 0 }), /maxAttempts 必须为正数/)
|
||||
assert.throws(() => createPollingStateMachine({ maxAttempts: -1 }), /maxAttempts 必须为正数/)
|
||||
assert.throws(() => createPollingStateMachine({ maxAttempts: 1.5 }), /maxAttempts 必须为整数/)
|
||||
assert.throws(
|
||||
() => createPollingStateMachine({ maxAttempts: 2, isTerminal: 'x' as never }),
|
||||
/isTerminal 必须是函数/,
|
||||
)
|
||||
// 未 start 时 fail/succeed 拒绝
|
||||
const fsm = createPollingStateMachine({ maxAttempts: 3 })
|
||||
assert.throws(() => fsm.fail('e'), /未开始轮询/)
|
||||
assert.throws(() => fsm.succeed('SUCCESS'), /未开始轮询/)
|
||||
})
|
||||
|
||||
test('test_task_095_task_dependency_failure_releases_resources', () => {
|
||||
// isTerminal 依赖抛错:fail 调用失败但状态不污染,恢复后可用
|
||||
let broken = true
|
||||
const fsm = createPollingStateMachine({
|
||||
maxAttempts: 3,
|
||||
isTerminal: (status: string) => {
|
||||
if (broken) throw new Error('isTerminal down')
|
||||
return status === 'SUCCESS'
|
||||
},
|
||||
})
|
||||
fsm.start()
|
||||
assert.throws(() => fsm.succeed('SUCCESS'), /isTerminal down/)
|
||||
assert.equal(fsm.status, 'polling', '失败不产生状态变更')
|
||||
broken = false
|
||||
fsm.succeed('SUCCESS')
|
||||
assert.equal(fsm.status, 'done')
|
||||
assert.equal(fsm.terminalStatus, 'SUCCESS')
|
||||
assert.equal(fsm.attempts, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user