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();
});
});