import { act, cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { getLegacyToolcraftSectionCollapseEntryKey, getLegacyToolcraftSectionCollapseStorageKey, readLegacyToolcraftSectionCollapseMigration, } from "../../state/persistence-legacy-section-collapse"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import { ToolcraftRoot } from "./toolcraft-root"; import { CaptureStore } from "./toolcraft-root-persistence-test-utils"; import { useToolcraftPersistenceStatus } from "./use-toolcraft-persistence"; function PersistenceStatusProbe() { const status = useToolcraftPersistenceStatus(); return {JSON.stringify(status)}; } function requireCapturedStore( store: ToolcraftExternalStore | undefined, ): ToolcraftExternalStore { if (!store) { throw new Error("Expected ToolcraftRoot to publish its external store."); } return store; } function createMigrationPersistenceSchema() { return defineToolcraft({ canvas: { enabled: true }, identity: { id: "root-migration-test", title: "Root Migration Test" }, panels: { controls: { sections: [ { controls: { opacity: { defaultValue: 75, target: "selectedLayer.opacity", type: "slider", }, }, id: "appearance", title: "Appearance", }, ], title: "Controls", }, }, }); } function createMediaMigrationPersistenceSchema() { return defineToolcraft({ canvas: { enabled: true }, identity: { id: "root-media-migration-test", title: "Root Media Migration Test", }, panels: { controls: { sections: [ { controls: { source: { defaultValue: null, target: "source.image", type: "fileDrop", }, }, }, ], title: "Controls", }, }, }); } afterEach(() => { vi.useRealTimers(); cleanup(); window.localStorage.clear(); }); describe("ToolcraftRoot persistence migration", () => { it("publishes v2 only after legacy media bytes become durable", async () => { vi.useFakeTimers(); const schema = createMediaMigrationPersistenceSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const legacySnapshot = JSON.stringify({ state: { mediaAssets: [ { dataUrl: "data:image/png;base64,AQID", fileName: "legacy.png", id: "legacy-image", layerId: "legacy-layer", mimeType: "image/png", position: { x: 0, y: 0 }, size: { height: 1, unit: "px", width: 1 }, }, ], }, version: 1, }); window.localStorage.setItem(schema.persistence.key, legacySnapshot); let store: ToolcraftExternalStore | undefined; render( { store = value; }} /> , ); act(() => { vi.advanceTimersByTime(120); }); expect(window.localStorage.getItem(schema.persistence.key)).toBe( legacySnapshot, ); await vi.waitFor(() => { expect(requireCapturedStore(store).getState().mediaAssets[0]).toMatchObject({ lifecycle: "ready", }); }); act(() => { vi.advanceTimersByTime(120); }); const migratedSnapshot = window.localStorage.getItem( schema.persistence.key, ); expect(migratedSnapshot).not.toContain("data:image/"); expect(JSON.parse(migratedSnapshot ?? "{}").version).toBe(2); }); it("preserves legacy media bytes when repository migration fails", async () => { vi.useFakeTimers(); const schema = createMediaMigrationPersistenceSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const legacySnapshot = JSON.stringify({ state: { mediaAssets: [ { dataUrl: "invalid-data-url", fileName: "legacy.png", id: "legacy-image", layerId: "legacy-layer", mimeType: "image/png", position: { x: 0, y: 0 }, }, ], }, version: 1, }); window.localStorage.setItem(schema.persistence.key, legacySnapshot); let store: ToolcraftExternalStore | undefined; render( { store = value; }} /> , ); await vi.waitFor(() => { expect(requireCapturedStore(store).getState().mediaAssets[0]).toMatchObject({ lifecycle: "unavailable", }); }); act(() => { window.dispatchEvent(new Event("pagehide")); vi.advanceTimersByTime(120); }); expect(screen.getByTestId("persistence-status").textContent).toBe( JSON.stringify({ reason: "unavailable", status: "failed" }), ); expect(window.localStorage.getItem(schema.persistence.key)).toBe( legacySnapshot, ); }); it("preserves a newer unsupported snapshot and reports incompatibility", () => { vi.useFakeTimers(); const schema = createMigrationPersistenceSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const newerSnapshot = JSON.stringify({ state: { values: { "selectedLayer.opacity": 50 } }, version: schema.persistence.version + 1, }); window.localStorage.setItem(schema.persistence.key, newerSnapshot); render( , ); act(() => { vi.advanceTimersByTime(500); }); expect(screen.getByTestId("persistence-status").textContent).toBe( JSON.stringify({ reason: "incompatible-version", status: "failed", }), ); expect(window.localStorage.getItem(schema.persistence.key)).toBe( newerSnapshot, ); }); it("removes legacy collapse data only after expanded state is persisted", () => { vi.useFakeTimers(); const schema = createMigrationPersistenceSchema(); if (schema.persistence.storage !== "localStorage") { throw new Error("Expected local persistence"); } const sections = schema.panels.controls?.sections ?? []; const sectionIndex = sections.findIndex( (candidate) => candidate.id === "appearance", ); const section = sections[sectionIndex]; const legacyKey = getLegacyToolcraftSectionCollapseStorageKey(schema); if (!section || !legacyKey) { throw new Error("Expected the migration section and legacy storage key."); } window.localStorage.setItem( legacyKey, JSON.stringify({ collapsed: [ getLegacyToolcraftSectionCollapseEntryKey(section, sectionIndex), ], version: 1, }), ); expect(readLegacyToolcraftSectionCollapseMigration(schema)).toEqual({ panels: { controls: { collapsedSections: { appearance: true } }, }, storageKey: legacyKey, }); let store: ToolcraftExternalStore | undefined; const first = render( { store = value; }} /> , ); expect( requireCapturedStore(store).getState().panels.controls.collapsedSections.appearance, ).toBe(true); expect(window.localStorage.getItem(legacyKey)).not.toBeNull(); act(() => { requireCapturedStore(store).dispatch({ collapsed: false, sectionId: "appearance", type: "panels.setSectionCollapsed", }); vi.advanceTimersByTime(120); }); expect(window.localStorage.getItem(legacyKey)).toBeNull(); first.unmount(); let remountedStore: ToolcraftExternalStore | undefined; render( { remountedStore = value; }} /> , ); expect( requireCapturedStore(remountedStore).getState().panels.controls .collapsedSections.appearance, ).toBeUndefined(); }); });