import { act, cleanup, render } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { createToolcraftState } from "../../state/create-template-state"; import { createState } from "../../state/reducer-test-utils"; import { createToolcraftExternalStore } from "../../state/toolcraft-external-store"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import type { ToolcraftCommand } from "../../state/types"; import { useToolcraftStoreSelector } from "./toolcraft-selectors"; import { ToolcraftStoreContext } from "./toolcraft-store-context"; const valueDependencies = [{ kind: "value", target: "value.first" }] as const; const otherValueDependencies = [ { kind: "value", target: "value.second" }, ] as const; const playbackDependencies = [{ kind: "playback" }] as const; const viewportDependencies = [{ kind: "viewport.zoom" }] as const; const firstKeyframeDependencies = [ { kind: "keyframeGroup", target: "value.first" }, ] as const; const secondKeyframeDependencies = [ { kind: "keyframeGroup", target: "value.second" }, ] as const; const keyframeEditCommands: Array<{ command: ToolcraftCommand; label: string; }> = [ { command: { keyframeId: "value.first::1", timeSeconds: 2, type: "timeline.moveKeyframe", }, label: "move", }, { command: { keyframeId: "value.first::1", type: "timeline.deleteKeyframe", }, label: "delete", }, { command: { easing: { type: "step" }, keyframeId: "value.first::1", type: "timeline.changeKeyframeEasing", }, label: "easing change", }, ]; function createSelectorLaneStore() { const schema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { first: { defaultValue: 1, target: "value.first", type: "slider", }, second: { defaultValue: 2, target: "value.second", type: "slider", }, }, }, ], title: "Controls", }, }, }); return createToolcraftExternalStore(createToolcraftState(schema)); } function createKeyframeLaneStore() { return createToolcraftExternalStore( createState({ timeline: { keyframeGroups: [ { controlId: "value.first", keyframes: [ { controlId: "value.first", controlLabel: "First", id: "value.first::1", timeSeconds: 1, value: 10, valueLabel: "10", }, ], label: "First", }, { controlId: "value.second", keyframes: [ { controlId: "value.second", controlLabel: "Second", id: "value.second::1", timeSeconds: 1, value: 20, valueLabel: "20", }, ], label: "Second", }, ], }, }), ); } function LaneProvider({ children, store, }: { children: React.ReactNode; store: ToolcraftExternalStore; }) { return ( {children} ); } afterEach(cleanup); describe("Toolcraft selector execution lanes", () => { it("does not execute selectors outside their changed dependency fields", () => { const store = createSelectorLaneStore(); const executions = { playback: vi.fn(), otherValue: vi.fn(), value: vi.fn(), viewport: vi.fn(), }; function ValueProbe() { useToolcraftStoreSelector( (state) => { executions.value(); return state.values["value.first"]; }, Object.is, valueDependencies, ); return null; } function PlaybackProbe() { useToolcraftStoreSelector( (state) => { executions.playback(); return state.timeline.currentTimeSeconds; }, Object.is, playbackDependencies, ); return null; } function OtherValueProbe() { useToolcraftStoreSelector( (state) => { executions.otherValue(); return state.values["value.second"]; }, Object.is, otherValueDependencies, ); return null; } function ViewportProbe() { useToolcraftStoreSelector( (state) => { executions.viewport(); return state.canvas.zoom; }, Object.is, viewportDependencies, ); return null; } render( , ); const initial = { playback: executions.playback.mock.calls.length, otherValue: executions.otherValue.mock.calls.length, value: executions.value.mock.calls.length, viewport: executions.viewport.mock.calls.length, }; act(() => { store.dispatchTransient({ currentTimeSeconds: 1, type: "timeline.setCurrentTime", }); }); expect(executions.playback.mock.calls.length).toBeGreaterThan( initial.playback, ); expect(executions.value).toHaveBeenCalledTimes(initial.value); expect(executions.otherValue).toHaveBeenCalledTimes(initial.otherValue); expect(executions.viewport).toHaveBeenCalledTimes(initial.viewport); const afterPlayback = { playback: executions.playback.mock.calls.length, otherValue: executions.otherValue.mock.calls.length, value: executions.value.mock.calls.length, viewport: executions.viewport.mock.calls.length, }; act(() => { store.dispatchTransient({ offset: { x: 0, y: 0 }, type: "canvas.setViewport", zoom: 150, }); }); expect(executions.viewport.mock.calls.length).toBeGreaterThan( afterPlayback.viewport, ); expect(executions.value).toHaveBeenCalledTimes(afterPlayback.value); expect(executions.otherValue).toHaveBeenCalledTimes( afterPlayback.otherValue, ); expect(executions.playback).toHaveBeenCalledTimes( afterPlayback.playback, ); const afterViewport = { playback: executions.playback.mock.calls.length, otherValue: executions.otherValue.mock.calls.length, value: executions.value.mock.calls.length, viewport: executions.viewport.mock.calls.length, }; act(() => { store.dispatch({ target: "value.first", type: "controls.setValue", value: 10, }); }); expect(executions.value.mock.calls.length).toBeGreaterThan( afterViewport.value, ); expect(executions.otherValue).toHaveBeenCalledTimes( afterViewport.otherValue, ); expect(executions.playback).toHaveBeenCalledTimes( afterViewport.playback, ); expect(executions.viewport).toHaveBeenCalledTimes( afterViewport.viewport, ); }); it.each(keyframeEditCommands)( "does not execute target B selector for target A $label", ({ command }) => { const store = createKeyframeLaneStore(); const executions = { first: vi.fn(), second: vi.fn() }; function FirstKeyframeProbe() { useToolcraftStoreSelector( (state) => { executions.first(); return state.timeline.keyframeGroups.find( (group) => group.controlId === "value.first", ); }, Object.is, firstKeyframeDependencies, ); return null; } function SecondKeyframeProbe() { useToolcraftStoreSelector( (state) => { executions.second(); return state.timeline.keyframeGroups.find( (group) => group.controlId === "value.second", ); }, Object.is, secondKeyframeDependencies, ); return null; } render( , ); const initialFirstExecutions = executions.first.mock.calls.length; const initialSecondExecutions = executions.second.mock.calls.length; act(() => { store.dispatch(command); }); expect(executions.first.mock.calls.length).toBeGreaterThan( initialFirstExecutions, ); expect(executions.second).toHaveBeenCalledTimes( initialSecondExecutions, ); }, ); });