Skip to content
Open
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
10 changes: 5 additions & 5 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
* privacy defect — sees a whole file the moment anything imports it, not just
* the one export it used. Routing `computeCoverageFromCorpus` through the barrel
* would drag a network primitive onto that graph for nothing. Measured: the
* value-edge closure of the specifiers below is 27 modules and reaches no
* value-edge closure of the specifiers below is 28 modules and reaches no
* `fetch`/`WebSocket`/`XMLHttpRequest`/`EventSource` at all. That is a statement
* about **this entry's** value-edge closure, and not about the tarball's file
* list, which is larger — see "The tarball ships more files than the graph
Expand Down Expand Up @@ -75,7 +75,7 @@
* cannot arrive here by accident. Read `src/job-search.ts`'s own docblock for
* the full argument; the two facts that belong on this side of the seam are:
*
* - **The two runtime closures are disjoint.** Measured: 27 modules from this
* - **The two runtime closures are disjoint.** Measured: 28 modules from this
* entry, 11 from `./job-search`, zero modules in common. Importing one
* cannot pull the other in, in either direction, which is what makes the
* network-free claim above survive the subpath's existence rather than merely
Expand All @@ -91,8 +91,8 @@
*
* `tsc` emits a `.js` for every file in the program, the ones reached only by
* `import type` included, and `files` ships all of them. So `npm pack` produces
* 62 modules, of which 38 are reachable — 27 from this barrel and 11 from
* `./job-search` — and two of the unreachable 24 read exactly like the thing
* 65 modules, of which 39 are reachable — 28 from this barrel and 11 from
* `./job-search` — and two of the unreachable 26 read exactly like the thing
* this file says is absent:
*
* - `dist/src/lib/analytics.js` — `import.meta.env`, `await import("posthog-js")`
Expand All @@ -111,7 +111,7 @@
* an egress path.
*
* The network-free claim above is therefore about **this entry's** RUNTIME GRAPH
* rather than about the file list, and on that graph it holds exactly: 27
* rather than about the file list, and on that graph it holds exactly: 28
* modules, no network primitive, one bare import (`idb`). Which means grepping
* the tarball for `fetch(` is the wrong audit twice over — it finds the seven
* modules that legitimately fetch on the OTHER entry, plus prose mentions in
Expand Down
8 changes: 4 additions & 4 deletions packages/core/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
"rewriteRelativeImportExtensions": true,

// Load-bearing for the privacy property the barrels' docblocks claim. The
// program is 62 modules: 38 are value-reachable (27 from `src/index.ts`, 11
// program is 65 modules: 39 are value-reachable (28 from `src/index.ts`, 11
// from `src/job-search.ts`, and those two sets are disjoint), and the other
// 24 are reachable from neither entry.
// 26 are reachable from neither entry.
//
// `import type` edges are how those 24 get into the program, but NOT how all
// of them got there directly — 13 of the 24 have an ordinary value edge into
// `import type` edges are how those 26 get into the program, but NOT how all
// of them got there directly — 15 of the 26 have an ordinary value edge into
// them from another module that is itself only type-reachable, so they enter
// BEHIND a type edge rather than through one. `job-search/sector.ts` is the
// clearest case: nothing imports it for a value, but its own
Expand Down
6 changes: 3 additions & 3 deletions scripts/check-core-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ const EXPECTED_EXPORTS = {
* `EXPECTED_EXPORTS` above catches a provider SYMBOL moved onto `.`. It cannot
* catch a value EDGE, which is the cheaper mistake by far: one
* `import "…/fetch-jd.ts";` side-effect line in `src/index.ts` leaves the export
* set byte-identical while taking that closure from 27 modules to 29, putting a
* set byte-identical while taking that closure from 28 modules to 30, putting a
* live `fetch(` on it, and making the two closures overlap. At that point every
* network-free claim in `src/index.ts`, `src/job-search.ts` and
* `tsconfig.build.json` is false — in a public repo — and the downstream
Expand All @@ -222,7 +222,7 @@ const EXPECTED_EXPORTS = {
* the assertion silently covering two of three surfaces.
*/
const ENTRY_CLOSURES = {
".": { modules: 27, networkBearingModules: 0 },
".": { modules: 28, networkBearingModules: 0 },
"./job-search": { modules: 11, networkBearingModules: 7 },
};

Expand All @@ -233,7 +233,7 @@ const ENTRY_CLOSURES = {
* same reason `importSpecifiers` does — and here the difference is not
* theoretical but load-bearing on the very first run. `tsc` preserves docblocks
* into the emit verbatim, and the emitted `.` entry contains the sentence "the
* value-edge closure of the specifiers below is 27 modules and reaches no
* value-edge closure of the specifiers below is 28 modules and reaches no
* `fetch`/`WebSocket`/…" — so the obvious `/\b(fetch|…)\s*\(/` sweep reports
* FOUR network primitives in the one file whose whole claim is that it has
* none. A comment is not a node; the parse simply does not see it.
Expand Down
20 changes: 19 additions & 1 deletion src/components/features/FindJobsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@
* Everything was reachable and nothing was findable. The steps ARE the
* reading order of the work, and each rail entry states its own current
* value (`describeQuerySteps`), so a closed step is still legible.
* 2. **Results**, owning the full width.
* 2. **The narrowing strip** (#809) — `JobResultRefineStrip`, rendered only
* once a search has loaded. The fold above is what made this necessary: it
* is right that a form worth the full page width while being filled in is
* worth none of it afterwards, but it also put every narrowing lever behind
* "Edit search" at the exact moment a user finally has results to react to.
* The strip is not a second query surface — it edits this same `query`
* through this same `setQuery`.
* 3. **Results**, owning the full width.
*
* The whole query folds to a one-line `JobQuerySummary` + Search again on
* submit — the rail included, since a form worth the full page width while
Expand Down Expand Up @@ -88,6 +95,7 @@ import type { HeuristicParsedResume } from "../../lib/heuristics/types.ts";
import { JobSearchResults } from "./JobSearchResults.tsx";
import { JobQueryEditor } from "./JobQueryEditor.tsx";
import { JobQuerySummary } from "./JobQuerySummary.tsx";
import { JobResultRefineStrip } from "./JobResultRefineStrip.tsx";
import { PasteJdPanel } from "./PasteJdPanel.tsx";
import { PendingCompaniesNotice } from "./PendingCompaniesNotice.tsx";
import { useCompanyTargets } from "../../hooks/useCompanyTargets.ts";
Expand Down Expand Up @@ -239,6 +247,16 @@ export function FindJobsPanel({
/>
)}

{/* The narrowing controls, WITH the results (#809). Only over a real
* result set: before the first search there is nothing to narrow, and
* over the loading skeleton or the error state the strip would be a
* control with no subject. Mounted OUTSIDE the fold on purpose — the
* fold is what hid these levers from the three respondents who reported
* the search "returns everything". */}
{phase.kind === "loaded" && (
<JobResultRefineStrip query={query} onChange={setQuery} />
)}

<JobSearchResults phase={phase} onRetry={runSearch} onTailor={onTailor} />
<PasteJdPanel parsed={parsed} onTailor={onTailor} />
</div>
Expand Down
9 changes: 4 additions & 5 deletions src/components/features/JobQueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { ROLE_HINT } from "../../lib/job-search/query-steps.ts";
import {
canonicalSkillLabels,
parseSeniorityLabel,
withExcludeTerm,
withoutExcludeTerm,
} from "../../lib/job-search/query-builder.ts";
import { promoteSkill, promoteTitle } from "../../lib/job-search/search-plan.ts";
import type { RoleFamily } from "../../lib/job-search/role-keywords.ts";
Expand Down Expand Up @@ -131,12 +133,9 @@ export function JobQueryEditor({
onChange((q) => withSkills(q, q.skills.filter((s) => s !== skill)));

const addExcludeTerm = (term: string) =>
onChange((q) => ({ ...q, excludeTerms: [...(q.excludeTerms ?? []), term] }));
onChange((q) => withExcludeTerm(q, term));
const removeExcludeTerm = (term: string) =>
onChange((q) => ({
...q,
excludeTerms: (q.excludeTerms ?? []).filter((t) => t !== term),
}));
onChange((q) => withoutExcludeTerm(q, term));

// Role families (#568): REMOVAL only — see RoleFamilyChips' doc for why
// there's no free-text add. Narrowing to an empty list is safe: readers
Expand Down
164 changes: 164 additions & 0 deletions src/components/features/JobResultRefineStrip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors

// @vitest-environment jsdom

/**
* Render + interaction coverage for `JobResultRefineStrip` (#809).
*
* The three assertions that matter are the three #809 acceptance criteria this
* component is responsible for: the local-only toggle exists and writes
* `locationOnly`; the level control is present for a query that derived NO
* seniority (the fresher case the form's `AddPill` gate hides); and every edit
* goes through the caller's single `onChange` — the strip owns no query state
* of its own, which is what keeps it from becoming a second query surface.
*
* Raw createRoot + act, matching `JobSearchResults.test.tsx`.
*/

import { describe, it, expect, afterEach } from "vitest";
import { createElement } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JobResultRefineStrip } from "./JobResultRefineStrip.tsx";
import type { JobQuery } from "../../lib/job-search/query-builder.ts";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;

let container: HTMLDivElement;
let root: Root;

/** Renders the strip and returns the container plus every query the component
* asked for. `onChange` takes an updater, so applying it here is what the real
* `FindJobsPanel` `setQuery` does. */
function render(query: JobQuery) {
const seen: JobQuery[] = [];
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root.render(
createElement(JobResultRefineStrip, {
query,
onChange: (next: (q: JobQuery) => JobQuery) => seen.push(next(query)),
}),
);
});
return { el: container, seen };
}

afterEach(() => {
act(() => root?.unmount());
container?.remove();
});

/** The checkbox whose label mentions locality — found by label text, the way a
* user finds it, rather than by DOM position. */
function localOnlyBox(el: HTMLElement): HTMLInputElement {
const label = [...el.querySelectorAll("label")].find((l) =>
/only jobs near/i.test(l.textContent ?? ""),
);
const input = label?.querySelector("input[type=checkbox]");
if (!input) throw new Error("local-only checkbox not found");
return input as HTMLInputElement;
}

function levelButton(el: HTMLElement, label: string): HTMLButtonElement {
const button = [...el.querySelectorAll("button[role=radio]")].find(
(b) => b.textContent?.trim() === label,
);
if (!button) throw new Error(`level "${label}" not found`);
return button as HTMLButtonElement;
}

const baseQuery: JobQuery = { titles: ["Frontend Engineer"], skills: ["React"] };

describe("JobResultRefineStrip (issue 809)", () => {
it("names the user's own location in the toggle, so it can be checked", () => {
const { el } = render({ ...baseQuery, location: "Austin, TX" });
expect(el.textContent).toContain("Only jobs near Austin, TX");
});

it("turning the toggle on sets locationOnly through the caller's onChange", () => {
const { el, seen } = render({ ...baseQuery, location: "Austin, TX" });
act(() => {
localOnlyBox(el).click();
});
expect(seen).toHaveLength(1);
expect(seen[0].locationOnly).toBe(true);
// The whole rest of the query is carried through untouched — the strip
// replaces the query wholesale, same contract as `JobQueryEditor`.
expect(seen[0].titles).toEqual(["Frontend Engineer"]);
expect(seen[0].location).toBe("Austin, TX");
});

it("turning it back off clears the flag rather than storing false", () => {
const { el, seen } = render({
...baseQuery,
location: "Austin, TX",
locationOnly: true,
});
act(() => {
localOnlyBox(el).click();
});
expect(seen[0].locationOnly).toBeUndefined();
});

it("disables the toggle until a location is set, and says why", () => {
const { el } = render(baseQuery);
expect(localOnlyBox(el).disabled).toBe(true);
expect(el.textContent).toContain("Add a location above to turn this on.");
});

it("offers the level control to a query that derived no seniority (the fresher case)", () => {
const { el, seen } = render(baseQuery);
expect(baseQuery.seniority).toBeUndefined();
act(() => {
levelButton(el, "Junior").click();
});
expect(seen[0].seniority).toBe("Junior");
});

it("offers the entry-level rungs, not just the ones a title can derive", () => {
const { el } = render(baseQuery);
for (const level of ["Intern", "Junior", "Mid"]) {
expect(levelButton(el, level)).toBeTruthy();
}
});

it("adds an exclude term through the same onChange", () => {
const { el, seen } = render({ ...baseQuery, excludeTerms: ["Sales"] });
const input = el.querySelector<HTMLInputElement>(
'input[aria-label="Add exclude term"]',
);
if (!input) throw new Error("exclude input not found");
// React tracks the DOM node's value, so assigning `.value` directly is
// swallowed as a no-op change — go through the prototype setter, same as
// `JobQueryEditor.test.tsx`'s `setNativeValue`.
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)!.set!;
act(() => {
setter.call(input, "Manager");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const add = [...el.querySelectorAll("button")].find(
(b) => b.textContent === "Add",
);
if (!add) throw new Error("Add button not found");
act(() => add.click());
expect(seen.at(-1)?.excludeTerms).toEqual(["Sales", "Manager"]);
});

it("removes an exclude term through the same onChange", () => {
const { el, seen } = render({ ...baseQuery, excludeTerms: ["Sales"] });
const remove = [...el.querySelectorAll("button")].find((b) =>
/remove/i.test(b.getAttribute("aria-label") ?? ""),
);
if (!remove) throw new Error("remove control not found");
act(() => remove.click());
expect(seen.at(-1)?.excludeTerms).toEqual([]);
});
});
Loading
Loading