Files
site-builder/craft/src/test-utils/integration/undo-steps.integration.test.tsx
T
shadowdao ec071c4c39 feat(site-builder): debounce style inputs and stop Layers recomputing on hover
Text/number/hex inputs keep local state and commit on 150ms idle, blur or
Enter through the continuous setters; native colour pickers get an
onContinuous path; ButtonStylePanel colour routes through the shared
setters. LayersPanel collectors return structural signatures/primitives
so hover and unrelated prop edits no longer rebuild the tree.

Refs #40
2026-10-07 11:28:47 -07:00

199 lines
7.8 KiB
TypeScript

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 (
<div>
<ColorPickerField label="Color" value={st.color} onChange={(v) => setPropStyleContinuous('color', v)} />
<input data-testid="slider" type="range" min={0} max={100} value={50}
onChange={(e) => setPropStyleContinuous('opacity', e.target.value)} />
<TextInputField label="Text" value={props().text} onChange={(v) => setPropContinuous('text', v)} />
<button data-testid="preset" onClick={() => setPropStyle('fontWeight', '700')}>preset</button>
<button data-testid="ratio" onClick={() => setPropStyles({ aspectRatio: '16/9', objectFit: 'cover', height: '' })}>ratio</button>
</div>
);
};
const AnimVisProbe: React.FC<{ media?: boolean }> = ({ media }) => {
const { setProps } = useNodeProp('h-1');
const Section = media ? MediaAnimVisSection : AnimVisSection;
return <Section nodeProps={props()} setProps={setProps} />;
};
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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<AnimVisProbe media={media} />);
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(<AnimVisProbe media={media} />);
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();
});
});