task-101(任务与重复分析中心): 定义统一任务状态和结果文件类型
新增 src/pages/tasks/task-model.ts:TaskStatus 归一(含同义词/终态判定)、 TaskId/ResultId 字符串兼容品牌类型与 ResultFileDescriptor 解析(snake/camel)。 TDD: task-101.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
/** 任务与重复分析中心统一任务类型(任务 101):任务状态归一、任务/结果 id 与结果文件描述;
|
||||||
|
* 纯逻辑、无框架依赖,供视频任务/店铺数据任务/重复分析共用。 */
|
||||||
|
|
||||||
|
/** 归一化任务状态。 */
|
||||||
|
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'UNKNOWN'
|
||||||
|
|
||||||
|
/** 结果文件 id(字符串兼容,保持与后端 task_id/result_id 一致)。 */
|
||||||
|
export type ResultId = string & { readonly __resultId: unique symbol }
|
||||||
|
|
||||||
|
export type TaskId = string & { readonly __taskId: unique symbol }
|
||||||
|
|
||||||
|
const STATUS_ALIAS: Record<string, TaskStatus> = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
WAITING: 'PENDING',
|
||||||
|
QUEUED: 'PENDING',
|
||||||
|
RUNNING: 'RUNNING',
|
||||||
|
PROCESSING: 'RUNNING',
|
||||||
|
IN_PROGRESS: 'RUNNING',
|
||||||
|
EXPORTING: 'RUNNING',
|
||||||
|
SUCCESS: 'SUCCESS',
|
||||||
|
SUCCEEDED: 'SUCCESS',
|
||||||
|
COMPLETED: 'SUCCESS',
|
||||||
|
DONE: 'SUCCESS',
|
||||||
|
FAILED: 'FAILED',
|
||||||
|
FAILURE: 'FAILED',
|
||||||
|
ERROR: 'FAILED',
|
||||||
|
CANCELLED: 'CANCELLED',
|
||||||
|
CANCELED: 'CANCELLED',
|
||||||
|
UNKNOWN: 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一分隔符/大小写后的任务状态;未知值一律回 UNKNOWN。 */
|
||||||
|
export function normalizeTaskStatus(value: unknown): TaskStatus {
|
||||||
|
if (typeof value !== 'string') return 'UNKNOWN'
|
||||||
|
const key = value.trim().toUpperCase()
|
||||||
|
return STATUS_ALIAS[key] ?? 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否终态(可清理轮询):成功/失败/取消。 */
|
||||||
|
export function isTerminalTaskStatus(status: TaskStatus): boolean {
|
||||||
|
return status === 'SUCCESS' || status === 'FAILED' || status === 'CANCELLED'
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandedId(value: string): string {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务/结果 id 归一为字符串兼容的带品牌类型;空白返回空串。 */
|
||||||
|
export function toTaskId(value: unknown): TaskId {
|
||||||
|
const text = typeof value === 'string' ? value.trim() : typeof value === 'number' && Number.isFinite(value) ? String(Math.floor(value)) : ''
|
||||||
|
return brandedId(text) as TaskId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toResultId(value: unknown): ResultId {
|
||||||
|
const text = typeof value === 'string' ? value.trim() : typeof value === 'number' && Number.isFinite(value) ? String(Math.floor(value)) : ''
|
||||||
|
return brandedId(text) as ResultId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果文件描述:文件名/URL/大小/状态/错误 的类型化边界。 */
|
||||||
|
export interface ResultFileDescriptor {
|
||||||
|
resultId: ResultId
|
||||||
|
taskId?: TaskId
|
||||||
|
filename: string
|
||||||
|
url: string
|
||||||
|
size?: number
|
||||||
|
rowCount?: number
|
||||||
|
status: TaskStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析一条结果文件记录(snake/camel 兜底);缺有效 resultId 视为无效。 */
|
||||||
|
export function normalizeResultFile(raw: unknown): ResultFileDescriptor | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const record = raw as Record<string, unknown>
|
||||||
|
const resultId = toResultId(record.result_id ?? record.resultId)
|
||||||
|
if (!resultId) return null
|
||||||
|
const descriptor: ResultFileDescriptor = {
|
||||||
|
resultId,
|
||||||
|
filename: text(record.output_filename ?? record.filename ?? record.result_filename ?? record.file_name),
|
||||||
|
url: text(record.file_url ?? record.fileUrl ?? record.url ?? record.display_url),
|
||||||
|
status: normalizeTaskStatus(record.status ?? record.file_status ?? record.task_status),
|
||||||
|
}
|
||||||
|
const taskId = toTaskId(record.task_id ?? record.taskId)
|
||||||
|
if (taskId) descriptor.taskId = taskId
|
||||||
|
const size = positiveNumber(record.file_size ?? record.fileSize)
|
||||||
|
if (size !== undefined) descriptor.size = size
|
||||||
|
const rowCount = positiveNumber(record.row_count ?? record.rows ?? record.rowCount)
|
||||||
|
if (rowCount !== undefined) descriptor.rowCount = rowCount
|
||||||
|
const error = text(record.error ?? record.error_message ?? record.result_error)
|
||||||
|
if (error) descriptor.error = error
|
||||||
|
return descriptor
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
isTerminalTaskStatus,
|
||||||
|
normalizeResultFile,
|
||||||
|
normalizeTaskStatus,
|
||||||
|
toResultId,
|
||||||
|
toTaskId,
|
||||||
|
} from '../src/pages/tasks/task-model.ts'
|
||||||
|
|
||||||
|
test('test_task_101_task_types_normal_primary_path', () => {
|
||||||
|
// 正常主路径:任务/结果 id 归一与结果文件解析(蛇形 payload)为类型化描述。
|
||||||
|
assert.equal(normalizeTaskStatus('SUCCESS'), 'SUCCESS')
|
||||||
|
assert.equal(toResultId(123), '123')
|
||||||
|
const file = normalizeResultFile({
|
||||||
|
result_id: 123,
|
||||||
|
output_filename: 'shop_data_DE.xlsx',
|
||||||
|
file_url: 'https://oss.aishufu.top/shop_data_DE.xlsx',
|
||||||
|
file_size: 2048,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
})
|
||||||
|
assert.ok(file)
|
||||||
|
assert.equal(file!.resultId, '123')
|
||||||
|
assert.equal(file!.filename, 'shop_data_DE.xlsx')
|
||||||
|
assert.equal(file!.url, 'https://oss.aishufu.top/shop_data_DE.xlsx')
|
||||||
|
assert.equal(file!.size, 2048)
|
||||||
|
assert.equal(file!.status, 'SUCCESS')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_normal_variant_input', () => {
|
||||||
|
// 正常变体:camel payload 与状态同义词归一。
|
||||||
|
const file = normalizeResultFile({ resultId: 9, filename: 'a.zip', fileUrl: 'http://x/a.zip', status: 'completed' })
|
||||||
|
assert.ok(file)
|
||||||
|
assert.equal(file!.status, 'SUCCESS')
|
||||||
|
assert.equal(normalizeTaskStatus('done'), 'SUCCESS')
|
||||||
|
assert.equal(normalizeTaskStatus('running'), 'RUNNING')
|
||||||
|
assert.equal(toTaskId('task-7'), 'task-7')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_repeated_is_idempotent', () => {
|
||||||
|
// 正常重复:归一稳定、不改输入。
|
||||||
|
const raw = { result_id: 2, filename: 'b', status: 'pending' }
|
||||||
|
assert.deepEqual(normalizeResultFile(raw), normalizeResultFile(raw))
|
||||||
|
assert.deepEqual(raw, { result_id: 2, filename: 'b', status: 'pending' })
|
||||||
|
assert.equal(normalizeTaskStatus('SUCCESS'), normalizeTaskStatus('success'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_boundary_empty_input', () => {
|
||||||
|
// 边界空值:缺 id/文件名视为无效描述。
|
||||||
|
assert.equal(normalizeResultFile(null), null)
|
||||||
|
assert.equal(normalizeResultFile({ filename: 'no-id' }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_boundary_single_item', () => {
|
||||||
|
// 边界单元素:单条描述字段完整;未知状态归 UNKNOWN。
|
||||||
|
const file = normalizeResultFile({ result_id: 1, filename: 'f', url: 'http://u', status: 'weird' })
|
||||||
|
assert.ok(file)
|
||||||
|
assert.equal(file!.status, 'UNKNOWN')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限/缺字段:可选 size/error 缺省可空。
|
||||||
|
const file = normalizeResultFile({ result_id: 4, filename: 'g', fileUrl: 'http://u/g' })
|
||||||
|
assert.ok(file)
|
||||||
|
assert.equal(file!.size, undefined)
|
||||||
|
assert.equal(file!.error, undefined)
|
||||||
|
assert.equal(file!.url, 'http://u/g')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:空白/非法 id 与未知状态被拒绝或归 UNKNOWN。
|
||||||
|
assert.equal(normalizeResultFile({ result_id: '', filename: 'x' }), null)
|
||||||
|
assert.equal(toResultId(''), '')
|
||||||
|
assert.equal(isTerminalTaskStatus('SUCCESS'), true)
|
||||||
|
assert.equal(isTerminalTaskStatus('RUNNING'), false)
|
||||||
|
assert.equal(isTerminalTaskStatus('CANCELLED'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_task_types_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败/可操作:类型模块纯逻辑、无框架/http。
|
||||||
|
const mod = readSource('src/pages/tasks/task-model.ts')
|
||||||
|
assert.equal(/axios|http\.|vue/.test(mod), false, '任务类型保持纯逻辑')
|
||||||
|
assert.match(mod, /ResultFileDescriptor/)
|
||||||
|
assert.match(mod, /ResultId/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user