From 6c674f0f1d8f7cf24d68b2857fadae6be745f74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 2 Sep 2026 05:48:09 +0800 Subject: [PATCH] =?UTF-8?q?task-144:=20=E5=89=8D=E7=AB=AF=20live=20progres?= =?UTF-8?q?s=20=E5=90=88=E5=B9=B6=E8=A1=A5=E5=BC=BA=EF=BC=88mergeHistoryIt?= =?UTF-8?q?emPreservingLiveProgress=20=E7=BA=AF=E5=87=BD=E6=95=B0=EF=BC=9A?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E4=BF=9D=E7=95=99/=E6=9C=89=E6=95=88?= =?UTF-8?q?=E8=A6=86=E7=9B=96/=E5=8F=AF=E4=B8=8B=E8=BD=BD=E4=B8=8D?= =?UTF-8?q?=E5=90=88=E5=B9=B6/=E5=B9=82=E7=AD=89=EF=BC=89+=2011=20?= =?UTF-8?q?=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/shared/merge-live-progress.ts | 52 +++++++ .../tests/merge-live-progress.test.ts | 134 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 frontend-vue/src/shared/merge-live-progress.ts create mode 100644 frontend-vue/tests/merge-live-progress.test.ts diff --git a/frontend-vue/src/shared/merge-live-progress.ts b/frontend-vue/src/shared/merge-live-progress.ts new file mode 100644 index 00000000..de26559e --- /dev/null +++ b/frontend-vue/src/shared/merge-live-progress.ts @@ -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>( + history: T, + live: T | undefined, +): T { + if (!live) return history + if (canDownloadItem(history as unknown as DownloadableItem)) return history + const result: Record = { ...history } + for (const key of FILE_PROGRESS_KEYS) { + const value = (live as Record)[key] + if (hasMeaningfulProgress(key, value)) { + result[key] = value + } + } + return result as T +} diff --git a/frontend-vue/tests/merge-live-progress.test.ts b/frontend-vue/tests/merge-live-progress.test.ts new file mode 100644 index 00000000..b173ec4a --- /dev/null +++ b/frontend-vue/tests/merge-live-progress.test.ts @@ -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) +})