Files
Reactive-Resume/packages/pdf/src/semantic/issue-fixtures.test.tsx
T

270 lines
10 KiB
TypeScript

import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { semanticNodeKeys } from "./node-keys";
import { resolveResumePresentation, resolveStylesheetMode } from "./resolve";
const source = (text: string) => ({ languageVersion: 1, text });
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 findText = (node: HostNode, text: string): HostNode | undefined => {
if (node.type === "TEXT" && nodeText(node) === text) return node;
for (const child of node.children ?? []) {
const match = findText(child, text);
if (match) return match;
}
};
const findPrimitive = (node: HostNode, type: string, text: string): HostNode | undefined => {
if (node.type === type && nodeText(node) === text) return node;
for (const child of node.children ?? []) {
const match = findPrimitive(child, type, text);
if (match) return match;
}
};
const mergedStyle = (node: HostNode | undefined): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(node?.style) ? node.style : node?.style ? [node.style] : []));
const containsStyle = (node: HostNode, property: string, value: unknown): boolean =>
mergedStyle(node)[property] === value || (node.children ?? []).some((child) => containsStyle(child, property, value));
const textRuns = (node: HostNode): string[] => [
...(node.type === "TEXT" ? [nodeText(node)] : []),
...(node.children ?? []).flatMap((child) => textRuns(child)),
];
const buildIssueFixture = (): ResumeData => {
const data = structuredClone(defaultResumeData);
data.basics = {
...data.basics,
name: "Ada Lovelace",
email: "ada@example.com",
};
data.sections.experience.items = [
{
id: "item-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
},
];
data.sections.skills.items = [
{
id: "item-1",
hidden: false,
icon: "",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 3,
keywords: [],
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience", "skills"], sidebar: [] }];
return data;
};
const resolveIssueFixture = (text: string) => {
const data = buildIssueFixture();
return resolveResumePresentation({
data,
template: "onyx",
source: source(text),
mode: "semantic",
});
};
const pageKey = semanticNodeKeys.page(1);
const headerKey = semanticNodeKeys.header(semanticNodeKeys.region(pageKey, "header"));
const sectionItemKey = (section: string) =>
semanticNodeKeys.item(
semanticNodeKeys.sectionItems(semanticNodeKeys.section(semanticNodeKeys.region(pageKey, "main"), section)),
"item-1",
);
describe("semantic issue fixtures", () => {
it("lets semantic field font weight override template bold defaults (#3146)", () => {
const presentation = resolveIssueFixture(`
@version 1;
section[type="experience"] field[name="company"] { font-weight: 400; }
`);
const company = semanticNodeKeys.field(semanticNodeKeys.itemHeader(sectionItemKey("experience")), "company");
expect(presentation[company]?.style?.fontWeight).toBe("400");
});
it("lets semantic link decoration override the builder underline base (#3134)", () => {
const presentation = resolveIssueFixture(`
@version 1;
link { text-decoration: none; }
`);
const contact = semanticNodeKeys.contactItem(semanticNodeKeys.contactList(headerKey), "email");
const link = semanticNodeKeys.link(contact, "contact");
expect(presentation[link]?.style?.textDecoration).toBe("none");
expect(presentation[contact]?.style?.textDecoration).toBe("none");
});
it("cascades contact-link and field-rich-text aliases before selecting winners", () => {
const data = buildIssueFixture();
const experience = data.sections.experience.items[0];
if (!experience) throw new Error("Expected experience fixture.");
experience.description = "<p>Description</p>";
const contact = semanticNodeKeys.contactItem(semanticNodeKeys.contactList(headerKey), "email");
const field = semanticNodeKeys.field(sectionItemKey("experience"), "description");
const resolve = (text: string) =>
resolveResumePresentation({
data,
template: "onyx",
source: source(`@version 1;${text}`),
mode: "semantic",
});
expect(resolve("contact-item { color: red; } link { color: blue; }")[contact]?.style?.color).toBe("blue");
expect(resolve("link { color: blue; } contact-item { color: red; }")[contact]?.style?.color).toBe("red");
expect(resolve("field { color: red; } rich-text { color: blue; }")[field]?.style?.color).toBe("blue");
expect(resolve("rich-text { color: blue; } field { color: red; }")[field]?.style?.color).toBe("red");
expect(resolve("link { color: blue; } contact-item[name='email'] { color: red; }")[contact]?.style?.color).toBe(
"red",
);
});
it("styles Basics/header nodes while rejecting unsupported gradients (#3137)", () => {
const valid = resolveIssueFixture(`
@version 1;
header { background-color: #1e293b; }
name { color: white; }
`);
const invalid = compileStylesheet(source("@version 1; header { background-image: linear-gradient(red, blue); }"));
expect(valid[headerKey]?.style?.backgroundColor).toBe("#1e293b");
expect(valid[semanticNodeKeys.headerPart(headerKey, "name")]?.style?.color).toBe("white");
expect(invalid.program).not.toBeNull();
});
it("unbolds only skill names and leaves experience titles unchanged (#2223)", () => {
const presentation = resolveIssueFixture(`
@version 1;
section[type="skills"] field[name="name"] { font-weight: 400; }
`);
const skillName = semanticNodeKeys.field(semanticNodeKeys.itemHeader(sectionItemKey("skills")), "name");
const company = semanticNodeKeys.field(semanticNodeKeys.itemHeader(sectionItemKey("experience")), "company");
expect(presentation[skillName]?.style?.fontWeight).toBe("400");
expect(presentation[company]?.style?.fontWeight).not.toBe("400");
});
it("never applies legacy and semantic custom styles together", () => {
const semanticData = buildIssueFixture();
semanticData.metadata.stylesheet = {
mode: "semantic",
source: source("@version 1;"),
};
const legacyData = buildIssueFixture();
expect(resolveStylesheetMode(semanticData)).toBe("semantic");
expect(resolveStylesheetMode(legacyData)).toBe("legacy");
expect(
resolveResumePresentation({
data: legacyData,
template: "onyx",
source: source("@version 1; name { color: red; }"),
mode: "legacy",
}),
).toEqual({});
});
it("applies issue-regression styles to the final existing PDF primitives", async () => {
const data = buildIssueFixture();
const stylesheet = source(`
@version 1;
header { background-color: #1e293b; }
name { color: white; }
link { text-decoration: none; }
section[type="experience"] field[name="company"] { font-weight: 400; }
section[type="skills"] field[name="name"] { font-weight: 400; }
level icon[role~="active"] { opacity: 0.2; }
`);
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const document = instance.container.document as HostNode;
expect(mergedStyle(findText(document, "Ada Lovelace"))).toMatchObject({ color: "white" });
expect(mergedStyle(findText(document, "Analytical Engines"))).toMatchObject({ fontWeight: "400" });
expect(mergedStyle(findText(document, "TypeScript"))).toMatchObject({ fontWeight: "400" });
expect(mergedStyle(findPrimitive(document, "LINK", "ada@example.com"))).toMatchObject({
textDecoration: "none",
});
expect(containsStyle(document, "backgroundColor", "#1e293b")).toBe(true);
expect(containsStyle(document, "opacity", 0.2)).toBe(true);
});
it("unbolds only the final skill-name primitive and preserves the experience title weight (#2223)", async () => {
const data = buildIssueFixture();
const stylesheet = source(`
@version 1;
section[type="skills"] field[name="name"] { font-weight: 400; }
`);
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const document = instance.container.document as HostNode;
expect(mergedStyle(findText(document, "TypeScript"))).toMatchObject({ fontWeight: "400" });
expect(mergedStyle(findText(document, "Analytical Engines")).fontWeight).not.toBe("400");
});
it("renders descriptor filtering and stable item order instead of remapping raw arrays", async () => {
const data = buildIssueFixture();
data.sections.skills.items = ["First", "Hidden", "Last"].map((name, index) => ({
id: `item-${index + 1}`,
hidden: false,
icon: "",
iconColor: "",
name,
proficiency: `${name} proficiency`,
level: 0,
keywords: [],
}));
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
const stylesheet = source(`
@version 1;
section[type="skills"] item:nth-child(2) { display: none; }
section[type="skills"] item:last-child { order: -1; }
`);
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const runs = textRuns(instance.container.document as HostNode);
expect(runs.indexOf("Last")).toBeLessThan(runs.indexOf("First"));
expect(runs).not.toContain("Hidden");
});
});