Skip to content
Closed
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
264 changes: 264 additions & 0 deletions frontend/src/__tests__/ArmorMembers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
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"];

// 25 members so the dataset spans 3 pages at pageSize=10. Three "Anote" members
// are deliberately placed on page 1 (index 0), page 2 (index 12), and page 3
// (index 24) so that filtering-across-the-full-dataset bugs are caught.
function buildRows() {
const rows = [];
for (let i = 0; i < 25; i++) {
if (i === 0) {
rows.push([`Member${i}`, `Test${i}`, "Anote", "Founder / CEO", ""]);
} else if (i === 12) {
rows.push([`Member${i}`, `Test${i}`, "Anote", "Data Scientist", ""]);
} else if (i === 24) {
rows.push([`Member${i}`, `Test${i}`, "Anote", "Founder & CEO", ""]);
} else {
rows.push([`Member${i}`, `Test${i}`, "Other Co", "Engineer", ""]);
}
}
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 getCompanyCombobox() {
return screen.getByRole("combobox", { name: "Filter by company" });
}

function getJobTitleCombobox() {
return screen.getByRole("combobox", { name: "Filter by job title" });
}

async function selectCompanyOption(text) {
const input = getCompanyCombobox();
fireEvent.change(input, { target: { value: text } });
const option = await screen.findByRole("option", { name: text });
fireEvent.mouseDown(option);
}

function typeJobTitleAndCommit(text) {
const input = getJobTitleCombobox();
fireEvent.change(input, { target: { value: text } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
}

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 sheet has no data", async () => {
global.fetch.mockResolvedValue({ ok: true, json: async () => ({}) });
renderMembers();
await screen.findByText(/Error: No data found in sheet/i);
});

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

test("filters by Company across the full dataset, not just the current page", async () => {
renderMembers();
await waitForLoaded();

await selectCompanyOption("Anote");

expect(await screen.findByText("Showing 3 members")).toBeInTheDocument();
// Member12 and Member24 would be on page 2/3 under naive current-page filtering.
expect(screen.getByText("Member0")).toBeInTheDocument();
expect(screen.getByText("Member12")).toBeInTheDocument();
expect(screen.getByText("Member24")).toBeInTheDocument();
expect(screen.queryByText("Member1")).not.toBeInTheDocument();
});

test("filters by Job Title using keyword/contains matching for CEO variants", async () => {
renderMembers();
await waitForLoaded();

typeJobTitleAndCommit("CEO");

await screen.findByText("Showing 2 members");
expect(screen.getByText("Member0")).toBeInTheDocument(); // Founder / CEO
expect(screen.getByText("Member24")).toBeInTheDocument(); // Founder & CEO
expect(screen.queryByText("Member12")).not.toBeInTheDocument(); // Data Scientist
});

test("matches Job Title case-insensitively", async () => {
renderMembers();
await waitForLoaded();

typeJobTitleAndCommit("ceo");

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

test("combines Company and Job Title filters with AND semantics", async () => {
renderMembers();
await waitForLoaded();

await selectCompanyOption("Anote");
typeJobTitleAndCommit("CEO");

await screen.findByText("Showing 2 members");
expect(screen.getByText("Member0")).toBeInTheDocument();
expect(screen.getByText("Member24")).toBeInTheDocument();
expect(screen.queryByText("Member12")).not.toBeInTheDocument();
});

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

await selectCompanyOption("Anote");
fireEvent.change(screen.getByLabelText("Search members"), {
target: { value: "Member24" },
});

await screen.findByText("Showing 1 member", {}, { timeout: 2000 });
expect(screen.getByText("Member24")).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);

typeJobTitleAndCommit("Engineer");

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

test("preserves active filters while navigating between pages", async () => {
renderMembers();
await waitForLoaded();

typeJobTitleAndCommit("Engineer"); // 22 matches -> 3 pages
await screen.findByText("Showing 22 members");

fireEvent.click(screen.getByLabelText("Next page"));

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

test("clears all filters via the Clear filters action", async () => {
renderMembers();
await waitForLoaded();

await selectCompanyOption("Anote");
typeJobTitleAndCommit("CEO");
await screen.findByText("Showing 2 members");

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

await screen.findByText("Showing 25 members");
expect(screen.queryByRole("button", { name: /Remove Company filter/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Remove Job Title filter/ })).not.toBeInTheDocument();
});

test("shows an empty state with a clear-filters action when nothing matches", async () => {
renderMembers();
await waitForLoaded();

await selectCompanyOption("Other Co");
typeJobTitleAndCommit("CEO"); // Other Co members are all "Engineer"

await screen.findByText("No members match the selected filters.");
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));

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

test("removing an individual filter chip clears only that filter", async () => {
renderMembers();
await waitForLoaded();

await selectCompanyOption("Anote");
typeJobTitleAndCommit("CEO");
await screen.findByText("Showing 2 members");

fireEvent.click(screen.getByLabelText("Remove Job Title filter: CEO"));

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

test("Company dropdown options are deduped, blank-free, and case/whitespace-insensitive", async () => {
renderMembers();
await waitForLoaded();

fireEvent.focus(getCompanyCombobox());
const options = await screen.findAllByRole("option");
const optionTexts = options.map((o) => o.textContent);

expect(optionTexts).toEqual(["Anote", "Other Co"]);
expect(optionTexts).not.toContain("");
});

test("hydrates search/filter/page state from the URL on initial load", async () => {
renderMembers(["/community/members?company=Anote&jobTitle=CEO"]);
await waitForLoaded();

await screen.findByText("Showing 2 members");
expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove Job Title filter: CEO" })).toBeInTheDocument();
});

test("syncs active filters to the URL and omits empty parameters", async () => {
renderMembers();
await waitForLoaded();

await selectCompanyOption("Anote");

await waitFor(() => {
expect(screen.getByTestId("location-search").textContent).toContain("company=Anote");
});
const search = screen.getByTestId("location-search").textContent;
expect(search).not.toContain("jobTitle=");
expect(search).not.toContain("page=");

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

await waitFor(() => {
expect(screen.getByTestId("location-search").textContent).toBe("");
});
});
121 changes: 121 additions & 0 deletions frontend/src/__tests__/memberFilters.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
normalizeValue,
normalizeForCompare,
getUniqueOptions,
matchesGeneralSearch,
matchesCompanyFilter,
matchesJobTitleFilter,
applyMemberFilters,
} from "../landing_page/landing_page_armor/memberFilters";

const members = [
{ firstName: "Alice", lastName: "Chen", company: "Anote", title: "Founder / CEO" },
{ firstName: "Bob", lastName: "Diaz", company: " anote ", title: "Data Scientist" },
{ firstName: "Carol", lastName: "Evans", company: "OpenAI", title: "Research Engineer" },
{ firstName: "Dave", lastName: "Frank", company: "", title: "" },
{ firstName: "Erin", lastName: "Gomez", company: "OpenAI", title: "Founder & CEO" },
];

describe("normalizeValue / normalizeForCompare", () => {
test("trims whitespace and preserves original casing for normalizeValue", () => {
expect(normalizeValue(" Anote ")).toBe("Anote");
expect(normalizeValue(null)).toBe("");
expect(normalizeValue(undefined)).toBe("");
});

test("normalizeForCompare lowercases and trims", () => {
expect(normalizeForCompare(" Anote ")).toBe("anote");
});
});

describe("getUniqueOptions", () => {
test("dedupes case/whitespace-insensitively, keeping first-seen casing", () => {
const options = getUniqueOptions(members, "company");
expect(options).toEqual(["Anote", "OpenAI"]);
});

test("excludes blank/null values", () => {
const options = getUniqueOptions(members, "title");
expect(options).not.toContain("");
// Founder / CEO, Data Scientist, Research Engineer, Founder & CEO
expect(options.length).toBe(4);
});

test("returns options sorted case-insensitively", () => {
const options = getUniqueOptions(
[{ company: "zeta" }, { company: "Alpha" }, { company: "beta" }],
"company"
);
expect(options).toEqual(["Alpha", "beta", "zeta"]);
});
});

describe("matchesGeneralSearch", () => {
test("matches across name, company, and title", () => {
expect(matchesGeneralSearch(members[0], "anote")).toBe(true);
expect(matchesGeneralSearch(members[0], "chen")).toBe(true);
expect(matchesGeneralSearch(members[0], "ceo")).toBe(true);
expect(matchesGeneralSearch(members[0], "nomatch")).toBe(false);
});

test("empty query matches everything", () => {
expect(matchesGeneralSearch(members[0], "")).toBe(true);
});
});

describe("matchesCompanyFilter", () => {
test("matches case-insensitively and trims whitespace", () => {
expect(matchesCompanyFilter(members[0], "anote")).toBe(true);
expect(matchesCompanyFilter(members[0], "ANOTE")).toBe(true);
expect(matchesCompanyFilter(members[1], "Anote")).toBe(true); // " anote " stored value
});

test("does not fuzzy-match distinct company names", () => {
expect(matchesCompanyFilter(members[2], "Open")).toBe(false); // "OpenAI" != "Open"
});

test("empty filter matches everything", () => {
expect(matchesCompanyFilter(members[0], "")).toBe(true);
});
});

describe("matchesJobTitleFilter", () => {
test("keyword/contains matching for CEO variants", () => {
expect(matchesJobTitleFilter(members[0], "CEO")).toBe(true); // Founder / CEO
expect(matchesJobTitleFilter(members[4], "ceo")).toBe(true); // Founder & CEO
expect(matchesJobTitleFilter(members[1], "CEO")).toBe(false); // Data Scientist
});

test("empty filter matches everything", () => {
expect(matchesJobTitleFilter(members[0], "")).toBe(true);
});
});

describe("applyMemberFilters", () => {
test("combines search, company, and job title with AND semantics", () => {
const result = applyMemberFilters(members, {
query: "",
company: "OpenAI",
jobTitle: "CEO",
});
expect(result.map((m) => m.firstName)).toEqual(["Erin"]);
});

test("combines general search with company filter", () => {
const result = applyMemberFilters(members, {
query: "data",
company: "Anote",
jobTitle: "",
});
expect(result.map((m) => m.firstName)).toEqual(["Bob"]);
});

test("returns empty array when nothing matches", () => {
const result = applyMemberFilters(members, {
query: "",
company: "Nonexistent Co",
jobTitle: "",
});
expect(result).toEqual([]);
});
});
Loading
Loading