diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:20 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:20 +0200 |
| commit | 30a36e45686db503fded68e4da5d4c465a06f978 (patch) | |
| tree | 2dbe5e2aaef543155b021f5f4c504fad77b40648 /apps/web/src/project/transfer/__tests__/remap.test.ts | |
| parent | d55d2583a71ae35fa8d0d1bfbec975e67e66aafa (diff) | |
| download | opencut-30a36e45686db503fded68e4da5d4c465a06f978.tar.gz | |
feat: single-file project import/export (.ocp)
fflate zip container: project.json (serialized project incl. schema
version) + media files with a manifest. Export gathers all project
media (missing blobs warn, never abort) after save-flush; import
validates everything before any write, migrates older archives through
the storage migration registry, remaps media ids, and creates a new
project with rollback on failure. Entry points: projects page
import/export, editor header menu, export-project-archive action.
29 tests incl. full round-trip.
Diffstat (limited to 'apps/web/src/project/transfer/__tests__/remap.test.ts')
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/remap.test.ts | 166 |
1 files changed, 166 insertions, 0 deletions
diff --git a/apps/web/src/project/transfer/__tests__/remap.test.ts b/apps/web/src/project/transfer/__tests__/remap.test.ts new file mode 100644 index 00000000..356a91ab --- /dev/null +++ b/apps/web/src/project/transfer/__tests__/remap.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRecord } from "@/services/storage/migrations/transformers/types"; +import { + collectReferencedMediaIds, + remapProjectMediaIds, + resolveImportedProjectName, +} from "../remap"; + +function buildProject(): ProjectRecord { + return { + version: 31, + metadata: { id: "project-1", name: "Demo" }, + currentSceneId: "scene-1", + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "track-main", + elements: [ + { id: "el-video", type: "video", mediaId: "media-video" }, + { id: "el-image", type: "image", mediaId: "media-image" }, + ], + }, + overlay: [ + { + id: "track-text", + elements: [{ id: "el-text", type: "text", content: "hello" }], + }, + ], + audio: [ + { + id: "track-audio", + elements: [ + { + id: "el-upload", + type: "audio", + sourceType: "upload", + mediaId: "media-audio", + }, + { + id: "el-library", + type: "audio", + sourceType: "library", + sourceUrl: "https://example.com/sound.mp3", + }, + { + id: "el-shared", + type: "audio", + sourceType: "upload", + mediaId: "media-video", + }, + ], + }, + ], + }, + }, + { + id: "scene-2", + tracks: { + main: { + id: "track-main-2", + elements: [ + { id: "el-video-2", type: "video", mediaId: "media-video-2" }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }; +} + +describe("collectReferencedMediaIds", () => { + test("collects media ids across scenes and track groups, deduplicated", () => { + const ids = collectReferencedMediaIds({ project: buildProject() }); + expect(ids.sort()).toEqual([ + "media-audio", + "media-image", + "media-video", + "media-video-2", + ]); + }); + + test("tolerates malformed shapes", () => { + expect(collectReferencedMediaIds({ project: null })).toEqual([]); + expect(collectReferencedMediaIds({ project: { scenes: "nope" } })).toEqual( + [], + ); + expect( + collectReferencedMediaIds({ + project: { scenes: [{ tracks: { main: { elements: "nope" } } }] }, + }), + ).toEqual([]); + }); +}); + +describe("remapProjectMediaIds", () => { + test("replaces mapped ids only, across every track group", () => { + const project = buildProject(); + const idMap = new Map([ + ["media-video", "new-video"], + ["media-image", "new-image"], + ["media-audio", "new-audio"], + ]); + + const remapped = remapProjectMediaIds({ project, idMap }); + const collected = collectReferencedMediaIds({ project: remapped }); + expect(collected.sort()).toEqual([ + "media-video-2", + "new-audio", + "new-image", + "new-video", + ]); + + // The shared media id is remapped in every referencing element. + const scenes = remapped.scenes; + expect(JSON.stringify(scenes)).not.toContain("media-video\""); + expect(JSON.stringify(scenes)).toContain("new-video"); + }); + + test("does not mutate the input document", () => { + const project = buildProject(); + const before = JSON.stringify(project); + remapProjectMediaIds({ + project, + idMap: new Map([["media-video", "new-video"]]), + }); + expect(JSON.stringify(project)).toBe(before); + }); + + test("returns the input unchanged when the map is empty", () => { + const project = buildProject(); + expect(remapProjectMediaIds({ project, idMap: new Map() })).toBe(project); + }); + + test("is fully reversible with the inverse map", () => { + const project = buildProject(); + const idMap = new Map([ + ["media-video", "new-video"], + ["media-image", "new-image"], + ["media-audio", "new-audio"], + ["media-video-2", "new-video-2"], + ]); + const inverse = new Map([...idMap].map(([from, to]) => [to, from])); + + const remapped = remapProjectMediaIds({ project, idMap }); + const restored = remapProjectMediaIds({ project: remapped, idMap: inverse }); + expect(restored).toEqual(project); + }); +}); + +describe("resolveImportedProjectName", () => { + test("keeps the name when there is no collision", () => { + expect( + resolveImportedProjectName({ name: "Demo", existingNames: ["Other"] }), + ).toBe("Demo"); + }); + + test("suffixes the name when it collides", () => { + expect( + resolveImportedProjectName({ name: "Demo", existingNames: ["Demo"] }), + ).toBe("Demo (imported)"); + }); +}); |
