H4 + H2 formatter half: carry raw numbers across the progress boundary

Closes H4 and the remaining formatter half of H2.

- New @shared/format.ts is the single home for fmtBytes/fmtSpeed/fmtEta, imported
  by both main and renderer. main/lib/formatters.ts re-exports them.
- DownloadProgress now carries raw speedBytesPerSec/etaSeconds instead of
  pre-formatted speed/eta strings. main's parseProgress emits the raw numbers.
- The renderer stores the raw numbers on DownloadItem; QueueItem formats them for
  per-item display, and summarizeQueue sums/maxes them directly. The lossy
  string->number->string round-trip in queueStats (parseSpeed / parseEtaSeconds /
  a local formatSpeed / formatEta) is deleted, along with a duplicate fmtEta in
  store/downloads.ts.
- Per-item and aggregate speed now use the same 1024-based scale; the aggregate
  previously used a 1000-based formatter (a latent inconsistency).

Tests updated for the raw-number contract (parseProgress, summarizeQueue).
typecheck + 248 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:03:14 -04:00
parent d050e48af6
commit 3d09174c16
9 changed files with 122 additions and 135 deletions
+7 -5
View File
@@ -66,7 +66,8 @@ describe('parseProgress', () => {
const p = parseProgress(raw)
expect(p).not.toBeNull()
expect(p!.progress).toBeCloseTo(0.5)
expect(p!.eta).toBe('0:02')
expect(p!.speedBytesPerSec).toBe(262144)
expect(p!.etaSeconds).toBe(2)
expect(p!.sizeLabel).toBe('1.0 MB')
expect(p!.sizeUnknown).toBe(false)
})
@@ -84,8 +85,8 @@ describe('parseProgress', () => {
const p = parseProgress(raw)
expect(p).not.toBeNull()
expect(p!.sizeUnknown).toBe(true)
expect(p!.eta).toBeUndefined()
expect(p!.speed).toBeUndefined()
expect(p!.etaSeconds).toBeUndefined()
expect(p!.speedBytesPerSec).toBeUndefined()
})
it('clamps progress to 1.0', () => {
@@ -94,9 +95,10 @@ describe('parseProgress', () => {
expect(p!.progress).toBe(1)
})
it('formats an ETA over 1 hour correctly (L166)', () => {
it('carries a raw ETA in seconds; the renderer formats it (L166)', () => {
const raw = makeRaw(['downloading', '100', '1000', '1000', '10', '3661'])
const p = parseProgress(raw)
expect(p!.eta).toBe('1:01:01')
expect(p!.etaSeconds).toBe(3661)
expect(fmtEta(p!.etaSeconds)).toBe('1:01:01')
})
})
+12 -6
View File
@@ -48,19 +48,25 @@ describe('summarizeQueue', () => {
expect(s.progress).toBeCloseTo(0.4, 5)
})
it('sums download speeds across MB/s and MiB/s strings', () => {
it('sums raw download rates into one formatted label', () => {
const MB = 1024 * 1024
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.1, speed: '4.0 MB/s' }),
item({ status: 'downloading', progress: 0.1, speed: '2000 KiB/s' })
item({ status: 'downloading', progress: 0.1, speedBytesPerSec: 4 * MB }),
item({ status: 'downloading', progress: 0.1, speedBytesPerSec: 2 * MB })
])
// 4.0 MB/s + ~2.0 MB/s 6.0 MB/s
// 4 MB/s + 2 MB/s = 6 MB/s
expect(s.speedLabel).toBe('6.0 MB/s')
})
it('leaves the speed label empty when no item reports a rate', () => {
const s = summarizeQueue([item({ status: 'downloading', progress: 0.1 })])
expect(s.speedLabel).toBe('')
})
it('reports the longest active ETA as the time left', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5, eta: '0:30' }),
item({ status: 'downloading', progress: 0.2, eta: '2:10' })
item({ status: 'downloading', progress: 0.5, etaSeconds: 30 }),
item({ status: 'downloading', progress: 0.2, etaSeconds: 130 })
])
expect(s.etaLabel).toBe('2:10')
})