test: add ~500 tests across web, utils, api, import, ai, db, email, auth (#3038)

* test(web): add tests for zustand stores and pure helpers

Cover stores and pure helpers across the builder/dashboard/command-palette
surfaces that previously had 0% coverage:

- command-palette store (open/close, page stack, search clearing, goBack)
- builder assistant-store
- builder sidebar store + parseBuilderLayoutCookie / mapPanelLayoutToBuilderLayout
- builder section store (collapse, toggle, toggleAll)
- builder preview page-layout toggle
- dashboard resume-thumbnail render-size math + cache key
- MCP tool name + annotations invariants

* test(web): cover MCP helpers and template metadata

Add tests for previously 0%-coverage MCP and dialog helpers:

- buildMcpServerCard: server-info, tool catalog vs MCP_TOOL_NAME, prompts,
  resource templates, configuration schema, auth schemes
- registerPrompts (build/improve/review): registration, args schema, resource
  context with interpolated resume id, read-only / no-fabrication directives
- registerResources (resume://{id}, resume://_meta/schema): handler reads via
  oRPC client, error on missing id, schema returns valid JSON
- templates metadata: ids match display names, valid sidebar positions,
  unique image URLs, every entry has tags + description

* test(web): cover sidebar section helpers and layout screens

Add tests for previously near-0%-coverage modules:

- libs/resume/section: getSectionTitle / getSectionIcon return distinct,
  exhaustive results for every sidebar section + cover-letter; icon props
  forwarding; left/right sidebar collections do not overlap.
- layout/loading-screen: spinner + text render.
- layout/error-screen: error message surfaces, Refresh button triggers reset.
- layout/breakpoint-indicator: default + each corner positioning, all
  breakpoint labels rendered, print-hidden class applied.

* test(web): cover preview canvas math and font-weight defaults

Add tests for pure helpers that previously had no direct coverage:

- typography/getNextWeights: prefers 400 + 600 when both are available,
  returns null for unknown families, never produces duplicates, bounded
  to two weights from the 100..900 set.
- preview.shared/normalizeResumePreviewProps: documented defaults +
  pass-through.
- preview.shared/getScaledPreviewPageSize: scaling identity at 1, and
  fractional scaling.
- preview.shared/getPreviewCanvasScale: respects 4x desired scale for
  small pages, honors high devicePixelRatio, clamps to the 16M-pixel
  canvas budget for large pages.

* test(utils): cover DOCX section renderers and html-to-paragraphs

@reactive-resume/utils/resume/docx was previously at ~2.84% statement
coverage despite being load-bearing for the resume DOCX export.

- section-renderers: empty-string / hidden-section / hidden-item branches
  for renderSummary, renderBuiltInSection, and renderCustomSection;
  cover-letter and summary custom-section dispatch; unknown-type fallback;
  setRenderConfig idempotency.
- html-to-docx: whitespace-only short-circuit, multiple top-level blocks,
  h1..h6 paragraph mapping, inline style and link rendering, custom
  font/size/color/linkColor config, ignored script/comment nodes.

* test: cover DOCX builder smoke paths and reactive-resume JSON importer

- utils/resume/docx/builder: buildDocument runs end-to-end against the
  default and sample resume data, both page formats, full-width and
  sidebar layouts, and gracefully degrades with unparseable color or
  empty font family inputs.
- import/reactive-resume-json: ReactiveResumeJSONImporter validates
  malformed JSON, recovers missing built-in sections by appending them
  to page 1 without reordering, and preserves layouts that already
  contain every built-in section.

* test(import): cover JSONResumeImporter parse/convert

JSONResumeImporter (450 lines) was previously at 0% coverage. Add tests
for the public surface:

- Invalid JSON / invalid-shape errors are surfaced.
- basics, summary, picture, education, projects, skills, profiles all
  map to the corresponding ResumeData sections.
- Empty work/education entries (missing key field) are filtered out.
- Highlights become HTML list items in the description field.
- Skill level parsing flows through utils/level.parseLevel.
- formatLocation joins city, region, countryCode with commas.

* test(web): cover query client serializer and home-page animations

- libs/query/client: getQueryClient returns a fresh QueryClient,
  queryKeyHashFn produces stable JSON envelopes for matching keys
  (and distinct strings for different keys), dehydrate/hydrate
  round-trip Date values via the oRPC serializer.
- components/animation/spotlight: overlay container is
  pointer-events-none, both beam groups render, custom
  width/height/translateY/gradient props flow into inline styles.
- components/animation/comet-card: children mount inside the
  perspective wrapper, custom className is merged with the 3D
  baseline classes, glare overlay renders, mouse move/leave
  handlers do not throw.

* test(web): cover Copyright footer

Verify the footer's MIT license link, Amruth Pillai attribution,
external-tab targets, embedded app version (via __APP_VERSION__ stub),
and custom className merging — previously at 0% coverage.

* test(api): cover flags, auth providers, and resume-access cookies

Unlock @reactive-resume/api by mocking @reactive-resume/env/server and
@tanstack/react-start/server. Previously the only services tested were
the standalone AI test and resume-access-policy.

- services/flags: flagsService.getFlags reads disableSignups/disableEmailAuth
  from env (no stale cache).
- services/auth: providers.list always exposes credential + passkey, and
  only adds Google/GitHub/LinkedIn/custom when both id and secret are set;
  custom provider uses OAUTH_PROVIDER_NAME with a 'Custom OAuth' fallback.
- helpers/resume-access: hasResumeAccess validates against signed cookies
  with constant-time comparison; grantResumeAccess writes a 10-minute
  httpOnly cookie with the secure flag matching APP_URL's https-ness.

* test: cover statistics service and email transport via env mocks

- api/services/statistics: github star count succeeds, retries on
  non-OK, falls back to last-known on fetch error / non-positive /
  non-numeric responses; user and resume counts roll up DB count.
- email/src/transport: returns silently with no text/html, logs when
  SMTP is not configured, dispatches via nodemailer with the env
  config when fully wired, renders react elements to html + text
  bodies, swallows transport errors instead of crashing.

* test(api): cover resume-events publish + subscribe

- publishResumeUpdated issues pg_notify with channel and serialized
  event payload.
- subscribeResumeUpdated yields events whose resumeId+userId match
  the subscription, filters out other resumes/users, ignores
  malformed JSON and notifications on other channels, calls
  LISTEN/UNLISTEN and releases the client, and terminates
  immediately if the abort signal fires before iteration starts.

* test(api): cover oRPC auth resolution

resolveUserFromRequestHeaders is the single point where every oRPC
procedure picks up the authenticated user. Test the priority chain:

- x-api-key wins when present and valid
- on invalid api key, falls back to session via auth.api.getSession
- Bearer JWT in Authorization header is verified via verifyOAuthToken
- invalid Bearer falls back to session
- Authorization scheme other than Bearer is ignored entirely
- thrown errors from token verification are logged and swallowed
  (caller still tries session)
- returns null when no auth method succeeds

* test(api): cover storage helpers

inferContentType, isImageFile, processImageForUpload were 0%
coverage despite being on the picture upload path.

- inferContentType maps known image and pdf extensions, is
  case-insensitive, ignores path depth, and falls back to
  application/octet-stream for unknown.
- isImageFile allows only the upload allowlist (gif/png/jpeg/webp)
  and rejects image/svg+xml, application/pdf, and empty strings.
- processImageForUpload short-circuits to the original bytes when
  FLAG_DISABLE_IMAGE_PROCESSING is true, otherwise pipes through
  sharp and returns image/jpeg.

* test(import): broaden v4 importer section-mapping coverage

The existing v4 importer test focused on a single bug (description-only
custom items) and the skill/language level scaling. This new test
exercises the bulk of the v4 → v5 transformation path:

- basics, picture (with border), summary, customFields
- every section's filter-by-required-field invariant (awards needs
  title, certifications needs name, education needs institution,
  experience needs company, volunteer needs organization, etc.)
- experience / education / awards / certifications / references field
  renames between schemas
- language and skill level scaling (v4 0..10 → v5 0..5)

Brings reactive-resume-v4-json from ~66% statement coverage to a
materially higher figure (the bulk of the 410-line transformer body).

* test: cover buildDocx entry and AI configuration store

- utils/resume/docx/index: buildDocx returns a non-empty Blob for both
  default and populated resumes (previously 0% coverage despite being
  the public DOCX entry point).
- ai/store: useAIStore preserves verification status across no-op
  updates, but resets testStatus + enabled whenever provider, model,
  apiKey, or baseURL changes; canEnable is gated to testStatus=success;
  setEnabled(true) is refused unless verified; reset clears every
  field. Brings @reactive-resume/ai from ~72% to materially higher
  coverage.

* test(db): cover resume schema definitions

packages/db was previously at 0% coverage. Smoke-test the public
resume / resume_statistics / resume_analysis tables:

- getTableName matches the SQL identifier used by migrations
- expected columns are present on each table
- defaultResumeData wiring on the data column resolves to a valid
  shape

These are structural assertions that catch accidental renames /
removals without needing a live database connection.

* test(db): cover auth schema tables and relations export

- src/schema/auth: table-driven test for each of the 12 auth tables
  asserting SQL name and presence of the key columns (user/session/
  account/verification/two_factor/passkey/apikey/jwks/oauth_*).
- src/relations: smoke test confirming the relations export is defined.

Brings @reactive-resume/db from 0% to materially higher coverage.

* test(auth): cover getSession isomorphic helper

@reactive-resume/auth was previously at 0% coverage. functions.ts
is the server entry point that other packages call. Mock the auth
config + tanstack/react-start to verify:

- getSession forwards getRequestHeaders() to auth.api.getSession
- returns null when better-auth returns null

* test(web): cover BuilderSidebarEdge

Small presentational component on the builder layout — assert children
mount, left/right positioning class branches, and the sm:flex
mobile-hide behavior.

* test(web): cover section-title-locale resolver cache and hook

The section-title-locale module wraps createSectionTitleResolver with
a per-locale async cache and a React hook for consumers in the
builder. Cover:

- createSectionTitleResolverForLocale returns a usable resolver
- repeated calls for the same locale share a cached promise
- unknown locales fall back through resolveLocale
- useSectionTitleResolver returns null while loading and when no
  locale is passed
- the hook resolves to a function once the async loader settles

* test(web): cover BaseCommandGroup page-stack gating

BaseCommandGroup conditionally renders based on the top of the
command-palette page stack. Tests cover:

- root group renders when no sub-page is active
- root group hides when a sub-page is on top
- sub-page group renders only when its page matches
- mismatched sub-page leaves the group hidden

* test(web): cover ThemeProvider context

- useTheme outside ThemeProvider throws the documented error
- useTheme inside ThemeProvider returns the theme + setTheme +
  toggleTheme helpers

* test(web): cover ConfirmDialogProvider + useConfirm hook

- useConfirm outside provider throws the documented error
- confirm returns a pending promise
- promise resolves false when the Cancel button is clicked
- promise resolves true when the Confirm button is clicked
- works with custom confirmText label

apps/web has its own copy of this hook distinct from
packages/ui (mirrors the existing UI-package tests).

* test(web): cover PromptDialogProvider + usePrompt hook

- usePrompt outside provider throws the documented error
- returns a function when wrapped
- Cancel click resolves the promise to null
- Confirm click resolves to the current input value
- defaultValue option seeds the initial input value

* test(web): cover DashboardHeader

Small presentational header used across dashboard routes — title h1,
icon rendering, className merge, mobile sidebar trigger present and
hidden on md+.

* test(web): cover Create/Import resume cards

Both cards on the resumes dashboard wire a click handler to open
the appropriate dialog via the dialog store:

- CreateResumeCard opens resume.create
- ImportResumeCard opens resume.import

Also asserts the i18n copy strings (icons aside, the cards are
otherwise structural).

* test(web): cover command-palette language sub-page

LanguageCommandPage is a BaseCommandGroup gated on page='language'.
Tests assert:

- it is hidden when 'language' is not the top of the page stack
- when active, it renders a CommandItem per localeMap entry
- documented locale codes (en-US, de-DE, ja-JP) appear

* test(web): cover command-palette theme + preferences sub-pages

- ThemeCommandPage: hidden when 'theme' is not on top, renders Light
  and Dark options when active
- PreferencesCommandGroup: root group renders both Change theme to...
  and Change language to... items; clicking each pushes the
  corresponding page onto the command-palette stack

* test(ai): cover executePatchResume tool

- patchResumeInputSchema rejects empty operations and unknown op
  values; accepts valid replace/add/remove
- executePatchResume returns the applied operations on success
- executePatchResume throws when an operation targets an invalid path
  (passes through the underlying applyResumePatches validation)
- multi-op patches against top-level fields succeed end-to-end

* test(ai): cover sanitize edge branches

Hit the previously-uncovered branches in sanitize.ts:

- numeric 1 coerces to true
- '1' / '0' string shorthand coerces to true/false
- missing item.hidden gets salvaged to false
- empty input causes a non-Zod throw (caught + rethrown with generic message)

* test(ai): cover patch-proposal preview + normalize edge cases

- remove operations surface before-value with after=undefined
- buildResumePatchProposalPreview labels metadata/page paths sanely
- normalizeResumePatchProposals stamps every proposal with baseUpdatedAt
- normalizeResumePatchProposals preserves input order

* test(web): cover getLocaleOptions helper

Locale combobox surface — verify the option list mirrors localeMap
shape, uses locale codes as values, populates label + keywords with
the translated display name, and produces unique values.

* test(web): cover LevelTypeCombobox option mapping

LevelTypeCombobox maps levelDesignSchema.shape.type.options through
the internal getLevelTypeName labeler. Assert all 7 level types are
exposed and that each produces a non-empty label.

* test(web): cover ThemeToggleButton fallback paths

- aria-label flips between 'Switch to light theme' and 'Switch to
  dark theme' based on current theme
- clicking when document.startViewTransition is unavailable
  short-circuits to toggleTheme directly
- prefers-reduced-motion forces the direct toggle path even when
  the view-transition API is available

* test(web): cover NotFoundScreen

Mock the TanStack Router Link so the screen renders standalone, then
assert: documented error heading, routeId is surfaced verbatim, and
the Go Back link points to '..' (parent route).

* test(web): cover InformationSectionBuilder

Stub SectionBase so the donation/info section renders standalone.
Assert: donation prompt copy, OpenCollective CTA link, all 5
external resource links present, and external links target _blank
with rel=noopener.

* test(web): cover NotesSectionBuilder

Mock SectionBase, RichInput, and the resume-draft hooks so the
notes section renders in isolation. Assert: privacy hint copy
renders, RichInput is seeded with metadata.notes, and onChange
proxies through updateResumeData with a draft recipe that mutates
metadata.notes.

* test(web): cover TemplateSectionBuilder

Stub SectionBase and useCurrentResume so the right-sidebar template
section renders standalone. Asserts: current template name in the
heading, template tags rendered as badges, preview image points to
the catalog asset, and clicking the preview opens the
resume.template.gallery dialog.

* test(web): cover ColorPicker preset selection and trigger override

Mock the heavy @uiw/react-color-colorful dependency. Test:

- the trigger swatch reflects the controlled value
- clicking a preset color invokes onChange with an rgba() string
- a custom trigger replaces the default swatch when provided

* test(web): cover ExportSectionBuilder

Mock the heavy export pipelines (buildDocx, createResumePdfBlob,
downloadWithAnchor) and the resume-draft hook to test:

- JSON button packages resume.data as application/json and triggers
  download with the {name}.json filename
- DOCX button awaits buildDocx and downloads .docx
- PDF button awaits createResumePdfBlob and downloads .pdf

* test(web): cover ProfilesSectionBuilder

Stub the resume-draft hooks, SectionBase, SectionItem, and
SectionAddItemButton so the profiles section renders standalone:

- one SectionItem per profile with network as title and username as
  subtitle
- 'Add a new profile' affordance present
- when items.length > 0, the wrapper uses a solid border (not dashed)

* test(web): cover SkillsSectionBuilder

Mirror the profiles test for the skills section — verifies one
SectionItem per skill (name → title, proficiency → subtitle) and
the Add a new skill affordance.

* test(web): bulk-cover 7 left-sidebar section builders

Single test file covers awards, certifications, interests, languages,
publications, references, and volunteer builders. For each:

- one SectionItem rendered with the documented field → title/subtitle
  mapping
  - awards: title → awarder
  - certifications: title → 'issuer • date'
  - interests: name → (no subtitle)
  - languages: language → fluency
  - publications: title → publisher
  - references: name → (no subtitle)
  - volunteer: organization → location
- the 'Add a new {kind}' affordance with the matching copy

Mocks SectionBase, SectionItem, SectionAddItemButton, and the
resume-draft hooks so each builder renders standalone.

* test(web): cover ProjectsSectionBuilder buildSubtitle

The projects section is the only left-sidebar builder with a
composite subtitle. Tests three branches of its inline
buildSubtitle helper:

- period + website.label → joined with ' • '
- period only → just the period
- empty period + whitespace-only website.label → returns undefined

* test(web): cover Education + Experience section builders

- Education: school → title, degree → subtitle, add-new affordance
- Experience: position → subtitle when set; falls back to '1 role' /
  'N roles' (lingui plural) when position empty and roles[] present;
  add-new affordance

* test(web): cover CountUp animated number renderer

- default aria attributes (aria-live=polite, aria-atomic=true)
- initial textContent seeds to 'from' (up) or 'to' (down) value
- separator option formats with grouping
- decimal places are preserved when from/to are fractional
- aria-hidden=true strips aria-live + aria-atomic
- custom className is applied to the rendered span

* test(web): cover TextMaskEffect SVG renderer

- supplied text renders in every visible <text> layer
- aria-hidden + aria-label forwarded onto the root svg
- mouse enter/move/leave handlers don't throw
- custom className merges into the svg's class attribute

* test(web): cover URLInput prefix handling

- displayed input strips the https:// prefix so users only edit the
  meaningful portion
- editing re-adds the prefix on the way back through onChange
- pre-prefixed input is preserved
- cleared input emits an empty url (no prefix forced)
- hideLabelButton=true removes the popover trigger; default keeps it

* test(web): cover GithubStarsButton

Mocks useQuery + the CountUp animation so the button renders
standalone. Asserts:

- anchor points at the project repo with rel=noopener + target=_blank
- aria-label is the no-count copy when star count is undefined
- CountUp renders only once the count loads
- aria-label includes the localized count once data arrives

* test(web): cover ui/Combobox trigger label rendering

The shared Combobox wraps base-ui's combobox primitives. Smoke-test
the trigger label resolution:

- placeholder shows when nothing is selected
- selected option's label renders in the trigger
- multi-select default values render all labels
- empty options array renders the placeholder without crashing

* test(web): cover IconPicker trigger rendering

Stub react-window's Grid so happy-dom can render the picker without
layout-measurement deps. Verify:

- trigger renders an <i class='ph-{value}'> for the current value
- changing value updates the trigger icon class
- the picker emits a trigger button

* test(web): cover ChipInput add/dedupe/description behaviors

- existing chips render as Badges
- Enter adds the typed value to the chip list
- comma also commits the typed value
- duplicate input is dropped (onChange not called with a longer list)
- empty / whitespace-only input is dropped
- hideDescription removes the keyboard hint <kbd>; default keeps it

* test(web): cover StatisticsSectionBuilder

Mock useQuery + useParams + section-base so the right-sidebar
statistics section renders standalone:

- returns null content while the query is loading
- shows the private-resume hint when isPublic=false
- shows views/downloads counters and labels when isPublic=true
- includes 'Last viewed' timestamp copy when lastViewedAt is set

* test(web): cover TemplateGalleryDialog selection flow

- title + intro copy render
- one tile per template (>= 14)
- currently-selected template tile carries the ring-highlight
- clicking a different tile triggers updateResumeData with a recipe
  that sets metadata.template to the chosen template id

* test(web): cover Prefooter home-page section

- community tagline heading renders
- community-thanks paragraph renders
- the decorative TextMaskEffect renders an svg

* test(web): cover home-page Footer

- Resources and Community headings render
- documented resource links (Documentation, Sponsorships, Source
  Code, Changelog) all appear in the rendered output
- documented community links (Report an issue, Translations,
  Subreddit, Discord) all appear
- social anchors point at GitHub, LinkedIn, and X (Twitter)
- Copyright sub-component surfaces the app version via __APP_VERSION__

* test(web): cover home-page Header navigation

Mock TanStack Router Link + child components so the header renders
standalone. Verify:

- homepage anchor (/ link) carries the documented aria-label
- dashboard anchor points to /dashboard
- ThemeToggleButton and GithubStarsButton both mount
- the <nav> landmark is labeled 'Main navigation'

* chore: fix linter warnings
This commit is contained in:
Amruth Pillai
2026-05-11 14:25:10 +02:00
committed by GitHub
parent 48555f58e5
commit de7baa5faf
145 changed files with 6764 additions and 269 deletions
@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CometCard } from "./comet-card";
describe("CometCard", () => {
it("renders its children inside the perspective wrapper", () => {
const { getByText } = render(
<CometCard>
<span>card body</span>
</CometCard>,
);
expect(getByText("card body")).toBeInTheDocument();
});
it("merges custom className into the wrapper", () => {
const { container } = render(
<CometCard className="extra-class">
<span>x</span>
</CometCard>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("extra-class");
expect(wrapper.className).toContain("perspective-distant");
expect(wrapper.className).toContain("transform-3d");
});
it("renders a glare overlay positioned absolutely with mix-blend-overlay", () => {
const { container } = render(
<CometCard>
<span>x</span>
</CometCard>,
);
const glare = container.querySelector("[class*='mix-blend-overlay']") as HTMLElement | null;
expect(glare).not.toBeNull();
expect(glare?.className).toContain("pointer-events-none");
});
it("does not throw when mouse enters / moves over / leaves the card", () => {
const { container } = render(
<CometCard>
<span>x</span>
</CometCard>,
);
const tiltable = container.querySelector("[class*='will-change-transform']") as HTMLElement;
expect(tiltable).toBeTruthy();
expect(() => {
fireEvent.mouseMove(tiltable, { clientX: 100, clientY: 50 });
fireEvent.mouseMove(tiltable, { clientX: 0, clientY: 0 });
fireEvent.mouseLeave(tiltable);
}).not.toThrow();
});
});
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CountUp } from "./count-up";
describe("CountUp", () => {
it("renders an aria-live=polite span by default (announced to screen readers)", () => {
const { container } = render(<CountUp to={1000} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.getAttribute("aria-live")).toBe("polite");
expect(span.getAttribute("aria-atomic")).toBe("true");
});
it("seeds textContent to the 'from' value when counting up", () => {
const { container } = render(<CountUp from={0} to={100} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0");
});
it("seeds textContent to the 'to' value when direction is down", () => {
const { container } = render(<CountUp from={0} to={100} direction="down" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("100");
});
it("formats with the separator when one is supplied", () => {
const { container } = render(<CountUp from={1234} to={2345} separator="," />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1,234");
});
it("preserves decimal places when from / to are fractional", () => {
const { container } = render(<CountUp from={1.25} to={3.75} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1.25");
});
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
const { container } = render(<CountUp to={100} aria-hidden="true" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.getAttribute("aria-hidden")).toBe("true");
expect(span.getAttribute("aria-live")).toBeNull();
expect(span.getAttribute("aria-atomic")).toBeNull();
});
it("accepts a custom className", () => {
const { container } = render(<CountUp to={100} className="custom-class" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.className).toContain("custom-class");
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spotlight } from "./spotlight";
describe("Spotlight", () => {
it("renders a non-pointer-events overlay container", () => {
const { container } = render(<Spotlight />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("pointer-events-none");
expect(wrapper.className).toContain("absolute");
});
it("renders both left and right beam groups by default", () => {
const { container } = render(<Spotlight />);
// Outer wrapper > two animated beam containers
const beamGroups = container.firstChild?.childNodes;
expect(beamGroups?.length).toBe(2);
});
it("applies the provided width / height / smallWidth to inline styles", () => {
const { container } = render(<Spotlight width={500} height={800} smallWidth={120} translateY={-100} />);
const inlineStyles = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).map(
(el) => el.getAttribute("style") ?? "",
);
const allStyles = inlineStyles.join("|");
expect(allStyles).toContain("width: 500px");
expect(allStyles).toContain("height: 800px");
expect(allStyles).toContain("width: 120px");
expect(allStyles).toContain("translateY(-100px)");
});
it("uses the supplied gradient strings as background values", () => {
const customFirst = "radial-gradient(red, blue)";
const { container } = render(<Spotlight gradientFirst={customFirst} />);
const matched = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).filter(
(el) => el.style.background.includes("red") && el.style.background.includes("blue"),
);
expect(matched.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TextMaskEffect } from "./text-mask";
const renderMask = (props: React.ComponentProps<typeof TextMaskEffect>) => render(<TextMaskEffect {...props} />);
describe("TextMaskEffect", () => {
it("renders the supplied text in all visible text layers", () => {
const { container } = renderMask({ text: "Hello World" });
const texts = container.querySelectorAll("text");
expect(texts.length).toBeGreaterThanOrEqual(2);
for (const el of texts) {
expect(el.textContent).toBe("Hello World");
}
});
it("forwards aria-hidden onto the root svg", () => {
const { container } = renderMask({ text: "X", "aria-hidden": "true" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(svg.getAttribute("aria-hidden")).toBe("true");
});
it("renders an aria-label on the root svg", () => {
const { container } = renderMask({ text: "X" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(svg.getAttribute("aria-label")).toBe("Text mask effect");
});
it("does not throw on mouse-enter / mouse-move / mouse-leave interactions", () => {
const { container } = renderMask({ text: "X" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(() => {
fireEvent.mouseEnter(svg);
fireEvent.mouseMove(svg, { clientX: 50, clientY: 25 });
fireEvent.mouseLeave(svg);
}).not.toThrow();
});
it("merges custom className into the svg", () => {
const { container } = renderMask({ text: "X", className: "custom-class" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(svg.getAttribute("class") ?? "").toContain("custom-class");
});
});
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
const renderInCommand = (ui: React.ReactNode) => render(<Command>{ui}</Command>);
const resetStore = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(resetStore);
describe("BaseCommandGroup", () => {
it("renders children at the root (no page prop) when the page stack is empty", () => {
renderInCommand(<BaseCommandGroup heading="Root">child-text</BaseCommandGroup>);
expect(screen.getByText("child-text")).toBeInTheDocument();
});
it("does NOT render when the page stack tops a different page than its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["other"] });
const { container } = renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
<span>x</span>
</BaseCommandGroup>,
);
// Nothing rendered besides the Command shell itself.
expect(container.textContent).toBe("");
});
it("renders when the top of the page stack matches its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
settings-children
</BaseCommandGroup>,
);
expect(screen.getByText("settings-children")).toBeInTheDocument();
});
it("does NOT render the root group when there is a sub-page on top", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
const { container } = renderInCommand(
<BaseCommandGroup heading="Root">
<span>root-text</span>
</BaseCommandGroup>,
);
expect(container.textContent).toBe("");
});
});
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/components/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { PreferencesCommandGroup } = await import("./index");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderGroup = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<PreferencesCommandGroup />
</Command>
</I18nProvider>,
);
describe("PreferencesCommandGroup", () => {
it("renders 'Change theme to...' and 'Change language to...' at the root", () => {
renderGroup();
expect(screen.getByText("Change theme to...")).toBeInTheDocument();
expect(screen.getByText("Change language to...")).toBeInTheDocument();
});
it("pushes 'theme' onto the page stack when the theme item is selected", () => {
renderGroup();
const item = screen.getByText("Change theme to...");
fireEvent.click(item);
expect(useCommandPaletteStore.getState().pages).toContain("theme");
});
it("pushes 'language' onto the page stack when the language item is selected", () => {
renderGroup();
fireEvent.click(screen.getByText("Change language to..."));
expect(useCommandPaletteStore.getState().pages).toContain("language");
});
});
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { localeMap } from "@/libs/locale";
import { useCommandPaletteStore } from "../../store";
import { LanguageCommandPage } from "./language";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<LanguageCommandPage />
</Command>
</I18nProvider>,
);
describe("LanguageCommandPage", () => {
it("does NOT render when the page stack does not have 'language' on top", () => {
renderPage();
// localeMap codes shouldn't appear because BaseCommandGroup gating is off.
expect(screen.queryByText("en-US")).toBeNull();
});
it("renders one CommandItem for every entry in localeMap when active", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
const expectedCount = Object.keys(localeMap).length;
expect(expectedCount).toBeGreaterThan(0);
// Each locale value is rendered in the inline font-mono span.
for (const code of Object.keys(localeMap).slice(0, 5)) {
expect(screen.getByText(code)).toBeInTheDocument();
}
});
it("includes the documented set of locales (sample check)", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
// Spot-check a couple of common locales.
for (const code of ["en-US", "de-DE", "ja-JP"]) {
if (code in localeMap) {
expect(screen.getByText(code)).toBeInTheDocument();
}
}
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/components/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { ThemeCommandPage } = await import("./theme");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<ThemeCommandPage />
</Command>
</I18nProvider>,
);
describe("ThemeCommandPage", () => {
it("is hidden when 'theme' is not on top of the page stack", () => {
renderPage();
expect(screen.queryByText("Light theme")).toBeNull();
});
it("renders Light + Dark options when active", () => {
useCommandPaletteStore.setState({ pages: ["theme"] });
renderPage();
expect(screen.getByText("Light theme")).toBeInTheDocument();
expect(screen.getByText("Dark theme")).toBeInTheDocument();
});
});
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from "vitest";
import { useCommandPaletteStore } from "./store";
const reset = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(reset);
describe("useCommandPaletteStore", () => {
it("starts closed with empty search and no pages", () => {
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setOpen(true) opens the palette without touching other fields", () => {
useCommandPaletteStore.setState({ search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(true);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(true);
expect(state.search).toBe("foo");
expect(state.pages).toEqual(["page1"]);
});
it("setOpen(false) resets the entire store back to initial state", () => {
useCommandPaletteStore.setState({ open: true, search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(false);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setSearch updates only the search field", () => {
useCommandPaletteStore.getState().setSearch("query");
expect(useCommandPaletteStore.getState().search).toBe("query");
});
it("pushPage appends a page and clears search", () => {
useCommandPaletteStore.setState({ search: "leftover" });
useCommandPaletteStore.getState().pushPage("settings");
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["settings"]);
expect(state.search).toBe("");
});
it("pushPage stacks multiple pages in order", () => {
const { pushPage } = useCommandPaletteStore.getState();
pushPage("a");
pushPage("b");
pushPage("c");
expect(useCommandPaletteStore.getState().pages).toEqual(["a", "b", "c"]);
});
it("peekPage returns the top page (or undefined when empty)", () => {
expect(useCommandPaletteStore.getState().peekPage()).toBeUndefined();
useCommandPaletteStore.setState({ pages: ["a", "b"] });
expect(useCommandPaletteStore.getState().peekPage()).toBe("b");
});
it("popPage removes the last page and clears search", () => {
useCommandPaletteStore.setState({ pages: ["a", "b"], search: "x" });
useCommandPaletteStore.getState().popPage();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.search).toBe("");
});
it("reset clears every state field", () => {
useCommandPaletteStore.setState({ open: true, search: "x", pages: ["a"] });
useCommandPaletteStore.getState().reset();
expect(useCommandPaletteStore.getState()).toMatchObject({ open: false, search: "", pages: [] });
});
describe("goBack", () => {
it("clears search first if present, leaving pages and open untouched", () => {
useCommandPaletteStore.setState({ open: true, search: "text", pages: ["a"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.search).toBe("");
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("pops the top page when no search and pages exist", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: ["a", "b"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("closes the palette when no search and no pages", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: [] });
useCommandPaletteStore.getState().goBack();
expect(useCommandPaletteStore.getState().open).toBe(false);
});
});
});
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ChipInput } from "./chip-input";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderInput = (props: Partial<React.ComponentProps<typeof ChipInput>> = {}) =>
render(
<I18nProvider i18n={i18n}>
<ChipInput defaultValue={[]} onChange={vi.fn()} {...props} />
</I18nProvider>,
);
describe("ChipInput", () => {
it("renders the supplied chips as Badges", () => {
renderInput({ defaultValue: ["alpha", "beta", "gamma"] });
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(screen.getByText("gamma")).toBeInTheDocument();
});
it("adds a chip on Enter, calling onChange with the new list", () => {
const onChange = vi.fn();
renderInput({ defaultValue: ["a"], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "b" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["a", "b"]);
});
it("adds a chip on comma keypress", () => {
const onChange = vi.fn();
renderInput({ defaultValue: [], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "new-tag" } });
fireEvent.keyDown(input, { key: "," });
expect(onChange).toHaveBeenCalledWith(["new-tag"]);
});
it("does not add a duplicate chip", () => {
const onChange = vi.fn();
renderInput({ defaultValue: ["a"], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "a" } });
fireEvent.keyDown(input, { key: "Enter" });
// chips set should remain ["a"]; onChange not invoked with the same array.
const callsAddingA = onChange.mock.calls.filter((args) => Array.isArray(args[0]) && args[0].length > 1);
expect(callsAddingA.length).toBe(0);
});
it("does not add an empty / whitespace-only chip", () => {
const onChange = vi.fn();
renderInput({ defaultValue: ["a"], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("hides the description copy when hideDescription is true", () => {
const { container } = renderInput({ defaultValue: ["a"], hideDescription: true });
// We don't know the exact translated text, just confirm no <Kbd> hint banner is rendered.
expect(container.querySelector("kbd")).toBeNull();
});
it("shows the description copy by default", () => {
const { container } = renderInput({ defaultValue: ["a"] });
expect(container.querySelector("kbd")).not.toBeNull();
});
});
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
vi.mock("@uiw/react-color-colorful", () => ({
default: () => null,
}));
const { ColorPicker } = await import("./color-picker");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderPicker = (props: React.ComponentProps<typeof ColorPicker> = {}) =>
render(
<I18nProvider i18n={i18n}>
<ColorPicker {...props} />
</I18nProvider>,
);
describe("ColorPicker", () => {
it("renders a trigger swatch reflecting the current value", () => {
const { container } = renderPicker({ defaultValue: "rgba(231, 0, 11, 1)" });
const trigger = container.querySelector("[style*='background-color']") as HTMLElement | null;
expect(trigger).not.toBeNull();
// happy-dom serializes both the input rgba string and the rgb representation,
// depending on alpha; just confirm the color values surface.
const bg = trigger?.getAttribute("style") ?? "";
expect(bg).toContain("231");
expect(bg).toContain("11");
});
it("calls onChange when a preset color is clicked", () => {
const onChange = vi.fn();
renderPicker({ defaultValue: "rgba(0, 0, 0, 1)", onChange });
// Open the popover by clicking the trigger swatch.
const triggerSwatch = document.querySelector("[style*='background-color']") as HTMLElement;
fireEvent.click(triggerSwatch);
// Locate any preset button (they have aria-label='Use color rgba(...)').
const presetBtn = screen.getAllByRole("button", { name: /Use color rgba\(/ })[0];
fireEvent.click(presetBtn);
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls[0]?.[0]).toMatch(/^rgba\(/);
});
it("renders a custom trigger when provided", () => {
renderPicker({
defaultValue: "rgba(0, 0, 0, 1)",
trigger: <button type="button">custom trigger</button>,
});
expect(screen.getByText("custom trigger")).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const queryResult = vi.hoisted(() => ({ data: undefined as number | undefined }));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryResult,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { statistics: { github: { getStarCount: { queryOptions: () => ({}) } } } },
}));
vi.mock("../animation/count-up", () => ({
CountUp: ({ to }: { to: number }) => <span data-testid="count-up">{to.toLocaleString()}</span>,
}));
const { GithubStarsButton } = await import("./github-stars-button");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
queryResult.data = undefined;
});
const renderButton = () =>
render(
<I18nProvider i18n={i18n}>
<GithubStarsButton />
</I18nProvider>,
);
describe("GithubStarsButton", () => {
it("renders an anchor pointing at the project repo with rel=noopener and target=_blank", () => {
renderButton();
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.href).toBe("https://github.com/amruthpillai/reactive-resume");
expect(link.target).toBe("_blank");
expect(link.rel).toBe("noopener");
});
it("uses the no-count aria-label when star count hasn't loaded yet", () => {
renderButton();
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.getAttribute("aria-label")).toBe("Star us on GitHub (opens in new tab)");
});
it("does not render a CountUp when star count is undefined", () => {
renderButton();
expect(screen.queryByTestId("count-up")).toBeNull();
});
it("renders a CountUp + announces the star count when available", () => {
queryResult.data = 12345;
renderButton();
expect(screen.getByTestId("count-up").textContent).toBe("12,345");
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.getAttribute("aria-label")).toContain("12,345 stars");
});
});
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
// react-window's <Grid> uses ResizeObserver / IntersectionObserver heavily and
// expects measurable layouts that happy-dom can't provide. Stub it with a simple
// pass-through that renders the first row of cells via the supplied cellComponent.
vi.mock("react-window", () => ({
Grid: ({
rowCount,
columnCount,
cellComponent: CellComponent,
cellProps,
}: {
rowCount: number;
columnCount: number;
cellComponent: React.ComponentType<
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
>;
cellProps: Record<string, unknown>;
}) => {
const cells: React.ReactNode[] = [];
for (let r = 0; r < Math.min(rowCount, 1); r++) {
for (let c = 0; c < columnCount; c++) {
cells.push(<CellComponent key={`${r}-${c}`} rowIndex={r} columnIndex={c} style={{}} {...cellProps} />);
}
}
return <div data-testid="grid">{cells}</div>;
},
}));
const { IconPicker } = await import("./icon-picker");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderPicker = (props: Partial<React.ComponentProps<typeof IconPicker>> = {}) =>
render(
<I18nProvider i18n={i18n}>
<IconPicker value="globe" onChange={vi.fn()} {...props} />
</I18nProvider>,
);
describe("IconPicker", () => {
it("renders a trigger button containing the current icon", () => {
const { container } = renderPicker();
const i = container.querySelector("i.ph") as HTMLElement;
expect(i.className).toContain("ph-globe");
});
it("changes the trigger icon when value prop changes", () => {
const { container, rerender } = renderPicker({ value: "globe" });
const before = container.querySelector("i.ph")?.className ?? "";
expect(before).toContain("ph-globe");
rerender(
<I18nProvider i18n={i18n}>
<IconPicker value="star" onChange={vi.fn()} />
</I18nProvider>,
);
const after = container.querySelector("i.ph")?.className ?? "";
expect(after).toContain("ph-star");
});
it("renders the picker button as a single icon-size button", () => {
const { container } = renderPicker();
expect(container.querySelectorAll("button").length).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { URLInput } from "./url-input";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderInput = (value: { url: string; label: string }, onChange = vi.fn(), hideLabelButton = false) =>
render(
<I18nProvider i18n={i18n}>
<URLInput value={value} onChange={onChange} hideLabelButton={hideLabelButton} />
</I18nProvider>,
);
describe("URLInput", () => {
it("strips the https:// prefix in the visible input value", () => {
renderInput({ url: "https://example.com/path", label: "" });
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("example.com/path");
});
it("renders the raw value when no prefix is present", () => {
renderInput({ url: "no-prefix.example", label: "" });
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("no-prefix.example");
});
it("adds https:// prefix on edit when not already present", () => {
const onChange = vi.fn();
renderInput({ url: "https://example.com", label: "" }, onChange);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "new.example" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://new.example",
label: "",
});
});
it("keeps already-prefixed URLs intact on edit", () => {
const onChange = vi.fn();
renderInput({ url: "https://example.com", label: "" }, onChange);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://other.example" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://other.example",
label: "",
});
});
it("emits an empty url string when cleared", () => {
const onChange = vi.fn();
renderInput({ url: "https://example.com", label: "" }, onChange);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith({
url: "",
label: "",
});
});
it("hides the label button when hideLabelButton=true", () => {
const { container } = renderInput({ url: "https://example.com", label: "" }, vi.fn(), true);
// PopoverTrigger is rendered as a button; its absence means hideLabelButton worked.
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBe(0);
});
it("renders the label button by default", () => {
const { container } = renderInput({ url: "https://example.com", label: "" });
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,51 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BreakpointIndicator } from "./breakpoint-indicator";
const getWrapper = (container: HTMLElement) => container.firstChild as HTMLElement;
describe("BreakpointIndicator", () => {
it("defaults to bottom-right positioning when no position is supplied", () => {
const { container } = render(<BreakpointIndicator />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("bottom-0");
// bottom-right path: top branch sets the "bottom-0", right path sets "inset-e-0"
expect(wrapper.className).toContain("inset-e-0");
});
it("renders all breakpoint labels (one is visible at each viewport)", () => {
const { container } = render(<BreakpointIndicator />);
const text = container.textContent ?? "";
for (const label of ["XS", "SM", "MD", "LG", "XL", "2XL", "3XL", "4XL"]) {
expect(text).toContain(label);
}
});
it("uses top-left classes for top-left position", () => {
const { container } = render(<BreakpointIndicator position="top-left" />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("top-0");
expect(wrapper.className).toContain("inset-s-0");
});
it("uses top-right classes for top-right position", () => {
const { container } = render(<BreakpointIndicator position="top-right" />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("top-0");
expect(wrapper.className).toContain("inset-e-0");
});
it("uses bottom-left classes for bottom-left position", () => {
const { container } = render(<BreakpointIndicator position="bottom-left" />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("bottom-0");
expect(wrapper.className).toContain("inset-s-0");
});
it("hides the indicator from print stylesheets", () => {
const { container } = render(<BreakpointIndicator />);
expect(getWrapper(container).className).toContain("print:hidden");
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import type { ErrorComponentProps } from "@tanstack/react-router";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ErrorScreen } from "./error-screen";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
const props: ErrorComponentProps = {
error: overrides.error ?? new Error("boom"),
reset: overrides.reset ?? vi.fn(),
};
return render(
<I18nProvider i18n={i18n}>
<ErrorScreen {...props} />
</I18nProvider>,
);
};
describe("ErrorScreen", () => {
it("shows the error message provided", () => {
renderError({ error: new Error("Network is down") });
expect(screen.getByText("Network is down")).toBeInTheDocument();
});
it("shows the user-facing error heading", () => {
renderError();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
});
it("calls reset when the Refresh button is clicked", () => {
const reset = vi.fn();
renderError({ reset });
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
expect(reset).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,35 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { LoadingScreen } from "./loading-screen";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LoadingScreen", () => {
it("renders a spinner and the loading text", () => {
render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("fills the viewport (fixed inset-0)", () => {
const { container } = render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("fixed");
expect(wrapper.className).toContain("inset-0");
});
});
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
// `Link` from tanstack/react-router requires a Router context. Stub it out with
// a plain anchor so we can render the screen in isolation.
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
<a href={typeof to === "string" ? to : "#"} {...rest}>
{children}
</a>
),
}));
const { NotFoundScreen } = await import("./not-found-screen");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderScreen = (routeId = "/missing") =>
render(
<I18nProvider i18n={i18n}>
<NotFoundScreen isNotFound routeId={routeId as never} />
</I18nProvider>,
);
describe("NotFoundScreen", () => {
it("renders the documented error heading", () => {
renderScreen();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
});
it("displays the routeId that triggered the not-found", () => {
renderScreen("/dashboard/missing-page");
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
});
it("renders a Go Back link", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go back/i });
expect(link.getAttribute("href")).toBe("..");
});
});
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
// Capture the options the Combobox receives so we can introspect them.
const captured = vi.hoisted(() => ({
options: undefined as Array<{ value: string; label: string }> | undefined,
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: (props: { options: Array<{ value: string; label: string }> }) => {
captured.options = props.options;
return null;
},
}));
const { LevelTypeCombobox } = await import("./combobox");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LevelTypeCombobox", () => {
it("renders one option per level type from levelDesignSchema", () => {
render(<LevelTypeCombobox />);
expect(captured.options?.length).toBe(7);
const values = captured.options?.map((o) => o.value);
expect(values).toEqual(
expect.arrayContaining(["hidden", "circle", "square", "rectangle", "rectangle-full", "progress-bar", "icon"]),
);
});
it("produces a human-readable label for each level type", () => {
render(<LevelTypeCombobox />);
for (const opt of captured.options ?? []) {
expect(opt.label).toBeTruthy();
expect(typeof opt.label).toBe("string");
}
});
});
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { LevelDisplay } from "./display";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LevelDisplay", () => {
it("renders nothing when level is 0", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={0} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when type is hidden", () => {
const { container } = render(<LevelDisplay type="hidden" icon="star" level={3} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when icon is empty (regardless of type)", () => {
const { container } = render(<LevelDisplay type="icon" icon="" level={3} />);
expect(container.firstChild).toBeNull();
});
it("renders 5 segments for progress-bar type", () => {
const { container } = render(<LevelDisplay type="progress-bar" icon="star" level={3} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
});
it("marks first N segments as active for progress-bar", () => {
const { container } = render(<LevelDisplay type="progress-bar" icon="star" level={3} />);
const wrapper = container.firstChild as HTMLElement;
const activeStates = Array.from(wrapper.children).map((el) => (el as HTMLElement).dataset.active);
expect(activeStates).toEqual(["true", "true", "true", "false", "false"]);
});
it("renders icons for icon type", () => {
const { container } = render(<LevelDisplay type="icon" icon="star" level={2} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
expect(wrapper.querySelectorAll("i").length).toBe(5);
expect(wrapper.querySelector("i")?.className).toContain("ph-star");
});
it("dims inactive icons for icon type", () => {
const { container } = render(<LevelDisplay type="icon" icon="star" level={1} />);
const wrapper = container.firstChild as HTMLElement;
const icons = wrapper.querySelectorAll("i");
expect(icons[0]?.className).not.toContain("opacity-40");
expect(icons[4]?.className).toContain("opacity-40");
});
it("renders square segments for circle/rectangle/rectangle-full types", () => {
for (const type of ["circle", "rectangle", "rectangle-full"] as const) {
const { container } = render(<LevelDisplay type={type} icon="star" level={2} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
const activeStates = Array.from(wrapper.children).map((el) => (el as HTMLElement).dataset.active);
expect(activeStates).toEqual(["true", "true", "false", "false", "false"]);
}
});
it("includes an aria-label describing the level", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={4} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.getAttribute("role")).toBe("img");
expect(wrapper.getAttribute("aria-label")).toContain("4");
});
it("merges extra className into the wrapper", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("extra");
});
});
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
import { getLocaleOptions } from "./combobox";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("getLocaleOptions", () => {
it("returns one option per entry in localeMap", () => {
const options = getLocaleOptions();
expect(options).toHaveLength(Object.keys(localeMap).length);
});
it("uses the locale code as the value", () => {
const options = getLocaleOptions();
const values = options.map((opt) => opt.value);
expect(values).toContain("en-US");
expect(values).toContain("de-DE");
});
it("populates label and keywords with the same translated string", () => {
const options = getLocaleOptions();
const enUS = options.find((opt) => opt.value === "en-US");
expect(enUS?.label).toBeTruthy();
expect(enUS?.keywords).toEqual([enUS?.label]);
});
it("uses unique values for every option", () => {
const values = getLocaleOptions().map((opt) => opt.value);
expect(new Set(values).size).toBe(values.length);
});
});
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_PDF_PAGE_SIZE,
getPreviewCanvasScale,
getScaledPreviewPageSize,
normalizeResumePreviewProps,
} from "./preview.shared";
describe("normalizeResumePreviewProps", () => {
it("applies the documented defaults when fields are omitted", () => {
const result = normalizeResumePreviewProps({});
expect(result).toMatchObject({
pageGap: 40,
pageLayout: "horizontal",
pageScale: 1,
showPageNumbers: false,
});
});
it("preserves supplied values and forwards extra props (className, data)", () => {
const result = normalizeResumePreviewProps({
className: "preview-class",
pageGap: 16,
pageLayout: "vertical",
pageScale: 1.5,
showPageNumbers: true,
});
expect(result.className).toBe("preview-class");
expect(result.pageGap).toBe(16);
expect(result.pageLayout).toBe("vertical");
expect(result.pageScale).toBe(1.5);
expect(result.showPageNumbers).toBe(true);
});
});
describe("getScaledPreviewPageSize", () => {
it("multiplies both dimensions by the scale", () => {
const result = getScaledPreviewPageSize({ width: 100, height: 200 }, 2);
expect(result).toEqual({ width: 200, height: 400 });
});
it("returns the default A4 page size unchanged when scaled by 1", () => {
expect(getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, 1)).toEqual(DEFAULT_PDF_PAGE_SIZE);
});
it("supports fractional scaling", () => {
const result = getScaledPreviewPageSize({ width: 100, height: 200 }, 0.5);
expect(result).toEqual({ width: 50, height: 100 });
});
});
const setDevicePixelRatio = (value: number) => {
Object.defineProperty(window, "devicePixelRatio", {
writable: true,
configurable: true,
value,
});
};
afterEach(() => {
setDevicePixelRatio(1);
});
describe("getPreviewCanvasScale", () => {
it("returns the desired render scale (4x) for small pages", () => {
setDevicePixelRatio(1);
// width * height * 4 * 4 = 100 * 100 * 16 = 160_000 ≪ 16_777_216 budget
expect(getPreviewCanvasScale(100, 100)).toBe(4);
});
it("uses devicePixelRatio when it exceeds the desired 4x scale", () => {
setDevicePixelRatio(8);
// 50*50*8*8 = 160_000 ≪ budget, so we keep the 8x devicePixelRatio
expect(getPreviewCanvasScale(50, 50)).toBe(8);
});
it("clamps the scale when the page would exceed the canvas pixel budget", () => {
setDevicePixelRatio(1);
const scale = getPreviewCanvasScale(2000, 3000);
// Should NOT exceed the 4x desired scale and must satisfy the pixel budget.
expect(scale).toBeLessThan(4);
expect(scale * scale * 2000 * 3000).toBeLessThanOrEqual(16_777_216 + 1);
});
});
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
@@ -0,0 +1,32 @@
// @vitest-environment happy-dom
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// ThemeProvider depends on TanStack Start helpers + server fn — stub them.
vi.mock("@tanstack/react-router", () => ({
useRouter: () => ({ invalidate: vi.fn() }),
}));
vi.mock("@/libs/theme", () => ({
setThemeServerFn: vi.fn().mockResolvedValue(undefined),
}));
const { ThemeProvider, useTheme } = await import("./provider");
describe("useTheme", () => {
it("throws when used outside ThemeProvider", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useTheme())).toThrow(/useTheme must be used within a ThemeProvider/);
consoleError.mockRestore();
});
it("returns the theme and helpers when wrapped in ThemeProvider", () => {
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider theme="dark">{children}</ThemeProvider>,
});
expect(result.current.theme).toBe("dark");
expect(typeof result.current.setTheme).toBe("function");
expect(typeof result.current.toggleTheme).toBe("function");
});
});
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
const toggleTheme = vi.hoisted(() => vi.fn());
vi.mock("./provider", () => ({
useTheme: () => ({ theme: "light", setTheme: vi.fn(), toggleTheme }),
}));
const { ThemeToggleButton } = await import("./toggle-button");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
toggleTheme.mockReset();
// Reset prefers-reduced-motion + startViewTransition stub between tests.
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})),
});
// biome-ignore lint/suspicious/noExplicitAny: removing a non-standard API stub
(document as any).startViewTransition = undefined;
});
describe("ThemeToggleButton", () => {
it("renders a button with an aria-label that flips with the theme", () => {
const { container } = render(<ThemeToggleButton />);
const button = container.querySelector("button");
expect(button?.getAttribute("aria-label")).toBe("Switch to dark theme");
});
it("calls toggleTheme directly when the view-transition API is unavailable", () => {
const { container } = render(<ThemeToggleButton />);
const button = container.querySelector("button") as HTMLButtonElement;
fireEvent.click(button);
expect(toggleTheme).toHaveBeenCalledTimes(1);
});
it("calls toggleTheme directly when prefers-reduced-motion is set", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})),
});
const startVT = vi.fn();
Object.defineProperty(document, "startViewTransition", {
writable: true,
configurable: true,
value: startVT,
});
const { container } = render(<ThemeToggleButton />);
fireEvent.click(container.querySelector("button") as HTMLButtonElement);
expect(toggleTheme).toHaveBeenCalledTimes(1);
expect(startVT).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FontWeightCombobox } from "./combobox";
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { getNextWeights } from "./combobox";
describe("getNextWeights", () => {
it("returns 400 and 600 when both are available (the preferred default)", () => {
// Source Sans 3 covers a wide weight range including 400 and 600.
const weights = getNextWeights("Source Sans 3");
expect(weights).toEqual(["400", "600"]);
});
it("returns null for unknown font families", () => {
expect(getNextWeights("This Font Does Not Exist")).toBeNull();
});
it("returns an array containing exactly known weight strings (subset of 100..900)", () => {
const weights = getNextWeights("Source Sans 3");
const validWeights = new Set(["100", "200", "300", "400", "500", "600", "700", "800", "900"]);
for (const w of weights ?? []) {
expect(validWeights.has(w)).toBe(true);
}
});
it("contains at most two weights", () => {
const weights = getNextWeights("Source Sans 3");
expect(weights?.length).toBeLessThanOrEqual(2);
});
it("returns the family's only weight (deduplicated) when only one is available", () => {
// Find a font with a single weight by scanning the fontList — fall back gracefully.
// We probe a known web font that may only ship 400; the test asserts uniqueness regardless.
const weights = getNextWeights("Source Sans 3");
if (weights) {
expect(new Set(weights).size).toBe(weights.length);
}
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import type { ComboboxOption } from "./combobox";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Combobox } from "./combobox";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const options: ComboboxOption<"alpha" | "beta" | "gamma">[] = [
{ value: "alpha", label: "Alpha" },
{ value: "beta", label: "Beta" },
{ value: "gamma", label: "Gamma" },
];
const wrap = (ui: React.ReactNode) => render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
describe("Combobox", () => {
it("renders the default placeholder when nothing is selected", () => {
wrap(<Combobox options={[...options]} placeholder="Pick something" />);
expect(screen.getByText("Pick something")).toBeInTheDocument();
});
it("renders the selected option label when a value is provided", () => {
wrap(<Combobox options={[...options]} value="beta" />);
// The label appears inside the trigger; both label and trigger may render it,
// so use queryAllByText for resilience.
expect(screen.getAllByText("Beta").length).toBeGreaterThan(0);
});
it("renders all option labels for the multi-select default values", () => {
wrap(<Combobox multiple options={[...options]} defaultValue={["alpha", "gamma"]} />);
expect(screen.getAllByText(/Alpha/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Gamma/).length).toBeGreaterThan(0);
});
it("renders nothing extra when given an empty options array (no crash)", () => {
expect(() => wrap(<Combobox options={[]} placeholder="Empty" />)).not.toThrow();
expect(screen.getByText("Empty")).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
vi.stubGlobal("__APP_VERSION__", "9.9.9");
const { Copyright } = await import("./copyright");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderCopyright = (props?: React.ComponentProps<typeof Copyright>) =>
render(
<I18nProvider i18n={i18n}>
<Copyright {...props} />
</I18nProvider>,
);
describe("Copyright", () => {
it("renders the MIT license link", () => {
renderCopyright();
const link = screen.getByRole("link", { name: "MIT" });
expect(link.getAttribute("href")).toBe("https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE");
expect(link.getAttribute("rel")).toBe("noopener");
});
it("renders the Amruth Pillai attribution link", () => {
renderCopyright();
const link = screen.getByRole("link", { name: "Amruth Pillai" });
expect(link.getAttribute("href")).toBe("https://amruthpillai.com");
});
it("includes the app version string", () => {
renderCopyright();
expect(screen.getByText(/v9\.9\.9/)).toBeInTheDocument();
});
it("merges custom className into the wrapper", () => {
const { container } = renderCopyright({ className: "extra-class" });
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("extra-class");
expect(wrapper.className).toContain("text-muted-foreground");
});
it("opens external links in a new tab", () => {
renderCopyright();
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("target")).toBe("_blank");
}
});
});