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 | d55d2583a71ae35fa8d0d1bfbec975e67e66aafa (patch) | |
| tree | c7a612f24b12c8602d7fbf139ac2709e4f2f4d9c /apps/web/src/versions/__tests__/media-pin.test.ts | |
| parent | 2a372fdec256b8be890e181c221c2a012ead9efa (diff) | |
| download | opencut-d55d2583a71ae35fa8d0d1bfbec975e67e66aafa.tar.gz | |
feat: git-like project versioning (v1)
Linear per-project history per notes/project-versioning.md: named
checkpoints (thumbnail via renderer.captureFrame) plus auto-checkpoints
on save (60s throttle, ring of 20, named never rotate). Restore writes a
safety checkpoint first, then swaps the document outside undo/redo.
Media assets referenced by any version are pinned against deletion.
Migration v31-v32 stamps the schema; the version store lives in its own
IDB database with an in-memory fallback for tests. Versions popover in
the editor header for jump/rename/delete. 12 new tests.
Diffstat (limited to 'apps/web/src/versions/__tests__/media-pin.test.ts')
| -rw-r--r-- | apps/web/src/versions/__tests__/media-pin.test.ts | 137 |
1 files changed, 137 insertions, 0 deletions
diff --git a/apps/web/src/versions/__tests__/media-pin.test.ts b/apps/web/src/versions/__tests__/media-pin.test.ts new file mode 100644 index 00000000..17fae4b2 --- /dev/null +++ b/apps/web/src/versions/__tests__/media-pin.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EditorCore } from "@/core"; +import { buildDefaultScene } from "@/timeline/scenes"; +import type { ImageElement } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { mediaTimeFromSeconds, ZERO_MEDIA_TIME } from "@/wasm"; +import { CURRENT_PROJECT_VERSION } from "@/services/storage/migrations"; +import { InMemoryVersionStore } from "@/versions"; + +const PROJECT_ID = "project-1"; +const IMAGE_DURATION = mediaTimeFromSeconds({ seconds: 5 }); + +function buildImageElement({ mediaId }: { mediaId: string }): ImageElement { + return { + id: `image-${mediaId}`, + type: "image", + name: `image-${mediaId}`, + startTime: ZERO_MEDIA_TIME, + duration: IMAGE_DURATION, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, + }; +} + +function buildImageAsset({ id }: { id: string }): MediaAsset { + return { + id, + name: id, + type: "image", + file: new File([new Uint8Array([1, 2, 3])], `${id}.png`, { + type: "image/png", + }), + }; +} + +function seedEditor(): { editor: EditorCore } { + const editor = EditorCore.getInstance(); + editor.versions.setStore({ store: new InMemoryVersionStore() }); + editor.versions.setNow({ now: () => 1_000_000_000 }); + + const scene = buildDefaultScene({ name: "Main scene", isMain: true }); + // Only media-pinned is referenced by the document (and thus snapshots); + // media-free exists as an asset but is used nowhere. + scene.tracks.main.elements = [buildImageElement({ mediaId: "media-pinned" })]; + editor.project.setActiveProject({ + project: { + metadata: { + id: PROJECT_ID, + name: "Test project", + duration: IMAGE_DURATION, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + scenes: [scene], + currentSceneId: scene.id, + settings: { + fps: { numerator: 30, denominator: 1 }, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + version: CURRENT_PROJECT_VERSION, + }, + }); + editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id }); + editor.media.setAssets({ + assets: [ + buildImageAsset({ id: "media-pinned" }), + buildImageAsset({ id: "media-free" }), + ], + }); + return { editor }; +} + +describe("Media pinning", () => { + beforeEach(() => { + EditorCore.reset(); + }); + + test("deleting a media asset referenced by a stored version is blocked", async () => { + const { editor } = seedEditor(); + + const version = await editor.versions.createNamedCheckpoint({ + name: "v1", + }); + expect(version).not.toBeNull(); + + editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-pinned" }); + expect( + editor.media.getAssets().some((asset) => asset.id === "media-pinned"), + ).toBe(true); + + // An unreferenced asset in the same project deletes normally. + editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-free" }); + expect( + editor.media.getAssets().some((asset) => asset.id === "media-free"), + ).toBe(false); + }); + + test("an asset is unpinned once the referencing version is deleted", async () => { + const { editor } = seedEditor(); + + const version = await editor.versions.createNamedCheckpoint({ + name: "v1", + }); + expect(version).not.toBeNull(); + if (!version) return; + + expect( + editor.versions.isMediaIdPinned({ + projectId: PROJECT_ID, + mediaId: "media-pinned", + }), + ).toBe(true); + + await editor.versions.deleteVersion({ versionId: version.id }); + expect( + editor.versions.isMediaIdPinned({ + projectId: PROJECT_ID, + mediaId: "media-pinned", + }), + ).toBe(false); + + editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-pinned" }); + expect( + editor.media.getAssets().some((asset) => asset.id === "media-pinned"), + ).toBe(false); + }); +}); |
