diff --git a/src/components/features/JdMatch.test.ts b/src/components/features/JdMatch.test.ts index 1b349e5d..f561ff6a 100644 --- a/src/components/features/JdMatch.test.ts +++ b/src/components/features/JdMatch.test.ts @@ -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, @@ -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 ·"); }); }); diff --git a/src/components/features/JdMatch.tsx b/src/components/features/JdMatch.tsx index 34ef9354..a513606c 100644 --- a/src/components/features/JdMatch.tsx +++ b/src/components/features/JdMatch.tsx @@ -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` → ``, `semantic` → + * ``. 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 + * `` 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 ( - -
-
-

- JD match -

- - alpha - -
-

- Your resume mentions {covered} of {total} terms from this JD. -

-

- Weighted coverage:{" "} - - {coverage.score}/100 - {" "} - — skill {coverage.weights.skill.toFixed(1)}, phrase{" "} - {coverage.weights.noun.toFixed(1)}. -

-

- 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. -

-
- -
- - -
- - {nounsDropped > 0 && ( -

- +{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. -

- )} -
- ); -} - -function TermColumn({ - heading, - tone, - terms, - emptyCopy, -}: { - heading: string; - tone: "covered" | "missing"; - terms: readonly ExtractedTerm[]; - emptyCopy: string; -}) { - return ( -
-

- {heading} -

- {terms.length === 0 ? ( -

{emptyCopy}

- ) : ( -
    - {terms.map((term) => ( - - ))} -
- )} -
- ); -} - -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 ( -
  • - {marker} - {term.display} - - {sourceLabel} - -
  • - ); + if (result.path === "keyword") return ; + return ; } diff --git a/src/components/features/KeywordMatch.parity.test.tsx b/src/components/features/KeywordMatch.parity.test.tsx new file mode 100644 index 00000000..59a63a15 --- /dev/null +++ b/src/components/features/KeywordMatch.parity.test.tsx @@ -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 = + '

    JD match

    alpha

    Your resume mentions 2 of 3 terms from this JD.

    Weighted coverage: 62/100 — skill 1.0, phrase 0.5.

    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.

    Covered (2)

    • reactskill
    • Distributed Systemsphrase

    Missing (1)

    • kubernetesskill

    +3 more capitalized phrases 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.

    '; + +const EMPTY_HTML = + '

    JD match

    alpha

    Your resume mentions 0 of 0 terms from this JD.

    Weighted coverage: 0/100 — skill 1.0, phrase 0.5.

    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.

    Covered (0)

    None of the JD terms we extracted show up in the resume text.

    Missing (0)

    Every term we extracted shows up somewhere in the resume.

    '; + +/** 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()).toBe( + POPULATED_HTML, + ); + }); + + it("renders both empty-state copies unchanged, with no footnote", () => { + expect(renderToStaticMarkup()).toBe(EMPTY_HTML); + }); + + it("keeps the singular footnote fork unchanged", () => { + expect(renderToStaticMarkup()).toBe( + SINGULAR_FOOTNOTE_HTML, + ); + }); +}); diff --git a/src/components/features/KeywordMatch.tsx b/src/components/features/KeywordMatch.tsx new file mode 100644 index 00000000..3a1609ce --- /dev/null +++ b/src/components/features/KeywordMatch.tsx @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * KeywordMatch — the deterministic term-coverage view of a JD match. + * + * This is the pre-#204 body of `JdMatch.tsx`, moved here VERBATIM when that + * file became a router on `result.path` (#204). Nothing was "cleaned up" on the + * way across: same element order, same class strings, same copy, same empty + * states, same `title` snippet, same `+N more` footnote wording. The keyword + * path is the default experience for every user who never opts into on-device + * analysis AND the fallback floor for every user who does, so a cosmetic drift + * here would be a regression for everyone. `KeywordMatch.parity.test.ts` pins + * that output against the shipped strings. + * + * It keeps the whole card — chrome, header, disclaimer — rather than just the + * two columns, so a keyword render is byte-identical to the pre-router one + * instead of being reassembled from a shared shell whose spacing would have to + * be re-derived. `SemanticMatch` owns the equivalent card for its own arm. + * + * 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. + * + * Two consumers reach this through `JdMatch`: `PasteJdPanel`'s pasted JD and + * `JobResultCard`'s "View match detail" (whose `RankedJob.jdMatch` is typed + * `KeywordJdMatch`, so it can only ever land on this arm). + */ + +import type { ExtractedTerm } from "../../lib/jd-match/extract-jd-terms.ts"; +import type { JdMatchResult } from "../../lib/jd-match"; +import { Card } from "@design-system"; + +/** The keyword arm of the union — same `Extract<…>` idiom `rank.ts` uses for + * `KeywordJdMatch`, so the two narrowings cannot drift. */ +type KeywordResult = Extract; + +export function KeywordMatch({ result }: { result: KeywordResult }) { + const { coverage, terms, nounsDropped } = result; + const total = terms.length; + const covered = coverage.covered.length; + + return ( + +
    +
    +

    + JD match +

    + + alpha + +
    +

    + Your resume mentions {covered} of {total} terms from this JD. +

    +

    + Weighted coverage:{" "} + + {coverage.score}/100 + {" "} + — skill {coverage.weights.skill.toFixed(1)}, phrase{" "} + {coverage.weights.noun.toFixed(1)}. +

    +

    + 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. +

    +
    + +
    + + +
    + + {nounsDropped > 0 && ( +

    + +{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. +

    + )} +
    + ); +} + +function TermColumn({ + heading, + tone, + terms, + emptyCopy, +}: { + heading: string; + tone: "covered" | "missing"; + terms: readonly ExtractedTerm[]; + emptyCopy: string; +}) { + return ( +
    +

    + {heading} +

    + {terms.length === 0 ? ( +

    {emptyCopy}

    + ) : ( +
      + {terms.map((term) => ( + + ))} +
    + )} +
    + ); +} + +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 ( +
  • + {marker} + {term.display} + + {sourceLabel} + +
  • + ); +} diff --git a/src/components/features/PasteJdPanel.semantic.test.tsx b/src/components/features/PasteJdPanel.semantic.test.tsx new file mode 100644 index 00000000..41327f64 --- /dev/null +++ b/src/components/features/PasteJdPanel.semantic.test.tsx @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +// @vitest-environment jsdom + +/** + * PasteJdPanel semantic opt-in (#204) — the wired lane, end to end. + * + * This is the INTEGRATION test for #204, and the mock seam is deliberately + * narrow: only the three things a jsdom run cannot have — the WebGPU probe, + * the persisted model id, and the WebLLM orchestrator — are stubbed. The + * panel, `useJdMatch`, `JdMatch`'s router, `KeywordMatch`, `SemanticMatch` and + * the whole deterministic keyword pipeline (`extractJdTerms` + + * `computeCoverage`) are REAL. A test that mocked `useJdMatch` would prove the + * panel renders whatever it is handed and nothing about whether the opt-in is + * actually gating anything. + * + * The claims worth the most here are the negative ones — that an untouched + * panel does no WebGPU work at all, and that an abandoned run can never flash + * a verdict over a newer state. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +// ── Mocks (capability + model layer + orchestrator only) ──────────────────── + +let webgpu: "available" | "no-webgpu" | "unsupported-os" = "available"; +const detectWebGpuMock = vi.fn(() => Promise.resolve(webgpu)); +vi.mock("../../lib/webllm/capability.ts", () => ({ + detectWebGpu: () => detectWebGpuMock(), +})); + +let modelId = "test-model"; +vi.mock("../../hooks/useModelSelection.ts", () => ({ + useModelSelection: () => ({ selectedModelId: modelId }), +})); + +/** One captured `runLlmMatch` invocation, with the handles a test needs to + * drive it: the progress/inference callbacks, the abort signal #803 threads, + * and the resolver that stands in for the model finishing. */ +interface RunCall { + jdText: string; + modelId: string; + onProgress: (update: { progress: number; text: string }) => void; + onInferenceStart?: () => void; + signal?: AbortSignal; + resolve: (result: JdMatchResult) => void; +} + +const runs: RunCall[] = []; +const runLlmMatchMock = vi.fn( + ( + jdText: string, + _parsed: unknown, + runModelId: string, + onProgress: RunCall["onProgress"], + onInferenceStart?: () => void, + signal?: AbortSignal, + ) => + new Promise((resolve) => { + runs.push({ + jdText, + modelId: runModelId, + onProgress, + onInferenceStart, + signal, + resolve, + }); + }), +); +vi.mock("../../lib/jd-match/llm/run-llm-match.ts", () => ({ + runLlmMatch: (...args: Parameters) => + runLlmMatchMock(...args), +})); + +import { PasteJdPanel } from "./PasteJdPanel.tsx"; +import { extractJdTerms, computeCoverage } from "../../lib/jd-match"; +import type { JdMatchResult } from "../../lib/jd-match"; +import type { HeuristicParsedResume } from "../../lib/heuristics/types.ts"; +import type { RequirementVerdict } from "../../lib/jd-match/llm/judge-evidence.ts"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const JD_A = + "We are hiring a platform engineer. You will work with Kubernetes, " + + "Terraform, and Go to run our production infrastructure."; +const JD_B = + "We are hiring a data engineer. You will work with Airflow, Spark, and " + + "Python to run our analytics warehouse."; +const JD_C = + "We are hiring a security engineer. You will work with Terraform, Rust, " + + "and threat modeling to harden our production estate."; + +const SPARSE_RESUME: HeuristicParsedResume = { + skills: ["React"], + experience: [ + { title: "Frontend Engineer", company: "Acme", description: "Built UIs" }, + ], + education: [], +} as unknown as HeuristicParsedResume; + +function verdicts(): RequirementVerdict[] { + return [ + { + requirement: { id: "req-1", kind: "skill", text: "Run Kubernetes" }, + status: "met", + reason: "Operated production clusters.", + evidence: "Ran a 40-node cluster", + }, + { + requirement: { id: "req-2", kind: "experience", text: "Five years of Go" }, + status: "missing", + reason: "No Go experience listed.", + }, + ]; +} + +function semanticResult(headline = "Run Kubernetes"): JdMatchResult { + const list = verdicts(); + list[0].requirement.text = headline; + return { + path: "semantic", + verdicts: list, + summary: { met: 1, partial: 0, missing: 1, total: 2 }, + }; +} + +function keywordResult(jdText: string): JdMatchResult { + const extracted = extractJdTerms(jdText); + return { + path: "keyword", + coverage: computeCoverage(SPARSE_RESUME, extracted.all), + terms: extracted.all, + nounsDropped: extracted.nounsDropped, + }; +} + +// ── Harness ──────────────────────────────────────────────────────────────── + +let container: HTMLDivElement; +let root: Root; + +function mount(strict = false): void { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const tree = ; + act(() => { + root.render(strict ? {tree} : tree); + }); + // Expand the collapsed-by-default disclosure. + act(() => discloseButton().click()); +} + +function discloseButton(): HTMLButtonElement { + const button = [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes("Paste it"), + ); + if (!button) throw new Error("paste-a-JD disclosure button not found"); + return button as HTMLButtonElement; +} + +/** Type a JD the way React's own synthetic events do, then flush the hook's + * 200 ms debounce and any awaited state writes. */ +async function setJd(text: string): Promise { + const textarea = container.querySelector("textarea"); + if (!textarea) throw new Error("JD textarea not found"); + act(() => { + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + setter?.call(textarea, text); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + await settle(); +} + +function optInBox(): HTMLInputElement { + const box = container.querySelector('input[type="checkbox"]'); + if (!box) throw new Error("opt-in checkbox not found"); + return box as HTMLInputElement; +} + +/** Click the opt-in checkbox and let the capability probe resolve. */ +async function toggleOptIn(): Promise { + act(() => optInBox().click()); + await settle(); +} + +/** Flush microtasks inside `act` so promise continuations' setState land. */ +async function settle(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function text(): string { + return container.textContent ?? ""; +} + +function showsKeywordColumns(): boolean { + return text().includes("Your resume mentions"); +} + +function showsSemanticVerdicts(): boolean { + return /Met \(\d+\)/.test(text()); +} + +function progressBar(): HTMLElement | null { + return container.querySelector('[role="progressbar"]'); +} + +/** The accessible label a screen reader would read for the opt-in control. */ +function optInLabel(): string { + const box = optInBox(); + const label = container.querySelector(`label[for="${box.id}"]`); + return label?.textContent ?? ""; +} + +beforeEach(() => { + vi.useFakeTimers(); + webgpu = "available"; + modelId = "test-model"; + runs.length = 0; + detectWebGpuMock.mockClear(); + runLlmMatchMock.mockClear(); +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + vi.useRealTimers(); +}); + +// ── Default path: opt-in OFF ─────────────────────────────────────────────── + +describe("PasteJdPanel — semantic opt-in defaults OFF", () => { + it("offers a labelled, unchecked opt-in control", async () => { + mount(); + await setJd(JD_A); + expect(optInBox().checked).toBe(false); + expect(optInLabel()).toContain("Analyze with on-device AI"); + }); + + it("renders the instant keyword result and no semantic chrome", async () => { + mount(); + await setJd(JD_A); + expect(showsKeywordColumns()).toBe(true); + expect(showsSemanticVerdicts()).toBe(false); + expect(progressBar()).toBeNull(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("never probes WebGPU, so no capability event enters the WebLLM funnel", async () => { + // `detectWebGpu` fires `webllm_capability_detected` — the funnel's top + // event. A keyword-only user must not appear in it at all. + mount(); + await setJd(JD_A); + expect(detectWebGpuMock).not.toHaveBeenCalled(); + }); + + it("never loads the orchestrator, so no model download starts", async () => { + mount(); + await setJd(JD_A); + expect(runLlmMatchMock).not.toHaveBeenCalled(); + expect(runs).toHaveLength(0); + }); +}); + +// ── Opting in ────────────────────────────────────────────────────────────── + +describe("PasteJdPanel — opting in", () => { + it("probes WebGPU only after the box is ticked, then starts one run", async () => { + mount(); + await setJd(JD_A); + expect(detectWebGpuMock).not.toHaveBeenCalled(); + + await toggleOptIn(); + + expect(detectWebGpuMock).toHaveBeenCalledTimes(1); + expect(runs).toHaveLength(1); + expect(runs[0].jdText).toBe(JD_A); + expect(runs[0].modelId).toBe("test-model"); + }); + + it("keeps the keyword floor on screen for the whole engine load", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + + // Loading: progress bar AND the keyword columns, not one replacing the + // other. The keyword result is the "always show something" return hook. + expect(progressBar()).toBeTruthy(); + expect(showsKeywordColumns()).toBe(true); + + act(() => runs[0].onProgress({ progress: 0.42, text: "shard 3 of 7" })); + await settle(); + expect(progressBar()?.getAttribute("aria-valuenow")).toBe("42"); + expect(text()).toContain("shard 3 of 7"); + expect(showsKeywordColumns()).toBe(true); + }); + + it("moves to a truthful running line with no invented requirement counts", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + + act(() => runs[0].onInferenceStart?.()); + await settle(); + + expect(progressBar()).toBeNull(); + const status = container.querySelector('[role="status"]'); + expect(status?.textContent).toContain("Reading this JD"); + // #204's example copy ("Judging requirement 4 of 9…") has no data behind + // it — `judgeEvidence` reports no per-requirement progress. Nothing may + // claim one. + expect(text()).not.toMatch(/requirement \d+ of \d+/i); + expect(showsKeywordColumns()).toBe(true); + }); + + it("swaps the keyword columns for the verdict list once the run resolves", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + + act(() => runs[0].onInferenceStart?.()); + await settle(); + act(() => runs[0].resolve(semanticResult())); + await settle(); + + expect(showsSemanticVerdicts()).toBe(true); + expect(text()).toContain("Run Kubernetes"); + expect(text()).toContain("Operated production clusters."); + expect(text()).toContain("1 met · 0 partial · 1 missing"); + expect(showsKeywordColumns()).toBe(false); + expect(progressBar()).toBeNull(); + }); + + it("reuses a finished run when the box is toggled off and back on", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + act(() => runs[0].resolve(semanticResult())); + await settle(); + expect(showsSemanticVerdicts()).toBe(true); + + await toggleOptIn(); // off + expect(showsKeywordColumns()).toBe(true); + expect(showsSemanticVerdicts()).toBe(false); + + await toggleOptIn(); // on again + // The cached `ready` slot answers immediately: no second engine load, no + // second extract + judge for a byte-identical input. + expect(showsSemanticVerdicts()).toBe(true); + expect(runs).toHaveLength(1); + }); +}); + +// ── No WebGPU ────────────────────────────────────────────────────────────── + +describe("PasteJdPanel — no WebGPU", () => { + it("keeps the keyword columns and explains in one muted line, with no error", async () => { + webgpu = "no-webgpu"; + mount(); + await setJd(JD_A); + await toggleOptIn(); + + expect(showsKeywordColumns()).toBe(true); + expect(showsSemanticVerdicts()).toBe(false); + // Not an error and not a warning strip — the panel is not in a failed + // state, it just can't add the optional layer. + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(text()).toContain("This browser can't run on-device analysis"); + expect(progressBar()).toBeNull(); + }); + + it("starts no run at all", async () => { + webgpu = "unsupported-os"; + mount(); + await setJd(JD_A); + await toggleOptIn(); + expect(runLlmMatchMock).not.toHaveBeenCalled(); + expect(showsKeywordColumns()).toBe(true); + }); + + it("does not route the panel away and back while the probe is resolving", async () => { + // The chosen UX for the detect window (#204 "no flicker"): the keyword + // card stays mounted throughout and only the line under the checkbox + // changes. Asserted by watching the card across every step. + mount(); + await setJd(JD_A); + expect(showsKeywordColumns()).toBe(true); + act(() => optInBox().click()); // ticked; probe not yet resolved + expect(showsKeywordColumns()).toBe(true); + await settle(); // probe resolves + expect(showsKeywordColumns()).toBe(true); + }); +}); + +// ── Cancellation / races (#803 seen from the UI) ──────────────────────────── + +describe("PasteJdPanel — cancellation and races", () => { + it("opting out mid-load returns to keyword at once and aborts the run", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + expect(runs[0].signal?.aborted).toBe(false); + + await toggleOptIn(); // opt out while still loading + + expect(runs[0].signal?.aborted).toBe(true); + expect(showsKeywordColumns()).toBe(true); + expect(progressBar()).toBeNull(); + }); + + it("opting out mid-inference aborts, and the abandoned verdict never flashes", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + act(() => runs[0].onInferenceStart?.()); + await settle(); + + await toggleOptIn(); // opt out mid-inference + expect(runs[0].signal?.aborted).toBe(true); + expect(showsKeywordColumns()).toBe(true); + + // The abandoned run resolves LATE. Its write must be dropped. + act(() => runs[0].resolve(semanticResult("STALE REQUIREMENT"))); + await settle(); + expect(text()).not.toContain("STALE REQUIREMENT"); + expect(showsSemanticVerdicts()).toBe(false); + expect(showsKeywordColumns()).toBe(true); + }); + + it("a JD edit mid-run supersedes it; the old run's late result is ignored", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + + await setJd(JD_B); + expect(runs).toHaveLength(2); + expect(runs[0].signal?.aborted).toBe(true); + expect(runs[1].signal?.aborted).toBe(false); + + act(() => runs[0].resolve(semanticResult("STALE REQUIREMENT"))); + await settle(); + expect(text()).not.toContain("STALE REQUIREMENT"); + + act(() => runs[1].resolve(semanticResult("FRESH REQUIREMENT"))); + await settle(); + expect(text()).toContain("FRESH REQUIREMENT"); + }); + + it("does not report a superseded run as a failed analysis", async () => { + // `runLlmMatch` resolves an aborted run to the KEYWORD arm by contract. + // If that landed in the slot, the panel would tell the user analysis + // "didn't return a verdict" every time they edited the JD mid-run. The + // id guard drops the write, so the note must never appear. + mount(); + await setJd(JD_A); + await toggleOptIn(); + await setJd(JD_B); + + act(() => runs[0].resolve(keywordResult(JD_A))); + await settle(); + + expect(text()).not.toContain("didn't return a verdict"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("A → B → C rapid edits leave one live run and render only its result", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + await setJd(JD_B); + await setJd(JD_C); + + expect(runs).toHaveLength(3); + expect(runs[0].signal?.aborted).toBe(true); + expect(runs[1].signal?.aborted).toBe(true); + expect(runs[2].signal?.aborted).toBe(false); + + // Resolve them out of order, oldest last — the id guard, not arrival + // order, is what decides which one is allowed to paint. + act(() => runs[1].resolve(semanticResult("STALE B"))); + act(() => runs[2].resolve(semanticResult("FRESH C"))); + act(() => runs[0].resolve(semanticResult("STALE A"))); + await settle(); + + expect(text()).toContain("FRESH C"); + expect(text()).not.toContain("STALE A"); + expect(text()).not.toContain("STALE B"); + }); + + it("a model change mid-run aborts the old run and starts a fresh one", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + expect(runs).toHaveLength(1); + + modelId = "another-model"; + act(() => { + root.render(); + }); + await settle(); + + expect(runs).toHaveLength(2); + expect(runs[0].signal?.aborted).toBe(true); + expect(runs[1].modelId).toBe("another-model"); + }); + + it("clearing the JD aborts the run and takes the panel back to nothing", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + + await setJd(""); + + expect(runs[0].signal?.aborted).toBe(true); + expect(showsKeywordColumns()).toBe(false); + expect(showsSemanticVerdicts()).toBe(false); + }); + + it("unmounting aborts the in-flight run", async () => { + mount(); + await setJd(JD_A); + await toggleOptIn(); + expect(runs[0].signal?.aborted).toBe(false); + + act(() => root.unmount()); + await settle(); // the abort is deferred by one microtask + + expect(runs[0].signal?.aborted).toBe(true); + }); + + it("StrictMode's double-invoke does not kill the live run", async () => { + mount(true); + await setJd(JD_A); + await toggleOptIn(); + await settle(); + + expect(runs).toHaveLength(1); + expect(runs[0].signal?.aborted).toBe(false); + + act(() => runs[0].resolve(semanticResult())); + await settle(); + expect(showsSemanticVerdicts()).toBe(true); + }); +}); + +// ── Fallback / failure ───────────────────────────────────────────────────── + +describe("PasteJdPanel — semantic fallback", () => { + it("routes a keyword-arm result from the semantic run back to the keyword view", async () => { + // `runLlmMatch` never rejects: an engine failure, an unparseable + // extraction or a JD with no requirements all come back as `path: + // "keyword"`. The router must show coverage, not a blank panel. + mount(); + await setJd(JD_A); + await toggleOptIn(); + + act(() => runs[0].resolve(keywordResult(JD_A))); + await settle(); + + expect(showsKeywordColumns()).toBe(true); + expect(showsSemanticVerdicts()).toBe(false); + expect(text()).toContain("On-device analysis didn't return a verdict"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("preserves the keyword floor when the orchestrator import itself fails", async () => { + // The one failure `runLlmMatch`'s own contract can't absorb — the dynamic + // import rejecting (a hashed chunk gone after a deploy). The hook falls + // back to the keyword result it snapshotted; the panel must show it, and + // must not leak the loader's message. + runLlmMatchMock.mockImplementationOnce(() => { + throw new Error( + "Failed to fetch dynamically imported module: /assets/run-llm-match-a1b2c3.js", + ); + }); + mount(); + await setJd(JD_A); + await toggleOptIn(); + await settle(); + + expect(showsKeywordColumns()).toBe(true); + expect(text()).not.toContain("Failed to fetch dynamically imported module"); + expect(text()).not.toContain("run-llm-match-a1b2c3"); + }); +}); + +// ── The tailor handoff must be untouched by any of this ──────────────────── + +describe("PasteJdPanel — tailor steering is unchanged by the opt-in", () => { + it("hands over the same keyword-derived steering on both paths", async () => { + const onTailor = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root.render(); + }); + act(() => discloseButton().click()); + await setJd(JD_A); + + const tailor = () => + [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes("Tailor résumé to this job"), + ) as HTMLButtonElement | undefined; + + expect(tailor()).toBeTruthy(); + act(() => tailor()?.click()); + const keywordSteering = onTailor.mock.calls[0][0] as string; + + // Same button, same payload, after a semantic verdict has replaced the + // columns — the rewrite steering is built from coverage, so ticking a + // checkbox must not change what a rewrite is told to do. + await toggleOptIn(); + act(() => runs[0].resolve(semanticResult())); + await settle(); + expect(showsSemanticVerdicts()).toBe(true); + + act(() => tailor()?.click()); + expect(onTailor).toHaveBeenCalledTimes(2); + expect(onTailor.mock.calls[1][0]).toBe(keywordSteering); + }); +}); diff --git a/src/components/features/PasteJdPanel.tsx b/src/components/features/PasteJdPanel.tsx index f40db8ce..d561a799 100644 --- a/src/components/features/PasteJdPanel.tsx +++ b/src/components/features/PasteJdPanel.tsx @@ -21,12 +21,30 @@ * `onTailor` is optional so this component is self-contained and testable * without a router; on `/jobs/` the parent (`JobsApp` → `FindJobsPanel`) is * what turns a coverage handoff into a navigation back to `/`. + * + * ## Semantic opt-in (#204) + * + * This panel is the interaction owner, so the "Analyze with on-device AI" + * boolean lives HERE — one `useState`, handed down to + * `SemanticAnalysisOptIn` (which renders it and its lifecycle line) and across + * to `useJdMatch` as `semanticOptIn` (which gates the WebGPU probe, the engine + * load and the two LLM calls on it). One owner, two readers; no duplicate + * state and no second JD-match controller. + * + * NOT gated on the `open` disclosure. Collapsing the panel mid-run would abort + * a load the user asked for and throw away a partially-finished one, and it + * would buy nothing on the expensive half: `loadEngine`'s promise is shared + * across consumers and deliberately not abortable (#803), so the weight + * download proceeds either way. Leaving the run alive means a user who + * collapses the panel and comes back finds the verdicts already there, served + * from the hook's cached `ready` slot. */ import { useMemo, useState } from "react"; import { Button } from "@design-system"; import { JdInput } from "./JdInput.tsx"; import { JdMatch } from "./JdMatch.tsx"; +import { SemanticAnalysisOptIn } from "./SemanticAnalysisOptIn.tsx"; import { buildJdRewriteContext } from "../../lib/jd-match/rewrite-context.ts"; import { useJdMatch } from "../../hooks/useJdMatch.ts"; import type { HeuristicParsedResume } from "../../lib/heuristics/types.ts"; @@ -44,26 +62,52 @@ interface PasteJdPanelProps { export function PasteJdPanel({ parsed, onTailor }: PasteJdPanelProps) { const [open, setOpen] = useState(false); const [jdText, setJdText] = useState(""); + // Default OFF — see the docblock. The hook reads this to gate everything + // WebLLM, so `false` here means no probe, no download and no analytics. + const [semanticOptIn, setSemanticOptIn] = useState(false); // Cross-cutting JD-match state (#203) lives in `useJdMatch`. The panel // renders its result; the hook owns the debounce, the extract → coverage - // composition, and the (future) semantic path. `semanticOptIn` defaults - // to false today — behavior stays byte-identical to pre-#203, and a - // follow-up that ships an opt-in UI can flip it without touching the - // hook. + // composition, and the semantic path. // // Read the `keyword` floor rather than narrowing `status`: with - // `semanticOptIn` false the two are equivalent, but once #204 flips the - // flag `status` is occupied by `loading`/`running` for the whole engine - // load while keyword coverage is already available. Reading `keyword` - // means this panel keeps showing coverage through that window instead of - // blanking, and #204 only has to ADD the semantic view on top. - const { keyword } = useJdMatch({ parsed, jdText }); + // `semanticOptIn` false the two are equivalent, but with it on `status` is + // occupied by `loading`/`running` for the whole engine load while keyword + // coverage is already available. Reading `keyword` means this panel keeps + // showing coverage through that window instead of blanking. + const { status, keyword, capability } = useJdMatch({ + parsed, + jdText, + semanticOptIn, + }); const jdMatch = keyword?.path === "keyword" ? keyword : null; + // What the card renders. Semantic verdicts REPLACE the keyword columns, but + // only once a semantic run has actually finished — every other state + // (detecting, loading, running, degraded, opted back out, errored) falls + // through to the keyword floor, which is what makes "the panel always shows + // something" true of the render and not just of the controller. + // + // This is also the whole of the no-stale-verdict guarantee at the UI layer: + // the semantic arm is read from `status`, and `useJdMatch` only ever puts a + // result there for the CURRENT inputs (request-id guard + slot-vs-input + // value comparison), so an abandoned run cannot flash a verdict here. + const semanticResult = + status.kind === "ready" && status.result.path === "semantic" + ? status.result + : null; + const displayed = semanticResult ?? jdMatch; + // Same one-call gate-and-payload as `JobResultCard` — see its docblock for // why the button's visibility must be derived from the built instruction // and not from `missing.length`. + // + // Built from the KEYWORD coverage regardless of which view is on screen: + // `buildJdRewriteContext` consumes a `CoverageResult`, which only the + // keyword arm carries, and the steering a rewrite gets must not silently + // change shape when a user ticks a checkbox. Wiring the semantic verdicts + // into rewrite steering is its own piece of work, not a side effect of the + // verdict UI. const jdContext = useMemo( () => (jdMatch === null ? null : buildJdRewriteContext(jdMatch.coverage)), [jdMatch], @@ -97,7 +141,13 @@ export function PasteJdPanel({ parsed, onTailor }: PasteJdPanelProps) { onChange={setJdText} resumeParsed={true} /> - {jdMatch && } + + {displayed && } {/* A JD the résumé already fully covers has nothing to steer with, so the button would render and silently no-op on click. Hide it instead (#576). */} diff --git a/src/components/features/SemanticAnalysisOptIn.test.tsx b/src/components/features/SemanticAnalysisOptIn.test.tsx new file mode 100644 index 00000000..bf28e3eb --- /dev/null +++ b/src/components/features/SemanticAnalysisOptIn.test.tsx @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +// @vitest-environment jsdom + +/** + * SemanticAnalysisOptIn (#204) — the opt-in control and its lifecycle line, + * driven directly. + * + * The sibling `PasteJdPanel.semantic.test.tsx` proves the wiring end to end + * through the real hook. This file drives the presentational component across + * every `JdMatchStatus` × capability combination, including the ones the + * controller cannot currently produce — `error` is public API on the hook's + * union but unreachable from today's `PasteJdPanel` (a semantic run only ever + * starts when a keyword floor already exists), so an integration test cannot + * reach it and it would otherwise ship unrendered. + */ + +import { describe, it, expect, afterEach, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { SemanticAnalysisOptIn } from "./SemanticAnalysisOptIn.tsx"; +import type { JdMatchStatus } from "../../hooks/useJdMatch.ts"; +import type { JdMatchResult } from "../../lib/jd-match"; +import type { WebGpuCapability } from "../../lib/webllm/types.ts"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const KEYWORD: JdMatchResult = { + path: "keyword", + coverage: { + covered: [], + missing: [], + score: 0, + weights: { skill: 1, noun: 0.5 }, + }, + terms: [], + nounsDropped: 0, +}; + +const SEMANTIC: JdMatchResult = { + path: "semantic", + verdicts: [], + summary: { met: 0, partial: 0, missing: 0, total: 0 }, +}; + +let container: HTMLDivElement | undefined; +let root: Root | undefined; + +function render( + status: JdMatchStatus, + capability: WebGpuCapability | null, + checked = true, + onChange: (next: boolean) => void = vi.fn(), +): HTMLDivElement { + const el = document.createElement("div"); + document.body.appendChild(el); + container = el; + root = createRoot(el); + act(() => { + root?.render( + , + ); + }); + return el; +} + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; +}); + +describe("SemanticAnalysisOptIn control", () => { + it("is a real checkbox with the label as its accessible name", () => { + const el = render({ kind: "idle" }, null, false); + const box = el.querySelector('input[type="checkbox"]'); + expect(box).toBeTruthy(); + expect((box as HTMLInputElement).checked).toBe(false); + const label = el.querySelector(`label[for="${(box as HTMLInputElement).id}"]`); + expect(label?.textContent).toContain("Analyze with on-device AI"); + }); + + it("reports the next checked state to its owner rather than holding one", () => { + const onChange = vi.fn(); + const el = render({ kind: "idle" }, null, false, onChange); + act(() => (el.querySelector("input") as HTMLInputElement).click()); + expect(onChange).toHaveBeenCalledWith(true); + // Still unchecked: the component is fully controlled, so there is exactly + // one copy of the opt-in boolean and it isn't here. + expect((el.querySelector("input") as HTMLInputElement).checked).toBe(false); + }); +}); + +describe("SemanticAnalysisOptIn status line", () => { + it("renders no line at all while unticked, whatever the status says", () => { + const el = render({ kind: "loading", progress: { progress: 0.5, text: "x" } }, "available", false); + expect(el.querySelector('[role="progressbar"]')).toBeNull(); + expect(el.querySelector('[role="status"]')).toBeNull(); + expect(el.querySelector('[role="alert"]')).toBeNull(); + }); + + it("renders no line for an empty JD", () => { + const el = render({ kind: "idle" }, "available"); + expect(el.querySelector('[role="status"]')).toBeNull(); + }); + + it("says it is checking while the capability probe is unresolved", () => { + const el = render({ kind: "ready", result: KEYWORD }, null); + expect(el.textContent).toContain("Checking whether this browser can run"); + expect(el.querySelector('[role="alert"]')).toBeNull(); + }); + + it("explains an unavailable GPU without sounding like a failure", () => { + for (const capability of ["no-webgpu", "unsupported-os"] as const) { + const el = render({ kind: "ready", result: KEYWORD }, capability); + expect(el.textContent).toContain("This browser can't run on-device analysis"); + expect(el.textContent).toContain("keyword coverage below is unaffected"); + expect(el.querySelector('[role="alert"]')).toBeNull(); + act(() => root?.unmount()); + container?.remove(); + } + }); + + it("renders the shared ModelLoadProgress with the real progress values", () => { + const el = render( + { kind: "loading", progress: { progress: 0.37, text: "params_shard_5.bin" } }, + "available", + ); + const bar = el.querySelector('[role="progressbar"]'); + expect(bar?.getAttribute("aria-valuenow")).toBe("37"); + expect(bar?.getAttribute("aria-valuemin")).toBe("0"); + expect(bar?.getAttribute("aria-valuemax")).toBe("100"); + expect(el.textContent).toContain("params_shard_5.bin"); + expect(el.textContent).toContain("one-time download"); + }); + + it("uses truthful running copy with no invented requirement count", () => { + const el = render({ kind: "running" }, "available"); + expect(el.querySelector('[role="status"]')?.textContent).toContain( + "Reading this JD", + ); + expect(el.textContent).not.toMatch(/\d+\s+of\s+\d+/); + }); + + it("says nothing extra once semantic verdicts are on screen", () => { + const el = render({ kind: "ready", result: SEMANTIC }, "available"); + expect(el.querySelector('[role="status"]')).toBeNull(); + expect(el.querySelector('[role="alert"]')).toBeNull(); + }); + + it("notes a degrade to keyword without calling it an error", () => { + const el = render({ kind: "ready", result: KEYWORD }, "available"); + expect(el.textContent).toContain("didn't return a verdict for this JD"); + expect(el.querySelector('[role="alert"]')).toBeNull(); + }); + + it("never leaks the controller's raw error message", () => { + const el = render( + { + kind: "error", + message: + "Failed to fetch dynamically imported module: /assets/run-llm-match-a1b2c3.js", + }, + "available", + ); + const alert = el.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("On-device analysis couldn't start"); + expect(el.textContent).not.toContain("Failed to fetch"); + expect(el.textContent).not.toContain("/assets/"); + }); +}); diff --git a/src/components/features/SemanticAnalysisOptIn.tsx b/src/components/features/SemanticAnalysisOptIn.tsx new file mode 100644 index 00000000..81a7f100 --- /dev/null +++ b/src/components/features/SemanticAnalysisOptIn.tsx @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * SemanticAnalysisOptIn — the "Analyze with on-device AI" control and the + * lifecycle line that belongs under it (#204). + * + * Display-only and fully controlled: the checked state lives in `PasteJdPanel` + * (which feeds it to `useJdMatch` as `semanticOptIn`), so the boolean has a + * single owner. This component holds no state, starts no work, and imports + * nothing from `webllm/` but a type — `detectWebGpu`, `loadEngine` and + * `runLlmMatch` are all upstream of it. + * + * The control and its progress sit together because that is the house pattern + * for every WebLLM surface in the repo: `ResumeQualityPanel`, `ResumeRewrite` + * and `SectionRewrite` each render `ModelLoadProgress` directly under the + * trigger that started the load, not inside the result panel. Keeping it out + * of `JdMatch` is also what keeps the keyword floor visible: the result card + * below goes on rendering keyword coverage for the whole engine load instead + * of being replaced by a spinner. + * + * ## Why the multi-GB download is opt-in and defaults OFF + * + * Per #172's stance and #204's scope: the instant keyword coverage is the + * return hook, and a panel that starts a hundreds-of-megabytes fetch because a + * user pasted a JD would be spending their bandwidth on a guess. OFF also + * carries a privacy property, not just a bandwidth one — with the box unticked + * `useJdMatch` never calls `detectWebGpu`, so no `webllm_capability_detected` + * event enters the funnel for a user who only ever wanted keyword coverage. + * The gate lives in the hook; this component's job is to make it a user's + * decision rather than a hardwired `false`. + * + * ## What this is NOT + * + * Not a model picker, and not a licence-consent gate. Those are separate, + * already-built mechanisms (`useModelSelection` supplies the selected id; + * `ConsentDialog` covers restricted-licence downloads on the surfaces that + * offer them). This is one boolean: "may this panel use the on-device model at + * all". It deliberately does not persist — a session-scoped `useState` in the + * panel, no `localStorage` — because #204 asks for an opt-in toggle and + * nothing in the issue or the repo asks a JD panel to remember it. + * + * ## No-WebGPU is not an error + * + * `WebGpuUnavailableNotice` is the repo's other answer to "no WebGPU", and it + * is the wrong one here: it renders a warning-toned strip with a how-to-enable + * dialog and fires `webllm_notice_shown`. #204 asks for the opposite — the + * keyword columns keep rendering, with at most one muted line saying why the + * box did nothing. A user whose browser can't run this still has the whole + * panel they came for, so nothing is in a failed state. + * + * ## Known limitation: the progress bar can sit at 0% (#804) + * + * `loadEngine`'s "already pending" fast path returns the shared promise + * without registering the new caller's `onProgress`, so only the FIRST caller + * for a model id ever receives progress. The reachable case here is + * self-inflicted rather than the cross-consumer one #804 describes — that one + * cites `job-search/sector.ts`'s `classifySector`, which has no production + * caller, and `/jobs/` has no other `loadEngine` caller at all. What IS + * reachable: opt in, then edit the JD while the weight download is still in + * flight. The superseded run owns `initProgressCallback`, its writes are + * dropped by the controller's id guard, and the new run joined a load it can't + * hear — so the bar reads 0% until the load resolves, then moves on to + * running → ready normally. + * + * Cosmetic, not functional, and deliberately NOT worked around here: the fix + * is a progress fan-out inside `web-llm.ts`, shared by every WebLLM surface in + * the repo, which is #804's scope and not this component's. The reason it is + * tolerable meanwhile is the keyword floor — the result card below keeps + * showing full coverage throughout, so a stalled bar costs the user a progress + * readout, never the answer they came for. + */ + +import type { ReactNode } from "react"; +import { Checkbox, ModelLoadProgress } from "@design-system"; +import type { JdMatchStatus } from "../../hooks/useJdMatch.ts"; +import type { WebGpuCapability } from "../../lib/webllm/types.ts"; + +interface SemanticAnalysisOptInProps { + /** Controlled opt-in state; owned by `PasteJdPanel`. */ + checked: boolean; + onChange: (next: boolean) => void; + /** The controller's semantic status. */ + status: JdMatchStatus; + /** The controller's WebGPU probe result; `null` until it resolves (and + * forever while `checked` is false, since the probe is gated on opt-in). */ + capability: WebGpuCapability | null; +} + +export function SemanticAnalysisOptIn({ + checked, + onChange, + status, + capability, +}: SemanticAnalysisOptInProps) { + return ( +
    + + +
    + ); +} + +/** Muted one-liner — the tone for "this is information, not a problem". */ +function Note({ children }: { children: ReactNode }) { + return ( +

    + {children} +

    + ); +} + +function StatusLine({ + checked, + status, + capability, +}: { + checked: boolean; + status: JdMatchStatus; + capability: WebGpuCapability | null; +}) { + // Unticked: render nothing at all, so the default panel is unchanged from + // its pre-#204 self down to the DOM. + if (!checked) return null; + // No JD yet (or one that extracted no terms) — there is nothing to analyze, + // so a progress line would be describing work that isn't happening. + if (status.kind === "idle") return null; + + // Capability first: with the probe unresolved OR resolved-unavailable, the + // hook's `status` is `ready` holding the KEYWORD result, which is + // indistinguishable from a semantic run that degraded. Only `capability` + // separates them, which is why the controller exposes it. + if (capability === null) { + return Checking whether this browser can run on-device analysis…; + } + if (capability !== "available") { + return ( + + This browser can't run on-device analysis (it needs WebGPU) — the + keyword coverage below is unaffected. + + ); + } + + if (status.kind === "loading") { + return ( + + ); + } + + if (status.kind === "running") { + // Generic on purpose. #204's example copy ("Judging requirement 4 of 9…") + // has nothing behind it: `judgeEvidence` takes no progress callback and + // `runLlmMatch` reports only engine load + a single `onInferenceStart`, so + // a count here would be invented. Adding a batch-progress API to the LLM + // layer to satisfy one string is not warranted; truthful copy is. + return ( +

    + Reading this JD and checking it against your résumé… +

    + ); + } + + if (status.kind === "error") { + // Not reachable from today's controller — a semantic run only starts when + // a keyword result already exists, and the hook's catch degrades to that + // rather than to `error` (see its state-machine docblock). Rendered anyway + // because the state is public API, and a UI that dropped it would blank + // the panel the day a semantic-only consumer reaches it. So the copy + // promises no keyword fallback: in that consumer there wouldn't be one. + // + // The controller's `message` is deliberately NOT rendered: its realistic + // source is a chunk-loader failure after a deploy, whose text is a hashed + // asset URL rather than anything a user can act on. The retry it offers is + // real — the hook clears an `error` slot on the way out of the semantic + // path, so re-ticking starts a fresh run. + return ( +

    + On-device analysis couldn't start. Untick the box and tick it again to + retry. +

    + ); + } + + // `ready` on the semantic path with a KEYWORD result: the run completed and + // `runLlmMatch` degraded internally (engine load failure, unparseable + // extraction, or a JD it found no requirements in). Note that a CANCELLED + // run never lands here — `useJdMatch` bumps its request id before aborting, + // so the abandoned run's keyword fallback fails the write guard and is never + // shown. That is what keeps this line off the screen on every opt-out, JD + // edit and model change (#803). + if (status.result.path === "keyword") { + return ( + + On-device analysis didn't return a verdict for this JD — showing keyword + coverage instead. + + ); + } + // Semantic verdicts are on screen in the card below; nothing to add. + return null; +} diff --git a/src/components/features/SemanticMatch.test.tsx b/src/components/features/SemanticMatch.test.tsx new file mode 100644 index 00000000..a4c10bac --- /dev/null +++ b/src/components/features/SemanticMatch.test.tsx @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +// @vitest-environment jsdom + +/** + * SemanticMatch (#204) — the on-device verdict view. + * + * jsdom rather than `renderToStaticMarkup` because the two things most worth + * pinning are structural, not textual: the ORDER the three status groups + * appear in, and whether the evidence disclosure is a real, focusable, + * keyboard-operable control. Both are DOM queries. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { SemanticMatch } from "./SemanticMatch.tsx"; +import type { JdMatchResult } from "../../lib/jd-match"; +import type { RequirementVerdict } from "../../lib/jd-match/llm/judge-evidence.ts"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +type SemanticResult = Extract; + +function verdict( + id: string, + text: string, + status: RequirementVerdict["status"], + reason: string, + evidence?: string, +): RequirementVerdict { + const base: RequirementVerdict = { + requirement: { id, kind: "skill", text }, + status, + reason, + }; + return evidence === undefined ? base : { ...base, evidence }; +} + +/** Wrap verdicts in the semantic arm, tallying the summary the way + * `runLlmMatch` does so the header's numbers are the real ones. */ +function semantic(verdicts: readonly RequirementVerdict[]): SemanticResult { + let met = 0; + let partial = 0; + let missing = 0; + for (const v of verdicts) { + if (v.status === "met") met += 1; + else if (v.status === "partial") partial += 1; + else missing += 1; + } + return { + path: "semantic", + verdicts, + summary: { met, partial, missing, total: verdicts.length }, + }; +} + +let container: HTMLDivElement | undefined; +let root: Root | undefined; + +function render(result: SemanticResult): HTMLDivElement { + const el = document.createElement("div"); + document.body.appendChild(el); + container = el; + root = createRoot(el); + act(() => { + root?.render(); + }); + return el; +} + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; +}); + +/** Group headings, in DOM order. */ +function headings(el: HTMLElement): string[] { + return [...el.querySelectorAll("h3")].map((h) => h.textContent ?? ""); +} + +/** Row texts, in DOM order across every group. */ +function rows(el: HTMLElement): string[] { + return [...el.querySelectorAll("li")].map((li) => li.textContent ?? ""); +} + +const MIXED = semantic([ + // Deliberately NOT in met/partial/missing order — the view must regroup. + verdict( + "req-1", + "Own an on-call rotation", + "missing", + "No on-call duty appears anywhere in the résumé.", + ), + verdict( + "req-2", + "Five years of Go", + "partial", + "Two years of Go at Acme, short of five.", + "Backend services in Go, 2023–2025", + ), + verdict( + "req-3", + "Run Kubernetes in production", + "met", + "Ran production clusters at Acme.", + "Operated a 40-node Kubernetes cluster", + ), + verdict( + "req-4", + "Terraform at scale", + "met", + "Managed the estate's Terraform modules.", + ), +]); + +describe("SemanticMatch grouping", () => { + it("groups verdicts Met → Partial → Missing regardless of input order", () => { + const el = render(MIXED); + expect(headings(el)).toEqual(["Met (2)", "Partial (1)", "Missing (1)"]); + // Row order follows the groups, and within a group the model's order. + expect( + rows(el).map((text) => text.replace(/^(Met|Partial|Missing)/, "$1|")), + ).toEqual([ + expect.stringMatching(/^Met\|Run Kubernetes in production/), + expect.stringMatching(/^Met\|Terraform at scale/), + expect.stringMatching(/^Partial\|Five years of Go/), + expect.stringMatching(/^Missing\|Own an on-call rotation/), + ]); + }); + + it("omits a status group entirely when nothing has that status", () => { + const el = render( + semantic([ + verdict("req-1", "Ship React", "met", "Five years of React."), + verdict("req-2", "Ship TypeScript", "met", "TypeScript throughout."), + ]), + ); + expect(headings(el)).toEqual(["Met (2)"]); + expect(el.textContent).not.toContain("Partial ("); + expect(el.textContent).not.toContain("Missing ("); + }); + + it("renders a partial-only result on its own", () => { + const el = render( + semantic([ + verdict("req-1", "Lead a team", "partial", "Mentored two juniors."), + ]), + ); + expect(headings(el)).toEqual(["Partial (1)"]); + }); + + it("renders a missing-only result on its own", () => { + const el = render( + semantic([verdict("req-1", "Hold a PhD", "missing", "No doctorate listed.")]), + ); + expect(headings(el)).toEqual(["Missing (1)"]); + }); +}); + +describe("SemanticMatch row content", () => { + it("renders the requirement text and the full reason for every row", () => { + const text = render(MIXED).textContent ?? ""; + expect(text).toContain("Run Kubernetes in production"); + expect(text).toContain("Ran production clusters at Acme."); + expect(text).toContain("Two years of Go at Acme, short of five."); + expect(text).toContain("No on-call duty appears anywhere in the résumé."); + }); + + it("states each row's status in TEXT, not only in colour", () => { + // A row read out of its heading's context — screen-reader row navigation, + // a long group scrolled past its heading — is still self-describing. + const rowTexts = rows(render(MIXED)); + expect(rowTexts[0].startsWith("Met")).toBe(true); + expect(rowTexts[2].startsWith("Partial")).toBe(true); + expect(rowTexts[3].startsWith("Missing")).toBe(true); + }); + + it("shows the headline tally from the pre-computed summary", () => { + const text = render(MIXED).textContent ?? ""; + expect(text).toContain("2 met · 1 partial · 1 missing"); + expect(text).toContain("Across 4 requirements"); + }); + + it("singularises the requirement count for a one-verdict result", () => { + const el = render( + semantic([verdict("req-1", "Ship React", "met", "Five years of React.")]), + ); + expect(el.textContent).toContain("Across 1 requirement the"); + }); +}); + +describe("SemanticMatch evidence disclosure", () => { + it("renders a collapsed, keyboard-operable disclosure only where evidence exists", () => { + const el = render(MIXED); + const details = [...el.querySelectorAll("details")]; + // Two of the four verdicts carry evidence. + expect(details).toHaveLength(2); + for (const d of details) { + // Collapsed by default — the snippet is opt-in detail, not row noise. + expect(d.open).toBe(false); + // `` is natively focusable and Enter/Space-activatable, which + // is what makes this keyboard-reachable with no raw