11/**
2- * Unit tests for resolveNearestHfElement ( pure resolver — no browser needed).
2+ * Unit tests for the pure functions in iframe.ts ( no browser needed).
33 *
4- * elementFromPoint itself requires a real browser layout engine. The adapter's
5- * elementAtPoint() method is therefore NOT tested here; cover it with an
6- * integration test that mounts a real same-origin iframe (WS-A1 follow-on).
4+ * elementFromPoint requires a real layout engine — the adapter's elementAtPoint()
5+ * is NOT tested here. Cover it with an integration test mounting a same-origin
6+ * iframe (WS-A1 follow-on).
7+ *
8+ * applyDraft / commitPreview / cancelPreview require HTMLElement.style + querySelector
9+ * which are also browser-only. They are tested via a lightweight fake-DOM helper
10+ * that simulates style.setProperty / getAttribute / removeProperty.
711 */
812
913import { describe , it , expect , vi } from "vitest" ;
10- import { resolveNearestHfElement } from "./iframe.js" ;
14+ import {
15+ resolveNearestHfElement ,
16+ computeDraftPosition ,
17+ createIframePreviewAdapter ,
18+ } from "./iframe.js" ;
1119import type { ElementAtPointResult } from "./types.js" ;
20+ import type { EditOp } from "../types.js" ;
1221
1322// ─── Minimal fake element ────────────────────────────────────────────────────
1423
@@ -41,7 +50,7 @@ function fakeEl(
4150const visible = ( ) => true ;
4251const invisible = ( ) => false ;
4352
44- // ─── Tests ────────────────── ──────────────────────────────────────────────────
53+ // ─── resolveNearestHfElement ──────────────────────────────────────────────────
4554
4655describe ( "resolveNearestHfElement" , ( ) => {
4756 it ( "returns null for a null input" , ( ) => {
@@ -78,16 +87,13 @@ describe("resolveNearestHfElement", () => {
7887 } ) ;
7988
8089 it ( "skips an opacity-0 element and returns null (isVisible called on the resolved node)" , ( ) => {
81- // isVisible is only checked on the RESOLVED node, not intermediary nodes.
8290 const parent = fakeEl ( { "data-hf-id" : "hf-parent" } , "div" ) ;
8391 const child = fakeEl ( { } , "span" , parent ) ;
84- // Make parent invisible
8592 const isVisible = vi . fn ( ( el : Element ) => {
8693 const fe = el as unknown as FakeEl ;
8794 return fe . attrs [ "data-hf-id" ] !== "hf-parent" ;
8895 } ) ;
8996 expect ( resolveNearestHfElement ( child as unknown as Element , isVisible ) ) . toBeNull ( ) ;
90- // isVisible was called once (on the resolved parent node)
9197 expect ( isVisible ) . toHaveBeenCalledTimes ( 1 ) ;
9298 } ) ;
9399
@@ -113,11 +119,31 @@ describe("resolveNearestHfElement", () => {
113119 } ) ;
114120} ) ;
115121
116- // ─── select + on('selection') wiring ─────────────────────────────────────────
117- // These cover the adapter-level selection state without needing a real iframe.
118- // We import createIframePreviewAdapter and pass a stub iframe.
122+ // ─── computeDraftPosition ─────────────────────────────────────────────────────
123+
124+ describe ( "computeDraftPosition" , ( ) => {
125+ it ( "applies delta to base data-x/data-y" , ( ) => {
126+ expect ( computeDraftPosition ( "100" , "200" , 30 , - 10 ) ) . toEqual ( { x : 130 , y : 190 } ) ;
127+ } ) ;
128+
129+ it ( "defaults missing data-x/data-y to 0" , ( ) => {
130+ expect ( computeDraftPosition ( null , null , 50 , 25 ) ) . toEqual ( { x : 50 , y : 25 } ) ;
131+ } ) ;
132+
133+ it ( "defaults non-numeric data-x/data-y to 0" , ( ) => {
134+ expect ( computeDraftPosition ( "abc" , "xyz" , 10 , 5 ) ) . toEqual ( { x : 10 , y : 5 } ) ;
135+ } ) ;
136+
137+ it ( "works with zero delta (no-move commit)" , ( ) => {
138+ expect ( computeDraftPosition ( "40" , "80" , 0 , 0 ) ) . toEqual ( { x : 40 , y : 80 } ) ;
139+ } ) ;
140+
141+ it ( "handles negative base positions" , ( ) => {
142+ expect ( computeDraftPosition ( "-20" , "0" , 5 , 10 ) ) . toEqual ( { x : - 15 , y : 10 } ) ;
143+ } ) ;
144+ } ) ;
119145
120- import { createIframePreviewAdapter } from "./iframe.js" ;
146+ // ─── IframePreviewAdapter selection ──────────────────────────────────────────
121147
122148function stubIframe ( ) {
123149 return { } as HTMLIFrameElement ;
@@ -170,3 +196,158 @@ describe("IframePreviewAdapter selection", () => {
170196 expect ( cb2 ) . toHaveBeenCalledOnce ( ) ;
171197 } ) ;
172198} ) ;
199+
200+ // ─── applyDraft / commitPreview / cancelPreview ───────────────────────────────
201+ // Tests use a fake iframe+element because HTMLElement.style requires a browser.
202+
203+ interface FakeStyle {
204+ _props : Record < string , string > ;
205+ setProperty ( name : string , value : string ) : void ;
206+ getPropertyValue ( name : string ) : string ;
207+ removeProperty ( name : string ) : void ;
208+ }
209+
210+ interface FakeDomEl {
211+ "data-hf-id" : string ;
212+ "data-x" : string | null ;
213+ "data-y" : string | null ;
214+ style : FakeStyle ;
215+ getAttribute ( name : string ) : string | null ;
216+ querySelector ( sel : string ) : FakeDomEl | null ;
217+ }
218+
219+ function fakeDomEl ( id : string , dataX : string | null , dataY : string | null ) : FakeDomEl {
220+ const style : FakeStyle = {
221+ _props : { } ,
222+ setProperty ( name , value ) {
223+ this . _props [ name ] = value ;
224+ } ,
225+ getPropertyValue ( name ) {
226+ return this . _props [ name ] ?? "" ;
227+ } ,
228+ removeProperty ( name ) {
229+ delete this . _props [ name ] ;
230+ } ,
231+ } ;
232+ const el : FakeDomEl = {
233+ "data-hf-id" : id ,
234+ "data-x" : dataX ,
235+ "data-y" : dataY ,
236+ style,
237+ getAttribute ( name ) {
238+ if ( name === "data-x" ) return this [ "data-x" ] ;
239+ if ( name === "data-y" ) return this [ "data-y" ] ;
240+ if ( name === "data-hf-id" ) return this [ "data-hf-id" ] ;
241+ return null ;
242+ } ,
243+ querySelector ( _sel : string ) {
244+ return null ;
245+ } ,
246+ } ;
247+ return el ;
248+ }
249+
250+ function fakeIframe ( el : FakeDomEl | null ) : HTMLIFrameElement {
251+ return {
252+ contentDocument : {
253+ querySelector ( _sel : string ) {
254+ return el ;
255+ } ,
256+ } ,
257+ } as unknown as HTMLIFrameElement ;
258+ }
259+
260+ describe ( "IframePreviewAdapter draft / commit / cancel" , ( ) => {
261+ it ( "commitPreview without applyDraft is a no-op" , ( ) => {
262+ const dispatch = vi . fn ( ) ;
263+ const adapter = createIframePreviewAdapter ( stubIframe ( ) , dispatch ) ;
264+ adapter . commitPreview ( ) ;
265+ expect ( dispatch ) . not . toHaveBeenCalled ( ) ;
266+ } ) ;
267+
268+ it ( "cancelPreview without applyDraft is a no-op" , ( ) => {
269+ const dispatch = vi . fn ( ) ;
270+ const adapter = createIframePreviewAdapter ( stubIframe ( ) , dispatch ) ;
271+ adapter . cancelPreview ( ) ;
272+ expect ( dispatch ) . not . toHaveBeenCalled ( ) ;
273+ } ) ;
274+
275+ it ( "commitPreview dispatches moveElement with correct absolute position" , ( ) => {
276+ const dispatch = vi . fn ( ) ;
277+ const el = fakeDomEl ( "hf-abc" , "100" , "200" ) ;
278+ const adapter = createIframePreviewAdapter ( fakeIframe ( el ) , dispatch ) ;
279+
280+ adapter . applyDraft ( "hf-abc" , { dx : 30 , dy : - 20 } ) ;
281+ adapter . commitPreview ( ) ;
282+
283+ expect ( dispatch ) . toHaveBeenCalledWith < [ EditOp ] > ( {
284+ type : "moveElement" ,
285+ target : "hf-abc" ,
286+ x : 130 ,
287+ y : 180 ,
288+ } ) ;
289+ } ) ;
290+
291+ it ( "commitPreview with missing data-x/data-y defaults base to 0" , ( ) => {
292+ const dispatch = vi . fn ( ) ;
293+ const el = fakeDomEl ( "hf-abc" , null , null ) ;
294+ const adapter = createIframePreviewAdapter ( fakeIframe ( el ) , dispatch ) ;
295+
296+ adapter . applyDraft ( "hf-abc" , { dx : 50 , dy : 25 } ) ;
297+ adapter . commitPreview ( ) ;
298+
299+ expect ( dispatch ) . toHaveBeenCalledWith < [ EditOp ] > ( {
300+ type : "moveElement" ,
301+ target : "hf-abc" ,
302+ x : 50 ,
303+ y : 25 ,
304+ } ) ;
305+ } ) ;
306+
307+ it ( "commitPreview without a dispatch callback is a no-op" , ( ) => {
308+ const el = fakeDomEl ( "hf-abc" , "0" , "0" ) ;
309+ const adapter = createIframePreviewAdapter ( fakeIframe ( el ) ) ;
310+
311+ adapter . applyDraft ( "hf-abc" , { dx : 10 , dy : 10 } ) ;
312+ // should not throw
313+ adapter . commitPreview ( ) ;
314+ } ) ;
315+
316+ it ( "cancelPreview clears draft vars without dispatching" , ( ) => {
317+ const dispatch = vi . fn ( ) ;
318+ const el = fakeDomEl ( "hf-abc" , "100" , "200" ) ;
319+ const adapter = createIframePreviewAdapter ( fakeIframe ( el ) , dispatch ) ;
320+
321+ adapter . applyDraft ( "hf-abc" , { dx : 30 , dy : 20 } ) ;
322+ adapter . cancelPreview ( ) ;
323+
324+ expect ( dispatch ) . not . toHaveBeenCalled ( ) ;
325+ // CSS vars cleared
326+ expect ( el . style . getPropertyValue ( "--hf-studio-dx" ) ) . toBe ( "" ) ;
327+ expect ( el . style . getPropertyValue ( "--hf-studio-dy" ) ) . toBe ( "" ) ;
328+ } ) ;
329+
330+ it ( "commitPreview clears draft vars after dispatching" , ( ) => {
331+ const dispatch = vi . fn ( ) ;
332+ const el = fakeDomEl ( "hf-abc" , "0" , "0" ) ;
333+ const adapter = createIframePreviewAdapter ( fakeIframe ( el ) , dispatch ) ;
334+
335+ adapter . applyDraft ( "hf-abc" , { dx : 10 , dy : 5 } ) ;
336+ adapter . commitPreview ( ) ;
337+
338+ expect ( el . style . getPropertyValue ( "--hf-studio-dx" ) ) . toBe ( "" ) ;
339+ expect ( el . style . getPropertyValue ( "--hf-studio-dy" ) ) . toBe ( "" ) ;
340+ } ) ;
341+
342+ it ( "second commitPreview after first is a no-op (draft cleared)" , ( ) => {
343+ const dispatch = vi . fn ( ) ;
344+ const el = fakeDomEl ( "hf-abc" , "0" , "0" ) ;
345+ const adapter = createIframePreviewAdapter ( fakeIframe ( el ) , dispatch ) ;
346+
347+ adapter . applyDraft ( "hf-abc" , { dx : 10 , dy : 5 } ) ;
348+ adapter . commitPreview ( ) ;
349+ adapter . commitPreview ( ) ;
350+
351+ expect ( dispatch ) . toHaveBeenCalledTimes ( 1 ) ;
352+ } ) ;
353+ } ) ;
0 commit comments