Capture & emission fidelity wave: dotLottie support, scroll-state isolation, authored-geometry trust, honest quality scoring
Capture: - Preserve real asset extensions (.lottie no longer truncated); extract the default animation JSON from dotLottie ZIP containers at materialization (minimal built-in ZIP reader, no new deps) - Reset scroll + settle before every viewport snapshot so scroll-linked styles can't bake into captured computed values - Marquee detection now requires content overflow (>1.35x), sustained velocity across separated windows, scroll independence (jiggle test), and genuine content repetition — kills scroll-settle-lerp false positives - Sizing probe trusts authored explicit heights (px/vh/calc) over circular parent/child reflow verdicts, fixing dropped 100vh heroes Normalize/generate: - Canonicalize identity transforms to "none" per viewport; emit explicit transform resets across bands when any band has a real transform - DittoLottie keeps the captured placeholder until DOMLoaded; failed loads no longer blank the container - mx-auto only for true auto-margin centering; literal per-band margins are preserved (fixes full-bleed regressions) - Spacing-scale snap tightened to 0.25px (3.5px stays p-[3.5px]); emit whitespace-nowrap for wrap-vulnerable single-line text leaves - Grid recipes no longer override captured track counts or column spans; responsive re-flow plans apply only when geometry agrees Quality scoring: - Rewritten 6-dimension rubric (payload, decomposition, duplication, semantics, hygiene, runtime) with catastrophe caps and per-dimension reporting; thresholds are documented calibration guides 218 tests pass (74 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
77be868ee3
commit
d3ec154bae
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { RawSizing } from "../src/capture/walker.js";
|
||||
import { generateCss } from "../src/generate/css.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
@@ -290,3 +291,196 @@ describe("generateCss scroll-timeline animation suppression", () => {
|
||||
assert.ok(rule.includes("animation-name:fadeInUp"), `time-based reveal keeps its animation, got: ${rule}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-viewport node builder (independent bbox / computed / sizing per width) plus a matching
|
||||
// 3-viewport IR wrapper — the fluid/centring/wrap detectors need ≥2 varying widths to run.
|
||||
const XVPS = [375, 768, 1280];
|
||||
type XPerVp = { cs?: StyleMap; bbox: BBox; sizing?: RawSizing; visible?: boolean };
|
||||
function xNode(id: string, tag: string, byVp: Record<number, XPerVp>, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
const sizingByVp: Record<number, RawSizing> = {};
|
||||
for (const vp of XVPS) {
|
||||
const s = byVp[vp]!;
|
||||
computedByVp[vp] = computed(s.cs);
|
||||
bboxByVp[vp] = s.bbox;
|
||||
visibleByVp[vp] = s.visible ?? true;
|
||||
if (s.sizing) sizingByVp[vp] = s.sizing;
|
||||
}
|
||||
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
if (Object.keys(sizingByVp).length) n.sizingByVp = sizingByVp;
|
||||
return n;
|
||||
}
|
||||
function xIr(root: IRNode): IR {
|
||||
const ir = irWith(root);
|
||||
ir.doc.viewports = XVPS;
|
||||
ir.doc.sampleViewports = XVPS;
|
||||
ir.doc.perViewport = Object.fromEntries(XVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }]));
|
||||
return ir;
|
||||
}
|
||||
/** Every `.c<id>{…}` body (base + banded) concatenated, for asserting a value appears at some vp. */
|
||||
function allRulesX(css: string, id: string): string {
|
||||
const re = new RegExp(`\\.c${id}\\{([^}]*)\\}`, "g");
|
||||
let out = "", m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css))) out += m[1] + ";";
|
||||
return out;
|
||||
}
|
||||
/** The `.c<id>{…}` body inside the first @media block whose query matches `mediaRe`. */
|
||||
function xBandRule(css: string, mediaRe: RegExp, id: string): string {
|
||||
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
|
||||
if (!mediaRe.test(m[1]!)) continue;
|
||||
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||
if (r) return r[1]!;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// BUG A — a padded pill/section with LITERAL equal side margins (a fraction of the viewport, varying
|
||||
// across widths) must keep those px margins per band. The width-fill sizing probe is what tells the
|
||||
// centring detector these are load-bearing spacing, not margin-auto centring slack: on a box the
|
||||
// probe reads as a container-fill (width:100% reproduces it), `margin:auto` resolves to 0 and would
|
||||
// blow the box out to full-bleed, deleting the real margins.
|
||||
describe("generateCss literal-margin vs auto-centring", () => {
|
||||
const fill = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: true, hFill: true });
|
||||
// A flex parent spanning the whole viewport, holding one padded pill child that fills the space
|
||||
// BETWEEN its literal side margins (box + 2×margin == container at every width).
|
||||
function pill() {
|
||||
const child = xNode("n1", "div", {
|
||||
375: { cs: { display: "flex", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 62 }, sizing: fill() },
|
||||
768: { cs: { display: "flex", marginLeft: "30.7188px", marginRight: "30.7188px", width: "706.562px" }, bbox: { x: 30.72, y: 0, width: 706.56, height: 62 }, sizing: fill() },
|
||||
1280: { cs: { display: "flex", marginLeft: "25.5938px", marginRight: "25.5938px", width: "1228.81px" }, bbox: { x: 25.59, y: 0, width: 1228.81, height: 62 }, sizing: fill() },
|
||||
});
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 62 } },
|
||||
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 62 } },
|
||||
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 62 } },
|
||||
}, [child]);
|
||||
return parent;
|
||||
}
|
||||
|
||||
it("keeps the literal px side margins on a width-filling pill (no mx-auto)", () => {
|
||||
const css = generateCss(xIr(pill()), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(!/margin-left:auto/.test(all), `filling pill must not be centred with auto margins, got: ${all}`);
|
||||
assert.ok(baseRule(css, "n1").includes("margin-left:25.5938px"), `base keeps the 1280 literal margin, got: ${baseRule(css, "n1")}`);
|
||||
// The narrowest band (a `max-width` query with no `min-width`) carries the 375-vp literal margin.
|
||||
const mobile = xBandRule(css, /^\(max-width/, "n1");
|
||||
assert.ok(/margin-left:15px/.test(mobile), `mobile band keeps its literal 15px margin, got: ${mobile}`);
|
||||
});
|
||||
|
||||
it("still emits margin:auto for a genuinely centred, width-CONSTRAINED block", () => {
|
||||
// Content-sized (not a fill): the probe says width:auto re-derives it, width narrower than the
|
||||
// container with symmetric slack that varies with width — real margin-auto centring.
|
||||
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
|
||||
const child = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 40 }, sizing: auto() },
|
||||
768: { cs: { display: "block", marginLeft: "84px", marginRight: "84px", width: "600px" }, bbox: { x: 84, y: 0, width: 600, height: 40 }, sizing: auto() },
|
||||
1280: { cs: { display: "block", marginLeft: "340px", marginRight: "340px", width: "600px" }, bbox: { x: 340, y: 0, width: 600, height: 40 }, sizing: auto() },
|
||||
});
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } },
|
||||
}, [child]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(/margin-left:auto/.test(all), `a constrained centred block should still auto-centre, got: ${all}`);
|
||||
});
|
||||
});
|
||||
|
||||
// BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets
|
||||
// `white-space:nowrap`, so a sub-pixel column shortfall in the clone can't wrap it to a second line.
|
||||
describe("generateCss wrap-vulnerable single-line text", () => {
|
||||
// Text bbox exactly fills the column (wMax == avail == bbox.width) at every width, single line
|
||||
// (height == line-height), and is genuinely wrappable (wMin < wMax).
|
||||
function edgeText(wMin: number) {
|
||||
const szAt = (w: number): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax: w });
|
||||
const leaf = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) },
|
||||
768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) },
|
||||
1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 }, sizing: szAt(107.81) },
|
||||
}, [{ text: "CEO — Academy" }]);
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 } },
|
||||
}, [leaf]);
|
||||
return parent;
|
||||
}
|
||||
|
||||
it("emits white-space:nowrap for text flush against its column edge", () => {
|
||||
const css = generateCss(xIr(edgeText(58.33)), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `edge-flush single-line text should get nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap for a single unbreakable token (wMin == wMax — can't wrap)", () => {
|
||||
const css = generateCss(xIr(edgeText(107.81)), new Map());
|
||||
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `an unbreakable token needs no nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap for a genuinely wrapping multi-line paragraph", () => {
|
||||
// Two line boxes tall (height ≈ 2×line-height) → already wrapping, must stay wrappable.
|
||||
const wMin = 100, wMax = 400;
|
||||
const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax });
|
||||
const para = xNode("n1", "p", {
|
||||
375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 345, height: 72 }, sizing: szAt() },
|
||||
768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 700, height: 48 }, sizing: szAt() },
|
||||
1280: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 400, height: 48 }, sizing: szAt() },
|
||||
}, [{ text: "A longer paragraph that wraps across multiple lines depending on the width." }]);
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 345, height: 72 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 700, height: 48 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 400, height: 48 } },
|
||||
}, [para]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
assert.ok(!allRulesX(css, "n1").includes("white-space:nowrap"), `a wrapping paragraph must not be forced nowrap, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap for text with comfortable slack in its container", () => {
|
||||
const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 60, wMax: 90 });
|
||||
const leaf = xNode("n1", "div", {
|
||||
375: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
|
||||
768: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
|
||||
1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
|
||||
}, [{ text: "Nav link" }]);
|
||||
// Wide container (300px+) — the 90px text has plenty of room, no wrap risk.
|
||||
const parent = xNode("n0", "body", {
|
||||
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 20 } },
|
||||
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 20 } },
|
||||
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 20 } },
|
||||
}, [leaf]);
|
||||
const css = generateCss(xIr(parent), new Map());
|
||||
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `slack text needs no nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Cross-band transform identity — a node with a NON-identity transform at one band must emit the
|
||||
// explicit identity `transform:none` at the bands where the source is untransformed, so the
|
||||
// transform can't cascade across bands and freeze at a width the source left untransformed.
|
||||
describe("generateCss cross-band transform identity", () => {
|
||||
it("emits transform:none at a band where a node with a non-identity transform elsewhere is identity", () => {
|
||||
const el = xNode("n1", "div", {
|
||||
375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } },
|
||||
1280: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } },
|
||||
});
|
||||
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]);
|
||||
const css = generateCss(xIr(root), new Map());
|
||||
const all = allRulesX(css, "n1");
|
||||
assert.ok(/transform:matrix/.test(all), `the non-identity transform must be emitted, got: ${all}`);
|
||||
assert.ok(/transform:none/.test(all), `the identity band must emit transform:none so it can't cascade, got: ${all}`);
|
||||
});
|
||||
|
||||
it("does NOT emit transform:none for a node that is identity at every band", () => {
|
||||
const el = xNode("n1", "div", {
|
||||
375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
768: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
1280: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
|
||||
});
|
||||
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]);
|
||||
const css = generateCss(xIr(root), new Map());
|
||||
assert.ok(!allRulesX(css, "n1").includes("transform:none"), `an always-identity node needs no explicit transform, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { isZipArchive, extractDotLottieJson, readZipEntry } from "../src/capture/dotlottie.js";
|
||||
import { extFromUrl } from "../src/capture/capture.js";
|
||||
|
||||
/**
|
||||
* Build a minimal, spec-correct ZIP archive in-memory from named entries. Supports both
|
||||
* stored (method 0) and deflated (method 8) compression so the extractor is exercised on both.
|
||||
* No CRC validation is needed for our reader, so CRC fields are left 0.
|
||||
*/
|
||||
function buildZip(entries: Array<{ name: string; data: Buffer; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
const nameBuf = Buffer.from(e.name, "utf8");
|
||||
const stored = e.deflate ? deflateRawSync(e.data) : e.data;
|
||||
const method = e.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + nameBuf.length);
|
||||
local.writeUInt32LE(0x04034b50, 0); // local file header sig
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0, 6); // flags
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(0, 10); // time
|
||||
local.writeUInt16LE(0, 12); // date
|
||||
local.writeUInt32LE(0, 14); // crc
|
||||
local.writeUInt32LE(stored.length, 18); // comp size
|
||||
local.writeUInt32LE(e.data.length, 22); // uncomp size
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra len
|
||||
nameBuf.copy(local, 30);
|
||||
const localFull = Buffer.concat([local, stored]);
|
||||
locals.push(localFull);
|
||||
|
||||
const central = Buffer.alloc(46 + nameBuf.length);
|
||||
central.writeUInt32LE(0x02014b50, 0); // central dir sig
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed
|
||||
central.writeUInt16LE(0, 8); // flags
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt16LE(0, 12); // time
|
||||
central.writeUInt16LE(0, 14); // date
|
||||
central.writeUInt32LE(0, 16); // crc
|
||||
central.writeUInt32LE(stored.length, 20); // comp size
|
||||
central.writeUInt32LE(e.data.length, 24); // uncomp size
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt16LE(0, 30); // extra
|
||||
central.writeUInt16LE(0, 32); // comment
|
||||
central.writeUInt16LE(0, 34); // disk
|
||||
central.writeUInt16LE(0, 36); // internal attrs
|
||||
central.writeUInt32LE(0, 38); // external attrs
|
||||
central.writeUInt32LE(offset, 42); // local header offset
|
||||
nameBuf.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += localFull.length;
|
||||
}
|
||||
|
||||
const centralStart = offset;
|
||||
const centralDir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4); // disk
|
||||
eocd.writeUInt16LE(0, 6); // cd start disk
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralDir.length, 12);
|
||||
eocd.writeUInt32LE(centralStart, 16);
|
||||
eocd.writeUInt16LE(0, 20); // comment len
|
||||
|
||||
return Buffer.concat([...locals, centralDir, eocd]);
|
||||
}
|
||||
|
||||
const ANIM_1 = { v: "5.7.4", nm: "one", layers: [{ ind: 1 }] };
|
||||
const ANIM_2 = { v: "5.7.4", nm: "two", layers: [{ ind: 2 }] };
|
||||
const MANIFEST = { version: "1.0", animations: [{ id: "animation_default" }, { id: "animation_2" }] };
|
||||
|
||||
describe("dotLottie ZIP extraction", () => {
|
||||
it("detects ZIP archives by the PK local-header magic", () => {
|
||||
assert.equal(isZipArchive(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0])), true);
|
||||
assert.equal(isZipArchive(Buffer.from('{"v":"5.7"}', "utf8")), false);
|
||||
assert.equal(isZipArchive(Buffer.alloc(2)), false);
|
||||
});
|
||||
|
||||
it("extracts the manifest's default animation JSON from a stored dotLottie ZIP", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8") },
|
||||
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
|
||||
{ name: "animations/animation_2.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out, "expected an extracted animation buffer");
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
|
||||
});
|
||||
|
||||
it("extracts a DEFLATE-compressed animation entry (the common real-world case)", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true },
|
||||
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8"), deflate: true },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
|
||||
});
|
||||
|
||||
it("falls back to the name-sorted first animation when the manifest is absent", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "animations/b.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
|
||||
{ name: "animations/a.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); // "a.json" sorts first
|
||||
});
|
||||
|
||||
it("returns null for non-ZIP bytes and for a ZIP with no animations", () => {
|
||||
assert.equal(extractDotLottieJson(Buffer.from('{"v":"5.7"}', "utf8")), null);
|
||||
const noAnims = buildZip([{ name: "manifest.json", data: Buffer.from("{}", "utf8") }]);
|
||||
assert.equal(extractDotLottieJson(noAnims), null);
|
||||
});
|
||||
|
||||
it("reads a named entry directly", () => {
|
||||
const zip = buildZip([{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true }]);
|
||||
const m = readZipEntry(zip, "manifest.json");
|
||||
assert.ok(m);
|
||||
assert.deepEqual(JSON.parse(m!.toString("utf8")), MANIFEST);
|
||||
assert.equal(readZipEntry(zip, "missing.json"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asset extension preservation (extFromUrl)", () => {
|
||||
it("preserves real long extensions instead of truncating to 5 chars", () => {
|
||||
assert.equal(extFromUrl("https://x/anim.lottie"), "lottie");
|
||||
assert.equal(extFromUrl("https://x/site.webmanifest"), "webmanifest");
|
||||
assert.equal(extFromUrl("https://x/pic.png?v=2"), "png");
|
||||
assert.equal(extFromUrl("https://x/font.woff2"), "woff2");
|
||||
});
|
||||
|
||||
it("rejects absurdly long or non-alphanumeric trailing segments", () => {
|
||||
assert.equal(extFromUrl("https://x/name.thisisnotanextension"), "");
|
||||
assert.equal(extFromUrl("https://x/dir.with.dots/file"), ""); // dot is in a path segment, not the file
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DITTO_LOTTIE_TSX } from "../src/generate/lottie.js";
|
||||
import { isIdentityTransform, canonicalizeTransforms } from "../src/normalize/ir.js";
|
||||
import type { StyleMap } from "../src/normalize/ir.js";
|
||||
|
||||
/**
|
||||
* The DittoLottie runtime must NOT erase the captured placeholder frame before the animation
|
||||
* has successfully loaded — a failed load would otherwise blank the container (erasing hero /
|
||||
* logo / footer media). These are string assertions on the emitted 'use client' template.
|
||||
*/
|
||||
describe("DittoLottie placeholder retention", () => {
|
||||
it("does not clear the container's innerHTML before mounting", () => {
|
||||
assert.ok(
|
||||
!/el\.innerHTML\s*=\s*""/.test(DITTO_LOTTIE_TSX),
|
||||
"template must not eagerly clear the placeholder via el.innerHTML = \"\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the live animation into a separate overlay child, not the container itself", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /createElement\(["']div["']\)/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /container:\s*mount/);
|
||||
});
|
||||
|
||||
it("only removes the placeholder after a successful load event (DOMLoaded)", () => {
|
||||
// The reveal/swap must be gated behind lottie's ready event, and the placeholder removal
|
||||
// must live inside that gated path (removing original children once mount is ready).
|
||||
assert.match(DITTO_LOTTIE_TSX, /addEventListener\(\s*["']DOMLoaded["']/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /removeChild/);
|
||||
// The removal must reference the ready swap, not run unconditionally at mount time.
|
||||
const revealIdx = DITTO_LOTTIE_TSX.indexOf("DOMLoaded");
|
||||
const clearIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
|
||||
assert.ok(revealIdx >= 0 && clearIdx >= 0, "both the ready gate and the placeholder removal must be present");
|
||||
});
|
||||
|
||||
it("handles a failed load without leaving a broken mount stacked over the placeholder", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /data_failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity-transform canonicalization (isIdentityTransform)", () => {
|
||||
it("treats none and identity matrices as identity", () => {
|
||||
assert.equal(isIdentityTransform("none"), true);
|
||||
assert.equal(isIdentityTransform(undefined), true);
|
||||
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 0)"), true);
|
||||
assert.equal(isIdentityTransform("matrix(1,0,0,1,0,0)"), true);
|
||||
assert.equal(
|
||||
isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT treat a real (non-identity) transform as identity", () => {
|
||||
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 67.75)"), false);
|
||||
assert.equal(isIdentityTransform("translateY(67.75px)"), false);
|
||||
assert.equal(isIdentityTransform("matrix(0.5, 0, 0, 0.5, 0, 0)"), false);
|
||||
assert.equal(isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 10, 0, 1)"), false);
|
||||
});
|
||||
|
||||
it("rewrites identity values to `none` while leaving a real transform observable at other widths", () => {
|
||||
// The contamination scenario: the base viewport (1280) baked a scroll-linked translateY,
|
||||
// while the other widths report identity (none / identity matrix). After canonicalization
|
||||
// the identity widths read literal `none`, so the generator's per-band delta emits the
|
||||
// explicit reset and the base transform can't cascade across bands.
|
||||
const computedByVp: Record<number, StyleMap> = {
|
||||
375: { transform: "none" } as StyleMap,
|
||||
768: { transform: "matrix(1, 0, 0, 1, 0, 0)" } as StyleMap,
|
||||
1280: { transform: "matrix(1, 0, 0, 1, 0, 67.75)" } as StyleMap, // contaminated base
|
||||
1920: { transform: "none" } as StyleMap,
|
||||
};
|
||||
canonicalizeTransforms(computedByVp);
|
||||
assert.equal(computedByVp[375]!.transform, "none");
|
||||
assert.equal(computedByVp[768]!.transform, "none"); // identity matrix normalized to none
|
||||
assert.equal(computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 67.75)"); // real transform preserved
|
||||
assert.equal(computedByVp[1920]!.transform, "none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
medianVelocityPxPerSec,
|
||||
classifyVelocitySamples,
|
||||
hasRepeatedChildren,
|
||||
} from "../src/capture/motion.js";
|
||||
|
||||
// These cover the pure discriminators extracted from detectMarquees' in-browser sampling.
|
||||
// The regression they defend: on a scroll-LINKED-easing page, a static logo row is still
|
||||
// lerping toward its scroll-target right after scrollIntoView, reading as a constant
|
||||
// velocity over ONE short window → four phantom marquees at identical -34px/s. The layered
|
||||
// tests below make that classification fail.
|
||||
|
||||
describe("medianVelocityPxPerSec", () => {
|
||||
it("converts a steady per-120ms delta to px/s and ignores the wrap-reset outlier", () => {
|
||||
// steady -4px per 120ms ≈ -33px/s; one big +200 wrap jump is an outlier the median drops.
|
||||
const deltas = [-4, -4, 200, -4, -4];
|
||||
assert.equal(medianVelocityPxPerSec(deltas, 120), Math.round((-4 / 120) * 1000));
|
||||
});
|
||||
it("returns 0 for empty deltas or a non-positive cadence", () => {
|
||||
assert.equal(medianVelocityPxPerSec([], 120), 0);
|
||||
assert.equal(medianVelocityPxPerSec([-4, -4], 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyVelocitySamples — sustained constant velocity (discriminator 2)", () => {
|
||||
const MS = 120;
|
||||
it("PASSES a real marquee: velocity holds across both windows", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [-4, -4, -4, -4, -4];
|
||||
const r = classifyVelocitySamples(w1, w2, MS);
|
||||
assert.equal(r.isMarquee, true);
|
||||
assert.equal(r.pxPerSec, medianVelocityPxPerSec(w1, MS));
|
||||
});
|
||||
|
||||
it("REJECTS a scroll-settle lerp: velocity decays sharply in window 2 (the false positive)", () => {
|
||||
// window1 still lerping fast toward scroll-target; window2 nearly settled.
|
||||
const w1 = [-4, -4, -4, -4, -4]; // ≈ -33px/s (the phantom "-34px/s")
|
||||
const w2 = [-0.4, -0.4, -0.4, -0.4, -0.4]; // decayed to ~-3px/s
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS when window 2 has fully settled (velocity ~0)", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [0, 0, 0, 0, 0];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS when the direction reverses between windows", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [4, 4, 4, 4, 4];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS a static row: no motion in either window", () => {
|
||||
assert.equal(classifyVelocitySamples([0, 0, 0], [0, 0, 0], MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("PASSES a slightly slower-but-still-moving second window (within tolerance)", () => {
|
||||
// small natural jitter (10% slower) must not disqualify a genuine ticker.
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [-3.6, -3.6, -3.6, -3.6, -3.6];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRepeatedChildren — genuine duplication (discriminator 4)", () => {
|
||||
it("PASSES two consecutive children with an identical outerHTML hash (a cloned copy)", () => {
|
||||
const hashes = [111, 111, 222]; // first two are a literal duplicate
|
||||
const widths = [80, 80, 120];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), true);
|
||||
});
|
||||
|
||||
it("PASSES a duplicated content block via the repeated width sequence [A B A B]", () => {
|
||||
// hashes differ (cloned then attribute-tweaked) but geometry repeats: [100,60,100,60].
|
||||
const hashes = [1, 2, 3, 4];
|
||||
const widths = [100, 60, 100, 60];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), true);
|
||||
});
|
||||
|
||||
it("REJECTS a row of distinct logos: no consecutive repetition (the false-positive shape)", () => {
|
||||
// four DIFFERENT logos — the exact static-logo-row case that produced phantom marquees.
|
||||
const hashes = [10, 20, 30, 40];
|
||||
const widths = [90, 110, 75, 130];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), false);
|
||||
});
|
||||
|
||||
it("REJECTS fewer than two children", () => {
|
||||
assert.equal(hasRepeatedChildren([5], [50]), false);
|
||||
assert.equal(hasRepeatedChildren([], []), false);
|
||||
});
|
||||
|
||||
it("does NOT treat a run of zero-width nodes as repetition (hash 0 / width 0 guards)", () => {
|
||||
assert.equal(hasRepeatedChildren([0, 0], [0, 0]), false);
|
||||
assert.equal(hasRepeatedChildren([1, 2, 3, 4], [0, 0, 0, 0]), false);
|
||||
});
|
||||
|
||||
it("REJECTS an odd-length width sequence that can't split into halves", () => {
|
||||
const hashes = [1, 2, 3];
|
||||
const widths = [100, 60, 100];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
inlineBlobBytes,
|
||||
filePayload,
|
||||
whitespaceLiterals,
|
||||
subpixelArbitraries,
|
||||
customPropTokens,
|
||||
probeArtifacts,
|
||||
uncleanedListeners,
|
||||
tagHistogram,
|
||||
ariaHiddenFocusables,
|
||||
duplicateHelpers,
|
||||
svgPathDuplication,
|
||||
nearDuplicateComponents,
|
||||
componentNames,
|
||||
toLetter,
|
||||
scoreApp,
|
||||
type SrcFile,
|
||||
} from "../src/runner/qualityScore.js";
|
||||
|
||||
const srcFile = (rel: string, text: string): SrcFile => ({
|
||||
path: rel, rel, ext: rel.slice(rel.lastIndexOf(".")), text, lines: text.split("\n").length,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("inlineBlobBytes", () => {
|
||||
it("counts base64 data-URI payload bytes", () => {
|
||||
const b64 = "A".repeat(500);
|
||||
const text = `const img = "data:image/png;base64,${b64}";`;
|
||||
assert.equal(inlineBlobBytes(text), 500);
|
||||
});
|
||||
|
||||
it("counts a long markup string handed in as a prop", () => {
|
||||
const html = "<div>" + "<span>x</span>".repeat(250) + "</div>";
|
||||
const text = `<Frame html={${JSON.stringify(html)}} />`;
|
||||
assert.ok(inlineBlobBytes(text) > 1000, "flags HTML-as-string blob");
|
||||
});
|
||||
|
||||
it("ignores ordinary short strings", () => {
|
||||
assert.equal(inlineBlobBytes(`const s = "hello world";`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filePayload", () => {
|
||||
it("reports a giant single line", () => {
|
||||
const giant = "x".repeat(200_000);
|
||||
const p = filePayload(srcFile("a.tsx", `const a = 1;\nconst b = "${giant}";\n`));
|
||||
assert.ok(p.maxLine >= 200_000, "detects the giant line");
|
||||
assert.ok(p.bytes >= 200_000, "counts the bytes");
|
||||
});
|
||||
|
||||
it("a normal formatted file has a small max line", () => {
|
||||
const p = filePayload(srcFile("a.tsx", "const a = 1;\nconst b = 2;\nexport default a;\n"));
|
||||
assert.ok(p.maxLine < 40, "small max line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespaceLiterals", () => {
|
||||
it("counts {\" \"} capture-whitespace literals", () => {
|
||||
const text = `<p>Hi{" "}there{" "}world{' '}!</p>`;
|
||||
assert.equal(whitespaceLiterals(text), 3);
|
||||
});
|
||||
|
||||
it("does not flag meaningful expression literals", () => {
|
||||
assert.equal(whitespaceLiterals(`<p>{name}{count}</p>`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpixelArbitraries", () => {
|
||||
it("flags non-integer px/rem arbitraries but not whole ones", () => {
|
||||
// 713.938px (frozen) + 12.5rem (=200px, whole) → only the first counts.
|
||||
const text = `<div className="w-[713.938px] h-[12.5rem] p-[16px]" />`;
|
||||
assert.equal(subpixelArbitraries(text), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customPropTokens", () => {
|
||||
it("splits opaque --clr-N / hash tokens from named ones", () => {
|
||||
const css = `:root{ --clr-7:#fff; --c12:#000; --a1b2c3:#111; --brand-primary:#f00; --space-4:1rem; }`;
|
||||
const t = customPropTokens(css);
|
||||
assert.equal(t.total, 5);
|
||||
assert.equal(t.opaque, 3, "clr-7, c12, hash a1b2c3 are opaque; brand-primary/space-4 are named");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeArtifacts", () => {
|
||||
it("flags off-screen capture-probe scaffolding", () => {
|
||||
const text = `<span data-probe="1">m</span><i style={{clip: 'rect(0 0 0 0)'}} />`;
|
||||
assert.ok(probeArtifacts(text) >= 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uncleanedListeners", () => {
|
||||
it("counts addEventListener with no cleanup", () => {
|
||||
const text = `el.addEventListener("scroll", fn);\nwin.addEventListener("resize", fn);`;
|
||||
assert.equal(uncleanedListeners(text), 2);
|
||||
});
|
||||
|
||||
it("does not flag when a matching removeEventListener / teardown exists", () => {
|
||||
const text = `useEffect(() => { el.addEventListener("scroll", fn); return () => el.removeEventListener("scroll", fn); });`;
|
||||
assert.equal(uncleanedListeners(text), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagHistogram", () => {
|
||||
it("counts opening element tags by name", () => {
|
||||
const h = tagHistogram(`<div><div/><span>x</span><button>b</button></div>`);
|
||||
assert.equal(h["div"], 2);
|
||||
assert.equal(h["span"], 1);
|
||||
assert.equal(h["button"], 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ariaHiddenFocusables", () => {
|
||||
it("flags aria-hidden on a focusable element", () => {
|
||||
const text = `<button aria-hidden="true">x</button><a aria-hidden={true} href="#">y</a>`;
|
||||
assert.equal(ariaHiddenFocusables(text), 2);
|
||||
});
|
||||
|
||||
it("ignores aria-hidden on a decorative div", () => {
|
||||
assert.equal(ariaHiddenFocusables(`<div aria-hidden="true" />`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateHelpers", () => {
|
||||
it("counts copy-paste helper bodies (e.g. a repeated cn())", () => {
|
||||
const body = `{ return classes.filter(Boolean).join(" ").replace(/\\s+/g, " ").trim(); }`;
|
||||
const text = `function cn(...classes) ${body}\nfunction cx(...classes) ${body}\nfunction merge(...classes) ${body}`;
|
||||
const d = duplicateHelpers(text);
|
||||
assert.equal(d.defs, 3);
|
||||
assert.equal(d.dups, 2, "two of the three are identical duplicates");
|
||||
});
|
||||
|
||||
it("does not flag distinct helper bodies", () => {
|
||||
const text = `function a(x) { return x + 1111111111; }\nfunction b(x) { return x - 2222222222; }`;
|
||||
assert.equal(duplicateHelpers(text).dups, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("svgPathDuplication", () => {
|
||||
it("counts repeated inline <path d=...> strings", () => {
|
||||
const d = "M10 10 L20 20 L30 30 Z aaaaaaaaaaaaaaaa";
|
||||
const text = `<path d="${d}"/><path d="${d}"/><path d="M1 1 L2 2 different pathhhhhhhh"/>`;
|
||||
const r = svgPathDuplication(text);
|
||||
assert.equal(r.total, 3);
|
||||
assert.equal(r.repeats, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearDuplicateComponents", () => {
|
||||
it("counts pairs sharing a tag signature", () => {
|
||||
assert.equal(nearDuplicateComponents(["div:2,span:1", "div:2,span:1", "nav:1"]), 1);
|
||||
assert.equal(nearDuplicateComponents(["a", "a", "a"]), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("componentNames", () => {
|
||||
it("extracts exported + declared component symbols", () => {
|
||||
const text = `export default function HeroSection(){}\nexport function Footer(){}\nconst Navbar = () => {};`;
|
||||
const names = componentNames(text);
|
||||
assert.ok(names.includes("HeroSection"));
|
||||
assert.ok(names.includes("Footer"));
|
||||
assert.ok(names.includes("Navbar"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("toLetter", () => {
|
||||
it("maps scores to the expected grade bands", () => {
|
||||
assert.equal(toLetter(95), "A");
|
||||
assert.equal(toLetter(82), "B-");
|
||||
assert.equal(toLetter(75), "C");
|
||||
assert.equal(toLetter(67), "D+");
|
||||
assert.equal(toLetter(59), "F");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scoreApp — end-to-end on synthetic app trees (generic fixture content)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTree(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "qs-"));
|
||||
for (const [rel, text] of Object.entries(files)) {
|
||||
const p = join(root, rel);
|
||||
mkdirSync(p.slice(0, p.lastIndexOf("/")), { recursive: true });
|
||||
writeFileSync(p, text);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("scoreApp — hard cap on catastrophic payload", () => {
|
||||
it("caps a tree containing a multi-megabyte source file into D-range", () => {
|
||||
const giant = "x".repeat(1_200_000); // >1MB single file
|
||||
const root = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>Hi</h1><section><p>ok</p></section></main>; }`,
|
||||
"src/app/svgs/blob.tsx": `export const blob = "${giant}";`,
|
||||
});
|
||||
try {
|
||||
const rep = scoreApp(root);
|
||||
assert.ok(rep.caps.length > 0, "a catastrophe cap is recorded");
|
||||
assert.ok(rep.total <= 68, `grade is capped into D-range, got ${rep.total}`);
|
||||
assert.ok(["D+", "D", "D-", "F"].includes(rep.grade), `grade ${rep.grade} is D-range or below`);
|
||||
assert.ok(rep.categories.payload!.score < rep.categories.payload!.max * 0.5, "payload dimension collapses");
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreApp — semantics", () => {
|
||||
it("penalizes a page with no h1 vs one with a single h1", () => {
|
||||
const withH1 = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>Title</h1><section><p>a</p></section><nav><a href="#">x</a></nav></main>; }`,
|
||||
});
|
||||
const noH1 = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <div><div><div><div><span>a</span></div></div></div></div>; }`,
|
||||
});
|
||||
try {
|
||||
const a = scoreApp(withH1);
|
||||
const b = scoreApp(noH1);
|
||||
assert.ok(a.categories.semantics!.score > b.categories.semantics!.score, "h1 + semantic tags score higher");
|
||||
assert.equal(b.categories.semantics!.metrics.h1, 0);
|
||||
} finally {
|
||||
rmSync(withH1, { recursive: true, force: true });
|
||||
rmSync(noH1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreApp — decomposition", () => {
|
||||
it("rates a decomposed tree above a single-file monolith of the same markup", () => {
|
||||
const bodyTags = "<div><p>x</p><a href='#'>l</a></div>".repeat(40);
|
||||
const monolith = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>H</h1>${bodyTags}</main>; }`,
|
||||
});
|
||||
const decomposed = makeTree({
|
||||
"src/app/page.tsx": `import Hero from "./sections/hero";\nimport Feature from "./sections/feature";\nexport default function Page(){ return <main><h1>H</h1><Hero/><Feature/></main>; }`,
|
||||
"src/app/sections/hero.tsx": `export function Hero(){ return <section>${"<div><p>x</p></div>".repeat(20)}</section>; }`,
|
||||
"src/app/sections/feature.tsx": `export function Feature(){ return <section>${"<div><a href='#'>l</a></div>".repeat(20)}</section>; }`,
|
||||
});
|
||||
try {
|
||||
const m = scoreApp(monolith);
|
||||
const d = scoreApp(decomposed);
|
||||
assert.ok(d.categories.decomposition!.score > m.categories.decomposition!.score, "decomposed scores higher on decomposition");
|
||||
assert.ok(d.total > m.total, "and grades higher overall");
|
||||
} finally {
|
||||
rmSync(monolith, { recursive: true, force: true });
|
||||
rmSync(decomposed, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { RecipeReport, RecipeCandidate, RecipeResponsiveRegime } from "../src/infer/recipes.js";
|
||||
import { recipeResponsiveClassCleaner } from "../src/generate/app.js";
|
||||
|
||||
// The recipe pass infers a container's column count by grouping item bounding boxes into rows and
|
||||
// taking the widest row. When an item spans multiple tracks that under-reports the real track count,
|
||||
// so the synthesized column plan must NOT override the authored/computed grid geometry that the
|
||||
// Tailwind emitter already baked into the className. These fixtures build a 3-track grid whose main
|
||||
// card spans 2 columns (heuristic reports 2 columns) and assert the emitted classes keep 3 tracks
|
||||
// and the span.
|
||||
|
||||
const VPS = [768, 1280];
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { ...computed(), ...(byVp[vp] ?? {}) };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/",
|
||||
title: "Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: 1280,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255,255,255)", bodyBg: "rgb(255,255,255)", bodyColor: "rgb(0,0,0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 4,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
function regime(viewport: number, columns: number, visibleItems: number): RecipeResponsiveRegime {
|
||||
return { viewport, layout: "grid", rootBox: { x: 0, y: 0, width: viewport, height: 400 }, visibleItems, columns, rows: 1 };
|
||||
}
|
||||
|
||||
function candidate(over: Partial<RecipeCandidate>): RecipeCandidate {
|
||||
return {
|
||||
id: "r0",
|
||||
kind: "product-grid",
|
||||
confidence: 0.89,
|
||||
risk: "low",
|
||||
rootCid: "n0",
|
||||
rootTag: "section",
|
||||
itemParentCid: "n1",
|
||||
componentName: "ProductGridSection",
|
||||
itemCount: 2,
|
||||
repeatedItems: [],
|
||||
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
|
||||
sourceHints: [],
|
||||
signals: [],
|
||||
emissionStatus: "report-only",
|
||||
fallbackReason: "",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function report(candidates: RecipeCandidate[]): RecipeReport {
|
||||
return {
|
||||
version: 1,
|
||||
sourceUrl: "https://example.test/",
|
||||
canonicalViewport: 1280,
|
||||
viewports: VPS,
|
||||
sampledViewports: VPS,
|
||||
summary: { totalCandidates: candidates.length, highConfidence: candidates.length, byKind: {}, templateReadyKinds: [] },
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
// A 3-track grid: the main card spans 2 tracks (`grid-column: 1 / 3`) and a photo occupies track 3.
|
||||
// The item-count heuristic groups the two items into one row → columns = 2, which disagrees with the
|
||||
// computed 3 tracks.
|
||||
function spanningGridIr(): IR {
|
||||
const threeTracks = "284px 284px 284px";
|
||||
const card = node("n2", "article", {
|
||||
768: { gridColumnStart: "1", gridColumnEnd: "3" },
|
||||
1280: { gridColumnStart: "1", gridColumnEnd: "3" },
|
||||
});
|
||||
const photo = node("n3", "img", {
|
||||
768: { gridColumnStart: "3", gridColumnEnd: "4" },
|
||||
1280: { gridColumnStart: "3", gridColumnEnd: "4" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: threeTracks },
|
||||
1280: { display: "grid", gridTemplateColumns: threeTracks },
|
||||
}, [card, photo]);
|
||||
return irWith(node("n0", "section", {}, [parent]));
|
||||
}
|
||||
|
||||
describe("recipe grid geometry: computed tracks/spans are ground truth", () => {
|
||||
it("does not override a 3-track grid with a heuristic 2-column plan (span-2 item present)", () => {
|
||||
const ir = spanningGridIr();
|
||||
const c = candidate({ itemParentCid: "n1", repeatedItems: [
|
||||
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 568, height: 300 } },
|
||||
{ cid: "n3", tag: "img", textSample: "", mediaCount: 1, headingCount: 0, bbox: { x: 584, y: 0, width: 284, height: 300 } },
|
||||
] });
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
|
||||
// The Tailwind emitter has already put the authored 3-track grid on the container className.
|
||||
const containerIn = "grid grid-cols-3 gap-4";
|
||||
const containerOut = clean("n1", containerIn)!.split(/\s+/);
|
||||
assert.ok(containerOut.includes("grid-cols-3"), "authored 3-track grid-cols-3 survives");
|
||||
assert.ok(!containerOut.some((t) => /(?:^|:)grid-cols-2$/.test(t)), "no synthesized grid-cols-2 override");
|
||||
// No responsive column-plan tokens are appended (the plan was rejected as untrustworthy).
|
||||
assert.ok(!containerOut.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), "no responsive grid-cols plan appended");
|
||||
|
||||
// The span-2 item keeps its authored column span (emitter tokens pass through untouched).
|
||||
const itemOut = clean("n2", "col-start-1 col-end-3 flex flex-col")!;
|
||||
assert.equal(itemOut, "col-start-1 col-end-3 flex flex-col", "span-2 item className is unchanged");
|
||||
});
|
||||
|
||||
it("still re-flows a genuinely uniform grid whose computed tracks match the heuristic", () => {
|
||||
// 2-track grid at 768, 3-track at 1280, no spanning items → heuristic agrees with computed.
|
||||
const item = (id: string): IRNode => node(id, "article", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: "300px 300px" },
|
||||
1280: { display: "grid", gridTemplateColumns: "284px 284px 284px" },
|
||||
}, [item("n2"), item("n3"), item("n4")]);
|
||||
const ir = irWith(node("n0", "section", {}, [parent]));
|
||||
const c = candidate({
|
||||
itemParentCid: "n1",
|
||||
itemCount: 3,
|
||||
responsiveRegimes: [regime(768, 2, 3), regime(1280, 3, 3)],
|
||||
repeatedItems: [
|
||||
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 300, height: 300 } },
|
||||
{ cid: "n3", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 316, y: 0, width: 300, height: 300 } },
|
||||
{ cid: "n4", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 316, width: 300, height: 300 } },
|
||||
],
|
||||
});
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
const out = clean("n1", "grid grid-cols-2 gap-4")!.split(/\s+/);
|
||||
// Geometry agreed → the responsive plan is applied: base 2 columns, lg:3 at the wider viewport.
|
||||
assert.ok(out.includes("grid-cols-2"), "base column count applied");
|
||||
assert.ok(out.includes("lg:grid-cols-3"), "responsive column bump applied");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { declToUtil } from "../src/generate/tailwind.js";
|
||||
import { declToUtil, snapBase } from "../src/generate/tailwind.js";
|
||||
|
||||
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
|
||||
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
|
||||
@@ -41,3 +41,33 @@ describe("declToUtil zero values", () => {
|
||||
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
|
||||
});
|
||||
});
|
||||
|
||||
// BUG B — the spacing-scale snap must only fire when a value lands ESSENTIALLY ON a step (≤0.25px).
|
||||
// The scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so 3.5px is BETWEEN steps — snapping it up to
|
||||
// p-1 (4px) adds +0.5px per side, which accumulates across a fixed-width flex row until items overflow
|
||||
// and wrap. On-step values (2px→p-0.5, 4px→p-1) still snap.
|
||||
describe("snapBase spacing-scale snapping", () => {
|
||||
it("keeps a between-steps value arbitrary (3.5px does NOT snap to p-1)", () => {
|
||||
assert.equal(snapBase("p-[3.5px]"), "p-[3.5px]");
|
||||
});
|
||||
|
||||
it("does not snap the same sub-step delta on other spacing props", () => {
|
||||
assert.equal(snapBase("pl-[3.5px]"), "pl-[3.5px]");
|
||||
assert.equal(snapBase("gap-[3.5px]"), "gap-[3.5px]");
|
||||
assert.equal(snapBase("mt-[13.5px]"), "mt-[13.5px]"); // between p-3 (12px) and p-3.5 (14px)
|
||||
});
|
||||
|
||||
it("still snaps a value that sits on a 0.5-step (2px → p-0.5, 6px → p-1.5)", () => {
|
||||
assert.equal(snapBase("p-[2px]"), "p-0.5");
|
||||
assert.equal(snapBase("p-[6px]"), "p-1.5");
|
||||
});
|
||||
|
||||
it("still snaps a near-exact on-step value within the tight budget (3.98px → p-1)", () => {
|
||||
assert.equal(snapBase("p-[3.98px]"), "p-1");
|
||||
});
|
||||
|
||||
it("leaves an already-clean scale utility unchanged and snaps 16px → mx-4", () => {
|
||||
assert.equal(snapBase("p-1"), "p-1");
|
||||
assert.equal(snapBase("mx-[16px]"), "mx-4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,3 +211,109 @@ describe("walker font-metric probe tagging (fix 4)", () => {
|
||||
assert.ok(!sr.probe, "sr-only accessible text is not a probe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker sizing probe: circular authored-height guard", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("keeps an explicit 100vh height (circular hero/fill-child pair)", async () => {
|
||||
// A hero authored `height:100vh` with a `height:100%` fill child: setting the hero to
|
||||
// `height:auto` still reproduces its box because the child pins it back, so the raw probe
|
||||
// would read hAuto:true and the authored 100vh would be dropped — hero collapses to 0.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.hero { height: 100vh; display: flex; }
|
||||
.fill { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<section class="hero"><div class="fill"><p>content</p></div></section>`);
|
||||
const hero = findByClass(snap.root, "hero")!;
|
||||
assert.ok(hero.sizing, "hero was probed");
|
||||
assert.equal(hero.sizing!.hAuto, false, "explicit 100vh is not content-sized");
|
||||
assert.equal(hero.sizing!.hFill, false, "explicit 100vh is authored, not a parent fill");
|
||||
// Box actually equals the viewport height (720), proving the circular reproduction.
|
||||
assert.ok(Math.abs(hero.bbox.height - 720) <= 1, "hero rendered at 100vh");
|
||||
});
|
||||
|
||||
it("keeps an explicit px height whose fill child reproduces it", async () => {
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.section { height: 400px; display: flex; }
|
||||
.fill { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<div class="section"><div class="fill"><p>content</p></div></div>`);
|
||||
const section = findByClass(snap.root, "section")!;
|
||||
assert.ok(section.sizing, "section was probed");
|
||||
assert.equal(section.sizing!.hAuto, false, "explicit 400px is not content-sized");
|
||||
assert.equal(section.sizing!.hFill, false, "explicit 400px is authored, not a fill");
|
||||
assert.ok(Math.abs(section.bbox.height - 400) <= 1, "section rendered at 400px");
|
||||
});
|
||||
|
||||
it("keeps an explicit height authored via inline style", async () => {
|
||||
const snap = await capture(`
|
||||
<style>.fill { height: 100%; width: 100%; }</style>
|
||||
<div class="box" style="height: 300px; display: flex;">
|
||||
<div class="fill"><p>content</p></div>
|
||||
</div>`);
|
||||
const box = findByClass(snap.root, "box")!;
|
||||
assert.ok(box.sizing, "box was probed");
|
||||
assert.equal(box.sizing!.hAuto, false, "inline explicit height is kept");
|
||||
assert.equal(box.sizing!.hFill, false, "inline explicit height is not a fill");
|
||||
});
|
||||
|
||||
it("resolves the mutual parent/child pair without disturbing the fill child", async () => {
|
||||
// The child is a GENUINE fill (height:100%) and must keep hFill:true / hAuto:false so the
|
||||
// generator emits h-full for it; only the parent's circular verdict is corrected.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.outer { height: 500px; display: flex; }
|
||||
.inner { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<div class="outer"><div class="inner"><p>content</p></div></div>`);
|
||||
const outer = findByClass(snap.root, "outer")!;
|
||||
const inner = findByClass(snap.root, "inner")!;
|
||||
assert.equal(outer.sizing!.hAuto, false, "parent explicit height kept");
|
||||
assert.equal(outer.sizing!.hFill, false, "parent is authored, not a fill");
|
||||
// The child authors only `height:100%` (a fill), so the explicit-height guard must NOT fire on
|
||||
// it — hFill stays true so the generator can still emit h-full for the genuine fill child.
|
||||
assert.equal(inner.sizing!.hFill, true, "child still fills the definite parent");
|
||||
});
|
||||
|
||||
it("still detects a genuinely content-sized (auto) height as hAuto", async () => {
|
||||
// No authored height anywhere: the box is content-sized and must stay droppable.
|
||||
const snap = await capture(`
|
||||
<style>.wrap { display: block; }</style>
|
||||
<div class="wrap"><p>just some flowing text content</p></div>`);
|
||||
const wrap = findByClass(snap.root, "wrap")!;
|
||||
assert.ok(wrap.sizing, "wrap was probed");
|
||||
assert.equal(wrap.sizing!.hAuto, true, "content-sized height is still auto");
|
||||
});
|
||||
|
||||
it("does not treat a percentage or zero authored height as explicit", async () => {
|
||||
// height:100% is the FILL case (handled by hFill), and height:0 is not definite; neither
|
||||
// should trip the explicit-height override.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.pct-parent { height: 300px; }
|
||||
.pct { height: 100%; }
|
||||
</style>
|
||||
<div class="pct-parent"><div class="pct"><p>x</p></div></div>`);
|
||||
const pct = findByClass(snap.root, "pct")!;
|
||||
assert.ok(pct.sizing, "pct was probed");
|
||||
// A true fill child: hFill true, hAuto false — untouched by the explicit-height guard.
|
||||
assert.equal(pct.sizing!.hFill, true, "percentage height stays a fill");
|
||||
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user