security and accessibility pass
This commit is contained in:
@@ -10,9 +10,17 @@
|
|||||||
--secondary: #f3f4f6;
|
--secondary: #f3f4f6;
|
||||||
--secondary-foreground: #171717;
|
--secondary-foreground: #171717;
|
||||||
--muted: #f3f4f6;
|
--muted: #f3f4f6;
|
||||||
--muted-foreground: #6b7280;
|
--muted-foreground: #5f6368;
|
||||||
--border: #e5e7eb;
|
--border: #e5e7eb;
|
||||||
--ring: #3b82f6;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -29,6 +37,14 @@
|
|||||||
--muted-foreground: #a3a3a3;
|
--muted-foreground: #a3a3a3;
|
||||||
--border: #262626;
|
--border: #262626;
|
||||||
--ring: #3b82f6;
|
--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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
frontend/src/components/questions/question-card.test.tsx
Normal file
92
frontend/src/components/questions/question-card.test.tsx
Normal file
@@ -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(<QuestionCard question={mockQuestion} />);
|
||||||
|
expect(screen.getByText("Two Sum")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders difficulty badge with correct label", () => {
|
||||||
|
render(<QuestionCard question={mockQuestion} />);
|
||||||
|
const badge = screen.getByText("Easy");
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
expect(badge).toHaveAttribute("aria-label", "Easy difficulty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links to question detail page", () => {
|
||||||
|
render(<QuestionCard question={mockQuestion} />);
|
||||||
|
const link = screen.getByRole("link");
|
||||||
|
expect(link).toHaveAttribute("href", "/questions/two-sum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all categories", () => {
|
||||||
|
render(<QuestionCard question={mockQuestion} />);
|
||||||
|
expect(screen.getByText("Arrays")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Hash Table")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders patterns", () => {
|
||||||
|
render(<QuestionCard question={mockQuestion} />);
|
||||||
|
expect(screen.getByText("Hash Map")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LeetCode ID when present", () => {
|
||||||
|
render(<QuestionCard question={mockQuestion} />);
|
||||||
|
expect(screen.getByText("LeetCode #1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render LeetCode ID when absent", () => {
|
||||||
|
const questionWithoutLeetcode = {
|
||||||
|
...mockQuestion,
|
||||||
|
leetcode_id: null,
|
||||||
|
};
|
||||||
|
render(<QuestionCard question={questionWithoutLeetcode} />);
|
||||||
|
expect(screen.queryByText(/LeetCode/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders medium difficulty with correct styling", () => {
|
||||||
|
const mediumQuestion = { ...mockQuestion, difficulty: "medium" as const };
|
||||||
|
render(<QuestionCard question={mediumQuestion} />);
|
||||||
|
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(<QuestionCard question={hardQuestion} />);
|
||||||
|
const badge = screen.getByText("Hard");
|
||||||
|
expect(badge).toHaveAttribute("aria-label", "Hard difficulty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty patterns array", () => {
|
||||||
|
const questionNoPatterns = { ...mockQuestion, patterns: [] };
|
||||||
|
render(<QuestionCard question={questionNoPatterns} />);
|
||||||
|
expect(screen.getByText("Two Sum")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Hash Map")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty categories array", () => {
|
||||||
|
const questionNoCategories = { ...mockQuestion, categories: [] };
|
||||||
|
render(<QuestionCard question={questionNoCategories} />);
|
||||||
|
expect(screen.getByText("Two Sum")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Arrays")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
198
frontend/src/components/questions/question-filters.test.tsx
Normal file
198
frontend/src/components/questions/question-filters.test.tsx
Normal file
@@ -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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Category")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders pattern filter with label", () => {
|
||||||
|
render(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Pattern")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all category options with counts", () => {
|
||||||
|
render(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const categorySelect = screen.getByLabelText("Category");
|
||||||
|
fireEvent.change(categorySelect, { target: { value: "arrays" } });
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/questions?category=arrays");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates when pattern is selected", () => {
|
||||||
|
render(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
currentCategory="arrays"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
currentPattern="two-pointers"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const patternSelect = screen.getByLabelText("Pattern");
|
||||||
|
fireEvent.change(patternSelect, { target: { value: "" } });
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/questions?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current category as selected", () => {
|
||||||
|
render(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
currentCategory="arrays"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const categorySelect = screen.getByLabelText("Category") as HTMLSelectElement;
|
||||||
|
expect(categorySelect.value).toBe("arrays");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current pattern as selected", () => {
|
||||||
|
render(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
currentPattern="two-pointers"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
currentCategory="strings"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<QuestionFilters
|
||||||
|
categories={mockCategories}
|
||||||
|
patterns={mockPatterns}
|
||||||
|
currentPattern="two-pointers"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const categorySelect = screen.getByLabelText("Category");
|
||||||
|
fireEvent.change(categorySelect, { target: { value: "arrays" } });
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(
|
||||||
|
"/questions?pattern=two-pointers&category=arrays"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
frontend/src/lib/utils.test.ts
Normal file
65
frontend/src/lib/utils.test.ts
Normal file
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,11 +7,22 @@ export function cn(...classes: (string | undefined | false)[]): string {
|
|||||||
export function getDifficultyColor(difficulty: Difficulty): string {
|
export function getDifficultyColor(difficulty: Difficulty): string {
|
||||||
switch (difficulty) {
|
switch (difficulty) {
|
||||||
case "easy":
|
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":
|
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":
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user