diff --git a/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts b/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts index 814472c..4d058b5 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts @@ -26,6 +26,11 @@ export interface TerminalProgressOptions { export interface ProgressHandle { start(total: number, startValue?: number, payload?: Record): void; update(value: number, payload?: Record): void; + /** + * 修正总量刻度(未知→已知 Content-Length / finish 补总量)。 + * LSP:勿用重 start 冒充改 total;实现须保留当前 value,并允许随后 update 按新刻度出帧。 + */ + setTotal(total: number): void; stop(): void; /** 当前是否已 start 且未 stop */ readonly active: boolean; @@ -115,7 +120,7 @@ export interface DownloadProgressBinderOptions { * BDS 更新与 CF 包下载共用,避免两处复制 lastTime/lastLoaded 环路(DRY)。 * * LSP:与 httpDownload 契约对齐——中途 total=0(无 Content-Length)仍可回调; - * 总量一旦变为已知(含 finish 用 finalBytes 补总量),须重设 bar 刻度,避免一直卡在占位 1MB。 + * 总量一旦变为已知(含 finish 用 finalBytes 补总量),经 setTotal 修正刻度,避免卡在占位 1MB。 */ export function bindByteProgressToBar( bar: ProgressHandle, @@ -138,15 +143,16 @@ export function bindByteProgressToBar( const speedText = formatDownloadSpeed(speed); if (!started) { - bar.start(totalMb, 0, { speed: "0 KB/s" }); + /* 首帧 startValue=已下载量,避免非 TTY 先打出虚假 0%(LSP) */ + bar.start(totalMb, dlMb, { speed: speedText }); started = true; knownTotal = hasTotal; } else if (hasTotal && !knownTotal) { - /* 未知→已知:重 start 以修正 total(ProgressHandle 无独立 setTotal) */ - bar.start(totalMb, dlMb, { speed: speedText }); + /* 未知→已知:setTotal 修正刻度,勿重 start(OCP/LSP) */ + bar.setTotal(totalMb); knownTotal = true; } - /* 始终 update:首帧反映已下载字节;非 TTY logger 也走统一刻度(LSP) */ + /* 始终 update:TTY 重绘 / 非 TTY 步进日志与 payload 对齐 */ bar.update(dlMb, { speed: speedText }); if (speedSampleMs <= 0 || now - lastTime >= speedSampleMs) { @@ -250,13 +256,21 @@ export function createTerminalProgress(opts: TerminalProgressOptions = {}): Prog if (useBar) { if (!id) register(); draw(); - } else if (opts.logger) { - /* 尊重 startValue(LSP:与 ProgressHandle.start 契约一致;重 start 修正总量时勿写死 0%) */ - const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0; - opts.logger(`进度 ${pct}% (${value.toFixed(1)}/${total.toFixed(1)})`); - lastLoggedPct = Math.floor(pct / stepPercent) * stepPercent; + } else { + /* 尊重 startValue(LSP);与 update 共用 emitNonTty(DRY) */ + emitNonTty(true); } }, + setTotal(t) { + if (!started || stopped) return; + total = t > 0 ? t : 1; + /* 刻度变更后允许按新百分比再出帧(含从错误占位%回落到真实%) */ + lastLoggedPct = -1; + if (useBar) { + draw(); + } + /* 非 TTY:留给随后 update 打一条正确刻度日志,避免 start+update 双打 */ + }, update(v, p) { if (!started || stopped) return; value = v; @@ -265,16 +279,7 @@ export function createTerminalProgress(opts: TerminalProgressOptions = {}): Prog draw(); return; } - if (!opts.logger) return; - const pct = total > 0 ? (value / total) * 100 : 0; - const stepped = Math.floor(pct / stepPercent) * stepPercent; - if (stepped !== lastLoggedPct && (stepped > lastLoggedPct || pct >= 100)) { - lastLoggedPct = stepped; - const speed = payload.speed ? ` ${payload.speed}` : ""; - opts.logger( - `进度 ${Math.min(100, Math.round(pct))}% (${value.toFixed(1)}/${total.toFixed(1)})${speed}` - ); - } + emitNonTty(false); }, stop() { if (stopped) return; @@ -288,6 +293,24 @@ export function createTerminalProgress(opts: TerminalProgressOptions = {}): Prog }, }; + /** 非 TTY 百分比日志唯一出口(start / update 共用 — DRY) */ + function emitNonTty(force: boolean): void { + if (!opts.logger) return; + const pct = total > 0 ? (value / total) * 100 : 0; + const stepped = Math.floor(pct / stepPercent) * stepPercent; + if ( + !force && + !(stepped !== lastLoggedPct && (stepped > lastLoggedPct || pct >= 100)) + ) { + return; + } + lastLoggedPct = stepped; + const speed = payload.speed ? ` ${payload.speed}` : ""; + opts.logger( + `进度 ${Math.min(100, Math.round(pct))}% (${value.toFixed(1)}/${total.toFixed(1)})${speed}` + ); + } + return handle; } diff --git a/sfmc/terminal-progress.test.mjs b/sfmc/terminal-progress.test.mjs index be831d2..25f7b1f 100644 --- a/sfmc/terminal-progress.test.mjs +++ b/sfmc/terminal-progress.test.mjs @@ -14,46 +14,50 @@ import { describe("progress pause/resume contract (registry)", () => { it("嵌套 pause 只在最外层 resume 时重绘", () => { + let writes = 0; const stream = new Writable({ write(_chunk, _e, cb) { + writes++; cb(); }, }); const bar = createTerminalProgress({ stream, forceBar: true }); bar.start(10, 0, { speed: "0 KB/s" }); + const afterStart = writes; + assert.ok(afterStart >= 1, "start 应至少绘制一次"); pauseAllProgress(); + const afterOuterPause = writes; + assert.ok(afterOuterPause > afterStart, "外层 pause 应清行"); + pauseAllProgress(); + assert.equal(writes, afterOuterPause, "嵌套 pause 不应再写流"); + /* 嵌套 pause:内层 resume 不应重绘(pausedDepth 仍 >0) */ resumeAllProgress(); + assert.equal(writes, afterOuterPause, "内层 resume 不应重绘"); assert.equal(bar.active, true); + resumeAllProgress(); + assert.ok(writes > afterOuterPause, "最外层 resume 应重绘"); assert.equal(bar.active, true); bar.stop(); }); - - it("Writable 可接收进度输出", () => { - const chunks = []; - const stream = new Writable({ - write(chunk, _e, cb) { - chunks.push(String(chunk)); - cb(); - }, - }); - stream.write("progress-line\n"); - assert.ok(chunks[0].includes("progress")); - }); }); describe("bindByteProgressToBar LSP(未知总量→已知)", () => { - it("无 Content-Length 中途 total=0,finish 补总量时重设 bar 刻度", () => { + it("无 Content-Length 中途 total=0,finish 补总量时 setTotal 修正刻度", () => { const starts = []; + const setTotals = []; const updates = []; const bar = { active: true, start(total, value = 0, payload) { starts.push({ total, value, payload }); }, + setTotal(total) { + setTotals.push(total); + }, update(value, payload) { updates.push({ value, payload }); }, @@ -65,11 +69,12 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => { onProgress: (dl, total) => seen.push([dl, total]), }); - /* 中途无 Content-Length */ + /* 中途无 Content-Length:首帧 startValue=已下载量 */ onByte(512 * 1024, 0); assert.equal(starts.length, 1); assert.equal(starts[0].total, 1); /* 占位 1MB */ - assert.equal(updates.length, 1, "首帧须 update 反映已下载字节"); + assert.equal(starts[0].value, 0.5, "首帧 startValue 须反映已下载字节"); + assert.equal(updates.length, 1, "首帧须 update"); assert.equal(updates[0].value, 0.5); assert.equal(seen[0][0], 512 * 1024); assert.equal(seen[0][1], 0); @@ -77,22 +82,26 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => { /* finish:用 finalBytes 作总量(与 httpDownload LSP 契约一致) */ const finalBytes = 5 * 1024 * 1024; onByte(finalBytes, finalBytes); - assert.equal(starts.length, 2, "总量从未知变为已知时应重 start"); - assert.equal(starts[1].total, 5); - assert.equal(starts[1].value, 5); - assert.equal(updates.length, 2, "重 start 后仍须 update(非 TTY logger 对齐)"); + assert.equal(starts.length, 1, "已知总量后不得再 start"); + assert.equal(setTotals.length, 1, "总量从未知变为已知时应 setTotal"); + assert.equal(setTotals[0], 5); + assert.equal(updates.length, 2, "setTotal 后仍须 update"); assert.equal(updates[1].value, 5); assert.deepEqual(seen[1], [finalBytes, finalBytes]); }); - it("一开始就有 Content-Length 时不重复 start,且首帧 update", () => { + it("一开始就有 Content-Length 时不重复 start/setTotal,且首帧带已下载量", () => { const starts = []; + const setTotals = []; const updates = []; const bar = { active: true, start(total, value = 0) { starts.push({ total, value }); }, + setTotal(total) { + setTotals.push(total); + }, update(value) { updates.push(value); }, @@ -103,11 +112,13 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => { onByte(2 * 1024 * 1024, 10 * 1024 * 1024); assert.equal(starts.length, 1); assert.equal(starts[0].total, 10); + assert.ok(starts[0].value > 0, "首帧 startValue > 0"); + assert.equal(setTotals.length, 0, "一开始已知总量时不应 setTotal"); assert.equal(updates.length, 2); assert.ok(updates[0] > 0); }); - it("createTerminalProgress 非 TTY:重 start 尊重 startValue(勿写死 0%)", () => { + it("createTerminalProgress 非 TTY:start 尊重 startValue;setTotal 后 update 出正确刻度", () => { const logs = []; const stream = new Writable({ write(_chunk, _e, cb) { @@ -124,6 +135,49 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => { assert.ok(logs.length >= 1); assert.match(logs[0], /100%/); assert.match(logs[0], /5(?:\.0)?\/5/); + + logs.length = 0; + /* 占位刻度 → setTotal 修正后由 update 打一条,勿虚假 0% */ + const bar2 = createTerminalProgress({ + stream, + forceBar: false, + logger: (m) => logs.push(m), + }); + bar2.start(1, 0.5, { speed: "0 B/s" }); + assert.match(logs[0], /50%/); + logs.length = 0; + bar2.setTotal(5); + assert.equal(logs.length, 0, "setTotal 本身不打日志(留给 update)"); + bar2.update(5, { speed: "1 MB/s" }); + assert.equal(logs.length, 1); + assert.match(logs[0], /100%/); + assert.match(logs[0], /5(?:\.0)?\/5/); + bar.stop(); + bar2.stop(); + }); + + it("binder+非 TTY:首帧不出现虚假 0%,finish 经 setTotal 收 100%", () => { + const logs = []; + const stream = new Writable({ + write(_chunk, _e, cb) { + cb(); + }, + }); + const bar = createTerminalProgress({ + stream, + forceBar: false, + logger: (m) => logs.push(m), + }); + const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 }); + onByte(512 * 1024, 0); + assert.ok(logs.length >= 1); + assert.ok(!logs.some((m) => /^进度 0%/.test(m)), "首帧不得虚假 0%"); + assert.match(logs[0], /50%/); + + logs.length = 0; + const finalBytes = 5 * 1024 * 1024; + onByte(finalBytes, finalBytes); + assert.ok(logs.some((m) => /100%/.test(m))); bar.stop(); });