import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import { ToolcraftRoot } from "./toolcraft-root"; import { CaptureStore, createHotPersistenceSchema, createValuesPersistenceSchema, StorePersistenceProbe, } from "./toolcraft-root-persistence-test-utils"; afterEach(() => { vi.useRealTimers(); cleanup(); window.localStorage.clear(); }); describe("ToolcraftRoot committed and effective persistence", () => { it("does not restart persistence debounce for transient or non-included state", () => { vi.useFakeTimers(); const schema = createValuesPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(100); }); fireEvent.click(screen.getByRole("button", { name: "Scrub timeline" })); fireEvent.click(screen.getByRole("button", { name: "Expand non-persisted timeline" })); act(() => { vi.advanceTimersByTime(20); }); expect( JSON.parse( window.localStorage.getItem("toolcraft:root-test-committed-values:state:v2") ?? "{}", ), ).toMatchObject({ state: { values: { "selectedLayer.opacity": 75 } }, version: 2, }); const unchanged = window.localStorage.getItem("toolcraft:root-test-committed-values:state:v2"); act(() => { vi.advanceTimersByTime(200); }); expect(window.localStorage.getItem("toolcraft:root-test-committed-values:state:v2")).toBe( unchanged, ); }); it("restarts persistence debounce for mandatory canvas changes", () => { vi.useFakeTimers(); const schema = createValuesPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(120); }); const unchanged = window.localStorage.getItem("toolcraft:root-test-committed-values:state:v2"); fireEvent.click(screen.getByRole("button", { name: "Resize canvas" })); act(() => { vi.advanceTimersByTime(119); }); expect(window.localStorage.getItem("toolcraft:root-test-committed-values:state:v2")).toBe( unchanged, ); act(() => { vi.advanceTimersByTime(1); }); expect( JSON.parse( window.localStorage.getItem("toolcraft:root-test-committed-values:state:v2") ?? "{}", ), ).toMatchObject({ state: { canvas: { size: { height: 480, unit: "px", width: 640 }, }, }, version: 2, }); }); it("schedules a persisted hot slice only when its transient lane commits", () => { vi.useFakeTimers(); const schema = createHotPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(120); }); const unchanged = window.localStorage.getItem("toolcraft:root-test-hot:state:v2"); fireEvent.click(screen.getByRole("button", { name: "Scrub timeline" })); act(() => { vi.advanceTimersByTime(200); }); expect(window.localStorage.getItem("toolcraft:root-test-hot:state:v2")).toBe(unchanged); fireEvent.click(screen.getByRole("button", { name: "Commit playback" })); act(() => { vi.advanceTimersByTime(119); }); expect(window.localStorage.getItem("toolcraft:root-test-hot:state:v2")).toBe(unchanged); act(() => { vi.advanceTimersByTime(1); }); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}"), ).toMatchObject({ state: { timeline: { currentTimeSeconds: 2 } }, version: 2, }); }); it("debounces committed state instead of transient overlays", () => { vi.useFakeTimers(); const schema = createHotPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(120); }); fireEvent.click(screen.getByRole("button", { name: "Scrub timeline" })); fireEvent.click(screen.getByRole("button", { name: "Move viewport" })); fireEvent.click(screen.getByRole("button", { name: "Set persisted value" })); act(() => { vi.advanceTimersByTime(120); }); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}"), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 50 }, }, version: 2, }); }); it("flushes committed state on pagehide without transient overlays", () => { vi.useFakeTimers(); const schema = createHotPersistenceSchema(); render( , ); fireEvent.click(screen.getByRole("button", { name: "Scrub timeline" })); fireEvent.click(screen.getByRole("button", { name: "Move viewport" })); fireEvent.click(screen.getByRole("button", { name: "Set persisted value" })); window.dispatchEvent(new Event("pagehide")); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}"), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 50 }, }, version: 2, }); act(() => { vi.advanceTimersByTime(200); }); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}"), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 50 }, }, version: 2, }); }); it("keeps transient hot fields out of persisted keyframe history roundtrips", () => { vi.useFakeTimers(); const schema = createHotPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(120); }); fireEvent.click(screen.getByRole("button", { name: "Scrub timeline" })); fireEvent.click(screen.getByRole("button", { name: "Move viewport" })); fireEvent.click(screen.getByRole("button", { name: "Set keyframe" })); fireEvent.click(screen.getByRole("button", { name: "Undo" })); fireEvent.click(screen.getByRole("button", { name: "Redo" })); act(() => { vi.advanceTimersByTime(120); }); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}"), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0, keyframeGroups: [ { keyframes: [{ id: "selectedLayer.opacity::2" }], }, ], }, }, version: 2, }); }); it("flushes pending committed state and cleans up subscriptions on unmount", () => { vi.useFakeTimers(); let store: ToolcraftExternalStore | undefined; const schema = createHotPersistenceSchema(); const view = render( { store = value; }} /> , ); act(() => { vi.advanceTimersByTime(120); }); act(() => { store?.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 50, }); view.unmount(); }); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}"), ).toMatchObject({ state: { values: { "selectedLayer.opacity": 50 } }, version: 2, }); act(() => { store?.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 25, }); vi.advanceTimersByTime(200); }); expect( JSON.parse(window.localStorage.getItem("toolcraft:root-test-hot:state:v2") ?? "{}").state .values["selectedLayer.opacity"], ).toBe(50); }); });