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"; } }