mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
270 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|