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
4 changes: 2 additions & 2 deletions src/components/filterable-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Section } from "@/components/section";
import { useFilter } from "@/contexts/filter";
import { useDebouncedSearch } from "@/contexts/search";
import { Position, Project, Certification, Degree, Publication } from "@/types";
import { filterByQuery, filterByArea, filterBySelection } from "@/utils/filter";
import { filterByQuery, filterByArea, filterBySelection, toLowerCaseCached } from "@/utils/filter";

export interface FilterableItem {
tags?: string[];
Expand Down Expand Up @@ -80,7 +80,7 @@ export const FilterableSection = <T extends FilterableItem>({

// ⚡ Optimization: Separate search filtering from selection filtering to minimize re-computations.
const filteredItems = useMemo(() => {
const lowercaseQuery = debouncedQuery.toLowerCase();
const lowercaseQuery = toLowerCaseCached(debouncedQuery);
const filtered = matchingItems.filter((item) =>
filterByQuery(item as unknown as SupportedItem, lowercaseQuery),
);
Expand Down
105 changes: 105 additions & 0 deletions src/components/sections/skills.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";

import React, { ReactNode } from "react";

import { render } from "@testing-library/react";

import { useFilter } from "@/contexts/filter";
import { useDebouncedSearch } from "@/contexts/search";

import { SkillsSection } from "./skills";

// Mock the context hooks
vi.mock("@/contexts/filter", () => ({
useFilter: vi.fn(),
}));

vi.mock("@/contexts/search", () => ({
useDebouncedSearch: vi.fn(),
}));

// Mock Section and EmptyState to simplify output checking
vi.mock("../section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
<div data-testid="section" data-title={title}>
{children}
</div>
),
}));

vi.mock("../empty-state", () => ({
EmptyState: () => <div data-testid="empty-state">No results found</div>,
}));

describe("SkillsSection", () => {
let mockSelected: Record<string, string[]> = {};
let mockDebouncedQuery = "";

beforeEach(() => {
vi.clearAllMocks();
mockSelected = {};
mockDebouncedQuery = "";

(useFilter as any).mockReturnValue({
selected: mockSelected,
});
(useDebouncedSearch as any).mockReturnValue({
debouncedQuery: mockDebouncedQuery,
});
});

it("renders all skills by default when no search query or filter is applied", () => {
const { container } = render(<SkillsSection />);
expect(container.textContent).toContain("Python");
expect(container.textContent).toContain("TypeScript");
expect(container.textContent).toContain("Java");
expect(container.querySelector('[data-testid="empty-state"]')).not.toBeInTheDocument();
});

it("filters skills by search query", () => {
(useDebouncedSearch as any).mockReturnValue({
debouncedQuery: "Python",
});

const { container } = render(<SkillsSection />);
expect(container.textContent).toContain("Python");
expect(container.textContent).not.toContain("TypeScript");
expect(container.textContent).not.toContain("Java");
});

it("filters skills by selected areas", () => {
mockSelected["areas"] = ["cloud"];
(useFilter as any).mockReturnValue({
selected: mockSelected,
});

const { container } = render(<SkillsSection />);
// Cloud skills in areaSkills include AWS, Docker, Kubernetes, etc. but not Kotlin or Java
expect(container.textContent).toContain("AWS");
expect(container.textContent).toContain("Docker");
expect(container.textContent).not.toContain("Kotlin");
});

it("filters skills by specifically selected skills", () => {
mockSelected["skills"] = ["kotlin", "swift"];
(useFilter as any).mockReturnValue({
selected: mockSelected,
});

const { container } = render(<SkillsSection />);
expect(container.textContent).toContain("Kotlin");
expect(container.textContent).toContain("Swift");
expect(container.textContent).not.toContain("Python");
});

it("renders EmptyState when no skills match the search/filter criteria", () => {
(useDebouncedSearch as any).mockReturnValue({
debouncedQuery: "non_existent_skill_name_xyz",
});

const { getByTestId, queryByText } = render(<SkillsSection />);
expect(getByTestId("empty-state")).toBeInTheDocument();
expect(queryByText("Python")).not.toBeInTheDocument();
});
});
16 changes: 8 additions & 8 deletions src/components/sections/skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { toLowerCaseCached } from "@/utils/filter";
import { EmptyState } from "../empty-state";
import { Section } from "../section";

const ALL_SKILLS_SET = new Set(Object.keys(skillsData));

export const SkillsSection = memo(() => {
const { debouncedQuery } = useDebouncedSearch();
const { selected } = useFilter();
Expand All @@ -19,22 +21,20 @@ export const SkillsSection = memo(() => {
const skills = selected["skills"];

const areaMatchingSkills = useMemo(() => {
let matchingSkills: Set<string>;
if (!areas || areas.length === 0) {
matchingSkills = new Set(Object.keys(skillsData));
} else {
matchingSkills = new Set();
areas.forEach((area) => {
areaSkills[area]?.forEach((skill) => matchingSkills.add(skill));
});
return ALL_SKILLS_SET;
}
const matchingSkills = new Set<string>();
areas.forEach((area) => {
areaSkills[area]?.forEach((skill) => matchingSkills.add(skill));
});
return matchingSkills;
}, [areas]);

const selectedSkills = useMemo(() => new Set(skills || []), [skills]);

const filteredSkills = useMemo(() => {
const lowercaseQuery = debouncedQuery.toLowerCase();
const lowercaseQuery = toLowerCaseCached(debouncedQuery);

return Object.entries(skillsData).filter(([key, skill]) => {
const matchesQuery =
Expand Down
77 changes: 69 additions & 8 deletions src/components/skills.test.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,96 @@
/* eslint-disable react/display-name, @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi } from "vitest";

import type { ReactNode } from "react";

import { render } from "@testing-library/react";

import { Areas } from "./skills";
import Skills, { Areas, Tools, Tags } from "./skills";

vi.mock("@heroui/react", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();

const MockTooltip = ({ children }: { children: ReactNode }) => (
<div>{children}</div>
<div data-testid="tooltip">{children}</div>
);
MockTooltip.displayName = "MockTooltip";

const Trigger = ({ children }: { children: ReactNode }) => <>{children}</>;
const Trigger = ({ children }: { children: ReactNode }) => (
<div data-testid="tooltip-trigger">{children}</div>
);
Trigger.displayName = "MockTooltip.Trigger";
MockTooltip.Trigger = Trigger;

const Content = ({ children }: { children: ReactNode }) => (
<div>{children}</div>
<div data-testid="tooltip-content">{children}</div>
);
Content.displayName = "MockTooltip.Content";
MockTooltip.Content = Content;

MockTooltip.Arrow = () => null;

return { ...actual, Tooltip: MockTooltip };
});

describe("Areas", () => {
it("renders correctly", () => {
const { container } = render(<Areas areas={[]} />);
expect(container).toBeInTheDocument();
describe("Skills components", () => {
describe("Areas", () => {
it("renders empty list correctly", () => {
const { container } = render(<Areas areas={[]} />);
expect(container.querySelector("ul")).toBeInTheDocument();
expect(container.querySelectorAll("li").length).toBe(0);
});

it("renders valid areas and skips invalid ones", () => {
const { container } = render(
<Areas areas={["cloud", "invalid_area_id"]} />,
);
expect(container.textContent).toContain("Cloud");
expect(container.textContent).not.toContain("invalid_area_id");
});
});

describe("Tools", () => {
it("renders empty tools correctly", () => {
const { container } = render(<Tools tools={[]} />);
expect(container.querySelector("ul")).toBeInTheDocument();
});

it("renders compact tools and skips invalid ones", () => {
const { container, getAllByLabelText } = render(
<Tools tools={["python", "nonexistent"]} compact={true} />,
);
expect(getAllByLabelText("Python")[0]).toBeInTheDocument();
expect(container.querySelectorAll("li").length).toBe(1);
});

it("renders non-compact tools and skips invalid ones", () => {
const { container, getAllByLabelText } = render(
<Tools tools={["python", "nonexistent"]} compact={false} />,
);
expect(getAllByLabelText("Python")[0]).toBeInTheDocument();
expect(container.querySelectorAll("li").length).toBe(1);
});
});

describe("Tags", () => {
it("renders tags correctly", () => {
const { container } = render(<Tags tags={["AI", "Mobile"]} />);
expect(container.textContent).toContain("AI");
expect(container.textContent).toContain("Mobile");
});
});

describe("Skills (default export)", () => {
it("renders the skill list correctly", () => {
const mockSkills = [
{ name: "Kotlin", icon: "kotlin-icon" },
{ name: "Swift", icon: "swift-icon" },
];
const { getByText } = render(<Skills skills={mockSkills as any} />);
expect(getByText("Kotlin")).toBeInTheDocument();
expect(getByText("Swift")).toBeInTheDocument();
expect(getByText("kotlin-icon")).toBeInTheDocument();
expect(getByText("swift-icon")).toBeInTheDocument();
});
});
});
5 changes: 3 additions & 2 deletions src/components/skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Tooltip } from "@heroui/react";
import areasData from "@/data/areas";
import skillsData from "@/data/skills";
import { Skill } from "@/types";
import { toLowerCaseCached } from "@/utils/filter";

export const Areas = ({
areas,
Expand All @@ -13,7 +14,7 @@ export const Areas = ({
}) => (
<ul className={`flex flex-row flex-wrap gap-2 ${className}`}>
{areas.map((areaId) => {
const area = areasData[areaId.toLowerCase()];
const area = areasData[toLowerCaseCached(areaId)];
if (!area) return null;
return (
<li key={area.name}>
Expand Down Expand Up @@ -47,7 +48,7 @@ export const Tools = ({
}) => (
<ul className="flex flex-row flex-wrap gap-2">
{tools.map((toolId) => {
const tool = skillsData[toolId.toLowerCase()];
const tool = skillsData[toLowerCaseCached(toolId)];
if (!tool) return null;
return (
<li key={tool.name} className="flex flex-col items-center">
Expand Down
2 changes: 2 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export default defineConfig({
"src/components/featured-section-container.tsx",
"src/components/header.tsx",
"src/components/chat/client.tsx",
"src/components/skills.tsx",
"src/components/sections/skills.tsx",
],
},
},
Expand Down
Loading