Files
AeroFetch/test/queuePersist.test.ts
debont80 0a681f864f feat(m4): persist the download queue across restarts
The queue/scheduler lived only in renderer memory, so saved/scheduled and other
pending downloads were silently lost on quit (audit M4). Persist them:

- main: queue.ts store (proven cached-atomic createJsonStore, queue.json) +
  queue:list / queue:save IPC; QUEUE_MAX cap.
- shared: PersistedQueueItem = the durable subset of DownloadItem (runtime-only
  progress/speed/eta/sizeLabel/finishing dropped).
- renderer: mirror the persistable items on every queue change (a signature over
  the subset means progress ticks don't re-save; main's jsonStore coalesces the
  writes) and rehydrate on launch via an App useEffect. Pure mappers extracted
  to store/queuePersist.ts and unit-tested.

Restore semantics: downloading -> queued (yt-dlp continues the .part), saved +
scheduledFor survives so the promoter fires when due, paused/error return
actionable; completed/canceled are never persisted. A queueHydrated gate stops a
launch-time store change from wiping queue.json before it's read.

Reconciles ROADMAP.md. typecheck + 268 tests + eslint + prettier green.
(OS-level quit/relaunch cycle is worth a manual smoke test.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:25:58 -04:00

76 lines
2.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
toPersisted,
fromPersisted,
persistableItems
} from '../src/renderer/src/store/queuePersist'
import type { DownloadItem } from '../src/renderer/src/store/downloadTypes'
function item(over: Partial<DownloadItem>): DownloadItem {
return {
id: 'i1',
url: 'https://example.com/1',
title: 'Title',
kind: 'video',
quality: '1080p',
status: 'queued',
progress: 0,
...over
}
}
describe('queuePersist (M4)', () => {
it('keeps pending states and drops transient completed/canceled', () => {
const items = [
item({ id: 'q', status: 'queued' }),
item({ id: 'd', status: 'downloading' }),
item({ id: 'p', status: 'paused' }),
item({ id: 's', status: 'saved', scheduledFor: 123 }),
item({ id: 'e', status: 'error', error: 'boom' }),
item({ id: 'c', status: 'completed' }),
item({ id: 'x', status: 'canceled' })
]
expect(persistableItems(items).map((i) => i.id)).toEqual(['q', 'd', 'p', 's', 'e'])
})
it('drops runtime-only fields when persisting', () => {
const p = toPersisted(
item({
progress: 0.5,
speedBytesPerSec: 1000,
etaSeconds: 10,
sizeLabel: '5 MB',
finishing: true
})
)
expect(p).not.toHaveProperty('progress')
expect(p).not.toHaveProperty('speedBytesPerSec')
expect(p).not.toHaveProperty('etaSeconds')
expect(p).not.toHaveProperty('sizeLabel')
expect(p).not.toHaveProperty('finishing')
})
it('normalizes a downloading item back to queued with progress 0 on restore', () => {
const restored = fromPersisted(toPersisted(item({ status: 'downloading', progress: 0.7 })))
expect(restored.status).toBe('queued')
expect(restored.progress).toBe(0)
})
it('preserves saved + scheduledFor across a round-trip (the core M4 promise)', () => {
const restored = fromPersisted(toPersisted(item({ status: 'saved', scheduledFor: 999 })))
expect(restored.status).toBe('saved')
expect(restored.scheduledFor).toBe(999)
})
it('round-trips durable fields (formatId/trim/incognito)', () => {
const restored = fromPersisted(
toPersisted(
item({ formatId: '137', formatHasAudio: false, trim: '1:00-2:00', incognito: true })
)
)
expect(restored.formatId).toBe('137')
expect(restored.trim).toBe('1:00-2:00')
expect(restored.incognito).toBe(true)
})
})