import { describe, expect, it } from 'vitest'; import { DEFAULT_RESOURCE_POLICY, MemoryDerivedCache, ResilientDerivedCache, createCacheKey, createResourcePolicy, estimatePlanResources, estimateOutputBytes, policyForDeviceMemory, preflightResources, readStorageQuota, stableStringify, type DerivedCache, } from '../../src/storage'; import type { FFmpegCommandPlan } from '../../src/commands/command-plan'; import type { MediaProbe } from '../../src/media'; describe('cache identities', () => { it('canonicalizes settings and produces deterministic path-safe keys', () => { const one = createCacheKey({ sourceFingerprint: 'source-123', operation: 'waveform', streamIndex: 1, settings: { zoom: 2, channels: ['left', 'right'] }, }); const two = createCacheKey({ sourceFingerprint: 'source-123', operation: 'waveform', streamIndex: 1, settings: { channels: ['left', 'right'], zoom: 2 }, }); expect(one).toBe(two); expect(one).toMatch(/^waveform-[0-9a-f]{16}$/u); expect(stableStringify({ z: 1, a: { y: 2, x: 3 } })).toBe( '{"a":{"x":3,"y":2},"z":1}' ); expect(() => createCacheKey({ sourceFingerprint: 'source', operation: '../outside', }) ).toThrow(/safe operation/u); }); }); describe('resource preflight', () => { it('allows small jobs and labels estimates as non-guarantees', () => { const decision = preflightResources({ inputBytes: [10 * 1024 * 1024], expectedOutputBytes: estimateOutputBytes(10, 2_000, 128), durationSeconds: 10, width: 1920, height: 1080, frameRate: 25, passes: 1, }); expect(decision.status).toBe('allowed'); expect(decision.totals.pixelWorkEstimate).toBe(518_400_000); expect(decision.estimateDisclaimer).toContain('not a guarantee'); }); it('blocks hard limits and provides actionable suggestions', () => { const decision = preflightResources({ inputBytes: [DEFAULT_RESOURCE_POLICY.hardSingleInputBytes + 1], width: 9000, height: 9000, thumbnailCount: 501, contactSheetCells: 501, expectedOutputBytes: DEFAULT_RESOURCE_POLICY.hardOutputEstimateBytes + 1, timelineClips: DEFAULT_RESOURCE_POLICY.maxTimelineClips + 1, streamsPerInput: [DEFAULT_RESOURCE_POLICY.maxStreamsPerInput + 1], }); expect(decision.status).toBe('blocked'); expect(decision.issues.map((issue) => issue.code)).toEqual( expect.arrayContaining([ 'single-input-hard-limit', 'total-input-hard-limit', 'pixel-limit', 'thumbnail-limit', 'contact-sheet-limit', 'output-hard-limit', 'timeline-clip-limit', 'stream-count-limit', ]) ); expect(decision.issues.every((issue) => issue.suggestions.length > 0)).toBe( true ); }); it('lowers policy for reliable low-memory signals', () => { const policy = createResourcePolicy(); expect(policyForDeviceMemory(policy, 2).softInputBytes).toBeLessThan( policy.softInputBytes ); }); it('reserves collection and report ZIP capacity before running a batch', () => { const policy = createResourcePolicy(); const decision = preflightResources( { inputBytes: [], generatedOutputs: 2, retainedOutputs: policy.maxGeneratedOutputs - 1, }, policy ); expect(decision.status).toBe('blocked'); expect(decision.issues).toContainEqual( expect.objectContaining({ code: 'generated-output-limit', message: expect.stringMatching(/ZIP with an optional report/iu), }) ); }); it('rejects malformed generated-image estimates before comparing limits', () => { expect(() => preflightResources({ inputBytes: [], thumbnailCount: -1, }) ).toThrow(/thumbnailCount/u); expect(() => preflightResources({ inputBytes: [], contactSheetCells: 1.5, }) ).toThrow(/contactSheetCells/u); }); it('derives output size, geometry, frame rate, and generated-image semantics from plans', () => { const contactEstimate = estimatePlanResources( plan({ operation: 'contact-sheet', args: [ '-vf', 'fps=0.5,scale=320:-2:force_original_aspect_ratio=decrease,tile=3x2:padding=4:margin=4', '/work/contact.jpg', ], outputs: [ { id: 'contact', path: '/work/contact.jpg', fileName: 'contact.jpg', mediaType: 'image/jpeg', role: 'contact-sheet', }, ], expectedDurationSeconds: 12, }), [{ size: 2_000_000, probe: videoProbe() }] ); expect(contactEstimate).toMatchObject({ width: 976, height: 372, frameRate: 0.5, contactSheetCells: 6, generatedOutputs: 1, }); const remuxEstimate = estimatePlanResources( plan({ operation: 'remux', args: ['-i', '/input/source.mp4', '-c', 'copy', '/work/output.mkv'], outputs: [ { id: 'media', path: '/work/output.mkv', fileName: 'output.mkv', mediaType: 'video/x-matroska', role: 'media', }, ], expectedDurationSeconds: 10, }), [{ size: 2_000_000, probe: videoProbe() }] ); expect(remuxEstimate.expectedOutputBytes).toBe(2_000_000); const bitrateEstimate = estimatePlanResources( plan({ operation: 'convert', args: [ '-c:v', 'libx264', '-b:v', '500k', '-c:a', 'aac', '-b:a', '128k', '/work/output.mp4', ], outputs: [ { id: 'media', path: '/work/output.mp4', fileName: 'output.mp4', mediaType: 'video/mp4', role: 'media', }, ], expectedDurationSeconds: 10, }), [{ size: 2_000_000, probe: audioVideoProbe() }] ); expect(bitrateEstimate.expectedOutputBytes).toBe( estimateOutputBytes(10, 628) ); const thumbnailEstimate = estimatePlanResources( plan({ operation: 'thumbnail-series', args: ['-vf', 'scale=320:-2', '/work/thumb.png'], outputs: Array.from({ length: 4 }, (_, index) => ({ id: `thumb-${index}`, path: `/work/thumb-${index}.png`, fileName: `thumb-${index}.png`, mediaType: 'image/png', role: 'thumbnail' as const, })), expectedDurationSeconds: 8, }), [{ size: 2_000_000, probe: videoProbe() }] ); expect(thumbnailEstimate).toMatchObject({ width: 320, height: 180, frameRate: 0.5, thumbnailCount: 4, generatedOutputs: 4, }); }); }); describe('derived cache lifecycle', () => { it('lists sizes, expires entries, clears projects and clears all', async () => { let now = new Date('2026-01-01T00:00:00.000Z'); const cache = new MemoryDerivedCache({ maxBytes: 1_024, now: () => now, }); await cache.put( { key: 'waveform-a', kind: 'waveform-peaks', projectId: 'project-a', mediaType: 'application/octet-stream', expiresAt: '2026-01-02T00:00:00.000Z', }, new Uint8Array([1, 2, 3]) ); await cache.put( { key: 'thumb-b', kind: 'thumbnail', projectId: 'project-b', mediaType: 'image/png', }, new Blob(['image'], { type: 'image/png' }) ); expect(await cache.sizeBytes()).toBe(8); expect((await cache.list()).map((entry) => entry.key)).toHaveLength(2); now = new Date('2026-01-03T00:00:00.000Z'); expect(await cache.pruneExpired()).toBe(1); expect(await cache.get('waveform-a')).toBeUndefined(); expect(await cache.clearProject('project-b')).toBe(1); expect(await cache.sizeBytes()).toBe(0); await cache.clear(); expect(await cache.list()).toEqual([]); }); it('evicts least recently used memory entries under the configured limit', async () => { let tick = 0; const cache = new MemoryDerivedCache({ maxBytes: 6, now: () => new Date(1_000 + tick++), }); await cache.put({ key: 'one', kind: 'project' }, new Blob(['123'])); await cache.put({ key: 'two', kind: 'project' }, new Blob(['456'])); await cache.get('one'); await cache.put({ key: 'three', kind: 'project' }, new Blob(['789'])); expect(await cache.get('two')).toBeUndefined(); expect(await cache.get('one')).toBeDefined(); expect(await cache.get('three')).toBeDefined(); }); it('switches explicitly to memory fallback when OPFS operations fail', async () => { const primary = new ThrowingCache(); const fallback = new MemoryDerivedCache(); const cache = new ResilientDerivedCache(primary, fallback); expect(cache.mode).toBe('opfs'); await cache.put({ key: 'fallback', kind: 'project' }, new Blob(['ok'])); expect(cache.mode).toBe('memory'); expect(cache.fallbackReason).toContain('quota denied'); expect(await cache.get('fallback')).toBeDefined(); await cache.clear(); expect(await cache.list()).toEqual([]); }); it('reports quota and unavailable estimate failures', async () => { expect( await readStorageQuota({ estimate: async () => ({ usage: 25, quota: 100 }), persisted: async () => true, }) ).toEqual({ available: true, usageBytes: 25, quotaBytes: 100, remainingBytes: 75, usageFraction: 0.25, persisted: true, }); expect( await readStorageQuota({ estimate: async () => { throw new Error('denied'); }, }) ).toEqual({ available: false, error: 'denied' }); }); }); class ThrowingCache implements DerivedCache { readonly mode = 'opfs' as const; async list(): Promise { throw new Error('quota denied'); } async get(): Promise { throw new Error('quota denied'); } async put(): Promise { throw new Error('quota denied'); } async delete(): Promise { throw new Error('quota denied'); } async clear(): Promise { throw new Error('quota denied'); } async clearProject(): Promise { throw new Error('quota denied'); } async pruneExpired(): Promise { throw new Error('quota denied'); } async sizeBytes(): Promise { throw new Error('quota denied'); } } function plan( options: Pick< FFmpegCommandPlan, 'operation' | 'args' | 'outputs' | 'expectedDurationSeconds' > ): FFmpegCommandPlan { return { id: `job:${options.operation}`, operation: options.operation, inputs: [], temporaryFiles: [], args: options.args, outputs: options.outputs, expectedDurationSeconds: options.expectedDurationSeconds, requiredCapabilities: {}, diagnostics: [], }; } function videoProbe(): MediaProbe { return { durationSeconds: 10, bitRate: 800_000, formatNames: ['mov', 'mp4'], tags: {}, chapters: [], warnings: [], streams: [ { index: 0, type: 'video', width: 1920, height: 1080, frameRate: 25, disposition: {}, tags: {}, }, ], }; } function audioVideoProbe(): MediaProbe { const probe = videoProbe(); return { ...probe, streams: [ ...probe.streams, { index: 1, type: 'audio', disposition: {}, tags: {}, }, ], }; }