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:v1", ) ?? "{}", ), ).toMatchObject({ state: { values: { "selectedLayer.opacity": 75 } }, version: 1, }); window.localStorage.setItem( "toolcraft:root-test-committed-values:state:v1", "unchanged", ); act(() => { vi.advanceTimersByTime(200); }); expect( window.localStorage.getItem( "toolcraft:root-test-committed-values:state:v1", ), ).toBe("unchanged"); }); it("restarts persistence debounce for mandatory canvas changes", () => { vi.useFakeTimers(); const schema = createValuesPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(120); }); window.localStorage.setItem( "toolcraft:root-test-committed-values:state:v1", "unchanged", ); fireEvent.click(screen.getByRole("button", { name: "Resize canvas" })); act(() => { vi.advanceTimersByTime(119); }); expect( window.localStorage.getItem( "toolcraft:root-test-committed-values:state:v1", ), ).toBe("unchanged"); act(() => { vi.advanceTimersByTime(1); }); expect( JSON.parse( window.localStorage.getItem( "toolcraft:root-test-committed-values:state:v1", ) ?? "{}", ), ).toMatchObject({ state: { canvas: { size: { height: 480, unit: "px", width: 640 }, }, }, version: 1, }); }); it("schedules a persisted hot slice only when its transient lane commits", () => { vi.useFakeTimers(); const schema = createHotPersistenceSchema(); render( , ); act(() => { vi.advanceTimersByTime(120); }); window.localStorage.setItem( "toolcraft:root-test-hot:state:v1", "unchanged", ); fireEvent.click(screen.getByRole("button", { name: "Scrub timeline" })); act(() => { vi.advanceTimersByTime(200); }); expect( window.localStorage.getItem("toolcraft:root-test-hot:state:v1"), ).toBe("unchanged"); fireEvent.click(screen.getByRole("button", { name: "Commit playback" })); act(() => { vi.advanceTimersByTime(119); }); expect( window.localStorage.getItem("toolcraft:root-test-hot:state:v1"), ).toBe("unchanged"); act(() => { vi.advanceTimersByTime(1); }); expect( JSON.parse( window.localStorage.getItem("toolcraft:root-test-hot:state:v1") ?? "{}", ), ).toMatchObject({ state: { timeline: { currentTimeSeconds: 2 } }, version: 1, }); }); 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:v1") ?? "{}", ), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 50 }, }, version: 1, }); }); 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:v1") ?? "{}", ), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 50 }, }, version: 1, }); act(() => { vi.advanceTimersByTime(200); }); expect( JSON.parse( window.localStorage.getItem("toolcraft:root-test-hot:state:v1") ?? "{}", ), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0 }, values: { "selectedLayer.opacity": 50 }, }, version: 1, }); }); 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:v1") ?? "{}", ), ).toMatchObject({ state: { canvas: { offset: { x: 0, y: 0 }, zoom: 100 }, timeline: { currentTimeSeconds: 0, keyframeGroups: [ { keyframes: [{ id: "selectedLayer.opacity::2" }], }, ], }, }, version: 1, }); }); 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); }); window.localStorage.setItem( "toolcraft:root-test-hot:state:v1", "unchanged", ); act(() => { store?.dispatch({ target: "selectedLayer.opacity", type: "controls.setValue", value: 50, }); view.unmount(); }); expect( JSON.parse( window.localStorage.getItem("toolcraft:root-test-hot:state:v1") ?? "{}", ), ).toMatchObject({ state: { values: { "selectedLayer.opacity": 50 } }, version: 1, }); 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:v1") ?? "{}", ).state.values["selectedLayer.opacity"], ).toBe(50); }); });