Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 47 additions & 13 deletions src/components/features/JdMatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ function term(
return { id, display, source, snippet: `…snippet for ${display}…` };
}

/** A single covered skill term, shared by the two tests that only need "one
* term, fully covered" — kept as one fixture so the identical eleven-line
* setup isn't written twice. */
const ONE_TERM = term("react", "react", "skill");

/** That term as a fully-covered keyword result. */
function fullyCovered(): JdMatchResult {
return kw(
{
covered: [ONE_TERM],
missing: [],
score: 100,
weights: { skill: 1, noun: 0.5 },
},
[ONE_TERM],
);
}

/** Wrap a keyword-path coverage result in the path-agnostic union (#199). */
function kw(
coverage: CoverageResult,
Expand Down Expand Up @@ -110,26 +128,42 @@ describe("JdMatch", () => {
});

it("emits the snippet on the term row as a hover tooltip (title attribute)", () => {
const t = term("react", "react", "skill");
const coverage: CoverageResult = {
covered: [t],
missing: [],
score: 100,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(
createElement(JdMatch, { result: kw(coverage, [t]) }),
createElement(JdMatch, { result: fullyCovered() }),
);
expect(html).toContain(`title="${t.snippet}"`);
expect(html).toContain(`title="${ONE_TERM.snippet}"`);
});

it("renders nothing for a non-keyword (semantic) path until M6 builds its UI", () => {
it("routes the semantic path to the verdict view instead of rendering null", () => {
// The pre-#204 behaviour was `return null` for anything not `keyword`, so
// a finished on-device match rendered a blank panel. This is the assertion
// that would fail if the router regressed to that.
const result: JdMatchResult = {
path: "semantic",
verdicts: [],
summary: { met: 0, partial: 0, missing: 0, total: 0 },
verdicts: [
{
requirement: { id: "req-1", kind: "skill", text: "Ship Kubernetes" },
status: "met",
reason: "Ran production clusters at Acme.",
},
],
summary: { met: 1, partial: 0, missing: 0, total: 1 },
};
const html = renderToStaticMarkup(createElement(JdMatch, { result }));
expect(html).toBe("");
expect(html).not.toBe("");
expect(html).toContain("Ship Kubernetes");
expect(html).toContain("1 met · 0 partial · 0 missing");
// …and it is the SEMANTIC view, not the keyword one dressed up: the
// keyword-only headline and its matcher disclaimer must be absent.
expect(html).not.toContain("terms from this JD");
expect(html).not.toContain("we don't read context");
});

it("routes the keyword path away from the semantic view", () => {
const html = renderToStaticMarkup(
createElement(JdMatch, { result: fullyCovered() }),
);
expect(html).toContain("Your resume mentions 1 of 1 terms from this JD.");
expect(html).not.toContain("met ·");
});
});
154 changes: 31 additions & 123 deletions src/components/features/JdMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,137 +2,45 @@
// Copyright 2026 The offlinecv Authors

/**
* JdMatch — diagnostic JD-coverage panel.
* JdMatch — the path router for the diagnostic JD-match panel (#204).
*
* Renders the covered/missing lists from `computeCoverage` against the
* extracted JD terms. Framing is diagnostic ("the JD asks for these; here's
* what we found"), not prescriptive ("add this to your resume"). The score
* is shown as N-of-M skill coverage, not as a percentage match label.
* `JdMatchResult` is a discriminated union (#199) with two arms, and this file
* is the ONE place that narrows it: `keyword` → `<KeywordMatch>`, `semantic` →
* `<SemanticMatch>`. Before #204 the semantic arm returned `null`, so a
* finished on-device match rendered a blank panel; that is the hole this
* closes.
*
* The narrowing is real, not a cast. The `keyword` early return leaves the
* semantic arm as the only remaining type on the fall-through, so
* `<SemanticMatch result={result} />` type-checks solely because TypeScript
* has already proved it. Adding a third arm to the union breaks THIS file at
* compile time rather than silently falling into the semantic view.
*
* Deliberately not here: any state, any effect, any WebLLM call. The opt-in
* lives in `PasteJdPanel`, the async state machine in `useJdMatch`, the engine
* work in `runLlmMatch`. This component receives a finished result and picks a
* view — which is what lets `JobResultCard` reuse it for a `RankedJob`'s
* keyword coverage with no controller in sight.
*
* The loading / running / degraded affordances are NOT here either: they
* belong beside the control that started the work, which is what every other
* WebLLM surface in the repo does (`ResumeQualityPanel`, `ResumeRewrite`,
* `SectionRewrite` all render `ModelLoadProgress` under their own trigger).
* See `SemanticAnalysisOptIn`. Keeping them out is also what keeps the keyword
* floor visible for the whole multi-minute engine load: the result card below
* the control keeps rendering keyword coverage while the semantic arm is still
* resolving.
*/

import type { ExtractedTerm } from "../../lib/jd-match/extract-jd-terms.ts";
import type { JdMatchResult } from "../../lib/jd-match";
import { Card } from "@design-system";
import { KeywordMatch } from "./KeywordMatch.tsx";
import { SemanticMatch } from "./SemanticMatch.tsx";

interface JdMatchProps {
result: JdMatchResult;
}

export function JdMatch({ result }: JdMatchProps) {
// Only the keyword path has a UI today; the semantic path (M6) renders nothing
// yet. Narrowing on `path` here keeps every consumer path-agnostic.
if (result.path !== "keyword") return null;
const { coverage, terms, nounsDropped } = result;
const total = terms.length;
const covered = coverage.covered.length;

return (
<Card className="flex flex-col gap-4 shadow-xs">
<header className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-muted">
JD match
</h2>
<span className="rounded bg-surface-subtle px-1.5 py-0.5 text-4xs font-semibold uppercase tracking-wider text-content-secondary">
alpha
</span>
</div>
<p className="text-base font-semibold text-content-primary">
Your resume mentions {covered} of {total} terms from this JD.
</p>
<p className="text-sm text-content-tertiary">
Weighted coverage:{" "}
<span className="font-mono text-content-secondary">
{coverage.score}/100
</span>{" "}
— skill {coverage.weights.skill.toFixed(1)}, phrase{" "}
{coverage.weights.noun.toFixed(1)}.
</p>
<p className="max-w-prose text-sm text-content-tertiary">
Diagnostic, not a verdict. We look for skills and phrases by name —
we don't read context. Your JD text stays in this browser tab.
</p>
</header>

<div className="grid gap-4 md:grid-cols-2">
<TermColumn
heading={`Covered (${coverage.covered.length})`}
tone="covered"
terms={coverage.covered}
emptyCopy="None of the JD terms we extracted show up in the resume text."
/>
<TermColumn
heading={`Missing (${coverage.missing.length})`}
tone="missing"
terms={coverage.missing}
emptyCopy="Every term we extracted shows up somewhere in the resume."
/>
</div>

{nounsDropped > 0 && (
<p className="text-2xs text-content-muted">
+{nounsDropped} more capitalized phrase{nounsDropped === 1 ? "" : "s"}{" "}
in this JD weren't surfaced — the noun-phrase pass ranks hits by how
often they recur (weighting the requirements section) and keeps the
top ones to keep the panel readable.
</p>
)}
</Card>
);
}

function TermColumn({
heading,
tone,
terms,
emptyCopy,
}: {
heading: string;
tone: "covered" | "missing";
terms: readonly ExtractedTerm[];
emptyCopy: string;
}) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wider text-content-muted">
{heading}
</h3>
{terms.length === 0 ? (
<p className="text-sm text-content-tertiary">{emptyCopy}</p>
) : (
<ul className="flex flex-col gap-1">
{terms.map((term) => (
<TermRow key={`${term.source}:${term.id}`} term={term} tone={tone} />
))}
</ul>
)}
</section>
);
}

function TermRow({
term,
tone,
}: {
term: ExtractedTerm;
tone: "covered" | "missing";
}) {
const marker = tone === "covered" ? "✓" : "•";
const markerCls =
tone === "covered"
? "text-feedback-success-text"
: "text-content-muted";
const sourceLabel = term.source === "skill" ? "skill" : "phrase";
return (
<li
className="flex items-baseline gap-2 rounded border border-border-light px-2 py-1.5"
title={term.snippet}
>
<span className={`text-sm font-semibold ${markerCls}`}>{marker}</span>
<span className="text-sm text-content-primary">{term.display}</span>
<span className="ml-auto font-mono text-3xs uppercase tracking-wider text-content-muted">
{sourceLabel}
</span>
</li>
);
if (result.path === "keyword") return <KeywordMatch result={result} />;
return <SemanticMatch result={result} />;
}
101 changes: 101 additions & 0 deletions src/components/features/KeywordMatch.parity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors

/**
* KeywordMatch parity (#204) — the keyword view is the DEFAULT experience.
*
* #204 turned `JdMatch` into a router and moved its body into `KeywordMatch`.
* The sibling `JdMatch.test.ts` asserts the behaviours a reader cares about
* (N-of-M headline, diagnostic framing, the `+N more` footnote); this file
* asserts the thing those tests CAN'T catch — that the move changed no markup
* at all. A dropped wrapper, a reordered class, a lost `title`, a re-tuned gap
* would all pass a behavioural assertion and still be a visual regression for
* every user who never opts into on-device analysis.
*
* The golden strings below were captured by rendering the PRE-#204
* `JdMatch.tsx` (`git show HEAD~:…`) against these three inputs, not by
* snapshotting the new component — a self-captured snapshot would pass no
* matter what the refactor did. Three inputs because they exercise the three
* branches the view has: populated columns + footnote, both empty-state
* copies, and the singular/plural fork in the footnote.
*
* If a future change to the keyword view is INTENDED, update these strings in
* the same commit; the point is that it cannot happen by accident.
*/

import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { JdMatch } from "./JdMatch.tsx";
import type { ExtractedTerm } from "../../lib/jd-match/extract-jd-terms.ts";
import type { JdMatchResult } from "../../lib/jd-match";

function term(
id: string,
display: string,
source: ExtractedTerm["source"],
): ExtractedTerm {
return { id, display, source, snippet: `…snippet for ${display}…` };
}

const covered = [
term("react", "react", "skill"),
term("Distributed Systems", "Distributed Systems", "noun"),
];
const missing = [term("kubernetes", "kubernetes", "skill")];

function keyword(nounsDropped: number): JdMatchResult {
return {
path: "keyword",
coverage: {
covered,
missing,
score: 62,
weights: { skill: 1, noun: 0.5 },
},
terms: [...covered, ...missing],
nounsDropped,
};
}

const EMPTY: JdMatchResult = {
path: "keyword",
coverage: {
covered: [],
missing: [],
score: 0,
weights: { skill: 1, noun: 0.5 },
},
terms: [],
nounsDropped: 0,
};

const POPULATED_HTML =
'<section class="rounded-xl border border-border-light bg-surface-card p-5 flex flex-col gap-4 shadow-xs"><header class="flex flex-col gap-1"><div class="flex items-baseline gap-2"><h2 class="text-sm font-semibold uppercase tracking-wider text-content-muted">JD match</h2><span class="rounded bg-surface-subtle px-1.5 py-0.5 text-4xs font-semibold uppercase tracking-wider text-content-secondary">alpha</span></div><p class="text-base font-semibold text-content-primary">Your resume mentions 2 of 3 terms from this JD.</p><p class="text-sm text-content-tertiary">Weighted coverage: <span class="font-mono text-content-secondary">62/100</span> — skill 1.0, phrase 0.5.</p><p class="max-w-prose text-sm text-content-tertiary">Diagnostic, not a verdict. We look for skills and phrases by name — we don&#x27;t read context. Your JD text stays in this browser tab.</p></header><div class="grid gap-4 md:grid-cols-2"><section class="flex flex-col gap-2"><h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Covered (2)</h3><ul class="flex flex-col gap-1"><li class="flex items-baseline gap-2 rounded border border-border-light px-2 py-1.5" title="…snippet for react…"><span class="text-sm font-semibold text-feedback-success-text">✓</span><span class="text-sm text-content-primary">react</span><span class="ml-auto font-mono text-3xs uppercase tracking-wider text-content-muted">skill</span></li><li class="flex items-baseline gap-2 rounded border border-border-light px-2 py-1.5" title="…snippet for Distributed Systems…"><span class="text-sm font-semibold text-feedback-success-text">✓</span><span class="text-sm text-content-primary">Distributed Systems</span><span class="ml-auto font-mono text-3xs uppercase tracking-wider text-content-muted">phrase</span></li></ul></section><section class="flex flex-col gap-2"><h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Missing (1)</h3><ul class="flex flex-col gap-1"><li class="flex items-baseline gap-2 rounded border border-border-light px-2 py-1.5" title="…snippet for kubernetes…"><span class="text-sm font-semibold text-content-muted">•</span><span class="text-sm text-content-primary">kubernetes</span><span class="ml-auto font-mono text-3xs uppercase tracking-wider text-content-muted">skill</span></li></ul></section></div><p class="text-2xs text-content-muted">+3 more capitalized phrases in this JD weren&#x27;t surfaced — the noun-phrase pass ranks hits by how often they recur (weighting the requirements section) and keeps the top ones to keep the panel readable.</p></section>';

const EMPTY_HTML =
'<section class="rounded-xl border border-border-light bg-surface-card p-5 flex flex-col gap-4 shadow-xs"><header class="flex flex-col gap-1"><div class="flex items-baseline gap-2"><h2 class="text-sm font-semibold uppercase tracking-wider text-content-muted">JD match</h2><span class="rounded bg-surface-subtle px-1.5 py-0.5 text-4xs font-semibold uppercase tracking-wider text-content-secondary">alpha</span></div><p class="text-base font-semibold text-content-primary">Your resume mentions 0 of 0 terms from this JD.</p><p class="text-sm text-content-tertiary">Weighted coverage: <span class="font-mono text-content-secondary">0/100</span> — skill 1.0, phrase 0.5.</p><p class="max-w-prose text-sm text-content-tertiary">Diagnostic, not a verdict. We look for skills and phrases by name — we don&#x27;t read context. Your JD text stays in this browser tab.</p></header><div class="grid gap-4 md:grid-cols-2"><section class="flex flex-col gap-2"><h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Covered (0)</h3><p class="text-sm text-content-tertiary">None of the JD terms we extracted show up in the resume text.</p></section><section class="flex flex-col gap-2"><h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Missing (0)</h3><p class="text-sm text-content-tertiary">Every term we extracted shows up somewhere in the resume.</p></section></div></section>';

/** Only the footnote differs from POPULATED_HTML — pinned in full anyway, so
* a change that "fixes" one case and breaks the other can't slip through. */
const SINGULAR_FOOTNOTE_HTML = POPULATED_HTML.replace(
"+3 more capitalized phrases in this JD",
"+1 more capitalized phrase in this JD",
);

describe("KeywordMatch parity with the pre-#204 JdMatch body", () => {
it("renders populated columns and the plural footnote unchanged", () => {
expect(renderToStaticMarkup(<JdMatch result={keyword(3)} />)).toBe(
POPULATED_HTML,
);
});

it("renders both empty-state copies unchanged, with no footnote", () => {
expect(renderToStaticMarkup(<JdMatch result={EMPTY} />)).toBe(EMPTY_HTML);
});

it("keeps the singular footnote fork unchanged", () => {
expect(renderToStaticMarkup(<JdMatch result={keyword(1)} />)).toBe(
SINGULAR_FOOTNOTE_HTML,
);
});
});
Loading
Loading