feat: publish av-tools 0.1.0
This commit is contained in:
66
tests/unit/project/project-fixture.ts
Normal file
66
tests/unit/project/project-fixture.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createEmptyProject } from '../../../src/project/project.schema';
|
||||
import type {
|
||||
AvProjectV1,
|
||||
MediaAssetReference,
|
||||
} from '../../../src/project/project.types';
|
||||
|
||||
export const CREATED_AT = '2026-01-02T03:04:05.000Z';
|
||||
export const UPDATED_AT = '2026-01-02T04:00:00.000Z';
|
||||
|
||||
export function mediaAsset(
|
||||
overrides: Partial<MediaAssetReference> = {}
|
||||
): MediaAssetReference {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
originalName: 'source.mp4',
|
||||
size: 1_024,
|
||||
lastModified: 1_700_000_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
probe: {
|
||||
durationSeconds: 10,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video',
|
||||
codecName: 'h264',
|
||||
disposition: { default: true },
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function populatedProject(): AvProjectV1 {
|
||||
const project = createEmptyProject({
|
||||
id: 'project-1',
|
||||
name: 'Demo project',
|
||||
timestamp: CREATED_AT,
|
||||
});
|
||||
return {
|
||||
...project,
|
||||
assets: [mediaAsset()],
|
||||
timeline: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 1,
|
||||
sourceOutSeconds: 4.5,
|
||||
audio: { enabled: true, fadeInSeconds: 0.2 },
|
||||
video: { enabled: true },
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
selectedAssetId: 'asset-1',
|
||||
selectedClipId: 'clip-1',
|
||||
inspectorPanel: 'clip',
|
||||
timelineZoom: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
191
tests/unit/project/project-reducer-selectors.test.ts
Normal file
191
tests/unit/project/project-reducer-selectors.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProjectReducerError,
|
||||
projectReducer,
|
||||
} from '../../../src/project/project.reducer';
|
||||
import {
|
||||
selectCanExport,
|
||||
selectMissingAssets,
|
||||
selectTimelineDuration,
|
||||
selectTimelineEntries,
|
||||
selectUnusedAssets,
|
||||
} from '../../../src/project/project.selectors';
|
||||
import { createEmptyProject } from '../../../src/project/project.schema';
|
||||
import {
|
||||
CREATED_AT,
|
||||
UPDATED_AT,
|
||||
mediaAsset,
|
||||
populatedProject,
|
||||
} from './project-fixture';
|
||||
|
||||
describe('project reducer', () => {
|
||||
it('adds assets and sequential clips without mutating prior state', () => {
|
||||
const empty = createEmptyProject({
|
||||
id: 'p',
|
||||
timestamp: CREATED_AT,
|
||||
});
|
||||
const withAsset = projectReducer(empty, {
|
||||
type: 'asset/add',
|
||||
asset: mediaAsset(),
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
const withClip = projectReducer(withAsset, {
|
||||
type: 'clip/add',
|
||||
clip: {
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 2,
|
||||
},
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
|
||||
expect(empty.assets).toEqual([]);
|
||||
expect(withAsset.timeline).toEqual([]);
|
||||
expect(withClip.timeline).toHaveLength(1);
|
||||
expect(withClip.updatedAt).toBe(UPDATED_AT);
|
||||
});
|
||||
|
||||
it('reorders clips deterministically and returns identity for a no-op', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
timeline: [
|
||||
populatedProject().timeline[0]!,
|
||||
{
|
||||
id: 'clip-2',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 4.5,
|
||||
sourceOutSeconds: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
const moved = projectReducer(project, {
|
||||
type: 'clip/move',
|
||||
clipId: 'clip-2',
|
||||
toIndex: 0,
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
expect(moved.timeline.map((clip) => clip.id)).toEqual(['clip-2', 'clip-1']);
|
||||
expect(
|
||||
projectReducer(moved, {
|
||||
type: 'clip/move',
|
||||
clipId: 'clip-2',
|
||||
toIndex: 0,
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toBe(moved);
|
||||
});
|
||||
|
||||
it('removes dependent clips/subtitles and clears stale UI selection', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
subtitles: [
|
||||
{ id: 'subtitle-1', assetId: 'asset-1', mode: 'mux' as const },
|
||||
],
|
||||
};
|
||||
const result = projectReducer(project, {
|
||||
type: 'asset/remove',
|
||||
assetId: 'asset-1',
|
||||
updatedAt: UPDATED_AT,
|
||||
});
|
||||
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.timeline).toEqual([]);
|
||||
expect(result.subtitles).toEqual([]);
|
||||
expect(result.ui).toMatchObject({
|
||||
selectedAssetId: undefined,
|
||||
selectedClipId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('guards duplicate IDs, missing assets and invalid ranges', () => {
|
||||
const project = populatedProject();
|
||||
expect(() =>
|
||||
projectReducer(project, {
|
||||
type: 'asset/add',
|
||||
asset: mediaAsset(),
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toThrow(ProjectReducerError);
|
||||
expect(() =>
|
||||
projectReducer(project, {
|
||||
type: 'clip/add',
|
||||
clip: {
|
||||
id: 'bad',
|
||||
assetId: 'missing',
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 1,
|
||||
},
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toThrow('unknown asset');
|
||||
expect(() =>
|
||||
projectReducer(project, {
|
||||
type: 'clip/update',
|
||||
clipId: 'clip-1',
|
||||
changes: { sourceOutSeconds: 0 },
|
||||
updatedAt: UPDATED_AT,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('does not mark the document updated for transient UI state', () => {
|
||||
const project = populatedProject();
|
||||
const result = projectReducer(project, {
|
||||
type: 'ui/set',
|
||||
ui: { inspectorPanel: 'output' },
|
||||
});
|
||||
expect(result.updatedAt).toBe(project.updatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project selectors', () => {
|
||||
it('derives timeline positions and total duration for a sequential edit', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
timeline: [
|
||||
populatedProject().timeline[0]!,
|
||||
{
|
||||
id: 'clip-2',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 5,
|
||||
sourceOutSeconds: 6.25,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(selectTimelineDuration(project)).toBe(4.75);
|
||||
expect(selectTimelineEntries(project)).toMatchObject([
|
||||
{
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 3.5,
|
||||
durationSeconds: 3.5,
|
||||
},
|
||||
{
|
||||
timelineStartSeconds: 3.5,
|
||||
timelineEndSeconds: 4.75,
|
||||
durationSeconds: 1.25,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies missing, unused and export-blocking sources', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
assets: [
|
||||
mediaAsset({ sourceStatus: 'missing' }),
|
||||
mediaAsset({ id: 'asset-unused', originalName: 'unused.wav' }),
|
||||
],
|
||||
};
|
||||
expect(selectMissingAssets(project).map((asset) => asset.id)).toEqual([
|
||||
'asset-1',
|
||||
]);
|
||||
expect(selectUnusedAssets(project).map((asset) => asset.id)).toEqual([
|
||||
'asset-unused',
|
||||
]);
|
||||
expect(selectCanExport(project)).toEqual({
|
||||
canExport: false,
|
||||
reasons: ['1 referenced source file is missing.'],
|
||||
});
|
||||
});
|
||||
});
|
||||
65
tests/unit/project/project-schema.test.ts
Normal file
65
tests/unit/project/project-schema.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createEmptyProject,
|
||||
createMediaAssetReference,
|
||||
} from '../../../src/project/project.schema';
|
||||
import { CREATED_AT } from './project-fixture';
|
||||
|
||||
describe('project factories', () => {
|
||||
it('creates a complete, deterministic empty document when inputs are supplied', () => {
|
||||
expect(
|
||||
createEmptyProject({
|
||||
id: 'project-1',
|
||||
name: ' Test ',
|
||||
timestamp: CREATED_AT,
|
||||
})
|
||||
).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
id: 'project-1',
|
||||
name: 'Test',
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: CREATED_AT,
|
||||
assets: [],
|
||||
timeline: [],
|
||||
output: {
|
||||
presetId: 'mp4-h264-balanced',
|
||||
fileName: 'output.mp4',
|
||||
container: 'mp4',
|
||||
sampleRate: 48_000,
|
||||
channelLayout: 'stereo',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('converts File-compatible identity metadata without retaining the File', () => {
|
||||
const file = {
|
||||
name: 'clip.mov',
|
||||
size: 42,
|
||||
lastModified: 123,
|
||||
type: 'video/quicktime',
|
||||
};
|
||||
const asset = createMediaAssetReference(file, { id: 'asset-1' });
|
||||
expect(asset).toEqual({
|
||||
id: 'asset-1',
|
||||
originalName: 'clip.mov',
|
||||
size: 42,
|
||||
lastModified: 123,
|
||||
mimeType: 'video/quicktime',
|
||||
sourceStatus: 'attached',
|
||||
});
|
||||
expect(asset).not.toHaveProperty('file');
|
||||
});
|
||||
|
||||
it('rejects malformed file identity metadata', () => {
|
||||
expect(() =>
|
||||
createMediaAssetReference({
|
||||
name: '',
|
||||
size: -1,
|
||||
lastModified: 0,
|
||||
type: '',
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
179
tests/unit/project/project-serialization.test.ts
Normal file
179
tests/unit/project/project-serialization.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProjectImportError,
|
||||
importProjectDocument,
|
||||
projectDocumentFileName,
|
||||
serializeProject,
|
||||
} from '../../../src/project/project.serialization';
|
||||
import { ProjectValidationError } from '../../../src/project/project.validation';
|
||||
import { populatedProject } from './project-fixture';
|
||||
|
||||
describe('project serialization', () => {
|
||||
it('serializes readable, versioned JSON and can omit transient UI state', () => {
|
||||
const project = populatedProject();
|
||||
const json = serializeProject(project, { includeUiState: false });
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
|
||||
expect(json.endsWith('\n')).toBe(true);
|
||||
expect(parsed.schemaVersion).toBe(1);
|
||||
expect(parsed.ui).toBeUndefined();
|
||||
expect(project.ui).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates before serializing', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
output: { ...populatedProject().output, fileName: '' },
|
||||
};
|
||||
expect(() => serializeProject(project)).toThrow(ProjectValidationError);
|
||||
});
|
||||
|
||||
it('imports a round trip but correctly marks browser sources missing', () => {
|
||||
const project = populatedProject();
|
||||
const result = importProjectDocument(serializeProject(project));
|
||||
|
||||
expect(result.fromVersion).toBe(1);
|
||||
expect(result.project.assets[0]?.sourceStatus).toBe('missing');
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({ code: 'source-reattachment-required' })
|
||||
);
|
||||
});
|
||||
|
||||
it('can retain runtime source status only when explicitly requested', () => {
|
||||
const project = populatedProject();
|
||||
const result = importProjectDocument(serializeProject(project), {
|
||||
markSourcesMissing: false,
|
||||
});
|
||||
expect(result.project).toEqual(project);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trips fade curves and editable chapter time bases', () => {
|
||||
const project = populatedProject();
|
||||
const persisted = {
|
||||
...project,
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0]!,
|
||||
audio: {
|
||||
enabled: true,
|
||||
fadeInSeconds: 0.2,
|
||||
fadeCurve: 'qsin' as const,
|
||||
},
|
||||
video: {
|
||||
enabled: true,
|
||||
fadeOutSeconds: 0.2,
|
||||
fadeCurve: 'exp' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Intro',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/48000',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = importProjectDocument(serializeProject(persisted), {
|
||||
markSourcesMissing: false,
|
||||
});
|
||||
|
||||
expect(result.project.timeline[0]?.audio?.fadeCurve).toBe('qsin');
|
||||
expect(result.project.timeline[0]?.video?.fadeCurve).toBe('exp');
|
||||
expect(result.project.chapters[0]?.timeBase).toBe('1/48000');
|
||||
});
|
||||
|
||||
it('still reports reattachment when an exported project was already missing sources', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
assets: [
|
||||
{ ...populatedProject().assets[0]!, sourceStatus: 'missing' as const },
|
||||
],
|
||||
};
|
||||
const result = importProjectDocument(serializeProject(project));
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({ code: 'source-reattachment-required' })
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates the documented legacy version zero shape', () => {
|
||||
const legacy = {
|
||||
schemaVersion: 0,
|
||||
id: 'legacy-project',
|
||||
title: 'Legacy project',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
media: [
|
||||
{
|
||||
id: 'legacy-source',
|
||||
fileName: 'legacy.mp4',
|
||||
size: 42,
|
||||
lastModified: 100,
|
||||
type: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
},
|
||||
],
|
||||
clips: [
|
||||
{
|
||||
id: 'legacy-clip',
|
||||
assetId: 'legacy-source',
|
||||
inSeconds: 0,
|
||||
outSeconds: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = importProjectDocument(JSON.stringify(legacy));
|
||||
|
||||
expect(result.fromVersion).toBe(0);
|
||||
expect(result.project).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
name: 'Legacy project',
|
||||
assets: [
|
||||
{
|
||||
originalName: 'legacy.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'missing',
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'renamed-field',
|
||||
'defaulted-field',
|
||||
'source-reattachment-required',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed, unversioned, future and oversized documents', () => {
|
||||
expect(() => importProjectDocument('{')).toThrow(ProjectImportError);
|
||||
expect(() => importProjectDocument('{}')).toThrow(
|
||||
'does not declare a schema version'
|
||||
);
|
||||
expect(() => importProjectDocument('{"schemaVersion":999}')).toThrow(
|
||||
'is not supported'
|
||||
);
|
||||
expect(() =>
|
||||
importProjectDocument('{"schemaVersion":1}', { maximumBytes: 5 })
|
||||
).toThrow('exceeds');
|
||||
});
|
||||
|
||||
it('creates a safe project-document filename', () => {
|
||||
expect(
|
||||
projectDocumentFileName({
|
||||
...populatedProject(),
|
||||
name: '../../My Project',
|
||||
})
|
||||
).toBe('My-Project.avproject.json');
|
||||
});
|
||||
});
|
||||
199
tests/unit/project/project-validation.test.ts
Normal file
199
tests/unit/project/project-validation.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ProjectValidationError,
|
||||
assertValidProject,
|
||||
validateProject,
|
||||
} from '../../../src/project/project.validation';
|
||||
import { populatedProject } from './project-fixture';
|
||||
|
||||
describe('project validation', () => {
|
||||
it('returns an isolated, typed copy for a valid version-1 project', () => {
|
||||
const input = populatedProject();
|
||||
const result = validateProject(input);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.project).toEqual(input);
|
||||
expect(result.project).not.toBe(input);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports duplicate identities, missing references and bad ranges', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = {
|
||||
...project,
|
||||
assets: [...project.assets, { ...project.assets[0] }],
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0],
|
||||
assetId: 'missing',
|
||||
sourceInSeconds: 5,
|
||||
sourceOutSeconds: 3,
|
||||
crop: { x: -1, y: 0, width: 0, height: 100 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateProject(invalid);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'duplicate-id',
|
||||
'missing-reference',
|
||||
'invalid-range',
|
||||
'invalid-value',
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('checks clip bounds, fade bounds, chapter bounds and output streams', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = {
|
||||
...project,
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0],
|
||||
sourceOutSeconds: 11,
|
||||
audio: {
|
||||
enabled: true,
|
||||
fadeInSeconds: 8,
|
||||
fadeOutSeconds: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
startSeconds: 0,
|
||||
endSeconds: 20,
|
||||
title: 'Too long',
|
||||
},
|
||||
],
|
||||
output: {
|
||||
...project.output,
|
||||
videoEnabled: false,
|
||||
audioEnabled: false,
|
||||
},
|
||||
};
|
||||
const result = validateProject(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid-range',
|
||||
path: 'timeline[0]',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'out-of-bounds',
|
||||
path: 'timeline[0].audio',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'out-of-bounds',
|
||||
path: 'chapters[0].endSeconds',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'invalid-value',
|
||||
path: 'output',
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates persisted fade curves and chapter time-base resolution', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = {
|
||||
...project,
|
||||
timeline: [
|
||||
{
|
||||
...project.timeline[0],
|
||||
audio: {
|
||||
enabled: true,
|
||||
fadeInSeconds: 0.2,
|
||||
fadeCurve: 'log',
|
||||
},
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
startSeconds: 0,
|
||||
endSeconds: 0.1,
|
||||
title: 'Too short for this base',
|
||||
timeBase: '1/1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateProject(invalid);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid-value',
|
||||
path: 'timeline[0].audio.fadeCurve',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'invalid-value',
|
||||
path: 'chapters[0].timeBase',
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported schema versions with a useful typed exception', () => {
|
||||
const project = { ...populatedProject(), schemaVersion: 99 };
|
||||
expect(() => assertValidProject(project)).toThrow(ProjectValidationError);
|
||||
const result = validateProject(project);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues[0]).toMatchObject({
|
||||
code: 'unsupported-version',
|
||||
path: 'schemaVersion',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trust malformed normalized probe fields from an import', () => {
|
||||
const project = populatedProject();
|
||||
const invalid = structuredClone(project) as unknown as {
|
||||
assets: Array<{
|
||||
probe?: {
|
||||
streams: Array<{
|
||||
width?: unknown;
|
||||
rotationDegrees?: unknown;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const stream = invalid.assets[0]?.probe?.streams[0];
|
||||
if (stream) {
|
||||
stream.width = '1920';
|
||||
stream.rotationDegrees = '90';
|
||||
}
|
||||
|
||||
const result = validateProject(invalid);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'invalid-type',
|
||||
path: 'assets[0].probe.streams[0].width',
|
||||
})
|
||||
);
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'invalid-type',
|
||||
path: 'assets[0].probe.streams[0].rotationDegrees',
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
169
tests/unit/project/source-reattachment.test.ts
Normal file
169
tests/unit/project/source-reattachment.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
SourceReattachmentError,
|
||||
bestReattachmentCandidate,
|
||||
confirmSourceReattachment,
|
||||
rankReattachmentCandidates,
|
||||
} from '../../../src/project/source-reattachment';
|
||||
import type { SourceHash } from '../../../src/project/project.types';
|
||||
import { UPDATED_AT, mediaAsset, populatedProject } from './project-fixture';
|
||||
|
||||
const HASH_A: SourceHash = {
|
||||
algorithm: 'sha-256',
|
||||
value: 'a'.repeat(64),
|
||||
scope: 'full',
|
||||
};
|
||||
const HASH_B: SourceHash = {
|
||||
algorithm: 'sha-256',
|
||||
value: 'b'.repeat(64),
|
||||
scope: 'full',
|
||||
};
|
||||
|
||||
describe('source reattachment matching', () => {
|
||||
it('ranks size, timestamp, filename, MIME type and optional hash evidence', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
|
||||
const candidates = [
|
||||
{
|
||||
name: 'renamed.mp4',
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
hash: HASH_A,
|
||||
},
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
hash: HASH_B,
|
||||
},
|
||||
];
|
||||
const matches = rankReattachmentCandidates(asset, candidates);
|
||||
|
||||
expect(matches.map((match) => match.confidence)).toEqual([
|
||||
'exact',
|
||||
'likely',
|
||||
'none',
|
||||
]);
|
||||
expect(matches[0]).toMatchObject({
|
||||
score: 190,
|
||||
requiresExplicitConfirmation: true,
|
||||
});
|
||||
expect(
|
||||
matches[0]?.evidence.find((evidence) => evidence.field === 'hash')
|
||||
).toMatchObject({ matches: true });
|
||||
});
|
||||
|
||||
it('does not suggest ambiguous ties', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing' });
|
||||
const candidates = [
|
||||
{
|
||||
name: 'a.mp4',
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
{
|
||||
name: 'b.mp4',
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
];
|
||||
expect(bestReattachmentCandidate(asset, candidates)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows filesystem timestamp tolerance but treats size/hash mismatch as unsafe', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
|
||||
expect(
|
||||
rankReattachmentCandidates(asset, [
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified + 1_500,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
])[0]?.confidence
|
||||
).toBe('likely');
|
||||
expect(
|
||||
rankReattachmentCandidates(asset, [
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size + 1,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
},
|
||||
])[0]?.confidence
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('does not treat incomparable partial and full hashes as a mismatch', () => {
|
||||
const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A });
|
||||
const partial: SourceHash = {
|
||||
algorithm: 'sha-256',
|
||||
value: 'b'.repeat(64),
|
||||
scope: 'partial',
|
||||
bytesHashed: 1_024,
|
||||
};
|
||||
expect(
|
||||
rankReattachmentCandidates(asset, [
|
||||
{
|
||||
name: asset.originalName,
|
||||
size: asset.size,
|
||||
lastModified: asset.lastModified,
|
||||
mimeType: asset.mimeType,
|
||||
hash: partial,
|
||||
},
|
||||
])[0]?.confidence
|
||||
).toBe('likely');
|
||||
});
|
||||
|
||||
it('rejects malformed file descriptors before scoring', () => {
|
||||
expect(() =>
|
||||
rankReattachmentCandidates(mediaAsset(), [
|
||||
{
|
||||
name: '',
|
||||
size: -1,
|
||||
lastModified: Number.NaN,
|
||||
},
|
||||
])
|
||||
).toThrow(SourceReattachmentError);
|
||||
});
|
||||
|
||||
it('only marks a source attached after an explicit confirmation call', () => {
|
||||
const project = {
|
||||
...populatedProject(),
|
||||
assets: [mediaAsset({ sourceStatus: 'missing' })],
|
||||
};
|
||||
const candidate = {
|
||||
name: 'renamed.mp4',
|
||||
size: 1_024,
|
||||
lastModified: 1_700_000_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
};
|
||||
const result = confirmSourceReattachment(
|
||||
project,
|
||||
'asset-1',
|
||||
candidate,
|
||||
UPDATED_AT
|
||||
);
|
||||
|
||||
expect(project.assets[0]?.sourceStatus).toBe('missing');
|
||||
expect(result.assets[0]?.sourceStatus).toBe('attached');
|
||||
expect(() =>
|
||||
confirmSourceReattachment(
|
||||
project,
|
||||
'asset-1',
|
||||
{ ...candidate, size: 1 },
|
||||
UPDATED_AT
|
||||
)
|
||||
).toThrow(SourceReattachmentError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user