Skip to content

Commit af8af62

Browse files
feat(sdk): ws-a2 — applyDraft/commitPreview/cancelPreview → moveElement op
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent f18751e commit af8af62

2 files changed

Lines changed: 297 additions & 22 deletions

File tree

packages/sdk/src/adapters/iframe.test.ts

Lines changed: 194 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
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

913
import { describe, it, expect, vi } from "vitest";
10-
import { resolveNearestHfElement } from "./iframe.js";
14+
import {
15+
resolveNearestHfElement,
16+
computeDraftPosition,
17+
createIframePreviewAdapter,
18+
} from "./iframe.js";
1119
import type { ElementAtPointResult } from "./types.js";
20+
import type { EditOp } from "../types.js";
1221

1322
// ─── Minimal fake element ────────────────────────────────────────────────────
1423

@@ -41,7 +50,7 @@ function fakeEl(
4150
const visible = () => true;
4251
const invisible = () => false;
4352

44-
// ─── Tests ────────────────────────────────────────────────────────────────────
53+
// ─── resolveNearestHfElement ──────────────────────────────────────────────────
4554

4655
describe("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

122148
function 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

Comments
 (0)