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