feat: add animated components for enhanced UI/UX
- 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.
This commit is contained in:
1 parent
0ab6930f92
commit
08dd046887
76 files changed
+21682
-1197
No files matched your search
@@ -0,0 +1,556 @@
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user