Files
av-tools/tests/unit/storage.test.ts

411 lines
11 KiB
TypeScript

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<never> {
throw new Error('quota denied');
}
async get(): Promise<never> {
throw new Error('quota denied');
}
async put(): Promise<never> {
throw new Error('quota denied');
}
async delete(): Promise<never> {
throw new Error('quota denied');
}
async clear(): Promise<never> {
throw new Error('quota denied');
}
async clearProject(): Promise<never> {
throw new Error('quota denied');
}
async pruneExpired(): Promise<never> {
throw new Error('quota denied');
}
async sizeBytes(): Promise<never> {
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: {},
},
],
};
}