From b03aaab493f7aa4164ce326d978fb3740f50b671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 23:11:11 +0800 Subject: [PATCH] =?UTF-8?q?task-95:=20=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E9=87=8D=E8=AF=95=E5=92=8C=E7=BB=88=E6=80=81=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E8=BD=AE=E8=AF=A2=E7=8A=B6=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/shared/polling-state-machine.ts | 145 ++++++++++++++++++ .../tests/polling-state-machine.test.ts | 132 ++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 frontend-vue/src/shared/polling-state-machine.ts create mode 100644 frontend-vue/tests/polling-state-machine.test.ts diff --git a/frontend-vue/src/shared/polling-state-machine.ts b/frontend-vue/src/shared/polling-state-machine.ts new file mode 100644 index 00000000..e39e65ed --- /dev/null +++ b/frontend-vue/src/shared/polling-state-machine.ts @@ -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], + } +} diff --git a/frontend-vue/tests/polling-state-machine.test.ts b/frontend-vue/tests/polling-state-machine.test.ts new file mode 100644 index 00000000..de7fb2db --- /dev/null +++ b/frontend-vue/tests/polling-state-machine.test.ts @@ -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) +})