diff --git a/src/components/filterable-section.tsx b/src/components/filterable-section.tsx index 860d645..1c65c8a 100644 --- a/src/components/filterable-section.tsx +++ b/src/components/filterable-section.tsx @@ -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[]; @@ -80,7 +80,7 @@ export const FilterableSection = ({ // ⚡ 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), ); diff --git a/src/components/sections/skills.test.tsx b/src/components/sections/skills.test.tsx new file mode 100644 index 0000000..77f6900 --- /dev/null +++ b/src/components/sections/skills.test.tsx @@ -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 }) => ( +
+ {children} +
+ ), +})); + +vi.mock("../empty-state", () => ({ + EmptyState: () =>
No results found
, +})); + +describe("SkillsSection", () => { + let mockSelected: Record = {}; + 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(); + 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(); + 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(); + // 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(); + 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(); + expect(getByTestId("empty-state")).toBeInTheDocument(); + expect(queryByText("Python")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/sections/skills.tsx b/src/components/sections/skills.tsx index 1b6583c..82c0a6c 100644 --- a/src/components/sections/skills.tsx +++ b/src/components/sections/skills.tsx @@ -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(); @@ -19,22 +21,20 @@ export const SkillsSection = memo(() => { const skills = selected["skills"]; const areaMatchingSkills = useMemo(() => { - let matchingSkills: Set; 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(); + 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 = diff --git a/src/components/skills.test.tsx b/src/components/skills.test.tsx index 143f9c8..8a21ea6 100644 --- a/src/components/skills.test.tsx +++ b/src/components/skills.test.tsx @@ -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>(); const MockTooltip = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); MockTooltip.displayName = "MockTooltip"; - const Trigger = ({ children }: { children: ReactNode }) => <>{children}; + const Trigger = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); Trigger.displayName = "MockTooltip.Trigger"; MockTooltip.Trigger = Trigger; const Content = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); Content.displayName = "MockTooltip.Content"; MockTooltip.Content = Content; + MockTooltip.Arrow = () => null; + return { ...actual, Tooltip: MockTooltip }; }); -describe("Areas", () => { - it("renders correctly", () => { - const { container } = render(); - expect(container).toBeInTheDocument(); +describe("Skills components", () => { + describe("Areas", () => { + it("renders empty list correctly", () => { + const { container } = render(); + expect(container.querySelector("ul")).toBeInTheDocument(); + expect(container.querySelectorAll("li").length).toBe(0); + }); + + it("renders valid areas and skips invalid ones", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("Cloud"); + expect(container.textContent).not.toContain("invalid_area_id"); + }); + }); + + describe("Tools", () => { + it("renders empty tools correctly", () => { + const { container } = render(); + expect(container.querySelector("ul")).toBeInTheDocument(); + }); + + it("renders compact tools and skips invalid ones", () => { + const { container, getAllByLabelText } = render( + , + ); + 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( + , + ); + expect(getAllByLabelText("Python")[0]).toBeInTheDocument(); + expect(container.querySelectorAll("li").length).toBe(1); + }); + }); + + describe("Tags", () => { + it("renders tags correctly", () => { + const { container } = render(); + 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(); + expect(getByText("Kotlin")).toBeInTheDocument(); + expect(getByText("Swift")).toBeInTheDocument(); + expect(getByText("kotlin-icon")).toBeInTheDocument(); + expect(getByText("swift-icon")).toBeInTheDocument(); + }); }); }); diff --git a/src/components/skills.tsx b/src/components/skills.tsx index db621e4..aa0fab4 100644 --- a/src/components/skills.tsx +++ b/src/components/skills.tsx @@ -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, @@ -13,7 +14,7 @@ export const Areas = ({ }) => (
    {areas.map((areaId) => { - const area = areasData[areaId.toLowerCase()]; + const area = areasData[toLowerCaseCached(areaId)]; if (!area) return null; return (
  • @@ -47,7 +48,7 @@ export const Tools = ({ }) => (
      {tools.map((toolId) => { - const tool = skillsData[toolId.toLowerCase()]; + const tool = skillsData[toLowerCaseCached(toolId)]; if (!tool) return null; return (
    • diff --git a/vitest.config.ts b/vitest.config.ts index 94b179a..0c29544 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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", ], }, },