diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index e688465..8601c9a 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -10,9 +10,17 @@
--secondary: #f3f4f6;
--secondary-foreground: #171717;
--muted: #f3f4f6;
- --muted-foreground: #6b7280;
+ --muted-foreground: #5f6368;
--border: #e5e7eb;
--ring: #3b82f6;
+
+ /* Difficulty colors */
+ --difficulty-easy: #16a34a;
+ --difficulty-easy-bg: #dcfce7;
+ --difficulty-medium: #ca8a04;
+ --difficulty-medium-bg: #fef9c3;
+ --difficulty-hard: #dc2626;
+ --difficulty-hard-bg: #fee2e2;
}
@media (prefers-color-scheme: dark) {
@@ -29,6 +37,14 @@
--muted-foreground: #a3a3a3;
--border: #262626;
--ring: #3b82f6;
+
+ /* Difficulty colors (dark mode) */
+ --difficulty-easy: #4ade80;
+ --difficulty-easy-bg: rgba(34, 197, 94, 0.2);
+ --difficulty-medium: #facc15;
+ --difficulty-medium-bg: rgba(234, 179, 8, 0.2);
+ --difficulty-hard: #f87171;
+ --difficulty-hard-bg: rgba(239, 68, 68, 0.2);
}
}
diff --git a/frontend/src/components/questions/question-card.test.tsx b/frontend/src/components/questions/question-card.test.tsx
new file mode 100644
index 0000000..8db6dbe
--- /dev/null
+++ b/frontend/src/components/questions/question-card.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QuestionCard } from "./question-card";
+import type { QuestionListItem } from "@/types";
+
+const mockQuestion: QuestionListItem = {
+ id: "123",
+ title: "Two Sum",
+ slug: "two-sum",
+ difficulty: "easy",
+ leetcode_id: 1,
+ leetcode_url: "https://leetcode.com/problems/two-sum",
+ categories: [
+ { id: "cat1", name: "Arrays", slug: "arrays" },
+ { id: "cat2", name: "Hash Table", slug: "hash-table" },
+ ],
+ patterns: [{ id: "pat1", name: "Hash Map", slug: "hash-map" }],
+ created_at: "2025-01-01T00:00:00Z",
+};
+
+describe("QuestionCard", () => {
+ it("renders question title", () => {
+ render();
+ expect(screen.getByText("Two Sum")).toBeInTheDocument();
+ });
+
+ it("renders difficulty badge with correct label", () => {
+ render();
+ const badge = screen.getByText("Easy");
+ expect(badge).toBeInTheDocument();
+ expect(badge).toHaveAttribute("aria-label", "Easy difficulty");
+ });
+
+ it("links to question detail page", () => {
+ render();
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/questions/two-sum");
+ });
+
+ it("renders all categories", () => {
+ render();
+ expect(screen.getByText("Arrays")).toBeInTheDocument();
+ expect(screen.getByText("Hash Table")).toBeInTheDocument();
+ });
+
+ it("renders patterns", () => {
+ render();
+ expect(screen.getByText("Hash Map")).toBeInTheDocument();
+ });
+
+ it("renders LeetCode ID when present", () => {
+ render();
+ expect(screen.getByText("LeetCode #1")).toBeInTheDocument();
+ });
+
+ it("does not render LeetCode ID when absent", () => {
+ const questionWithoutLeetcode = {
+ ...mockQuestion,
+ leetcode_id: null,
+ };
+ render();
+ expect(screen.queryByText(/LeetCode/)).not.toBeInTheDocument();
+ });
+
+ it("renders medium difficulty with correct styling", () => {
+ const mediumQuestion = { ...mockQuestion, difficulty: "medium" as const };
+ render();
+ const badge = screen.getByText("Medium");
+ expect(badge).toHaveAttribute("aria-label", "Medium difficulty");
+ });
+
+ it("renders hard difficulty with correct styling", () => {
+ const hardQuestion = { ...mockQuestion, difficulty: "hard" as const };
+ render();
+ const badge = screen.getByText("Hard");
+ expect(badge).toHaveAttribute("aria-label", "Hard difficulty");
+ });
+
+ it("handles empty patterns array", () => {
+ const questionNoPatterns = { ...mockQuestion, patterns: [] };
+ render();
+ expect(screen.getByText("Two Sum")).toBeInTheDocument();
+ expect(screen.queryByText("Hash Map")).not.toBeInTheDocument();
+ });
+
+ it("handles empty categories array", () => {
+ const questionNoCategories = { ...mockQuestion, categories: [] };
+ render();
+ expect(screen.getByText("Two Sum")).toBeInTheDocument();
+ expect(screen.queryByText("Arrays")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/questions/question-filters.test.tsx b/frontend/src/components/questions/question-filters.test.tsx
new file mode 100644
index 0000000..9da5b04
--- /dev/null
+++ b/frontend/src/components/questions/question-filters.test.tsx
@@ -0,0 +1,198 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { QuestionFilters } from "./question-filters";
+import type { Category, Pattern } from "@/types";
+
+const mockPush = vi.fn();
+const mockSearchParams = new URLSearchParams();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+ useSearchParams: () => mockSearchParams,
+}));
+
+const mockCategories: Category[] = [
+ { id: "1", name: "Arrays", slug: "arrays", description: null, question_count: 5 },
+ { id: "2", name: "Strings", slug: "strings", description: null, question_count: 3 },
+];
+
+const mockPatterns: Pattern[] = [
+ {
+ id: "1",
+ name: "Two Pointers",
+ slug: "two-pointers",
+ description: null,
+ when_to_use: null,
+ question_count: 4,
+ },
+ {
+ id: "2",
+ name: "Sliding Window",
+ slug: "sliding-window",
+ description: null,
+ when_to_use: null,
+ question_count: 2,
+ },
+];
+
+describe("QuestionFilters", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSearchParams.delete("category");
+ mockSearchParams.delete("pattern");
+ mockSearchParams.delete("page");
+ });
+
+ it("renders category filter with label", () => {
+ render(
+
+ );
+ expect(screen.getByLabelText("Category")).toBeInTheDocument();
+ });
+
+ it("renders pattern filter with label", () => {
+ render(
+
+ );
+ expect(screen.getByLabelText("Pattern")).toBeInTheDocument();
+ });
+
+ it("renders all category options with counts", () => {
+ render(
+
+ );
+ expect(screen.getByText("All Categories")).toBeInTheDocument();
+ expect(screen.getByText("Arrays (5)")).toBeInTheDocument();
+ expect(screen.getByText("Strings (3)")).toBeInTheDocument();
+ });
+
+ it("renders all pattern options with counts", () => {
+ render(
+
+ );
+ expect(screen.getByText("All Patterns")).toBeInTheDocument();
+ expect(screen.getByText("Two Pointers (4)")).toBeInTheDocument();
+ expect(screen.getByText("Sliding Window (2)")).toBeInTheDocument();
+ });
+
+ it("navigates when category is selected", () => {
+ render(
+
+ );
+ const categorySelect = screen.getByLabelText("Category");
+ fireEvent.change(categorySelect, { target: { value: "arrays" } });
+ expect(mockPush).toHaveBeenCalledWith("/questions?category=arrays");
+ });
+
+ it("navigates when pattern is selected", () => {
+ render(
+
+ );
+ const patternSelect = screen.getByLabelText("Pattern");
+ fireEvent.change(patternSelect, { target: { value: "two-pointers" } });
+ expect(mockPush).toHaveBeenCalledWith("/questions?pattern=two-pointers");
+ });
+
+ it("removes category param when All Categories is selected", () => {
+ mockSearchParams.set("category", "arrays");
+ render(
+
+ );
+ const categorySelect = screen.getByLabelText("Category");
+ fireEvent.change(categorySelect, { target: { value: "" } });
+ expect(mockPush).toHaveBeenCalledWith("/questions?");
+ });
+
+ it("removes pattern param when All Patterns is selected", () => {
+ mockSearchParams.set("pattern", "two-pointers");
+ render(
+
+ );
+ const patternSelect = screen.getByLabelText("Pattern");
+ fireEvent.change(patternSelect, { target: { value: "" } });
+ expect(mockPush).toHaveBeenCalledWith("/questions?");
+ });
+
+ it("shows current category as selected", () => {
+ render(
+
+ );
+ const categorySelect = screen.getByLabelText("Category") as HTMLSelectElement;
+ expect(categorySelect.value).toBe("arrays");
+ });
+
+ it("shows current pattern as selected", () => {
+ render(
+
+ );
+ const patternSelect = screen.getByLabelText("Pattern") as HTMLSelectElement;
+ expect(patternSelect.value).toBe("two-pointers");
+ });
+
+ it("resets page when filter changes", () => {
+ mockSearchParams.set("page", "3");
+ mockSearchParams.set("category", "strings");
+ render(
+
+ );
+ const categorySelect = screen.getByLabelText("Category");
+ fireEvent.change(categorySelect, { target: { value: "arrays" } });
+ expect(mockPush).toHaveBeenCalledWith("/questions?category=arrays");
+ });
+
+ it("preserves other params when changing category", () => {
+ mockSearchParams.set("pattern", "two-pointers");
+ render(
+
+ );
+ const categorySelect = screen.getByLabelText("Category");
+ fireEvent.change(categorySelect, { target: { value: "arrays" } });
+ expect(mockPush).toHaveBeenCalledWith(
+ "/questions?pattern=two-pointers&category=arrays"
+ );
+ });
+});
diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts
new file mode 100644
index 0000000..7e0e9f1
--- /dev/null
+++ b/frontend/src/lib/utils.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from "vitest";
+import { cn, getDifficultyColor, getDifficultyLabel, capitalize } from "./utils";
+
+describe("cn", () => {
+ it("joins multiple class names", () => {
+ expect(cn("foo", "bar")).toBe("foo bar");
+ });
+
+ it("filters out falsy values", () => {
+ expect(cn("foo", undefined, false, "bar")).toBe("foo bar");
+ });
+
+ it("returns empty string for no classes", () => {
+ expect(cn()).toBe("");
+ });
+});
+
+describe("getDifficultyColor", () => {
+ it("returns easy difficulty CSS variable classes", () => {
+ const result = getDifficultyColor("easy");
+ expect(result).toContain("--difficulty-easy");
+ });
+
+ it("returns medium difficulty CSS variable classes", () => {
+ const result = getDifficultyColor("medium");
+ expect(result).toContain("--difficulty-medium");
+ });
+
+ it("returns hard difficulty CSS variable classes", () => {
+ const result = getDifficultyColor("hard");
+ expect(result).toContain("--difficulty-hard");
+ });
+});
+
+describe("getDifficultyLabel", () => {
+ it("returns accessible label for easy", () => {
+ expect(getDifficultyLabel("easy")).toBe("Easy difficulty");
+ });
+
+ it("returns accessible label for medium", () => {
+ expect(getDifficultyLabel("medium")).toBe("Medium difficulty");
+ });
+
+ it("returns accessible label for hard", () => {
+ expect(getDifficultyLabel("hard")).toBe("Hard difficulty");
+ });
+});
+
+describe("capitalize", () => {
+ it("capitalizes first letter", () => {
+ expect(capitalize("hello")).toBe("Hello");
+ });
+
+ it("handles already capitalized strings", () => {
+ expect(capitalize("Hello")).toBe("Hello");
+ });
+
+ it("handles single character", () => {
+ expect(capitalize("a")).toBe("A");
+ });
+
+ it("handles empty string", () => {
+ expect(capitalize("")).toBe("");
+ });
+});
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index 302e46c..8d76c53 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -7,11 +7,22 @@ export function cn(...classes: (string | undefined | false)[]): string {
export function getDifficultyColor(difficulty: Difficulty): string {
switch (difficulty) {
case "easy":
- return "text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30";
+ return "text-[var(--difficulty-easy)] bg-[var(--difficulty-easy-bg)]";
case "medium":
- return "text-yellow-600 bg-yellow-100 dark:text-yellow-400 dark:bg-yellow-900/30";
+ return "text-[var(--difficulty-medium)] bg-[var(--difficulty-medium-bg)]";
case "hard":
- return "text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30";
+ return "text-[var(--difficulty-hard)] bg-[var(--difficulty-hard-bg)]";
+ }
+}
+
+export function getDifficultyLabel(difficulty: Difficulty): string {
+ switch (difficulty) {
+ case "easy":
+ return "Easy difficulty";
+ case "medium":
+ return "Medium difficulty";
+ case "hard":
+ return "Hard difficulty";
}
}