import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest'; import React from 'react'; import { renderEditorHarness, EditorHarness } from '../editorHarness'; import { useNodeProp, ColorPickerField, TextInputField, closeContinuousBurst, CONTINUOUS_EDIT_IDLE_MS, COMMIT_IDLE_MS } from '../../panels/right/styles/shared'; import { AnimVisSection } from '../../panels/right/styles/containerBoxModel'; import { AnimVisSection as MediaAnimVisSection } from '../../panels/right/styles/mediaBoxModel'; /** * Undo granularity against a REAL Craft.js history: one user edit = one undo * step. Uses the real style-panel controls (ColorPickerField, AnimVisSection) * wired through the real `useNodeProp`, so a regression to per-tick or * per-prop `setProp` calls shows up as extra undo steps here. */ const INITIAL_STATE = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['h-1'], linkedNodes: {}, }, 'h-1': { type: { resolvedName: 'Heading' }, isCanvas: false, props: { text: 'Hi', level: 'h2', style: { color: '#000000' } }, displayName: 'Heading', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {}, }, }); let harness: EditorHarness | null = null; beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { closeContinuousBurst(); vi.useRealTimers(); harness?.unmount(); harness = null; }); const props = () => harness!.query.node('h-1').get().data.props; /** How many undos until nothing is left (history is cleared after mount). */ function undoDepth(): number { let n = 0; while (harness!.query.history.canUndo() && n < 100) { harness!.act(() => { harness!.actions.history.undo(); }); n++; } return n; } function setNative(input: HTMLInputElement | HTMLTextAreaElement, value: string) { const proto = input instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })); } const tick = (ms: number) => harness!.act(() => { vi.advanceTimersByTime(ms); }); const edit = (el: HTMLInputElement, v: string) => harness!.act(() => { setNative(el, v); }); function mountFresh(ui: React.ReactNode) { harness = renderEditorHarness({ initialState: INITIAL_STATE }); harness.mountChild(ui); harness.act(() => { harness!.actions.history.clear(); }); } const Probe: React.FC = () => { const { setPropStyleContinuous, setPropContinuous, setPropStyle, setPropStyles } = useNodeProp('h-1'); const st = props().style; return (
setPropStyleContinuous('color', v)} /> setPropStyleContinuous('opacity', e.target.value)} /> setPropContinuous('text', v)} />
); }; const AnimVisProbe: React.FC<{ media?: boolean }> = ({ media }) => { const { setProps } = useNodeProp('h-1'); const Section = media ? MediaAnimVisSection : AnimVisSection; return
; }; function openSection() { const toggle = Array.from(harness!.container.querySelectorAll('button')).find((b) => /Animation & Visibility/.test(b.textContent || '')); harness!.act(() => { toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); } const q = (id: string) => harness!.container.querySelector(`[data-testid="${id}"]`) as HTMLElement; const clickEl = (el: Element) => harness!.act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); const color = () => harness!.container.querySelector('input[type="color"]') as HTMLInputElement; const textField = () => Array.from(harness!.container.querySelectorAll('input[type="text"]')).pop() as HTMLInputElement; // last: ColorPickerField has its own hex text input const hex = (i: number) => `#00${(i % 256).toString(16).padStart(2, '0')}00`; describe('undo granularity (real Craft history)', () => { test('a 2-second colour drag (50ms ticks) is one undo step restoring the original', () => { mountFresh(); for (let i = 1; i <= 40; i++) { edit(color(), hex(i)); tick(50); } expect(props().style.color).toBe(hex(40)); expect(undoDepth()).toBe(1); expect(props().style.color).toBe('#000000'); }); test('typing 30 characters at 100ms intervals is one undo step', () => { mountFresh(); let t = ''; for (let i = 0; i < 30; i++) { t += 'x'; edit(textField(), t); tick(100); } tick(COMMIT_IDLE_MS); // TextInputField commits on a 150ms idle expect(props().text).toBe(t); expect(undoDepth()).toBe(1); expect(props().text).toBe('Hi'); }); test('a slider drag is one undo step', () => { mountFresh(); const slider = q('slider') as HTMLInputElement; for (let i = 1; i <= 30; i++) { edit(slider, String(i)); tick(40); } expect(undoDepth()).toBe(1); expect(props().style.opacity).toBeUndefined(); }); test('pointerup closes the burst even when the next edit follows immediately', () => { mountFresh(); edit(color(), '#111111'); edit(color(), '#222222'); harness!.act(() => { document.dispatchEvent(new Event('pointerup', { bubbles: true })); }); edit(color(), '#333333'); expect(undoDepth()).toBe(2); }); test('after the idle window the same control starts a new step', () => { mountFresh(); edit(color(), '#111111'); tick(CONTINUOUS_EDIT_IDLE_MS + 1); edit(color(), '#222222'); expect(undoDepth()).toBe(2); }); test('a discrete preset click 100ms after a colour drag is its own step', () => { mountFresh(); for (let i = 1; i <= 10; i++) { edit(color(), hex(i)); tick(20); } tick(100); clickEl(q('preset')); expect(props().style.fontWeight).toBe('700'); harness!.act(() => { harness!.actions.history.undo(); }); expect(props().style.fontWeight).toBeUndefined(); expect(props().style.color).toBe(hex(10)); // drag still intact expect(undoDepth()).toBe(1); }); test('two different controls edited back-to-back are two steps', () => { mountFresh(); edit(color(), '#111111'); edit(color(), '#121212'); edit(textField(), 'abc'); edit(textField(), 'abcd'); tick(COMMIT_IDLE_MS); expect(undoDepth()).toBe(2); }); test('applying an aspect ratio (3 style keys) is one undo step', () => { mountFresh(); clickEl(q('ratio')); expect(props().style.aspectRatio).toBe('16/9'); expect(undoDepth()).toBe(1); expect(props().style.aspectRatio).toBeUndefined(); }); test.each([false, true])('visibility toggle is one undo step (media=%s)', (media) => { mountFresh(); openSection(); clickEl(q('visibility-hideOnDesktop')); expect(props().hideOnDesktop).toBe(true); expect(undoDepth()).toBe(1); expect(props().hideOnDesktop).toBeFalsy(); }); test.each([false, true])('animation change is one undo step (media=%s)', (media) => { mountFresh(); openSection(); const btn = Array.from(harness!.container.querySelectorAll('[data-testid="animation-control"] .preset-btn')) .find((b) => /fade/i.test(b.textContent || ''))!; clickEl(btn); expect(props().animation).not.toBe('none'); expect(undoDepth()).toBe(1); expect(props().animation).toBeFalsy(); }); });