feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle (#1379)

This commit is contained in:
Vance Ingalls
2026-06-15 00:46:17 -07:00
committed by GitHub
parent 8b56e558c6
commit 6dcbb5530e
17 changed files with 1442 additions and 61 deletions
@@ -0,0 +1,217 @@
/**
* setClassStyle handler tests — flat CSS rule upsert via <style> element.
*/
import { describe, it, expect } from "vitest";
import { parseMutable } from "./model.js";
import { applyOp, validateOp } from "./mutate.js";
import { applyPatchesToDocument } from "./apply-patches.js";
import { serializeDocument } from "./serialize.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const CSS = `.box { opacity: 0; transform: translateX(-50px); }
.title { color: #fff; font-size: 64px; }
`;
function makeHtml(style = CSS) {
return `<!DOCTYPE html><html><head><style>${style}</style></head><body>
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" class="box"></div>
<h1 data-hf-id="hf-title" class="title">Hello</h1>
</div></body></html>`.trim();
}
function fresh(style = CSS) {
return parseMutable(makeHtml(style));
}
function getStyleText(parsed: ReturnType<typeof parseMutable>): string {
const doc = serializeDocument(parsed);
const m = /<style>([\s\S]*?)<\/style>/i.exec(doc);
return m ? m[1]! : "";
}
// ─── validateOp ───────────────────────────────────────────────────────────────
describe("validateOp setClassStyle", () => {
it("returns true (always valid — creates <style> if absent)", () => {
expect(
validateOp(fresh(), { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }),
).toBe(true);
});
it("returns true even when no <style> element present", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
expect(
validateOp(noStyle, { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }),
).toBe(true);
});
});
// ─── setClassStyle: update existing rule ──────────────────────────────────────
describe("setClassStyle — update existing rule", () => {
it("adds a new property to an existing rule", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { color: "red" },
});
expect(result.forward).toHaveLength(1);
expect(result.forward[0]?.path).toBe("/style/css");
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("color: red");
expect(newCss).toContain("opacity: 0");
});
it("overwrites an existing property value", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("opacity: 1");
expect(newCss).not.toContain("opacity: 0");
expect(newCss).toContain("translateX(-50px)");
});
it("removes a property when value is null", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: null },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).not.toContain("opacity");
expect(newCss).toContain("translateX(-50px)");
});
it("leaves other rules untouched", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".title");
expect(newCss).toContain("color: #fff");
});
});
// ─── setClassStyle: insert new rule ──────────────────────────────────────────
describe("setClassStyle — insert new rule", () => {
it("appends a new rule when selector not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".new",
styles: { display: "flex", gap: "8px" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".new");
expect(newCss).toContain("display: flex");
expect(newCss).toContain("gap: 8px");
expect(newCss).toContain(".box");
});
it("creates <style> element when none exists", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noStyle, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
expect(result.forward).toHaveLength(1);
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".box");
expect(newCss).toContain("opacity: 1");
});
});
// ─── no-op cases ─────────────────────────────────────────────────────────────
describe("setClassStyle — no-ops", () => {
it("returns EMPTY when all values are null and selector not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".nonexistent",
styles: { opacity: null },
});
expect(result.forward).toHaveLength(0);
});
});
// ─── inverse restores original ────────────────────────────────────────────────
describe("setClassStyle — inverse patches", () => {
it("inverse restores original CSS", () => {
const parsed = fresh();
const original = getStyleText(parsed);
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1", color: "blue" },
});
applyPatchesToDocument(parsed, result.inverse);
expect(getStyleText(parsed)).toBe(original);
});
it("undo on style-less composition does not create spurious <style> element", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noStyle, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
applyPatchesToDocument(noStyle, result.inverse);
const html = serializeDocument(noStyle);
expect(html).not.toContain("<style");
});
});
// ─── semicolon-containing CSS values ─────────────────────────────────────────
describe("setClassStyle — CSS values with semicolons (data URIs)", () => {
it("preserves data URI value when updating another property in same rule", () => {
const dataUriCss = ".hero { background: url(data:image/png;base64,abc=); color: red; }\n";
const parsed = fresh(dataUriCss);
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".hero",
styles: { color: "blue" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("url(data:image/png;base64,abc=)");
expect(newCss).toContain("color: blue");
expect(newCss).not.toContain("color: red");
});
});
// ─── DOM side-effect ─────────────────────────────────────────────────────────
describe("setClassStyle — DOM mutation", () => {
it("mutates the live <style> element in the document", () => {
const parsed = fresh();
applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
expect(getStyleText(parsed)).toContain("opacity: 1");
expect(getStyleText(parsed)).not.toContain("opacity: 0");
});
});