- Introduced DrawLine component for drawing animations. - Added LazyRevealImage component for lazy loading images with reveal animations. - Implemented TextDiffuseReveal for animated text reveals with staggered effects. - Created TopProgressBar for a dynamic loading indicator. - Added PointCloud mock for testing animations. - Developed useReducedMotion hook to handle user preferences for reduced motion. - Centralized animation configurations in config.ts for easier management. - Defined animation variants in variants.ts for consistent animation effects. - Established AnimationProvider to supply global animation settings. - Created tests for AnimationProvider and useReducedMotion hook. - Added SVG assets for branding and UI elements. - Documented frontend review plan and hero circuit board animation plan.
557 lines
17 KiB
TypeScript
557 lines
17 KiB
TypeScript
import { render } from "@testing-library/react";
|
|
import Experience from "./Experience";
|
|
import fc from "fast-check";
|
|
import { usePortfolioData } from "@/lib/usePortfolioData";
|
|
|
|
// Mock the portfolio data hook
|
|
jest.mock("@/lib/usePortfolioData");
|
|
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
|
typeof usePortfolioData
|
|
>;
|
|
|
|
// Mock framer-motion
|
|
jest.mock("framer-motion", () => {
|
|
const actual = jest.requireActual("framer-motion");
|
|
return {
|
|
...actual,
|
|
useReducedMotion: () => false,
|
|
motion: {
|
|
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
article: ({ children, ...props }: any) => (
|
|
<article {...props}>{children}</article>
|
|
),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Mock Section component
|
|
jest.mock("./Section", () => ({
|
|
Section: ({ children, ...props }: any) => (
|
|
<section {...props}>{children}</section>
|
|
),
|
|
}));
|
|
|
|
// Mock animation components
|
|
jest.mock("./animations/DrawLine", () => ({
|
|
DrawLine: () => <div data-testid="draw-line" />,
|
|
}));
|
|
|
|
jest.mock("./animations/CounterValue", () => ({
|
|
CounterValue: ({ value, suffix }: any) => (
|
|
<span>
|
|
{value}
|
|
{suffix}
|
|
</span>
|
|
),
|
|
}));
|
|
|
|
// Mock SectionLoading
|
|
jest.mock("./SectionLoading", () => ({
|
|
SectionLoading: ({ message }: any) => <div>{message}</div>,
|
|
}));
|
|
|
|
describe("Property 5: Experience Categorization", () => {
|
|
// Counter for unique IDs
|
|
let idCounter = 0;
|
|
|
|
// Generator for experience entries
|
|
const experienceArbitrary = (type: "Software" | "Hardware") =>
|
|
fc.record({
|
|
id: fc
|
|
.string({ minLength: 10, maxLength: 20 })
|
|
.map((s) => `exp-${type.toLowerCase()}-${idCounter++}-${s}`),
|
|
title: fc.string({ minLength: 5, maxLength: 50 }),
|
|
company: fc.string({ minLength: 3, maxLength: 30 }),
|
|
period: fc.string({ minLength: 10, maxLength: 30 }),
|
|
location: fc.string({ minLength: 5, maxLength: 40 }),
|
|
type: fc.constant(type),
|
|
description: fc.string({ minLength: 20, maxLength: 200 }),
|
|
achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), {
|
|
minLength: 1,
|
|
maxLength: 5,
|
|
}),
|
|
technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), {
|
|
minLength: 1,
|
|
maxLength: 10,
|
|
}),
|
|
featured: fc.constant(true),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
idCounter = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it("should filter and render Software experiences in the Software category", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any experience entry with type="Software", it should be
|
|
* filtered and rendered within the Software category group. This ensures
|
|
* correct categorization of software-related work experiences.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 5,
|
|
}),
|
|
fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 0,
|
|
maxLength: 5,
|
|
}),
|
|
(softwareExps, hardwareExps) => {
|
|
const allExperiences = [...softwareExps, ...hardwareExps];
|
|
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
// Find the Software category section
|
|
const softwareHeading = Array.from(
|
|
container.querySelectorAll("h3"),
|
|
).find((h3) => h3.textContent?.includes("Software"));
|
|
|
|
if (!softwareHeading) {
|
|
// If no software heading, there should be no software experiences
|
|
return softwareExps.length === 0;
|
|
}
|
|
|
|
// Get the parent container of software experiences
|
|
const softwareSection = softwareHeading.closest("div");
|
|
if (!softwareSection) return false;
|
|
|
|
// Count experience cards in the software section
|
|
const softwareCards = softwareSection.querySelectorAll("article");
|
|
|
|
// The number of software cards should match the number of software experiences
|
|
return softwareCards.length === softwareExps.length;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should filter and render Hardware experiences in the Hardware category", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any experience entry with type="Hardware", it should be
|
|
* filtered and rendered within the Hardware category group. This ensures
|
|
* correct categorization of hardware-related work experiences.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 0,
|
|
maxLength: 5,
|
|
}),
|
|
fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 1,
|
|
maxLength: 5,
|
|
}),
|
|
(softwareExps, hardwareExps) => {
|
|
const allExperiences = [...softwareExps, ...hardwareExps];
|
|
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
// Find the Hardware category section
|
|
const hardwareHeading = Array.from(
|
|
container.querySelectorAll("h3"),
|
|
).find((h3) => h3.textContent?.includes("Hardware"));
|
|
|
|
if (!hardwareHeading) {
|
|
// If no hardware heading, there should be no hardware experiences
|
|
return hardwareExps.length === 0;
|
|
}
|
|
|
|
// Get the parent container of hardware experiences
|
|
const hardwareSection = hardwareHeading.closest("div");
|
|
if (!hardwareSection) return false;
|
|
|
|
// Count experience cards in the hardware section
|
|
const hardwareCards = hardwareSection.querySelectorAll("article");
|
|
|
|
// The number of hardware cards should match the number of hardware experiences
|
|
return hardwareCards.length === hardwareExps.length;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should separate Software and Hardware experiences into distinct category groups", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any mix of Software and Hardware experiences, they should be
|
|
* rendered in separate category groups with distinct headings. No experience
|
|
* should appear in both categories.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 3,
|
|
}),
|
|
fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 1,
|
|
maxLength: 3,
|
|
}),
|
|
(softwareExps, hardwareExps) => {
|
|
const allExperiences = [...softwareExps, ...hardwareExps];
|
|
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
// Find both category headings
|
|
const headings = Array.from(container.querySelectorAll("h3"));
|
|
const softwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Software"),
|
|
);
|
|
const hardwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Hardware"),
|
|
);
|
|
|
|
// Both headings should exist
|
|
if (!softwareHeading || !hardwareHeading) return false;
|
|
|
|
// Get all experience cards
|
|
const allCards = container.querySelectorAll("article");
|
|
|
|
// Total cards should equal total experiences
|
|
return allCards.length === allExperiences.length;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should not render category heading when no experiences exist for that type", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any category (Software or Hardware) with zero experiences,
|
|
* the category heading should not be rendered. This ensures clean UI without
|
|
* empty category sections.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.oneof(
|
|
fc.record({
|
|
software: fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 5,
|
|
}),
|
|
hardware: fc.constant([]),
|
|
}),
|
|
fc.record({
|
|
software: fc.constant([]),
|
|
hardware: fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 1,
|
|
maxLength: 5,
|
|
}),
|
|
}),
|
|
),
|
|
({ software, hardware }) => {
|
|
const allExperiences = [...software, ...hardware];
|
|
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
const headings = Array.from(container.querySelectorAll("h3"));
|
|
const softwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Software"),
|
|
);
|
|
const hardwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Hardware"),
|
|
);
|
|
|
|
// If software array is empty, no software heading should exist
|
|
const softwareCheck =
|
|
software.length === 0 ? !softwareHeading : !!softwareHeading;
|
|
|
|
// If hardware array is empty, no hardware heading should exist
|
|
const hardwareCheck =
|
|
hardware.length === 0 ? !hardwareHeading : !!hardwareHeading;
|
|
|
|
return softwareCheck && hardwareCheck;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should maintain experience data integrity within each category", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any experience entry, its data (title, company, description)
|
|
* should be rendered correctly within its assigned category. The categorization
|
|
* process should not modify or lose experience data.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 3,
|
|
}),
|
|
fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 1,
|
|
maxLength: 3,
|
|
}),
|
|
(softwareExps, hardwareExps) => {
|
|
const allExperiences = [...softwareExps, ...hardwareExps];
|
|
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
// Check that all experience titles are present in the rendered output
|
|
const allTitlesPresent = allExperiences.every((exp) => {
|
|
const titleElements = Array.from(container.querySelectorAll("h3"));
|
|
return titleElements.some((el) =>
|
|
el.textContent?.includes(exp.title),
|
|
);
|
|
});
|
|
|
|
// Check that all company names are present
|
|
const allCompaniesPresent = allExperiences.every((exp) => {
|
|
return container.textContent?.includes(exp.company);
|
|
});
|
|
|
|
return allTitlesPresent && allCompaniesPresent;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should handle mixed case and variations in type field values", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any experience entry, the type field should be matched
|
|
* case-sensitively to ensure correct categorization. Only exact matches
|
|
* to "Software" or "Hardware" should be categorized.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 3,
|
|
}),
|
|
(softwareExps) => {
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: softwareExps,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
// Find the Software category heading
|
|
const headings = Array.from(container.querySelectorAll("h3"));
|
|
const softwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Software"),
|
|
);
|
|
|
|
// Software heading should exist
|
|
if (!softwareHeading) return false;
|
|
|
|
// All experiences should be rendered
|
|
const cards = container.querySelectorAll("article");
|
|
return cards.length === softwareExps.length;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should render all experiences when both categories have entries", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any non-empty set of Software and Hardware experiences,
|
|
* all experiences should be rendered across both categories. No experiences
|
|
* should be lost during the categorization process.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 4,
|
|
}),
|
|
fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 1,
|
|
maxLength: 4,
|
|
}),
|
|
(softwareExps, hardwareExps) => {
|
|
const allExperiences = [...softwareExps, ...hardwareExps];
|
|
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container } = render(<Experience />);
|
|
|
|
// Count all rendered experience cards
|
|
const allCards = container.querySelectorAll("article");
|
|
|
|
// Total cards should equal total experiences
|
|
return allCards.length === allExperiences.length;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
it("should maintain category separation across multiple renders", () => {
|
|
/**
|
|
* **Validates: Requirements 4.1**
|
|
*
|
|
* Property: For any number of component renders with the same data,
|
|
* the categorization should remain consistent. Software experiences
|
|
* should always appear in the Software category and Hardware experiences
|
|
* in the Hardware category.
|
|
*/
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 2, max: 4 }), // Number of renders
|
|
fc.array(experienceArbitrary("Software"), {
|
|
minLength: 1,
|
|
maxLength: 2,
|
|
}),
|
|
fc.array(experienceArbitrary("Hardware"), {
|
|
minLength: 1,
|
|
maxLength: 2,
|
|
}),
|
|
(numRenders, softwareExps, hardwareExps) => {
|
|
const allExperiences = [...softwareExps, ...hardwareExps];
|
|
|
|
let allRendersValid = true;
|
|
|
|
for (let i = 0; i < numRenders; i++) {
|
|
mockUsePortfolioData.mockReturnValue({
|
|
data: {
|
|
experiences: allExperiences,
|
|
personal: {} as any,
|
|
projects: [],
|
|
skills: { technical: [], soft: [] },
|
|
contact: {} as any,
|
|
social: [],
|
|
},
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const { container, unmount } = render(<Experience />);
|
|
|
|
// Check that both categories exist
|
|
const headings = Array.from(container.querySelectorAll("h3"));
|
|
const softwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Software"),
|
|
);
|
|
const hardwareHeading = headings.find((h3) =>
|
|
h3.textContent?.includes("Hardware"),
|
|
);
|
|
|
|
if (!softwareHeading || !hardwareHeading) {
|
|
allRendersValid = false;
|
|
}
|
|
|
|
// Check total card count
|
|
const allCards = container.querySelectorAll("article");
|
|
if (allCards.length !== allExperiences.length) {
|
|
allRendersValid = false;
|
|
}
|
|
|
|
unmount();
|
|
}
|
|
|
|
return allRendersValid;
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
});
|