Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 301 additions & 0 deletions frontend/src/__tests__/ArmorMembers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import ArmorMembers from "../landing_page/landing_page_armor/ArmorMembers";

const HEADER = ["First Name", "Last Name", "Company Name", "Job Title", "LinkedIn"];

// 30 members so the unfiltered dataset spans 3 pages at pageSize=10. The
// non-"Other Co" members are deliberately scattered across page 1 (idx 0/7),
// page 2 (idx 15), and page 3 (idx 20/29) so full-dataset-filtering bugs
// (only filtering the visible page) are caught.
function buildRows() {
const special = {
0: ["Anote", "Founder / CEO"],
7: ["Google", "Product Manager"],
15: ["Anote", "Data Scientist"],
20: ["Google", "Senior Product Manager"],
29: ["Anote", "Founder & CEO"], // equivalent title group to idx 0
};
const rows = [];
for (let i = 0; i < 30; i++) {
const [company, title] = special[i] || ["Other Co", "Engineer"];
rows.push([`Member${i}`, `Test${i}`, company, title, ""]);
}
return rows;
}

const SHEET_VALUES = [HEADER, ...buildRows()];

function LocationProbe() {
const location = useLocation();
return <div data-testid="location-search">{location.search}</div>;
}

function renderMembers(initialEntries = ["/community/members"]) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<ArmorMembers />
<LocationProbe />
</MemoryRouter>
);
}

async function waitForLoaded() {
await screen.findByRole("heading", { name: "Community Members" });
}

function openDrawer() {
fireEvent.click(screen.getByRole("button", { name: /Open filters/ }));
}

function getCompanyCheckbox(label) {
return screen.getByRole("checkbox", { name: label });
}

beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ values: SHEET_VALUES }),
});
});

afterEach(() => {
jest.restoreAllMocks();
});

test("shows a loading state while the sheet is being fetched", () => {
global.fetch.mockReturnValue(new Promise(() => {}));
renderMembers();
expect(screen.getByText("Loading...")).toBeInTheDocument();
});

test("shows an error state when the fetch fails", async () => {
global.fetch.mockRejectedValue(new Error("Network down"));
renderMembers();
await screen.findByText(/Error: Network down/i);
});

test("opens the filter drawer on click and closes on Escape and via the close button", async () => {
renderMembers();
await waitForLoaded();

expect(screen.queryByRole("dialog", { name: "Filter members" })).not.toBeInTheDocument();

openDrawer();
expect(screen.getByRole("dialog", { name: "Filter members" })).toBeInTheDocument();

fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());

openDrawer();
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});

test("shows Company options with full-dataset counts, not just the current page", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

expect(getCompanyCheckbox("Anote")).toBeInTheDocument();
expect(screen.getByLabelText("3 members")).toBeInTheDocument();
expect(getCompanyCheckbox("Google")).toBeInTheDocument();
expect(getCompanyCheckbox("Other Co")).toBeInTheDocument();
});

test("shows normalized Job Title options with merged counts, without merging distinct seniority levels", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

// "Founder / CEO" merges idx0 ("Founder / CEO") + idx29 ("Founder & CEO") = 2
expect(getCompanyCheckbox("Founder / CEO")).toBeInTheDocument();
expect(getCompanyCheckbox("Product Manager")).toBeInTheDocument();
expect(getCompanyCheckbox("Senior Product Manager")).toBeInTheDocument();
// both distinct options must exist independently (no accidental merge)
expect(getCompanyCheckbox("Product Manager")).not.toBe(getCompanyCheckbox("Senior Product Manager"));
});

test("searches within the Company section", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.change(screen.getByPlaceholderText("Search companies…"), { target: { value: "goog" } });
expect(getCompanyCheckbox("Google")).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "Anote" })).not.toBeInTheDocument();
});

test("searches within the Job Title section", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.change(screen.getByPlaceholderText("Search job titles…"), { target: { value: "senior" } });
expect(getCompanyCheckbox("Senior Product Manager")).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "Product Manager" })).not.toBeInTheDocument();
});

test("selecting multiple companies uses OR logic and updates the Filter button count", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.click(getCompanyCheckbox("Anote"));
fireEvent.click(getCompanyCheckbox("Google"));
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));

await screen.findByText("Showing 5 members"); // Anote(3) + Google(2)
expect(screen.getByRole("button", { name: /2 active/ })).toHaveTextContent("Filter (2)");
});

test("selecting multiple job titles uses OR logic within the section", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.click(getCompanyCheckbox("Product Manager"));
fireEvent.click(getCompanyCheckbox("Senior Product Manager"));
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));

await screen.findByText("Showing 2 members");
});

test("AND logic between Company and Job Title sections", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.click(getCompanyCheckbox("Google"));
fireEvent.click(getCompanyCheckbox("Product Manager"));
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));

// Only idx7 (Google + Product Manager); idx20 is Google + Senior Product Manager.
await screen.findByText("Showing 1 member");
expect(screen.getByText("Member7")).toBeInTheDocument();
});

test("combines Company + Job Title filters with the general search box", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.click(getCompanyCheckbox("Anote"));
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
fireEvent.change(screen.getByLabelText("Search members"), { target: { value: "Member29" } });

await screen.findByText("Showing 1 member", {}, { timeout: 2000 });
expect(screen.getByText("Member29")).toBeInTheDocument();
});

test("resets pagination to page 1 when a filter changes", async () => {
renderMembers(["/community/members?page=2"]);
await waitForLoaded();
expect(screen.getByLabelText("Current page")).toHaveValue(2);

openDrawer();
fireEvent.click(getCompanyCheckbox("Other Co"));

await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(1));
});

test("preserves active filters while navigating between pages", async () => {
renderMembers();
await waitForLoaded();
openDrawer();
fireEvent.click(getCompanyCheckbox("Other Co")); // 25 matches -> 3 pages
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));

await screen.findByText("Showing 25 members");
fireEvent.click(screen.getByLabelText("Next page"));

await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(2));
expect(screen.getByRole("button", { name: "Remove Company filter: Other Co" })).toBeInTheDocument();
});

test("clears all filters via the drawer's Clear all action", async () => {
renderMembers();
await waitForLoaded();
openDrawer();
fireEvent.click(getCompanyCheckbox("Anote"));
fireEvent.click(getCompanyCheckbox("Founder / CEO"));
await screen.findByText("Showing 2 members"); // idx0 + idx29 (equivalent title group)

fireEvent.click(screen.getByRole("button", { name: "Clear all" }));

await screen.findByText("Showing 30 members");
expect(screen.queryByRole("button", { name: /Remove Company filter/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open filters" })).toHaveTextContent("Filter");
});

test("shows an empty state with a working clear action when nothing matches", async () => {
// Hydrated directly via URL: Company and Job Title cross-filter each other,
// so a real user can't sequentially check two mutually-exclusive options in
// one drawer session (selecting either one hides the other's now-zero-count
// option). Loading both at once via URL exercises the same "zero matches,
// but selections stay visible" path a shared link would hit.
renderMembers(["/community/members?company=Google&jobTitle=Data%20Scientist"]);
await waitForLoaded();

await screen.findByText("No members match the selected filters.");
expect(screen.getByRole("button", { name: "Remove Company filter: Google" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove Job Title filter: Data Scientist" })).toBeInTheDocument();

openDrawer();
expect(getCompanyCheckbox("Google")).toBeChecked();
expect(getCompanyCheckbox("Data Scientist")).toBeChecked();
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));

fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
await screen.findByText("Showing 30 members");
});

test("removing an individual chip clears only that filter", async () => {
renderMembers();
await waitForLoaded();
openDrawer();
fireEvent.click(getCompanyCheckbox("Anote"));
fireEvent.click(getCompanyCheckbox("Founder / CEO"));
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
await screen.findByText("Showing 2 members"); // idx0 + idx29 (equivalent title group)

fireEvent.click(screen.getByRole("button", { name: "Remove Job Title filter: Founder / CEO" }));

await screen.findByText("Showing 3 members"); // back to just Company: Anote
expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
});

test("hydrates filters and page from the URL, and syncs the URL on change", async () => {
renderMembers(["/community/members?company=Anote&jobTitle=Founder%20%2F%20CEO"]);
await waitForLoaded();

await screen.findByText("Showing 2 members"); // idx0 + idx29
expect(screen.getByRole("button", { name: /2 active/ })).toHaveTextContent("Filter (2)");
expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove Job Title filter: Founder / CEO" })).toBeInTheDocument();

const search = screen.getByTestId("location-search").textContent;
expect(search).toContain("company=Anote");
expect(search).toContain("jobTitle=Founder");

openDrawer();
fireEvent.click(screen.getByRole("button", { name: "Clear all" }));
await waitFor(() => expect(screen.getByTestId("location-search").textContent).toBe(""));
});

test("filters apply to the full dataset, not only the currently visible page", async () => {
renderMembers();
await waitForLoaded();
openDrawer();

fireEvent.click(getCompanyCheckbox("Anote"));
fireEvent.click(screen.getByRole("button", { name: "Close filters" }));

await screen.findByText("Showing 3 members");
// idx0 (page 1), idx15 (page 2), idx29 (page 3) would be unreachable
// together under naive current-page-only filtering.
expect(screen.getByText("Member0")).toBeInTheDocument();
expect(screen.getByText("Member15")).toBeInTheDocument();
expect(screen.getByText("Member29")).toBeInTheDocument();
});
94 changes: 94 additions & 0 deletions frontend/src/__tests__/jobTitleNormalization.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
cleanTitle,
normalizeSeparators,
jobTitleGroupKey,
canonicalJobTitleDisplay,
} from "../landing_page/landing_page_armor/jobTitleNormalization";

describe("cleanTitle", () => {
test("trims and collapses whitespace only", () => {
expect(cleanTitle(" Founder / CEO ")).toBe("Founder / CEO");
expect(cleanTitle(null)).toBe("");
expect(cleanTitle(undefined)).toBe("");
});
});

describe("normalizeSeparators", () => {
test("collapses &, +, comma, and 'and' into a single canonical separator", () => {
expect(normalizeSeparators("Founder & CEO")).toBe("Founder / CEO");
expect(normalizeSeparators("Founder + CEO")).toBe("Founder / CEO");
expect(normalizeSeparators("Founder and CEO")).toBe("Founder / CEO");
expect(normalizeSeparators("Founder, CEO")).toBe("Founder / CEO");
expect(normalizeSeparators("Founder / CEO")).toBe("Founder / CEO");
});

test("does not touch titles with no separators", () => {
expect(normalizeSeparators("Product Manager")).toBe("Product Manager");
expect(normalizeSeparators("Data Scientist")).toBe("Data Scientist");
});

test("does not merge different words just because they're related", () => {
// These should remain distinguishable strings — normalizeSeparators only
// rewrites separators, it never removes or substitutes words.
expect(normalizeSeparators("Senior Product Manager")).toBe("Senior Product Manager");
expect(normalizeSeparators("Product Marketing Manager")).toBe("Product Marketing Manager");
expect(normalizeSeparators("Engineering Manager")).toBe("Engineering Manager");
});
});

describe("jobTitleGroupKey", () => {
test("groups equivalent CEO-title variants under the same key", () => {
const key = jobTitleGroupKey("Founder / CEO");
expect(jobTitleGroupKey("Founder & CEO")).toBe(key);
expect(jobTitleGroupKey("Founder + CEO")).toBe(key);
expect(jobTitleGroupKey("founder and ceo")).toBe(key);
expect(jobTitleGroupKey(" FOUNDER and CEO ")).toBe(key);
});

test("is case-insensitive", () => {
expect(jobTitleGroupKey("Product Manager")).toBe(jobTitleGroupKey("PRODUCT MANAGER"));
expect(jobTitleGroupKey("Product Manager")).toBe(jobTitleGroupKey("product manager"));
});

test("does NOT merge distinct seniority levels", () => {
expect(jobTitleGroupKey("Product Manager")).not.toBe(jobTitleGroupKey("Senior Product Manager"));
});

test("does NOT merge distinct roles that share a word", () => {
expect(jobTitleGroupKey("Product Manager")).not.toBe(jobTitleGroupKey("Product Marketing Manager"));
expect(jobTitleGroupKey("Data Scientist")).not.toBe(jobTitleGroupKey("Data Engineer"));
expect(jobTitleGroupKey("Engineer")).not.toBe(jobTitleGroupKey("Engineering Manager"));
});

test("does NOT merge a single role with a combined role sharing a word", () => {
expect(jobTitleGroupKey("Founder")).not.toBe(jobTitleGroupKey("CEO"));
expect(jobTitleGroupKey("CEO")).not.toBe(jobTitleGroupKey("Founder / CEO"));
expect(jobTitleGroupKey("Founder")).not.toBe(jobTitleGroupKey("Founder / CEO"));
});

test("blank/null input yields an empty key", () => {
expect(jobTitleGroupKey("")).toBe("");
expect(jobTitleGroupKey(null)).toBe("");
expect(jobTitleGroupKey(" ")).toBe("");
});

test("treats common placeholder values as blank", () => {
expect(jobTitleGroupKey("N/A")).toBe("");
expect(jobTitleGroupKey("na")).toBe("");
expect(jobTitleGroupKey("None")).toBe("");
expect(jobTitleGroupKey("-")).toBe("");
});
});

describe("canonicalJobTitleDisplay", () => {
test("returns the curated display for known equivalence groups", () => {
expect(canonicalJobTitleDisplay(jobTitleGroupKey("Founder & CEO"), "whatever")).toBe("Founder / CEO");
expect(canonicalJobTitleDisplay(jobTitleGroupKey("Founder CEO"), "whatever")).toBe("Founder / CEO");
});

test("falls back to the provided display when there is no explicit mapping", () => {
expect(canonicalJobTitleDisplay(jobTitleGroupKey("Product Manager"), "Product Manager")).toBe(
"Product Manager"
);
});
});
Loading
Loading