fix(pdf): ignore phantom gengar skill text nodes (#3289)

* docs(agents): align Redis compose commands with development guide

- Problem: AGENTS.md omitted Redis from dev infrastructure compose commands
  while docs/contributing/development.mdx starts redis for local dev.
- Fix: document full postgres/redis/seaweedfs compose command and note that
  REDIS_URL and ENCRYPTION_SECRET are required for AI agent features.
- Verification: preflight upstream fetch; manual diff against development.mdx
  and compose.dev.yml redis service; duplicate PR gate passed.

* fix(pdf): ignore phantom gengar skill text nodes

- Problem: gengar template resumes with skills keywords fail semantic CSS activation because the legacy renderer emits a harmless empty text node that parity treats as a mismatch.

- Fix: treat the specific empty text artifact as presentation-neutral in the legacy parity comparator and add a regression test for the phantom fontSize 9 node.

- Verification: pnpm test src/semantic/legacy-parity.test.ts in packages/pdf passed (31 tests).

* docs: clarify host and container Redis URLs

- Problem: the development guide only showed the Docker Redis hostname, which fails for host-run development.\n- Fix: document localhost for host execution and redis for Docker execution.\n- Verification: pnpm test src/semantic/legacy-parity.test.ts (31 passed).

* fix(pdf): omit empty skill proficiency text

* test(pdf): cover blank skill proficiency

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Santhi Prakash
2026-08-13 23:07:49 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent bad431b2fc
commit f64d02df7f
2 changed files with 41 additions and 1 deletions
@@ -3,9 +3,12 @@ import type { Template } from "@reactive-resume/schema/templates";
import type { LegacyParityHostNode } from "./legacy-parity";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumeDocument } from "../document";
import { convertLegacyStyleRules } from "./legacy-converter";
import { compareLegacyParityHostNodes, compareLegacySemanticPresentation } from "./legacy-parity";
@@ -157,6 +160,27 @@ const buildFixture = (rules: StyleRule[]): ResumeData => {
return data;
};
const nodeText = (node: LegacyParityHostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const renderHostDocument = async (data: ResumeData): Promise<LegacyParityHostNode> => {
const element = createElement(ResumeDocument, { data, template: "gengar" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return instance.container.document as LegacyParityHostNode;
};
const findEmptyTextNodes = (node: LegacyParityHostNode): LegacyParityHostNode[] => [
...(node.type === "TEXT" && nodeText(node).trim() === "" ? [node] : []),
...(node.children ?? []).flatMap(findEmptyTextNodes),
];
const hasFontSize = (node: LegacyParityHostNode, fontSize: number): boolean =>
(Array.isArray(node.style) ? node.style : [node.style]).some(
(style) =>
style !== null && typeof style === "object" && (style as Readonly<Record<string, unknown>>).fontSize === fontSize,
);
describe("compareLegacySemanticPresentation", () => {
it("renders the mandatory target shapes in the shared parity document", () => {
const data = buildFixture([]);
@@ -231,6 +255,22 @@ describe("compareLegacySemanticPresentation", () => {
expect(comparison.mismatches).toEqual([]);
});
it("omits empty Gengar skill proficiency text while preserving populated text", async () => {
const data = buildFixture([]);
data.metadata.template = "gengar";
data.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: ["skills"] }];
data.metadata.typography.body.fontSize = 9;
const [skill] = data.sections.skills.items;
if (!skill) throw new Error("Expected Gengar skill fixture.");
for (const proficiency of ["", " \t"]) {
skill.proficiency = proficiency;
expect(findEmptyTextNodes(await renderHostDocument(data)).filter((node) => hasFontSize(node, 9))).toEqual([]);
}
skill.proficiency = "Expert";
expect(nodeText(await renderHostDocument(data))).toContain("Expert");
});
it.each(["onyx", "meowth"] as const)(
"matches combined separator and box-style primitives on %s",
async (template) => {
@@ -1153,7 +1153,7 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps<S
</SectionItemHeader>
<View>
<Text semanticField="proficiency">{item.proficiency}</Text>
{hasSplitRowText(item.proficiency) && <Text semanticField="proficiency">{item.proficiency}</Text>}
<Small semanticField="keywords">{item.keywords.join(", ")}</Small>
</View>