Files
Reactive-Resume/packages/pdf/src/semantic/binding-host-fidelity.test.tsx
T

229 lines
8.1 KiB
TypeScript

import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const mergedStyle = (node: HostNode): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(node.style) ? node.style : node.style ? [node.style] : []));
const findPath = (node: HostNode, predicate: (candidate: HostNode) => boolean): HostNode[] | undefined => {
if (predicate(node)) return [node];
for (const child of node.children ?? []) {
const path = findPath(child, predicate);
if (path) return [node, ...path];
}
};
const nodesWithStyle = (node: HostNode, property: string, value: unknown): HostNode[] => [
...(mergedStyle(node)[property] === value ? [node] : []),
...(node.children ?? []).flatMap((child) => nodesWithStyle(child, property, value)),
];
const buildFixture = (
text: string,
section: "summary" | "skills" | "experience" | "languages" | "references" = "summary",
): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.basics.headline = "Programmer";
data.basics.email = "ada@example.com";
data.basics.phone = "";
data.basics.location = "";
data.basics.website = { url: "", label: "" };
data.basics.customFields = [];
data.summary.content = "<p>Computing pioneer.</p>";
data.sections.skills.title = "Expertise";
data.sections.skills.items = [
{
id: "skill-1",
hidden: false,
icon: "brain",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 0,
keywords: [],
},
];
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [
{
id: "role-1",
position: "Architect",
period: "1843",
description: "<p>Designed the engine.</p>",
},
],
},
];
data.sections.languages.items = [
{ id: "language-1", hidden: false, language: "English", fluency: "Native", level: 0 },
];
data.sections.references.items = [
{
id: "reference-1",
hidden: false,
name: "Charles Babbage",
position: "Inventor",
phone: "+44 123",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
data.metadata.page.hideSectionIcons = false;
data.metadata.page.hideIcons = false;
data.metadata.layout.pages = [{ fullWidth: true, main: [section], sidebar: [] }];
const stylesheet = { languageVersion: 1, text: `@version 1; ${text}` };
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
return data;
};
const renderFixture = async (template: Template, data: ResumeData): Promise<HostNode> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return instance.container.document as HostNode;
};
describe("semantic binding host fidelity", () => {
it("attaches region, contact-list, icon-row heading, no-border item-header, and item-icon styles", async () => {
const document = await renderFixture(
"onyx",
buildFixture(
`
region[region="main"] { background-color: #101010; }
contact-list { background-color: #202020; }
section-heading { background-color: #303030; }
item-header { background-color: #404040; }
icon { opacity: 0.25; }
`,
"skills",
),
);
const emailPath = findPath(document, (node) => node.type === "LINK" && nodeText(node) === "ada@example.com");
const headingPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "Expertise");
const skillPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "TypeScript");
expect(emailPath?.some((node) => mergedStyle(node).backgroundColor === "#202020")).toBe(true);
expect(headingPath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#303030")).toBe(
true,
);
expect(mergedStyle(headingPath?.at(-1) as HostNode).backgroundColor).toBeUndefined();
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#404040")).toBe(true);
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#101010")).toBe(true);
expect(nodesWithStyle(document, "opacity", 0.25).some(({ type }) => type === "SVG")).toBe(true);
});
it.each([
["languages", "English"],
["references", "Charles Babbage"],
] as const)("keeps direct %s Text siblings outside the item-header contract", async (section, text) => {
const document = await renderFixture(
"onyx",
buildFixture(
`
item-header { display: none; background-color: #414141; break-before: page; }
field[role~="primary-text"] { color: #515151; }
`,
section,
),
);
const textPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === text);
const textNode = textPath?.at(-1);
expect(textNode).toBeDefined();
expect(textNode && mergedStyle(textNode)).toMatchObject({ color: "#515151" });
expect(textPath?.some((node) => mergedStyle(node).backgroundColor === "#414141")).toBe(false);
});
it("binds nested experience-role item and item-header styles to their existing Views", async () => {
const document = await renderFixture(
"onyx",
buildFixture(
`
item[role~="nested-role"] { background-color: #515151; }
item[role~="nested-role"] > item-header { border-top-width: 7pt; }
`,
"experience",
),
);
const rolePath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "Architect");
expect(rolePath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#515151")).toBe(
true,
);
expect(rolePath?.some((node) => node.type === "VIEW" && mergedStyle(node).borderTopWidth === 7)).toBe(true);
});
it("binds Rhyhorn's outer contact owner, nested content primitive, link alias, and last-owner alias separately", async () => {
const document = await renderFixture(
"rhyhorn",
buildFixture(`
contact-item { background-color: #616161; }
template-part[name="contact-item-content"] { color: #717171; }
link { text-decoration: none; }
contact-item[part~="contact-item-last"] { border-bottom-width: 9pt; }
`),
);
const emailPath = findPath(document, (node) => node.type === "LINK" && nodeText(node) === "ada@example.com");
const link = emailPath?.at(-1);
expect(link && mergedStyle(link)).toMatchObject({ color: "#717171", textDecoration: "none" });
expect(emailPath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#616161")).toBe(
true,
);
expect(emailPath?.some((node) => node.type === "VIEW" && mergedStyle(node).borderBottomWidth === 9)).toBe(true);
});
it.each([
["azurill", 4],
["ditgar", 1],
["ditto", 3],
["gengar", 1],
["glalie", 1],
["leafish", 3],
["meowth", 3],
["pikachu", 1],
["rhyhorn", 1],
["scizor", 1],
] as const)("%s attaches every visible primitive template part to an existing host", async (template, expected) => {
const section = template === "meowth" ? "experience" : "summary";
const document = await renderFixture(template, buildFixture("template-part { opacity: 0.37; }", section));
expect(nodesWithStyle(document, "opacity", 0.37)).toHaveLength(expected);
});
it("honors primitive template-part visibility on the exact existing host", async () => {
const document = await renderFixture(
"pikachu",
buildFixture('template-part[name="header-divider"] { display: none; }'),
);
expect(nodeText(document)).not.toContain("Ada Lovelace");
expect(nodeText(document)).toContain("ada@example.com");
});
});