0a681f864f
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>
76 lines
2.4 KiB
TypeScript
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)
|
|
})
|
|
})
|