`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].startTime).toBe(3);
// Default duration is 5 when data-end is missing
expect(result.elements[0].duration).toBe(5);
});
it("assigns generated ids when elements have no id", () => {
const html = `
No ID
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
});
it("extracts GSAP script from script tags", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.gsapScript).not.toBeNull();
expect(result.gsapScript).toContain("gsap.timeline");
expect(result.gsapScript).toContain('tl.to("#text1"');
});
it("extracts GSAP script from composition templates", () => {
const html = `
`;
const result = parseHtml(html);
expect(result.gsapScript).toContain("gsap.timeline");
});
it("extracts styles from style tags", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.styles).not.toBeNull();
expect(result.styles).toContain(".my-class");
});
it("detects landscape resolution from data attribute", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape");
});
it("detects portrait resolution from data attribute", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("keeps explicit portrait resolution even when dimensions are square", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("defaults to portrait when no resolution info is available", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("detects landscape-4k resolution from data attribute", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape-4k");
});
it("infers landscape-4k from composition dimensions", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape-4k");
});
it("infers portrait-4k from inline stage style", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait-4k");
});
it("classifies 1440p (QHD) as landscape, not landscape-4k", () => {
// Regression: an earlier `>= 2560` cutoff misclassified QHD compositions
// as 4K. The current rule uses the canonical 4K long-side (3840) so
// 2560×1440 stays in the landscape preset.
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape");
});
it("infers square resolution from equal width/height", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("square");
});
it("infers square-4k from equal width/height >= 2160", () => {
const html = `
Hello
`;
const result = parseHtml(html);
expect(result.resolution).toBe("square-4k");
});
it("extracts x, y, scale, opacity from data attributes", () => {
const html = `
`;
const result = parseHtml(html);
expect(result.elements[0].x).toBe(100);
expect(result.elements[0].y).toBe(200);
expect(result.elements[0].scale).toBe(1.5);
expect(result.elements[0].opacity).toBe(0.8);
});
it("parses text element properties (color, fontSize, fontWeight, fontFamily)", () => {
const html = `
Styled
`;
const result = parseHtml(html);
const textEl = result.elements[0];
expect(textEl.type).toBe("text");
if (textEl.type === "text") {
expect(textEl.color).toBe("red");
expect(textEl.fontSize).toBe(72);
expect(textEl.fontWeight).toBe(900);
expect(textEl.fontFamily).toBe("Montserrat");
}
});
it("parses media element properties (mediaStartTime, sourceDuration, volume)", () => {
const html = `
`;
const updated = updateElementInHtml(html, "el1", { name: "New Name" });
expect(updated).toContain('data-name="New Name"');
});
it("returns original html when element not found", () => {
const html = `
Hello
`;
const updated = updateElementInHtml(html, "nonexistent", { name: "Test" });
expect(updated).toBe(html);
});
});
describe("addElementToHtml", () => {
it("adds a new text element to the HTML", () => {
const html = `
`;
const { html: updated, id } = addElementToHtml(html, {
type: "text",
name: "New Text",
content: "Hello!",
startTime: 1,
duration: 3,
zIndex: 1,
});
expect(id).toBeDefined();
expect(updated).toContain(`id="${id}"`);
expect(updated).toContain('data-start="1"');
expect(updated).toContain('data-duration="3"');
expect(updated).toContain('data-track-index="1"');
expect(updated).not.toContain('data-end="');
expect(updated).not.toContain('data-layer="');
expect(updated).toContain("Hello!");
});
it("adds a video element", () => {
const html = `
`;
const { html: updated, id } = addElementToHtml(html, {
type: "video",
name: "My Video",
src: "video.mp4",
startTime: 0,
duration: 10,
zIndex: 0,
});
expect(updated).toContain(`id="${id}"`);
expect(updated).toContain("video.mp4");
});
});
describe("removeElementFromHtml", () => {
it("removes an element by id", () => {
const html = `
Hello
World
`;
const updated = removeElementFromHtml(html, "el1");
expect(updated).not.toContain('id="el1"');
expect(updated).toContain('id="el2"');
});
it("strips ALL gsap tweens for the removed element, not just the first", () => {
// Two tweens on the same element → count-based ids renumber when the first is
// removed, so a single up-front parse left the second tween orphaned.
const html = `
box
`;
const updated = removeElementFromHtml(html, "box");
expect(updated).not.toContain('data-hf-id="box"');
// Neither tween may survive — the orphaned second tl.to referenced a deleted element.
expect(updated).not.toContain("x: 100");
expect(updated).not.toContain("x: 200");
});
it("strips GSAP tweens from composition templates", () => {
const html = `
box
`;
const updated = removeElementFromHtml(html, "box");
expect(updated).not.toContain('data-hf-id="box"');
expect(updated).not.toContain("x: 100");
});
});
describe("validateCompositionHtml", () => {
it("returns valid for a well-formed composition", () => {
const html = `
`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("reports error for missing composition-id", () => {
const html = `
`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing data-composition-id attribute on element");
});
it("reports error for missing composition-duration", () => {
const html = `
`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"Missing data-composition-duration attribute on element",
);
});
it("reports error for missing #stage", () => {
const html = `
`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing #stage element");
});
it("reports error for inline event handlers", () => {
const html = `
`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Inline event handlers (onclick, onload, etc.) not allowed");
});
it("validates GSAP scripts inside composition templates", () => {
const html = `
`;
const result = validateCompositionHtml(html);
expect(result.errors).toContain("onComplete callback not allowed");
});
});
describe("extractCompositionMetadata", () => {
it("extracts composition id and duration", () => {
const html = `
`;
const meta = extractCompositionMetadata(html);
expect(meta.compositionId).toBe("comp-abc");
expect(meta.compositionDuration).toBe(15.5);
});
it("returns null for missing metadata", () => {
const html = ``;
const meta = extractCompositionMetadata(html);
expect(meta.compositionId).toBeNull();
expect(meta.compositionDuration).toBeNull();
});
it("extracts composition variables", () => {
const variables = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "count", type: "number", label: "Count", default: 5 },
]);
const html = `
`;
const meta = extractCompositionMetadata(html);
expect(meta.variables).toHaveLength(2);
expect(meta.variables[0].id).toBe("title");
expect(meta.variables[0].type).toBe("string");
expect(meta.variables[1].id).toBe("count");
expect(meta.variables[1].type).toBe("number");
});
// T9 — CompositionVariable font/image parse (WS-B R1 implemented).
it("parses a font variable (type: font) with name and source", () => {
const variables = JSON.stringify([
{
id: "brand-font-primary",
type: "font",
label: "Heading font",
default: "Inter",
source: "https://fonts.googleapis.com/css2?family=Inter",
default_name: "sans-serif",
default_source: "",
},
]);
const html = `
`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "brand-font-primary");
expect(v).toBeDefined();
expect(v?.type).toBe("font");
// source, default_name, default_source are load-bearing for brand-kit font apply.
expect((v as Record)?.source).toBe(
"https://fonts.googleapis.com/css2?family=Inter",
);
expect((v as Record)?.default_name).toBe("sans-serif");
expect((v as Record)?.default_source).toBe("");
});
it("parses an image variable with brandRole logo:primary", () => {
const variables = JSON.stringify([
{ id: "brand-logo", type: "image", label: "Logo", default: "", brandRole: "logo:primary" },
]);
const html = `
`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "brand-logo");
expect(v).toBeDefined();
expect(v?.type).toBe("image");
expect((v as Record)?.brandRole).toBe("logo:primary");
});
it("[spec] parses a color variable with brandRole and preserves the brandRole field", () => {
const variables = JSON.stringify([
{
id: "brand-color-primary",
type: "color",
label: "Primary",
default: "#0066cc",
brandRole: "color:primary",
},
]);
const html = `
`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "brand-color-primary");
expect(v).toBeDefined();
// TODO(R1): remove cast once ColorVariable.brandRole is typed
expect((v as Record)?.brandRole).toBe("color:primary");
});
it("color variable without brandRole parses normally (brandRole is optional)", () => {
const variables = JSON.stringify([
{ id: "accent", type: "color", label: "Accent", default: "#ff0000" },
]);
const html = `
`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "accent");
expect(v).toBeDefined();
expect(v?.type).toBe("color");
expect((v as Record)?.brandRole).toBeUndefined();
});
it.todo(
"[spec] font variable without source field is rejected (source is required for brand-kit font apply)",
);
it.todo(
"[spec] image variable without default is rejected gracefully (default is required for image variables)",
);
it("unknown variable type is rejected gracefully (does not throw, variable is skipped)", () => {
const variables = JSON.stringify([
{ id: "ok", type: "string", label: "Ok", default: "yes" },
{ id: "x", type: "widget", label: "Unknown", default: null },
]);
const html = `
`;
expect(() => extractCompositionMetadata(html)).not.toThrow();
const meta = extractCompositionMetadata(html);
expect(meta.variables.find((v) => v.id === "x")).toBeUndefined();
expect(meta.variables.find((v) => v.id === "ok")).toBeDefined();
});
});