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) =>
{children}
,
article: ({ children, ...props }: any) => (
{children}
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => ,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
{value}
{suffix}
),
}));
// Mock SectionLoading
jest.mock("./SectionLoading", () => ({
SectionLoading: ({ message }: any) => {message}
,
}));
describe("Property 11: Semantic Heading Hierarchy", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should maintain proper heading hierarchy (h2 for section, h3 for cards)", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any sequence of heading elements in the Experience section,
* the heading levels should follow proper semantic hierarchy (h2 for section
* title, h3 for experience card titles, no skipped levels). This ensures
* screen readers can properly navigate the document structure.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 10 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render();
// Get all heading elements in order
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
if (headings.length === 0) {
return false;
}
// Extract heading levels
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
// Check for proper hierarchy:
// 1. Should start with h2 (section title)
// 2. Experience card titles should be h3
// 3. No skipped levels (difference should not be > 1)
// First heading should be h2 (section title)
if (levels[0] !== 2) {
return false;
}
// Check for skipped levels
for (let i = 1; i < levels.length; i++) {
const diff = levels[i] - levels[i - 1];
// Difference should be -1, 0, or 1 (no skipping levels)
if (Math.abs(diff) > 1 && diff > 0) {
return false;
}
}
// All experience card titles should be h3
const cardTitles = container.querySelectorAll("article h3");
if (cardTitles.length === 0) {
return false;
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should not skip heading levels in the hierarchy", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any generated set of experiences, the heading hierarchy
* should never skip levels (e.g., h2 directly to h4). This is critical
* for accessibility as screen reader users rely on heading levels to
* understand document structure.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
title: fc.lorem({ maxCount: 5 }),
company: fc.lorem({ maxCount: 2 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.lorem({ maxCount: 10 }),
achievements: fc.array(fc.lorem({ maxCount: 5 }), { maxLength: 3 }),
technologies: fc.array(fc.lorem({ maxCount: 1 }), { maxLength: 5 }),
featured: fc.constant(true),
}),
{ minLength: 2, maxLength: 8 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render();
// Get all headings
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
// Check that we never skip levels when going deeper
for (let i = 1; i < levels.length; i++) {
const currentLevel = levels[i];
const previousLevel = levels[i - 1];
// When going deeper (increasing level number), should only increase by 1
if (
currentLevel > previousLevel &&
currentLevel - previousLevel > 1
) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should use h2 for section title consistently", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any Experience section render, the main section title
* should always be an h2 element. This maintains consistency with the
* overall page structure where main sections use h2.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 5 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render();
// Find the section element
const section = container.querySelector("section");
if (!section) return false;
// The first heading in the section should be h2
const firstHeading = section.querySelector("h1, h2, h3, h4, h5, h6");
if (!firstHeading) return false;
return firstHeading.tagName === "H2";
},
),
{ numRuns: 100 },
);
});
it("should use h3 for all experience card titles", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any set of experience cards rendered, each card's title
* should be an h3 element. This maintains proper hierarchy under the
* section h2 and ensures consistent navigation for assistive technology.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
title: fc.lorem({ maxCount: 5 }),
company: fc.lorem({ maxCount: 2 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.lorem({ maxCount: 10 }),
achievements: fc.array(fc.lorem({ maxCount: 5 })),
technologies: fc.array(fc.lorem({ maxCount: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 10 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render();
// Find all article elements (experience cards)
const articles = container.querySelectorAll("article");
if (articles.length === 0) {
return false;
}
// Each article should have at least one h3 for the title
let allArticlesHaveH3 = true;
articles.forEach((article) => {
const h3Elements = article.querySelectorAll("h3");
// Should have at least one h3 (the title)
if (h3Elements.length === 0) {
allArticlesHaveH3 = false;
}
});
return allArticlesHaveH3;
},
),
{ numRuns: 100 },
);
});
it("should maintain hierarchy across multiple renders", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any number of component re-renders with the same data,
* the heading hierarchy should remain consistent. This ensures the
* semantic structure is stable and reliable for assistive technology.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 5 },
),
fc.integer({ min: 2, max: 5 }),
(experiences, numRenders) => {
let previousHierarchy: number[] | null = null;
for (let i = 0; i < numRenders; i++) {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container, unmount } = render();
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
const currentHierarchy = headings.map((h) =>
parseInt(h.tagName.substring(1)),
);
if (previousHierarchy !== null) {
// Hierarchy should be identical across renders
if (
JSON.stringify(currentHierarchy) !==
JSON.stringify(previousHierarchy)
) {
return false;
}
}
previousHierarchy = currentHierarchy;
unmount();
}
return true;
},
),
{ numRuns: 100 },
);
});
});