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

437 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
AUDIO_PRESETS,
BUILT_IN_PRESETS,
MemoryUserPresetPersistence,
UserPresetRegistry,
VIDEO_PRESETS,
migrateUserPreset,
parseUserPresetJson,
presetAvailability,
presetEncodingArguments,
serializeUserPreset,
validateUserPreset,
type UserExportPreset,
} from '../../src/presets';
const USER_PRESET: UserExportPreset = {
schemaVersion: 1,
id: 'my-podcast',
name: 'My podcast',
description: 'Speech export',
kind: 'audio',
container: 'mp3',
fileExtension: 'mp3',
audio: {
codec: 'libmp3lame',
bitrateKbps: 128,
sampleRate: 44_100,
channels: 2,
},
metadataPolicy: 'copy',
chapterPolicy: 'remove',
subtitlePolicy: 'none',
};
describe('built-in preset registry', () => {
it('contains immutable, uniquely identified video and audio definitions', () => {
expect(VIDEO_PRESETS.length).toBeGreaterThanOrEqual(6);
expect(AUDIO_PRESETS.length).toBeGreaterThanOrEqual(6);
expect(new Set(BUILT_IN_PRESETS.map((preset) => preset.id)).size).toBe(
BUILT_IN_PRESETS.length
);
expect(Object.isFrozen(BUILT_IN_PRESETS[0])).toBe(true);
expect(Object.isFrozen(BUILT_IN_PRESETS[0]?.settingsSummary)).toBe(true);
});
it('disables a preset with exact missing capability reasons', () => {
const preset = BUILT_IN_PRESETS.find(
(candidate) => candidate.id === 'webm-vp9-opus'
)!;
const unavailable = presetAvailability(preset, {
muxers: new Set(['webm']),
encoders: new Set(['libvpx-vp9']),
decoders: new Set(),
filters: new Set(),
});
expect(unavailable).toEqual({
status: 'unavailable',
reasons: ['Missing encoders: libopus'],
});
expect(
presetAvailability(preset, {
muxers: new Set(['webm']),
encoders: new Set(['libvpx-vp9', 'libopus']),
decoders: new Set(),
filters: new Set(),
})
).toEqual({ status: 'available', reasons: [] });
expect(
presetAvailability(
preset,
{
muxers: new Set(['webm']),
encoders: new Set(['libvpx-vp9', 'libopus']),
decoders: new Set(),
filters: new Set(),
},
{ filters: ['drawtext'] }
)
).toEqual({
status: 'degraded',
reasons: ['Optional filters unavailable: drawtext'],
});
});
it('turns allowlisted settings into structured encoder arguments', () => {
expect(presetEncodingArguments(USER_PRESET)).toEqual([
'-vn',
'-c:a',
'libmp3lame',
'-b:a',
'128k',
'-ar',
'44100',
'-ac',
'2',
]);
});
});
describe('user preset validation and persistence', () => {
it('round-trips a versioned preset as JSON', () => {
const json = serializeUserPreset(USER_PRESET);
expect(parseUserPresetJson(json)).toEqual(USER_PRESET);
expect(validateUserPreset(USER_PRESET)).toMatchObject({ valid: true });
});
it('migrates an allowlisted legacy schema without accepting raw fields', () => {
expect(
migrateUserPreset({
schemaVersion: 0,
id: 'legacy',
name: 'Legacy',
kind: 'audio',
format: 'ipod',
audio: { codec: 'aac', bitrateKbps: 128 },
includeChapters: true,
})
).toEqual({
schemaVersion: 1,
id: 'legacy',
name: 'Legacy',
kind: 'audio',
container: 'ipod',
fileExtension: 'm4a',
audio: { codec: 'aac', bitrateKbps: 128 },
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'none',
});
expect(() =>
migrateUserPreset({
schemaVersion: 0,
id: 'legacy',
name: 'Legacy',
kind: 'audio',
format: 'mp3',
args: ['-i', 'remote'],
})
).toThrow(/unsupported field/iu);
});
it.each([
['args', ['-i', 'https://example.invalid/a']],
['filter', 'movie=https://example.invalid'],
['inputPath', '/etc/passwd'],
['protocol', 'https'],
])('rejects imported arbitrary %s fields', (field, value) => {
expect(
validateUserPreset({ ...USER_PRESET, [field]: value })
).toMatchObject({
valid: false,
errors: [expect.stringContaining(`"${field}"`)],
});
});
it('rejects unknown codecs and unsafe identifiers', () => {
expect(
validateUserPreset({
...USER_PRESET,
id: '../../unsafe',
audio: { codec: 'shell', bitrateKbps: 128 },
})
).toMatchObject({ valid: false });
});
it.each([
[
'MP4 H.264/AAC video',
{
...USER_PRESET,
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: { codec: 'libx264', qualityMode: 'balanced', crf: 23 },
audio: { codec: 'aac', bitrateKbps: 160 },
chapterPolicy: 'keep',
subtitlePolicy: 'convert',
fastStart: true,
},
],
[
'WebM VP9/Opus video',
{
...USER_PRESET,
kind: 'video',
container: 'webm',
fileExtension: 'webm',
video: { codec: 'libvpx-vp9', qualityMode: 'smaller-file', crf: 32 },
audio: { codec: 'libopus', bitrateKbps: 128 },
chapterPolicy: 'keep',
subtitlePolicy: 'convert',
},
],
[
'Matroska VP8/FLAC video',
{
...USER_PRESET,
kind: 'video',
container: 'matroska',
fileExtension: 'mkv',
video: { codec: 'libvpx', qualityMode: 'compatibility', crf: 18 },
audio: { codec: 'flac' },
chapterPolicy: 'keep',
subtitlePolicy: 'copy-compatible',
},
],
[
'animated WebP derivative',
{
...USER_PRESET,
kind: 'derivative',
container: 'webp',
fileExtension: 'webp',
video: {
codec: 'libwebp_anim',
qualityMode: 'balanced',
crf: 60,
},
audio: undefined,
metadataPolicy: 'remove',
chapterPolicy: 'remove',
subtitlePolicy: 'none',
},
],
[
'GIF derivative',
{
...USER_PRESET,
kind: 'derivative',
container: 'gif',
fileExtension: 'gif',
video: { codec: 'gif', qualityMode: 'compatibility' },
audio: undefined,
metadataPolicy: 'remove',
chapterPolicy: 'remove',
subtitlePolicy: 'none',
},
],
['MP3 audio', USER_PRESET],
[
'M4A/AAC audio',
{
...USER_PRESET,
container: 'ipod',
fileExtension: 'm4a',
audio: { codec: 'aac', bitrateKbps: 160 },
fastStart: true,
},
],
[
'Ogg/Vorbis audio',
{
...USER_PRESET,
container: 'ogg',
fileExtension: 'ogg',
audio: { codec: 'libvorbis', bitrateKbps: 160 },
},
],
[
'Opus audio',
{
...USER_PRESET,
container: 'opus',
fileExtension: 'opus',
audio: { codec: 'libopus', bitrateKbps: 128 },
},
],
[
'WAV/PCM audio',
{
...USER_PRESET,
container: 'wav',
fileExtension: 'wav',
audio: { codec: 'pcm_s16le' },
},
],
[
'FLAC audio',
{
...USER_PRESET,
container: 'flac',
fileExtension: 'flac',
audio: { codec: 'flac' },
},
],
])('accepts the reviewed %s combination', (_label, preset) => {
expect(validateUserPreset(preset)).toMatchObject({
valid: true,
errors: [],
});
});
it.each([
[
'an unknown container',
{ ...USER_PRESET, container: 'avi', fileExtension: 'avi' },
/container "avi".*allowlist/iu,
],
[
'a mismatched extension',
{
...USER_PRESET,
container: 'ipod',
fileExtension: 'mp4',
audio: { codec: 'aac' },
},
/fileExtension must be "m4a"/u,
],
[
'the wrong media kind',
{
...USER_PRESET,
kind: 'audio',
container: 'webm',
fileExtension: 'webm',
video: undefined,
audio: { codec: 'libopus' },
},
/container "webm".*kind "audio"/u,
],
[
'an incompatible video codec',
{
...USER_PRESET,
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: { codec: 'libvpx-vp9', qualityMode: 'balanced' },
audio: { codec: 'aac' },
},
/video codec "libvpx-vp9".*container "mp4"/u,
],
[
'an incompatible audio codec',
{
...USER_PRESET,
kind: 'video',
container: 'webm',
fileExtension: 'webm',
video: { codec: 'libvpx', qualityMode: 'balanced' },
audio: { codec: 'aac' },
},
/audio codec "aac".*container "webm"/u,
],
[
'audio in an image derivative',
{
...USER_PRESET,
kind: 'derivative',
container: 'gif',
fileExtension: 'gif',
video: { codec: 'gif', qualityMode: 'compatibility' },
},
/container "gif" does not support audio settings/u,
],
[
'a video container without video',
{
...USER_PRESET,
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
audio: { codec: 'aac' },
video: undefined,
},
/container "mp4" requires video settings/u,
],
[
'fast-start outside MP4/M4A',
{ ...USER_PRESET, fastStart: true },
/fastStart is only supported.*not "mp3"/u,
],
])('rejects %s with a clear compatibility error', (_label, preset, error) => {
const result = validateUserPreset(preset);
expect(result.valid).toBe(false);
expect(result.errors.join('; ')).toMatch(error);
});
it.each([
['metadataPolicy', 'edit', /metadataPolicy "edit".*Quick presets/u],
['chapterPolicy', 'replace', /chapterPolicy "replace".*Quick presets/u],
[
'subtitlePolicy',
'burn-in',
/subtitlePolicy "burn-in".*explicit subtitle source/u,
],
])(
'rejects the unsupported Quick preset policy %s=%s',
(field, value, error) => {
const result = validateUserPreset({
...USER_PRESET,
[field]: value,
});
expect(result.valid).toBe(false);
expect(result.errors.join('; ')).toMatch(error);
}
);
it('applies relational compatibility checks to imported JSON', () => {
expect(() =>
parseUserPresetJson(
JSON.stringify({
...USER_PRESET,
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: { codec: 'libvpx-vp9', qualityMode: 'balanced' },
audio: { codec: 'aac' },
})
)
).toThrow(/video codec "libvpx-vp9".*container "mp4"/iu);
});
it('supports create, clone, rename, export, import, delete and reset', async () => {
const persistence = new MemoryUserPresetPersistence();
const registry = new UserPresetRegistry(persistence);
await registry.initialize();
await registry.save(USER_PRESET);
const clone = await registry.clone('my-podcast', 'my-podcast-copy', 'Copy');
expect(clone.name).toBe('Copy');
await registry.rename(clone.id, 'Renamed');
expect(registry.get(clone.id)?.name).toBe('Renamed');
const exported = registry.export('my-podcast');
await registry.delete('my-podcast');
expect(registry.get('my-podcast')).toBeUndefined();
await registry.import(exported);
expect(registry.get('my-podcast')).toEqual(USER_PRESET);
await registry.reset();
expect(registry.list()).toEqual([]);
const reloaded = new UserPresetRegistry(persistence);
await reloaded.initialize();
expect(reloaded.list()).toEqual([]);
});
});