2026-10-07 09:56:12 -07:00
import { describe , test , expect , afterEach , beforeEach , vi } from 'vitest' ;
import React from 'react' ;
import { renderEditorHarness , EditorHarness } from '../editorHarness' ;
2026-10-07 10:00:29 -07:00
import { useNodeProp , ColorPickerField , TextInputField , closeContinuousBurst , CONTINUOUS_EDIT_IDLE_MS } from '../../panels/right/styles/shared' ;
2026-10-07 09:56:12 -07:00
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 ;
2026-10-07 10:00:29 -07:00
beforeEach (() => { vi . useFakeTimers (); });
2026-10-07 09:56:12 -07:00
afterEach (() => {
2026-10-07 10:00:29 -07:00
closeContinuousBurst ();
2026-10-07 09:56:12 -07:00
vi . useRealTimers ();
harness ? . unmount ();
harness = null ;
});
const props = () => harness ! . query . node ( 'h-1' ). get (). data . props ;
2026-10-07 10:00:29 -07:00
/** How many undos until nothing is left (history is cleared after mount). */
2026-10-07 09:56:12 -07:00
function undoDepth () : number {
let n = 0 ;
2026-10-07 10:00:29 -07:00
while ( harness ! . query . history . canUndo () && n < 100 ) {
2026-10-07 09:56:12 -07:00
harness ! . act (() => { harness ! . actions . history . undo (); });
n ++ ;
}
return n ;
}
2026-10-07 10:00:29 -07:00
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 );
2026-10-07 09:56:12 -07:00
input . dispatchEvent ( new Event ( 'input' , { bubbles : true }));
}
2026-10-07 10:00:29 -07:00
const tick = ( ms : number ) => harness ! . act (() => { vi . advanceTimersByTime ( ms ); });
const edit = ( el : HTMLInputElement , v : string ) => harness ! . act (() => { setNative ( el , v ); });
2026-10-07 09:56:12 -07:00
function mountFresh ( ui : React.ReactNode ) {
harness = renderEditorHarness ({ initialState : INITIAL_STATE });
harness . mountChild ( ui );
harness . act (() => { harness ! . actions . history . clear (); });
}
2026-10-07 10:00:29 -07:00
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 >
);
2026-10-07 09:56:12 -07:00
};
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 })); });
2026-10-07 10:00:29 -07:00
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` ;
2026-10-07 09:56:12 -07:00
describe ( 'undo granularity (real Craft history)' , () => {
2026-10-07 10:00:29 -07:00
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 ));
2026-10-07 09:56:12 -07:00
expect ( undoDepth ()). toBe ( 1 );
expect ( props (). style . color ). toBe ( '#000000' );
});
2026-10-07 10:00:29 -07:00
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 ); }
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' );
2026-10-07 09:56:12 -07:00
expect ( undoDepth ()). toBe ( 2 );
});
2026-10-07 10:00:29 -07:00
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' );
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 ();
});
2026-10-07 09:56:12 -07:00
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 ();
});
});