task-144: 前端 live progress 合并补强(mergeHistoryItemPreservingLiveProgress 纯函数:缺失保留/有效覆盖/可下载不合并/幂等)+ 11 条测试

This commit is contained in:
2026-09-02 05:48:09 +08:00
parent 5e5056b99d
commit 6c674f0f1d
2 changed files with 186 additions and 0 deletions
@@ -0,0 +1,52 @@
/**
* live progress 合并(Task 144)。
*
* history 条目缺失 file 进度时保留实时缓存(live)进度;live 有值且有效时
* 以 live 为准;history 已可下载(fileReady/downloadUrl 就绪)时不覆盖——
* 结果文件既成,实时进度无意义。纯函数无副作用,入参不修改。
*/
export const FILE_PROGRESS_KEYS = [
'fileProgressPercent',
'fileProgressCurrent',
'fileProgressTotal',
'fileProgressMessage',
'fileReady',
'fileStatus',
] as const
export interface DownloadableItem {
resultId?: number | null
fileReady?: boolean | null
downloadUrl?: string | null
}
/** 可下载判定:有结果记录且文件已就绪(fileReady 或 downloadUrl 非空)。 */
export function canDownloadItem(item: DownloadableItem | null | undefined): boolean {
if (!item) return false
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
}
/** 有效进度值:仅 fileProgressPercent 需要严格 >0,其余字段非空即可(0 是合法计数)。 */
export function hasMeaningfulProgress(key: string, value: unknown): boolean {
if (value === undefined || value === null) return false
if (key === 'fileProgressPercent' && typeof value === 'number') {
return value > 0
}
return true
}
export function mergeHistoryItemPreservingLiveProgress<T extends Record<string, unknown>>(
history: T,
live: T | undefined,
): T {
if (!live) return history
if (canDownloadItem(history as unknown as DownloadableItem)) return history
const result: Record<string, unknown> = { ...history }
for (const key of FILE_PROGRESS_KEYS) {
const value = (live as Record<string, unknown>)[key]
if (hasMeaningfulProgress(key, value)) {
result[key] = value
}
}
return result as T
}
@@ -0,0 +1,134 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
canDownloadItem,
FILE_PROGRESS_KEYS,
hasMeaningfulProgress,
mergeHistoryItemPreservingLiveProgress,
} from '../src/shared/merge-live-progress.ts'
interface HistoryItem {
taskId: number
resultId?: number
fileReady?: boolean
fileStatus?: string
fileProgressPercent?: number
fileProgressCurrent?: number
fileProgressTotal?: number
fileProgressMessage?: string
downloadUrl?: string
sourceFilename?: string
}
const historyMissingProgress: HistoryItem = {
taskId: 1,
resultId: 100,
fileStatus: 'RUNNING',
sourceFilename: 'source.xlsx',
}
const liveWithProgress: HistoryItem = {
taskId: 1,
resultId: 100,
fileStatus: 'RUNNING',
fileProgressPercent: 45,
fileProgressCurrent: 9,
fileProgressTotal: 20,
fileProgressMessage: 'LLM 处理中',
}
test('merge keeps live progress when history missing file progress', () => {
const merged = mergeHistoryItemPreservingLiveProgress(historyMissingProgress, liveWithProgress)
assert.equal(merged.fileProgressPercent, 45, 'history 缺失进度时保留 live 的百分比')
assert.equal(merged.fileProgressCurrent, 9)
assert.equal(merged.fileProgressTotal, 20)
assert.equal(merged.fileProgressMessage, 'LLM 处理中')
assert.equal(merged.sourceFilename, 'source.xlsx', '非进度字段保留 history')
})
test('merge incoming wins when live has progress', () => {
const history = { ...historyMissingProgress, fileProgressPercent: 10, fileProgressMessage: '旧消息' }
const merged = mergeHistoryItemPreservingLiveProgress(history, liveWithProgress)
assert.equal(merged.fileProgressPercent, 45, 'live 有有效进度时以 live 为准')
assert.equal(merged.fileProgressMessage, 'LLM 处理中')
})
test('merge does not keep live when history is downloadable', () => {
const history: HistoryItem = {
taskId: 1,
resultId: 100,
fileReady: true,
fileStatus: 'SUCCESS',
fileProgressPercent: 100,
downloadUrl: 'https://dl.example/a.xlsx',
}
const merged = mergeHistoryItemPreservingLiveProgress(history, liveWithProgress)
assert.equal(merged.fileProgressPercent, 100, '可下载时保留 history 的终态进度')
assert.equal(merged.downloadUrl, 'https://dl.example/a.xlsx')
assert.equal(merged.fileReady, true)
})
test('merge without live returns history as-is', () => {
const merged = mergeHistoryItemPreservingLiveProgress(historyMissingProgress, undefined)
assert.equal(merged, historyMissingProgress, '无 live 时原样返回 history')
assert.equal(merged.fileProgressPercent, undefined)
})
test('merge partial fields keeps only present live values', () => {
const live: HistoryItem = { taskId: 1, resultId: 100, fileStatus: 'RUNNING', fileProgressPercent: 30 }
const merged = mergeHistoryItemPreservingLiveProgress(historyMissingProgress, live)
assert.equal(merged.fileProgressPercent, 30)
assert.equal(merged.fileProgressCurrent, undefined, 'live 缺失的字段不写入')
assert.equal(merged.fileProgressTotal, undefined)
assert.equal(merged.fileStatus, 'RUNNING')
})
test('merge ignores meaningless progress values', () => {
assert.equal(hasMeaningfulProgress('fileProgressPercent', 0), false, '0% 不算有效进度')
assert.equal(hasMeaningfulProgress('fileProgressPercent', -1), false)
assert.equal(hasMeaningfulProgress('fileProgressPercent', 5), true)
assert.equal(hasMeaningfulProgress('fileProgressCurrent', 0), true, '0 是合法计数')
assert.equal(hasMeaningfulProgress('fileProgressMessage', null), false)
assert.equal(hasMeaningfulProgress('fileProgressMessage', undefined), false)
const live: HistoryItem = { taskId: 1, resultId: 100, fileProgressPercent: 0 }
const merged = mergeHistoryItemPreservingLiveProgress(historyMissingProgress, live)
assert.equal(merged.fileProgressPercent, undefined, '0% 不覆盖 history')
})
test('merge preserves fileReady flag from live', () => {
const live: HistoryItem = { taskId: 1, resultId: 100, fileReady: true, fileProgressPercent: 100 }
const merged = mergeHistoryItemPreservingLiveProgress(historyMissingProgress, live)
assert.equal(merged.fileReady, true)
assert.equal(merged.fileProgressPercent, 100)
})
test('merge is idempotent', () => {
const once = mergeHistoryItemPreservingLiveProgress(historyMissingProgress, liveWithProgress)
const twice = mergeHistoryItemPreservingLiveProgress(once, liveWithProgress)
assert.deepEqual(twice, once, '重复合并结果一致')
})
test('file progress keys cover the live progress contract', () => {
assert.deepEqual(
[...FILE_PROGRESS_KEYS].sort(),
[
'fileProgressCurrent',
'fileProgressMessage',
'fileProgressPercent',
'fileProgressTotal',
'fileReady',
'fileStatus',
].sort(),
)
})
test('canDownloadItem requires result and ready state', () => {
assert.equal(canDownloadItem({ resultId: 1, fileReady: true }), true)
assert.equal(canDownloadItem({ resultId: 1, downloadUrl: 'https://x' }), true)
assert.equal(canDownloadItem({ resultId: 1 }), false)
assert.equal(canDownloadItem({ fileReady: true }), false, '无 resultId 不可下载')
assert.equal(canDownloadItem(null), false)
assert.equal(canDownloadItem(undefined), false)
})