Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{
}
124 changes: 124 additions & 0 deletions dongle/__tests__/components/FeaturedProjects.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import FeaturedProjects from "@/components/landing/FeaturedProjects";
import { projects, ALL_CATEGORIES } from "@/data/projects";

// Mock next/link to a plain anchor so we don't need the Next.js router
vi.mock("next/link", () => ({
default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
<a href={href} className={className}>
{children}
</a>
),
}));

beforeEach(() => {
sessionStorage.clear();
});

describe("FeaturedProjects component", () => {
it("renders the section heading", async () => {
render(<FeaturedProjects />);
expect(await screen.findByText("Featured Projects")).toBeInTheDocument();
});

it("renders a 'View all projects' link pointing to /discover", async () => {
render(<FeaturedProjects />);
const link = await screen.findByRole("link", { name: /view all projects/i });
expect(link).toHaveAttribute("href", "/discover");
});

it("renders category filter buttons for all categories", async () => {
render(<FeaturedProjects />);
for (const cat of ALL_CATEGORIES) {
expect(await screen.findByRole("button", { name: cat })).toBeInTheDocument();
}
});

it("renders sort dropdown with all options", async () => {
render(<FeaturedProjects />);
const select = await screen.findByRole("combobox");
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Top Rated" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Newest" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Most Reviewed" })).toBeInTheDocument();
});

it("shows at most 6 project cards after hydration", async () => {
render(<FeaturedProjects />);
// Wait for hydration — project names appear
await screen.findByText(projects[0].name);
const headings = screen.getAllByRole("heading", { level: 3 });
expect(headings.length).toBeLessThanOrEqual(6);
});

it("filters projects when a category button is clicked", async () => {
render(<FeaturedProjects />);
await screen.findByText(projects[0].name); // wait for hydration

const defiBtn = screen.getByRole("button", { name: "DeFi / DEX" });
fireEvent.click(defiBtn);

await waitFor(() => {
expect(defiBtn).toHaveAttribute("aria-pressed", "true");
});

const defiProjects = projects.filter((p) => p.category === "DeFi / DEX");
for (const p of defiProjects) {
expect(screen.getByText(p.name)).toBeInTheDocument();
}
});

it("shows empty state when no projects match the selected category", async () => {
// Temporarily mock projects to have no DAO entries — instead just pick a
// category that has no projects by filtering to a known-empty state.
// We'll click "DAO" and if there are DAO projects they show; if not, empty state shows.
// This test verifies the empty state renders without crashing.
render(<FeaturedProjects />);
await screen.findByText(projects[0].name);

// Find a category with zero projects (if any)
const emptyCategory = ALL_CATEGORIES.find(
(cat) => cat !== "All" && !projects.some((p) => p.category === cat)
);

if (emptyCategory) {
fireEvent.click(screen.getByRole("button", { name: emptyCategory }));
await screen.findByText(/no projects found/i);
} else {
// All categories have projects — just verify no crash on category switch
fireEvent.click(screen.getByRole("button", { name: "DeFi / DEX" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "DeFi / DEX" })
).toHaveAttribute("aria-pressed", "true")
);
}
});

it("updates sort when dropdown changes", async () => {
render(<FeaturedProjects />);
await screen.findByText(projects[0].name);

const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "recency" } });

await waitFor(() => {
expect((select as HTMLSelectElement).value).toBe("recency");
});
});

it("persists filter state to sessionStorage on interaction", async () => {
render(<FeaturedProjects />);
await screen.findByText(projects[0].name);

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

await waitFor(() => {
const stored = JSON.parse(
sessionStorage.getItem("dongle_project_filters") ?? "{}"
);
expect(stored.category).toBe("Infrastructure");
});
});
});
48 changes: 48 additions & 0 deletions dongle/__tests__/data/projects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { projects, ALL_CATEGORIES } from "@/data/projects";

describe("projects data", () => {
it("has at least one project", () => {
expect(projects.length).toBeGreaterThan(0);
});

it("every project has required fields", () => {
for (const p of projects) {
expect(p.id).toBeTruthy();
expect(p.name).toBeTruthy();
expect(p.category).toBeTruthy();
expect(p.description).toBeTruthy();
expect(typeof p.rating).toBe("number");
expect(typeof p.reviews).toBe("number");
expect(p.createdAt).toBeTruthy();
}
});

it("all project ids are unique", () => {
const ids = projects.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});

it("ratings are between 0 and 5", () => {
for (const p of projects) {
expect(p.rating).toBeGreaterThanOrEqual(0);
expect(p.rating).toBeLessThanOrEqual(5);
}
});

it("createdAt values are valid ISO dates", () => {
for (const p of projects) {
expect(new Date(p.createdAt).toString()).not.toBe("Invalid Date");
}
});

it("ALL_CATEGORIES includes 'All'", () => {
expect(ALL_CATEGORIES).toContain("All");
});

it("every project category exists in ALL_CATEGORIES", () => {
for (const p of projects) {
expect(ALL_CATEGORIES).toContain(p.category);
}
});
});
118 changes: 118 additions & 0 deletions dongle/__tests__/hooks/useProjectFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useProjectFilters } from "@/hooks/useProjectFilters";
import { projects } from "@/data/projects";

// sessionStorage is available in jsdom but we reset it between tests
beforeEach(() => {
sessionStorage.clear();
});

describe("useProjectFilters", () => {
it("returns all projects by default (no limit)", () => {
const { result } = renderHook(() => useProjectFilters());
// wait for hydration effect
act(() => {});
expect(result.current.filtered.length).toBe(projects.length);
});

it("respects the limit parameter", () => {
const { result } = renderHook(() => useProjectFilters(2));
act(() => {});
expect(result.current.filtered.length).toBeLessThanOrEqual(2);
});

it("defaults to 'All' category and 'rating' sort", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {});
expect(result.current.filters.category).toBe("All");
expect(result.current.filters.sort).toBe("rating");
});

it("filters by category correctly", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {
result.current.setCategory("DeFi / DEX");
});
const categories = result.current.filtered.map((p) => p.category);
expect(categories.every((c) => c === "DeFi / DEX")).toBe(true);
});

it("returns empty array when no projects match category", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {
result.current.setCategory("DAO");
});
// DAO projects exist in our data — just verify it filters correctly
const daoProjects = projects.filter((p) => p.category === "DAO");
expect(result.current.filtered.length).toBe(daoProjects.length);
});

it("sorts by rating descending", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {
result.current.setSort("rating");
});
const ratings = result.current.filtered.map((p) => p.rating);
for (let i = 0; i < ratings.length - 1; i++) {
expect(ratings[i]).toBeGreaterThanOrEqual(ratings[i + 1]);
}
});

it("sorts by recency descending", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {
result.current.setSort("recency");
});
const dates = result.current.filtered.map((p) =>
new Date(p.createdAt).getTime()
);
for (let i = 0; i < dates.length - 1; i++) {
expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]);
}
});

it("sorts by reviews descending", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {
result.current.setSort("reviews");
});
const reviews = result.current.filtered.map((p) => p.reviews);
for (let i = 0; i < reviews.length - 1; i++) {
expect(reviews[i]).toBeGreaterThanOrEqual(reviews[i + 1]);
}
});

it("persists filter state to sessionStorage", () => {
const { result } = renderHook(() => useProjectFilters());
act(() => {
result.current.setCategory("Gaming / NFT");
result.current.setSort("recency");
});
const stored = JSON.parse(
sessionStorage.getItem("dongle_project_filters") ?? "{}"
);
expect(stored.category).toBe("Gaming / NFT");
expect(stored.sort).toBe("recency");
});

it("restores persisted state on mount", () => {
sessionStorage.setItem(
"dongle_project_filters",
JSON.stringify({ category: "Infrastructure", sort: "reviews" })
);
const { result } = renderHook(() => useProjectFilters());
// After hydration effect fires
act(() => {});
expect(result.current.filters.category).toBe("Infrastructure");
expect(result.current.filters.sort).toBe("reviews");
});

it("falls back to defaults when sessionStorage has corrupt data", () => {
sessionStorage.setItem("dongle_project_filters", "not-valid-json{{{");
const { result } = renderHook(() => useProjectFilters());
act(() => {});
expect(result.current.filters.category).toBe("All");
expect(result.current.filters.sort).toBe("rating");
});
});
Loading
Loading