diff --git a/src/App.test.tsx b/src/App.test.tsx index 2382012..3396fba 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,67 +1,34 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import App from "./App"; describe("App", () => { - beforeEach(() => { + it("renders the complete portfolio page", () => { render(); - }); - it("renders the portfolio hero", () => { - expect( - screen.getByRole("heading", { name: /rashell\s*guerrero/i }), - ).toBeInTheDocument(); expect( - screen.getByText(/i build reliable, user-focused software/i), + screen.getByRole("navigation", { name: /main navigation/i }), ).toBeInTheDocument(); - }); - it("renders the about section", () => { + const main = screen.getByRole("main"); + expect(main).toBeInTheDocument(); + expect( screen.getByRole("heading", { - name: /think\. design\. build\.\s*refine\./i, + level: 1, + name: /rashell\s*guerrero/i, }), ).toBeInTheDocument(); - expect( - screen.getAllByText(/full-stack software engineer/i).length, - ).toBeGreaterThan(0); - expect(screen.getByText(/available for/i)).toBeInTheDocument(); - expect( - screen.getByRole("link", { name: /full-time opportunities/i }), - ).toHaveAttribute("href", "#contact"); - }); - it("renders the expected page sections", () => { - expect(document.querySelector("#hero")).toBeInTheDocument(); - expect(document.querySelector("#about")).toBeInTheDocument(); - }); + const sectionIds = ["hero", "about", "projects", "skills", "contact"]; - it("renders the main navigation", () => { - expect( - screen.getByRole("navigation", { name: /main navigation/i }), - ).toBeInTheDocument(); - }); + sectionIds.forEach((sectionId) => { + const section = document.getElementById(sectionId); - it("has working primary links", () => { - expect(screen.getByRole("link", { name: /view my work/i })).toHaveAttribute( - "href", - "#projects", - ); - expect(screen.getByRole("link", { name: /get in touch/i })).toHaveAttribute( - "href", - "#contact", - ); - }); + expect(section).toBeInTheDocument(); + expect(main).toContainElement(section); + }); - it("has accessible social links", () => { - expect( - screen.getByRole("link", { name: /github profile/i }), - ).toHaveAttribute("href", "https://github.com/rxshellg"); - expect( - screen.getByRole("link", { name: /linkedin profile/i }), - ).toHaveAttribute("href", "https://www.linkedin.com/in/rashell-guerrero/"); - expect( - screen.getByRole("link", { name: /email rashell/i }), - ).toHaveAttribute("href", "mailto:rashellguerrero123@gmail.com"); + expect(screen.getByRole("contentinfo")).toBeInTheDocument(); }); }); diff --git a/src/components/layout/Navbar.test.tsx b/src/components/layout/Navbar.test.tsx index 96a1f71..e6638c5 100644 --- a/src/components/layout/Navbar.test.tsx +++ b/src/components/layout/Navbar.test.tsx @@ -1,41 +1,123 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { navigationLinks } from "../../data/navigation"; import Navbar from "./Navbar"; +function expectNavigationLinks(container: HTMLElement) { + expect(navigationLinks).toHaveLength(5); + + navigationLinks.forEach(({ label, href }) => { + expect( + within(container).getByRole("link", { + name: new RegExp(label, "i"), + }), + ).toHaveAttribute("href", href); + }); +} + describe("Navbar", () => { - let nav: HTMLElement; + let mainNavigation: HTMLElement; beforeEach(() => { render(); - nav = screen.getByRole("navigation", { name: /main navigation/i }); + + mainNavigation = screen.getByRole("navigation", { + name: /main navigation/i, + }); }); it("renders as the main navigation landmark", () => { - expect(nav).toBeInTheDocument(); + expect(mainNavigation).toBeInTheDocument(); }); - it("renders the logo link pointing to #hero", () => { + it("renders the logo link pointing to the hero section", () => { expect( - within(nav).getByRole("link", { name: /go to top/i }), + within(mainNavigation).getByRole("link", { + name: /go to top/i, + }), ).toHaveAttribute("href", "#hero"); }); - it("renders all active navigation links with correct hrefs", () => { - expect(within(nav).getByRole("link", { name: /home/i })).toHaveAttribute( - "href", - "#hero", - ); - expect(within(nav).getByRole("link", { name: /about/i })).toHaveAttribute( - "href", - "#about", + it("renders all five navigation links with the correct destinations", () => { + expectNavigationLinks(mainNavigation); + }); + + it("opens and closes the mobile navigation menu", async () => { + const user = userEvent.setup(); + + const menuButton = within(mainNavigation).getByRole("button", { + name: /toggle navigation menu/i, + }); + + expect(menuButton).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("navigation", { + name: /mobile navigation/i, + }), + ).not.toBeInTheDocument(); + + await user.click(menuButton); + + expect(menuButton).toHaveAttribute("aria-expanded", "true"); + + const mobileNavigation = screen.getByRole("navigation", { + name: /mobile navigation/i, + }); + + expect(mobileNavigation).toBeInTheDocument(); + expectNavigationLinks(mobileNavigation); + + await user.click(menuButton); + + expect(menuButton).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("navigation", { + name: /mobile navigation/i, + }), + ).not.toBeInTheDocument(); + }); + + it("closes the mobile menu after selecting a navigation link", async () => { + const user = userEvent.setup(); + + const menuButton = within(mainNavigation).getByRole("button", { + name: /toggle navigation menu/i, + }); + + await user.click(menuButton); + + const mobileNavigation = screen.getByRole("navigation", { + name: /mobile navigation/i, + }); + + await user.click( + within(mobileNavigation).getByRole("link", { + name: /projects/i, + }), ); + + expect(menuButton).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("navigation", { + name: /mobile navigation/i, + }), + ).not.toBeInTheDocument(); }); - it("opens the resume modal", () => { - fireEvent.click(within(nav).getByRole("button", { name: /resume\.pdf/i })); + it("opens the resume modal", async () => { + const user = userEvent.setup(); + + await user.click( + within(mainNavigation).getByRole("button", { + name: /resume\.pdf/i, + }), + ); expect( - screen.getByRole("dialog", { name: /hey there/i }), + screen.getByRole("dialog", { + name: /hey there/i, + }), ).toBeInTheDocument(); }); }); diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 48c4eea..b4f7caa 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -79,7 +79,9 @@ function Navbar() { {isMobileMenuOpen && ( -
{navLinks(true)}
+ )} { + it("cycles through images and wraps in both directions", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByRole("img", { + name: /test project preview 1 of 3/i, + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { + name: /show previous test project image/i, + }), + ); + + expect( + screen.getByRole("img", { + name: /test project preview 3 of 3/i, + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { + name: /show next test project image/i, + }), + ); + + expect( + screen.getByRole("img", { + name: /test project preview 1 of 3/i, + }), + ).toBeInTheDocument(); + }); + + it("renders only the available project links", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("link", { name: /live demo/i })).toHaveAttribute( + "href", + project.liveDemoUrl, + ); + + expect( + screen.getByRole("link", { + name: /test project on github/i, + }), + ).toHaveAttribute("href", project.githubUrl); + + rerender( + , + ); + + expect( + screen.queryByRole("link", { name: /live demo/i }), + ).not.toBeInTheDocument(); + + expect(screen.getByText(/live demo/i)).toHaveAttribute( + "aria-disabled", + "true", + ); + + expect( + screen.queryByRole("link", { name: /github/i }), + ).not.toBeInTheDocument(); + }); + + it("calls onToggle when expanding and collapsing", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + + const { rerender } = render( + , + ); + + const summaryButton = screen.getByRole("button", { + name: project.title, + }); + + expect(summaryButton).toHaveAttribute("aria-expanded", "false"); + + await user.click(summaryButton); + + expect(onToggle).toHaveBeenCalledTimes(1); + + rerender(); + + expect(screen.getByRole("button", { name: project.title })).toHaveAttribute( + "aria-expanded", + "true", + ); + + await user.click( + screen.getByRole("button", { + name: /collapse test project/i, + }), + ); + + expect(onToggle).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/ui/ResumeModal.test.tsx b/src/components/ui/ResumeModal.test.tsx new file mode 100644 index 0000000..4833df0 --- /dev/null +++ b/src/components/ui/ResumeModal.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ResumeModal from "./ResumeModal"; + +function renderModal() { + const onClose = vi.fn(); + const onGetInTouch = vi.fn(); + + const view = render( + , + ); + + return { ...view, onClose, onGetInTouch }; +} + +describe("ResumeModal", () => { + afterEach(() => { + document.body.style.overflow = ""; + }); + + it("focuses the dialog, locks scrolling, and restores it when closed", () => { + document.body.style.overflow = "auto"; + + const { rerender, onClose, onGetInTouch } = renderModal(); + const dialog = screen.getByRole("dialog"); + + expect(dialog).toHaveFocus(); + expect(document.body.style.overflow).toBe("hidden"); + + rerender( + , + ); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(document.body.style.overflow).toBe("auto"); + }); + + it("closes through Escape, the backdrop, and both close buttons", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: /close dialog/i })); + expect(onClose).toHaveBeenCalledTimes(2); + + await user.click(screen.getByRole("button", { name: /maybe later/i })); + expect(onClose).toHaveBeenCalledTimes(3); + + const backdrop = screen.getByRole("dialog").parentElement!; + await user.click(backdrop); + + expect(onClose).toHaveBeenCalledTimes(4); + }); + + it("calls onGetInTouch from the primary button", async () => { + const user = userEvent.setup(); + const { onGetInTouch, onClose } = renderModal(); + + await user.click(screen.getByRole("button", { name: /get in touch/i })); + + expect(onGetInTouch).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/home/components/ContactSection.test.tsx b/src/pages/home/components/ContactSection.test.tsx new file mode 100644 index 0000000..c4c3338 --- /dev/null +++ b/src/pages/home/components/ContactSection.test.tsx @@ -0,0 +1,110 @@ +import emailjs from "@emailjs/browser"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ContactSection from "./ContactSection"; + +vi.mock("@emailjs/browser", () => ({ + default: { + sendForm: vi.fn(), + }, +})); + +const sendFormMock = vi.mocked(emailjs.sendForm); + +const FORM_VALUES = { + name: "Rashell Guerrero", + email: "rashell@example.com", + subject: "Portfolio inquiry", + message: "I would like to discuss a software engineering opportunity.", +}; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +} + +async function renderFilledForm() { + const user = userEvent.setup(); + + render(); + + for (const [field, value] of Object.entries(FORM_VALUES)) { + await user.type( + screen.getByLabelText(new RegExp(`^${field}$`, "i")), + value, + ); + } + + return { + user, + form: screen.getByRole("form", { + name: /contact form/i, + }), + submitButton: screen.getByRole("button", { + name: /send message/i, + }), + }; +} + +describe("ContactSection", () => { + beforeEach(() => { + sendFormMock.mockReset(); + }); + + it("submits once, shows progress, succeeds, and resets the form", async () => { + const response = { status: 200, text: "OK" }; + const request = createDeferred(); + + sendFormMock.mockReturnValueOnce(request.promise); + + const { user, form, submitButton } = await renderFilledForm(); + + await user.click(submitButton); + + expect(submitButton).toBeDisabled(); + expect(submitButton).toHaveTextContent(/sending/i); + expect(sendFormMock).toHaveBeenCalledTimes(1); + expect(sendFormMock.mock.calls[0][2]).toBe(form); + + act(() => { + form.requestSubmit(); + }); + + expect(sendFormMock).toHaveBeenCalledTimes(1); + + request.resolve(response); + + expect(await screen.findByRole("status")).toHaveTextContent( + "Message sent! I'll get back to you soon.", + ); + + expect(form).toHaveFormValues({ + name: "", + email: "", + subject: "", + message: "", + }); + + expect(submitButton).toBeEnabled(); + }); + + it("shows an error and preserves the form when sending fails", async () => { + sendFormMock.mockRejectedValueOnce(new Error("EmailJS request failed")); + + const { user, form, submitButton } = await renderFilledForm(); + + await user.click(submitButton); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Something went wrong. Try again or email me directly.", + ); + + expect(form).toHaveFormValues(FORM_VALUES); + expect(submitButton).toBeEnabled(); + }); +}); diff --git a/src/pages/home/components/ContactSection.tsx b/src/pages/home/components/ContactSection.tsx index 7afdd9c..90645fb 100644 --- a/src/pages/home/components/ContactSection.tsx +++ b/src/pages/home/components/ContactSection.tsx @@ -68,7 +68,11 @@ function ContactSection() { Say hello. I don't bite. -
+
{fields.slice(0, 2).map((field) => ( @@ -87,13 +91,20 @@ function ContactSection() { />
- {status in STATUS_MESSAGES && ( -

- {STATUS_MESSAGES[status as keyof typeof STATUS_MESSAGES]} + {(status === "success" || status === "error") && ( +

+ {STATUS_MESSAGES[status]}

)} - diff --git a/src/pages/home/components/ProjectsSection.test.tsx b/src/pages/home/components/ProjectsSection.test.tsx new file mode 100644 index 0000000..70477b2 --- /dev/null +++ b/src/pages/home/components/ProjectsSection.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { projects } from "../../../data/projects"; +import ProjectsSection from "./ProjectsSection"; + +const featuredProjects = projects + .filter(({ featured }) => featured) + .slice(0, 4); + +describe("ProjectsSection", () => { + it("renders three desktop projects and one mobile-only project", () => { + render(); + + const cards = screen.getAllByRole("article"); + + expect(cards).toHaveLength(4); + + cards.slice(0, 3).forEach((card) => { + expect(card).not.toHaveClass("project-card--mobile-only"); + }); + + expect(cards[3]).toHaveClass("project-card--mobile-only"); + + featuredProjects.forEach(({ title }) => { + expect(screen.getByRole("heading", { name: title })).toBeInTheDocument(); + }); + }); + + it("allows only one project to be expanded at a time", async () => { + const user = userEvent.setup(); + + render(); + + const first = screen.getByRole("button", { + name: featuredProjects[0].title, + }); + + const second = screen.getByRole("button", { + name: featuredProjects[1].title, + }); + + await user.click(first); + + expect(first).toHaveAttribute("aria-expanded", "true"); + expect(second).toHaveAttribute("aria-expanded", "false"); + + await user.click(second); + + expect(first).toHaveAttribute("aria-expanded", "false"); + expect(second).toHaveAttribute("aria-expanded", "true"); + + await user.click(second); + + expect(second).toHaveAttribute("aria-expanded", "false"); + }); +}); diff --git a/src/pages/home/components/SkillsSection.test.tsx b/src/pages/home/components/SkillsSection.test.tsx new file mode 100644 index 0000000..eb1357a --- /dev/null +++ b/src/pages/home/components/SkillsSection.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { skills } from "../../../data/skills"; +import SkillsSection from "./SkillsSection"; + +describe("SkillsSection", () => { + it("switches between hard and soft skills", async () => { + const user = userEvent.setup(); + + render(); + + const hardTab = screen.getByRole("tab", { + name: /hard skills/i, + }); + const softTab = screen.getByRole("tab", { + name: /soft skills/i, + }); + + expect(hardTab).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("TypeScript")).toBeInTheDocument(); + expect(screen.queryByText("Problem Solving")).not.toBeInTheDocument(); + + await user.click(softTab); + + expect(softTab).toHaveAttribute("aria-selected", "true"); + expect(hardTab).toHaveAttribute("aria-selected", "false"); + expect(screen.getByText("Problem Solving")).toBeInTheDocument(); + expect(screen.queryByText("TypeScript")).not.toBeInTheDocument(); + }); + + it("gives every soft skill an accessible meter value", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("tab", { + name: /soft skills/i, + }), + ); + + skills.soft.forEach(({ name, level }) => { + const meter = screen.getByRole("meter", { + name: `${name}: ${level}%`, + }); + + expect(meter).toHaveAttribute("aria-valuenow", String(level)); + expect(meter).toHaveAttribute("aria-valuemin", "0"); + expect(meter).toHaveAttribute("aria-valuemax", "100"); + }); + }); +});