|
1 | 1 | import { describe, expect, it } from "vitest"; |
2 | 2 | import type { EditorProjectData } from "@/components/video-editor/projectPersistence"; |
3 | 3 | import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; |
4 | | -import { migrateAxcutDocumentToProjectData, migrateProjectDataToAxcutDocument } from "./migrate"; |
| 4 | +import { documentSchema } from "../schema"; |
| 5 | +import { |
| 6 | + migrateAxcutDocumentToProjectData, |
| 7 | + migrateProjectDataToAxcutDocument, |
| 8 | + migrateRawDocumentToCurrent, |
| 9 | +} from "./migrate"; |
5 | 10 |
|
6 | 11 | function makeV2Project(overrides: Partial<EditorProjectData> = {}): EditorProjectData { |
7 | 12 | return { |
@@ -338,3 +343,134 @@ describe("migrateAxcutDocumentToProjectData", () => { |
338 | 343 | expect(doc.zoomRanges[0].focus.cy).toBe(0); |
339 | 344 | }); |
340 | 345 | }); |
| 346 | + |
| 347 | +// Load-time migration helper. Replaces the `z.preprocess` chain that used to |
| 348 | +// run on every `documentSchema.parse(...)` call site. The pre-hoist chain ran |
| 349 | +// v3→v4 and v4→v5 on every parse, including in-memory parses that were |
| 350 | +// already v5; the post-hoist chain runs once at the disk (or localStorage) |
| 351 | +// read site, and the in-memory `documentSchema.parse` is a pure v6 validator. |
| 352 | + |
| 353 | +describe("migrateRawDocumentToCurrent", () => { |
| 354 | + const createdAt = "2024-01-01T00:00:00.000Z"; |
| 355 | + |
| 356 | + function makeV3Doc(overrides: Record<string, unknown> = {}) { |
| 357 | + return { |
| 358 | + schemaVersion: 3, |
| 359 | + project: { id: "p", title: "t", createdAt, updatedAt: createdAt }, |
| 360 | + assets: [ |
| 361 | + { id: "asset_1", kind: "video", label: "a1", originalPath: "/a1.mp4" }, |
| 362 | + { id: "asset_2", kind: "video", label: "a2", originalPath: "/a2.mp4" }, |
| 363 | + ], |
| 364 | + cameraTrack: { sourcePath: "/cam.mp4", startMs: 0, offsetMs: 0, visible: true }, |
| 365 | + ...overrides, |
| 366 | + }; |
| 367 | + } |
| 368 | + |
| 369 | + function makeV4Doc(overrides: Record<string, unknown> = {}) { |
| 370 | + return { |
| 371 | + schemaVersion: 4, |
| 372 | + project: { id: "p", title: "t", createdAt, updatedAt: createdAt }, |
| 373 | + assets: [{ id: "a", kind: "video", label: "A", originalPath: "/a.mp4", cameraTrack: null }], |
| 374 | + timeline: { |
| 375 | + clips: [ |
| 376 | + { |
| 377 | + id: "c1", |
| 378 | + assetId: "a", |
| 379 | + sourceStartSec: 0, |
| 380 | + sourceEndSec: 10, |
| 381 | + timelineStartSec: 0, |
| 382 | + timelineEndSec: 10, |
| 383 | + origin: "user", |
| 384 | + }, |
| 385 | + ], |
| 386 | + }, |
| 387 | + ...overrides, |
| 388 | + }; |
| 389 | + } |
| 390 | + |
| 391 | + it("upgrades a v3 document to v6 (cameraTrack relocated onto the primary asset)", () => { |
| 392 | + // Models the full load path: every disk-read site runs the helper, then |
| 393 | + // the schema parse fills in defaults (cameraTrack: null on non-target |
| 394 | + // assets). The helper alone is just the upgrader chain; the schema is |
| 395 | + // what produces the final v5 shape with defaults filled in. |
| 396 | + const migrated = documentSchema.parse( |
| 397 | + migrateRawDocumentToCurrent( |
| 398 | + makeV3Doc({ |
| 399 | + project: { |
| 400 | + id: "p", |
| 401 | + title: "t", |
| 402 | + createdAt, |
| 403 | + updatedAt: createdAt, |
| 404 | + primaryAssetId: "asset_2", |
| 405 | + }, |
| 406 | + }), |
| 407 | + ), |
| 408 | + ); |
| 409 | + expect(migrated.schemaVersion).toBe(6); |
| 410 | + expect((migrated as Record<string, unknown>).cameraTrack).toBeUndefined(); |
| 411 | + expect(migrated.assets[0].cameraTrack).toBeNull(); |
| 412 | + expect(migrated.assets[1].cameraTrack?.sourcePath).toBe("/cam.mp4"); |
| 413 | + }); |
| 414 | + |
| 415 | + it("upgrades a v4 document to v6 (anchors modifiers onto clips)", () => { |
| 416 | + const migrated = migrateRawDocumentToCurrent( |
| 417 | + makeV4Doc({ |
| 418 | + zoomRanges: [ |
| 419 | + { id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }, |
| 420 | + ], |
| 421 | + }), |
| 422 | + ) as Record<string, unknown>; |
| 423 | + expect(migrated.schemaVersion).toBe(6); |
| 424 | + const zooms = migrated.zoomRanges as Array<Record<string, unknown>>; |
| 425 | + expect(zooms).toHaveLength(1); |
| 426 | + expect(zooms[0]).toMatchObject({ id: "z1", clipId: "c1", depth: 3 }); |
| 427 | + }); |
| 428 | + |
| 429 | + it("is a no-op for an already-current document (returns an equal value)", () => { |
| 430 | + const v5 = makeV4Doc(); // makeV4Doc's body is the v5-compatible shape |
| 431 | + const once = migrateRawDocumentToCurrent({ ...v5, schemaVersion: 6 }); |
| 432 | + // ponytail: the upgrader chain checks schemaVersion and returns the input |
| 433 | + // unchanged, so the round-trip allocation is bounded to a property |
| 434 | + // comparison per upgrader — the same per-parse overhead the old |
| 435 | + // `z.preprocess` carried. |
| 436 | + expect(once).toEqual({ ...v5, schemaVersion: 6 }); |
| 437 | + }); |
| 438 | + |
| 439 | + it("passes non-document input through unchanged (the schema is the gate, not this helper)", () => { |
| 440 | + // null, primitives, arrays — none of these are v3/v4 documents, so the |
| 441 | + // upgraders return them untouched. The downstream `documentSchema.parse` |
| 442 | + // is what rejects them via the `schemaVersion` literal. |
| 443 | + expect(migrateRawDocumentToCurrent(null)).toBe(null); |
| 444 | + expect(migrateRawDocumentToCurrent(undefined)).toBe(undefined); |
| 445 | + expect(migrateRawDocumentToCurrent(42)).toBe(42); |
| 446 | + expect(migrateRawDocumentToCurrent("not-a-doc")).toBe("not-a-doc"); |
| 447 | + expect(migrateRawDocumentToCurrent([])).toEqual([]); |
| 448 | + }); |
| 449 | + |
| 450 | + it("passes v2 input through unchanged (the legacy migrator is a separate path)", () => { |
| 451 | + // The pre-hoist schema's `z.preprocess` also passed v2 through; the |
| 452 | + // post-hoist helper keeps the same shape so `documentSchema.parse` is |
| 453 | + // the single rejection point for unknown versions. |
| 454 | + const v2ish = { schemaVersion: 2, project: { id: "p" } }; |
| 455 | + const out = migrateRawDocumentToCurrent(v2ish) as Record<string, unknown>; |
| 456 | + expect(out.schemaVersion).toBe(2); |
| 457 | + }); |
| 458 | + |
| 459 | + it("the upgraded v5 result round-trips through documentSchema.parse with no error", () => { |
| 460 | + // The whole point of the hoist: after `migrateRawDocumentToCurrent` |
| 461 | + // runs once at load, the in-memory parse is a pure v6 validation |
| 462 | + // step. This is the contract every load site relies on. |
| 463 | + const upgraded = migrateRawDocumentToCurrent( |
| 464 | + makeV3Doc({ |
| 465 | + project: { |
| 466 | + id: "p", |
| 467 | + title: "t", |
| 468 | + createdAt, |
| 469 | + updatedAt: createdAt, |
| 470 | + primaryAssetId: "asset_1", |
| 471 | + }, |
| 472 | + }), |
| 473 | + ); |
| 474 | + expect(() => documentSchema.parse(upgraded)).not.toThrow(); |
| 475 | + }); |
| 476 | +}); |
0 commit comments