mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 22:11:42 +10:00
feat(resume): add a deterministic ATS parseability check (#3314)
* feat(resume): add a deterministic ATS parseability check Adds an offline ATS linter that reports whether a parser can read a resume, surfaced as an always-on panel in the builder. The existing Resume Analysis panel needs a configured AI provider, so users who never set one up get no feedback at all. These 22 rules run as a pure function over ResumeData with no provider, no network and no rendered PDF, so they work for everyone on every edit. Rules cover contact details, date parseability, sections that hold content but never render, column and sidebar placement, and typography thresholds. The catalog mirrors the Semantic CSS diagnostic catalog: stable codes carrying a severity, meaning and action, with no i18n dependency so the web layer translates by code. Each finding carries a JSON Pointer, which is what makes jump-to-field work. Deliberately no second score. Resume Analysis owns overallScore, so this reports "N of M checks passed" and counts by severity instead. * fix(resume): accept localized ongoing periods and reject bare ones Two period-parsing bugs found in review. The ongoing-token set was English-only, so a German resume reading "2020 - heute" was reported as unparseable and the panel told the user to rewrite a perfectly valid range. Rather than guess translations for 55 locales, a range ending that carries no digits and is not a month name in the resume's locale is now read as ongoing. That keeps a genuinely incomplete ending such as "Jan 2020 - Feb" reported, since "Feb" resolves as a month. A bare "Present" also parsed as a valid period, so an experience entry with no start date passed the check. A standalone ongoing token is now rejected; ongoing tokens remain valid as the end of a range. * feat(web): scroll ATS findings to the item they belong to Findings for different items in one section all landed on the section header, so a date problem on the third role gave no more help than naming the section. getAtsFindingTarget now resolves the offending item from the finding's JSON Pointer against the resume, and SectionItem carries a matching DOM id. The panel scrolls to that item and falls back to the section header when the item is not mounted, which is what happens while its section is collapsed. * fix(resume): recognize ongoing periods by token, not by shape The previous heuristic read any short, digit-free range ending as an ongoing marker, so "2020 - unknown", "2020 - later" and "2020 - tbd" parsed cleanly and suppressed the finding they should have raised. Replaced with an explicit table of ongoing words keyed by language, covering the locales the app ships. Matching is exact, so unrecognized endings are reported again. A locale missing from the table falls back to the earlier behaviour of reporting its ongoing periods, which is a visible gap someone can close by adding a word rather than a silent hole in detection. Tests assert every listed token parses and that the table stays lowercase, since lookups normalize that way. * fix(ats): parse punctuated ongoing tokens * fix(ats): parse Unicode punctuated ongoing tokens --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
co-authored by
Amruth Pillai
parent
f64d02df7f
commit
45303fb465
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { ATS_RULE_CODES } from "@reactive-resume/resume/ats";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { atsFindingItemElementId, getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "./ats";
|
||||
|
||||
const experienceItem = (id: string) => ({
|
||||
id,
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Engineer",
|
||||
location: "London",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
|
||||
function makeResume(): ResumeData {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.sections.experience.items = [experienceItem("exp-a"), experienceItem("exp-b")] as never;
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Consulting",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [experienceItem("custom-item-a")],
|
||||
},
|
||||
] as never;
|
||||
return data;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("getAtsFindingMessage", () => {
|
||||
it("covers every rule in the catalog", () => {
|
||||
for (const code of ATS_RULE_CODES) {
|
||||
const message = getAtsFindingMessage(code);
|
||||
expect(message.title.length, code).toBeGreaterThan(0);
|
||||
expect(message.action.length, code).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives each rule a distinct title", () => {
|
||||
const titles = ATS_RULE_CODES.map((code) => getAtsFindingMessage(code).title);
|
||||
expect(new Set(titles).size).toBe(titles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAtsFindingTarget", () => {
|
||||
it.each([
|
||||
["/basics/email", { side: "left", section: "basics" }],
|
||||
["/basics/customFields/0/link", { side: "left", section: "basics" }],
|
||||
["/picture", { side: "left", section: "picture" }],
|
||||
["/summary/content", { side: "left", section: "summary" }],
|
||||
["/sections/experience/items/0/period", { side: "left", section: "experience" }],
|
||||
["/customSections/2/items/1/period", { side: "left", section: "custom" }],
|
||||
["/metadata/typography/body/fontSize", { side: "right", section: "typography" }],
|
||||
["/metadata/page/marginX", { side: "right", section: "page" }],
|
||||
["/metadata/layout/pages", { side: "right", section: "layout" }],
|
||||
])("resolves %s", (pointer, expected) => {
|
||||
expect(getAtsFindingTarget(pointer)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for a pointer with no sidebar home", () => {
|
||||
expect(getAtsFindingTarget("/metadata/styleRules/0")).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes escaped pointer tokens", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAtsFindingTarget item resolution", () => {
|
||||
it("resolves the item a finding belongs to", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/0/period", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
itemId: "exp-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends two findings in the same section to different items", () => {
|
||||
const data = makeResume();
|
||||
const first = getAtsFindingTarget("/sections/experience/items/0/period", data);
|
||||
const second = getAtsFindingTarget("/sections/experience/items/1/period", data);
|
||||
|
||||
expect(first?.itemId).toBe("exp-a");
|
||||
expect(second?.itemId).toBe("exp-b");
|
||||
expect(first?.section).toBe(second?.section);
|
||||
});
|
||||
|
||||
it("resolves items inside custom sections", () => {
|
||||
expect(getAtsFindingTarget("/customSections/0/items/0/period", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "custom",
|
||||
itemId: "custom-item-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the item when no resume data is supplied", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the item for a section-level pointer", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the item when the index is out of range", () => {
|
||||
expect(getAtsFindingTarget("/sections/experience/items/9/period", makeResume())).toEqual({
|
||||
side: "left",
|
||||
section: "experience",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a stable element id", () => {
|
||||
expect(atsFindingItemElementId("exp-a")).toBe("resume-item-exp-a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAtsFindingLocation", () => {
|
||||
it("names the section for a section-level pointer", () => {
|
||||
expect(getAtsFindingLocation("/basics/email")).toBe("Basics");
|
||||
});
|
||||
|
||||
it("adds a one-based item position for an item-level pointer", () => {
|
||||
expect(getAtsFindingLocation("/sections/experience/items/0/period")).toBe("Experience · Item 1");
|
||||
});
|
||||
|
||||
it("returns null when the pointer has no sidebar home", () => {
|
||||
expect(getAtsFindingLocation("/metadata/styleRules/0")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { AtsRuleCode } from "@reactive-resume/resume/ats";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { LeftSidebarSection, SidebarSection } from "./section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { match } from "ts-pattern";
|
||||
import { getSectionTitle, leftSidebarSections } from "./section";
|
||||
|
||||
export type AtsFindingMessage = {
|
||||
title: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
export type AtsFindingTarget = {
|
||||
side: "left" | "right";
|
||||
section: SidebarSection;
|
||||
itemId?: string;
|
||||
};
|
||||
|
||||
export const atsFindingItemElementId = (itemId: string) => `resume-item-${itemId}`;
|
||||
|
||||
export function getAtsFindingMessage(code: AtsRuleCode): AtsFindingMessage {
|
||||
return match(code)
|
||||
.with("MISSING_NAME", () => ({
|
||||
title: t`Your resume has no name.`,
|
||||
action: t`Add your full name under Basics.`,
|
||||
}))
|
||||
.with("MISSING_EMAIL", () => ({
|
||||
title: t`Your resume has no email address.`,
|
||||
action: t`Most systems file candidates by email. Add one under Basics.`,
|
||||
}))
|
||||
.with("MALFORMED_EMAIL", () => ({
|
||||
title: t`This email address will not be recognized.`,
|
||||
action: t`Use a plain address such as name@example.com, with no surrounding text.`,
|
||||
}))
|
||||
.with("MISSING_PHONE", () => ({
|
||||
title: t`Your resume has no phone number.`,
|
||||
action: t`Some application systems require one before you can submit.`,
|
||||
}))
|
||||
.with("MISSING_LOCATION", () => ({
|
||||
title: t`Your resume has no location.`,
|
||||
action: t`Add at least a city and country so roles can be matched to your region.`,
|
||||
}))
|
||||
.with("MALFORMED_URL", () => ({
|
||||
title: t`This link is missing its protocol.`,
|
||||
action: t`Write the full address, including https://.`,
|
||||
}))
|
||||
.with("PICTURE_PRESENT", () => ({
|
||||
title: t`Your resume includes a photo.`,
|
||||
action: t`Some parsers mishandle images, and photos are discouraged in some regions.`,
|
||||
}))
|
||||
.with("EMPTY_PERIOD", () => ({
|
||||
title: t`This entry has no dates.`,
|
||||
action: t`Add a period such as "Jan 2020 - Present" so it lands on your timeline.`,
|
||||
}))
|
||||
.with("UNPARSEABLE_PERIOD", () => ({
|
||||
title: t`These dates will not be read correctly.`,
|
||||
action: t`Use a recognized form such as "Jan 2020 - Mar 2022" or "2020 - 2022".`,
|
||||
}))
|
||||
.with("UNPARSEABLE_DATE", () => ({
|
||||
title: t`This date will not be read correctly.`,
|
||||
action: t`Use a recognized form such as "March 2022" or "2022".`,
|
||||
}))
|
||||
.with("REVERSED_PERIOD", () => ({
|
||||
title: t`This period ends before it starts.`,
|
||||
action: t`Swap the start and end dates.`,
|
||||
}))
|
||||
.with("FUTURE_DATED_PERIOD", () => ({
|
||||
title: t`This period starts in the future.`,
|
||||
action: t`Correct the year, or use "Present" for ongoing work.`,
|
||||
}))
|
||||
.with("SECTION_MISSING_FROM_LAYOUT", () => ({
|
||||
title: t`This section has content but never appears.`,
|
||||
action: t`Place it on a page from the Layout panel, or hide it if you meant to park it.`,
|
||||
}))
|
||||
.with("EMPTY_RENDERED_SECTION", () => ({
|
||||
title: t`This section renders as an empty heading.`,
|
||||
action: t`Add an item to it, or hide it.`,
|
||||
}))
|
||||
.with("NO_VISIBLE_EXPERIENCE", () => ({
|
||||
title: t`Your resume shows no work experience.`,
|
||||
action: t`Add an entry, or use projects and volunteer work to show equivalent history.`,
|
||||
}))
|
||||
.with("MISSING_EXPERIENCE_DESCRIPTION", () => ({
|
||||
title: t`This role has no description.`,
|
||||
action: t`Describe what you did so the entry contributes keywords for matching.`,
|
||||
}))
|
||||
.with("NON_STANDARD_SECTION_TITLE", () => ({
|
||||
title: t`This heading is not one parsers look for.`,
|
||||
action: t`Prefer a conventional heading such as "Work Experience" or "Education".`,
|
||||
}))
|
||||
.with("MULTI_COLUMN_PROSE_SECTION", () => ({
|
||||
title: t`This section is split across columns.`,
|
||||
action: t`Columns commonly scramble the order text is read in. Set it to a single column.`,
|
||||
}))
|
||||
.with("PROSE_SECTION_IN_SIDEBAR", () => ({
|
||||
title: t`This section sits in the sidebar.`,
|
||||
action: t`Move it into the main column and keep the sidebar for short lists.`,
|
||||
}))
|
||||
.with("SMALL_BODY_FONT", () => ({
|
||||
title: t`Your body text is very small.`,
|
||||
action: t`Use a size of at least 9pt so re-rendered copies stay accurate.`,
|
||||
}))
|
||||
.with("TIGHT_LINE_HEIGHT", () => ({
|
||||
title: t`Your lines are packed very tightly.`,
|
||||
action: t`Use a line height of at least 1.15 so lines are not merged together.`,
|
||||
}))
|
||||
.with("TIGHT_PAGE_MARGINS", () => ({
|
||||
title: t`Your page margins are very narrow.`,
|
||||
action: t`Increase them so content stays inside the reliably read area.`,
|
||||
}))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function decodePointerToken(token: string): string {
|
||||
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
}
|
||||
|
||||
function pointerTokens(pointer: string): string[] {
|
||||
return pointer.split("/").slice(1).map(decodePointerToken);
|
||||
}
|
||||
|
||||
const isLeftSidebarSection = (value: string): value is LeftSidebarSection =>
|
||||
(leftSidebarSections as string[]).includes(value);
|
||||
|
||||
function resolveItemId(tokens: readonly string[], data?: ResumeData): string | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
const itemsIndex = tokens.indexOf("items");
|
||||
if (itemsIndex === -1) return undefined;
|
||||
|
||||
const position = Number(tokens[itemsIndex + 1]);
|
||||
if (!Number.isInteger(position)) return undefined;
|
||||
|
||||
const [head, next] = tokens;
|
||||
let items: readonly unknown[] | undefined;
|
||||
|
||||
if (head === "sections" && next) {
|
||||
items = (data.sections as Record<string, { items?: readonly unknown[] }>)[next]?.items;
|
||||
} else if (head === "customSections") {
|
||||
items = data.customSections[Number(next)]?.items;
|
||||
}
|
||||
|
||||
const item = items?.[position] as { id?: unknown } | undefined;
|
||||
return typeof item?.id === "string" ? item.id : undefined;
|
||||
}
|
||||
|
||||
export function getAtsFindingTarget(pointer: string, data?: ResumeData): AtsFindingTarget | null {
|
||||
const tokens = pointerTokens(pointer);
|
||||
const [head, next] = tokens;
|
||||
const itemId = resolveItemId(tokens, data);
|
||||
const withItem = (target: AtsFindingTarget): AtsFindingTarget => (itemId ? { ...target, itemId } : target);
|
||||
|
||||
if (head === "basics") return { side: "left", section: "basics" };
|
||||
if (head === "picture") return { side: "left", section: "picture" };
|
||||
if (head === "summary") return { side: "left", section: "summary" };
|
||||
if (head === "customSections") return withItem({ side: "left", section: "custom" });
|
||||
|
||||
if (head === "sections" && next && isLeftSidebarSection(next)) {
|
||||
return withItem({ side: "left", section: next });
|
||||
}
|
||||
|
||||
if (head === "metadata") {
|
||||
if (next === "typography") return { side: "right", section: "typography" };
|
||||
if (next === "page") return { side: "right", section: "page" };
|
||||
if (next === "layout") return { side: "right", section: "layout" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAtsFindingLocation(pointer: string): string | null {
|
||||
const target = getAtsFindingTarget(pointer);
|
||||
if (!target) return null;
|
||||
|
||||
const tokens = pointerTokens(pointer);
|
||||
const itemsIndex = tokens.indexOf("items");
|
||||
const sectionTitle = getSectionTitle(target.section);
|
||||
|
||||
if (itemsIndex === -1) return sectionTitle;
|
||||
|
||||
const position = Number(tokens[itemsIndex + 1]);
|
||||
if (!Number.isInteger(position)) return sectionTitle;
|
||||
|
||||
const itemNumber = position + 1;
|
||||
return t`${sectionTitle} · Item ${itemNumber}`;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PaletteIcon,
|
||||
PhoneIcon,
|
||||
ReadCvLogoIcon,
|
||||
SealCheckIcon,
|
||||
ShareFatIcon,
|
||||
StarIcon,
|
||||
TextTIcon,
|
||||
@@ -52,6 +53,7 @@ export type RightSidebarSection =
|
||||
| "notes"
|
||||
| "sharing"
|
||||
| "statistics"
|
||||
| "ats"
|
||||
| "analysis"
|
||||
| "export"
|
||||
| "information";
|
||||
@@ -87,6 +89,7 @@ export const rightSidebarSections: RightSidebarSection[] = [
|
||||
"styles",
|
||||
"page",
|
||||
"notes",
|
||||
"ats",
|
||||
"analysis",
|
||||
"export",
|
||||
"information",
|
||||
@@ -126,6 +129,7 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
|
||||
.with("notes", () => t`Notes`)
|
||||
.with("sharing", () => t`Sharing`)
|
||||
.with("statistics", () => t`Statistics`)
|
||||
.with("ats", () => t`ATS Check`)
|
||||
.with("analysis", () => t`Resume Analysis`)
|
||||
.with("export", () => t`Export`)
|
||||
.with("information", () => t`Information`)
|
||||
@@ -170,6 +174,7 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
|
||||
.with("notes", () => <NotepadIcon {...iconProps} />)
|
||||
.with("sharing", () => <ShareFatIcon {...iconProps} />)
|
||||
.with("statistics", () => <ChartLineIcon {...iconProps} />)
|
||||
.with("ats", () => <SealCheckIcon {...iconProps} />)
|
||||
.with("analysis", () => <BrainIcon {...iconProps} />)
|
||||
.with("export", () => <DownloadIcon {...iconProps} />)
|
||||
.with("information", () => <InfoIcon {...iconProps} />)
|
||||
|
||||
@@ -39,6 +39,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { atsFindingItemElementId } from "@/libs/resume/ats";
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
@@ -244,6 +245,7 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
id={atsFindingItemElementId(item.id)}
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Copyright } from "@/components/ui/copyright";
|
||||
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { AtsCheckSectionBuilder } from "./sections/ats-check";
|
||||
import { CustomStylesSectionBuilder } from "./sections/custom-styles";
|
||||
import { DesignSectionBuilder } from "./sections/design";
|
||||
import { ExportSectionBuilder } from "./sections/export";
|
||||
@@ -33,6 +34,7 @@ function getSectionComponent(type: RightSidebarSection) {
|
||||
.with("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
.with("statistics", () => <StatisticsSectionBuilder />)
|
||||
.with("ats", () => <AtsCheckSectionBuilder />)
|
||||
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
|
||||
.with("export", () => <ExportSectionBuilder />)
|
||||
.with("information", () => <InformationSectionBuilder />)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefined }));
|
||||
const sidebarState = vi.hoisted(() => ({ toggleSidebar: vi.fn() }));
|
||||
const sectionState = vi.hoisted(() => ({ setCollapsed: vi.fn() }));
|
||||
|
||||
type SectionBaseProps = { children: React.ReactNode };
|
||||
type SectionStoreSelector = (state: { setCollapsed: typeof sectionState.setCollapsed }) => unknown;
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResumeData: () => resumeState.data,
|
||||
}));
|
||||
vi.mock("../../../-store/sidebar", () => ({
|
||||
useBuilderSidebar: () => sidebarState,
|
||||
}));
|
||||
vi.mock("../../../-store/section", () => ({
|
||||
useSectionStore: (selector: SectionStoreSelector) => selector(sectionState),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const { AtsCheckSectionBuilder } = await import("./ats-check");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resumeState.data = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const experienceItem = (overrides: Partial<ExperienceItem> = {}): ExperienceItem => ({
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Engineer",
|
||||
location: "London",
|
||||
period: "Jan 2020 - Present",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "<p>Designed and shipped the difference engine.</p>",
|
||||
roles: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.basics.email = "ada@example.com";
|
||||
data.basics.phone = "+44 20 7946 0100";
|
||||
data.basics.location = "London, UK";
|
||||
data.sections.experience.items = [experienceItem()];
|
||||
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }];
|
||||
|
||||
mutate(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const renderPanel = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AtsCheckSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("AtsCheckSectionBuilder", () => {
|
||||
it("renders nothing before the resume is ready", () => {
|
||||
const { container } = renderPanel();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("reports a clean resume as fully passing", () => {
|
||||
resumeState.data = makeResume();
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("22 of 22 checks passed")).toBeTruthy();
|
||||
expect(screen.getByText(/Every check passed/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists a finding with its title and remedy", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.basics.email = "ada at example dot com";
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("This email address will not be recognized.")).toBeTruthy();
|
||||
expect(screen.getByText(/Use a plain address/)).toBeTruthy();
|
||||
expect(screen.getByText("21 of 22 checks passed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("counts findings by severity", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.basics.email = "";
|
||||
data.picture.url = "/uploads/ada.png";
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText("1 error")).toBeTruthy();
|
||||
expect(screen.getByText("1 note")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the owning sidebar section when a finding's location is clicked", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.basics.email = "";
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Basics/ }));
|
||||
|
||||
expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("left", true);
|
||||
expect(sectionState.setCollapsed).toHaveBeenCalledWith("basics", false);
|
||||
});
|
||||
|
||||
it("points typography findings at the right sidebar", () => {
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.metadata.typography.body.fontSize = 8;
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Typography/ }));
|
||||
|
||||
expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("right", true);
|
||||
expect(sectionState.setCollapsed).toHaveBeenCalledWith("typography", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigating to a finding", () => {
|
||||
const stubElement = (id: string) => {
|
||||
const element = document.createElement("div");
|
||||
element.id = id;
|
||||
element.scrollIntoView = vi.fn();
|
||||
document.body.append(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
it("scrolls to the item a finding belongs to, not the section header", () => {
|
||||
const section = stubElement("sidebar-experience");
|
||||
const item = stubElement("resume-item-exp-1");
|
||||
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
|
||||
|
||||
expect(item.scrollIntoView).toHaveBeenCalled();
|
||||
expect(section.scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends two findings in the same section to different items", () => {
|
||||
const first = stubElement("resume-item-exp-1");
|
||||
const second = stubElement("resume-item-exp-2");
|
||||
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.sections.experience.items = [
|
||||
experienceItem({ id: "exp-1", period: "nonsense" }),
|
||||
experienceItem({ id: "exp-2", period: "gibberish" }),
|
||||
];
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
const buttons = screen.getAllByRole("button", { name: /Experience · Item/ });
|
||||
fireEvent.click(buttons[0] as HTMLElement);
|
||||
fireEvent.click(buttons[1] as HTMLElement);
|
||||
|
||||
expect(first.scrollIntoView).toHaveBeenCalledOnce();
|
||||
expect(second.scrollIntoView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to the section header when the item is not mounted", () => {
|
||||
const section = stubElement("sidebar-experience");
|
||||
|
||||
resumeState.data = makeResume((data) => {
|
||||
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
|
||||
|
||||
expect(section.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { lintResumeForAts } from "@reactive-resume/resume/ats";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useResumeData } from "@/features/resume/builder/draft";
|
||||
import {
|
||||
atsFindingItemElementId,
|
||||
getAtsFindingLocation,
|
||||
getAtsFindingMessage,
|
||||
getAtsFindingTarget,
|
||||
} from "@/libs/resume/ats";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { useBuilderSidebar } from "../../../-store/sidebar";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
const SEVERITIES = ["error", "warning", "info"] as const;
|
||||
|
||||
function severityDotClass(severity: AtsSeverity) {
|
||||
return match(severity)
|
||||
.with("error", () => "bg-rose-600")
|
||||
.with("warning", () => "bg-amber-600")
|
||||
.with("info", () => "bg-sky-600")
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function severityLabel(severity: AtsSeverity) {
|
||||
return match(severity)
|
||||
.with("error", () => t`Error`)
|
||||
.with("warning", () => t`Warning`)
|
||||
.with("info", () => t`Note`)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
type SeverityCountProps = {
|
||||
severity: AtsSeverity;
|
||||
count: number;
|
||||
};
|
||||
|
||||
function SeverityCount({ severity, count }: SeverityCountProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span className={cn("size-2 shrink-0 rounded-full", severityDotClass(severity))} aria-hidden />
|
||||
{match(severity)
|
||||
.with("error", () => <Plural value={count} one="# error" other="# errors" />)
|
||||
.with("warning", () => <Plural value={count} one="# warning" other="# warnings" />)
|
||||
.with("info", () => <Plural value={count} one="# note" other="# notes" />)
|
||||
.exhaustive()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type AtsFindingRowProps = {
|
||||
finding: AtsFinding;
|
||||
onJump: (pointer: string) => void;
|
||||
};
|
||||
|
||||
function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
|
||||
const message = getAtsFindingMessage(finding.code);
|
||||
const location = getAtsFindingLocation(finding.pointer);
|
||||
|
||||
return (
|
||||
<li className="space-y-2 rounded-md border bg-card p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", severityDotClass(finding.severity))} aria-hidden />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-sm leading-snug">{message.title}</p>
|
||||
<p className="text-muted-foreground text-xs leading-normal">{message.action}</p>
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{severityLabel(finding.severity)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{location && (
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1.5 px-2 text-xs" onClick={() => onJump(finding.pointer)}>
|
||||
{location}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AtsCheckSectionBuilder() {
|
||||
const data = useResumeData();
|
||||
const { toggleSidebar } = useBuilderSidebar();
|
||||
const setCollapsed = useSectionStore((state) => state.setCollapsed);
|
||||
|
||||
const report = useMemo(() => (data ? lintResumeForAts(data) : null), [data]);
|
||||
|
||||
const onJump = useCallback(
|
||||
(pointer: string) => {
|
||||
const target = getAtsFindingTarget(pointer, data);
|
||||
if (!target) return;
|
||||
|
||||
toggleSidebar(target.side, true);
|
||||
setCollapsed(target.section, false);
|
||||
|
||||
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
|
||||
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
|
||||
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[data, setCollapsed, toggleSidebar],
|
||||
);
|
||||
|
||||
if (!report) return null;
|
||||
|
||||
const { counts, findings, passedRules, totalRules } = report;
|
||||
|
||||
return (
|
||||
<SectionBase type="ats" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-md border bg-card p-3">
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
These checks run as you type and never leave your browser. They ask whether a machine can read your
|
||||
resume, not whether it reads well.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
<Trans>
|
||||
{passedRules} of {totalRules} checks passed
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{findings.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{SEVERITIES.filter((severity) => counts[severity] > 0).map((severity) => (
|
||||
<SeverityCount key={severity} severity={severity} count={counts[severity]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{findings.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-md border border-dashed p-3">
|
||||
<CheckCircleIcon className="size-5 shrink-0 text-emerald-600" />
|
||||
<p className="text-muted-foreground text-sm leading-normal">
|
||||
<Trans>Every check passed. Nothing here should stop a parser from reading your resume.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{findings.map((finding) => (
|
||||
<AtsFindingRow key={`${finding.code}:${finding.pointer}`} finding={finding} onJump={onJump} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user