From b630559e0bb9d7a59c8b650f77834445fc01c9ec Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Sat, 8 Nov 2025 17:06:14 +0100 Subject: [PATCH] Add comprehensive unit tests for homepage components and utilities - Introduced unit tests for homepage components: `QuickStartCode`, `Header`, `DemoCredentialsModal`, `AnimatedTerminal`, `CTASection`, and `StatsSection`. - Added utility tests for `chart-colors` including opacity, palettes, and gradient validation. - Mocked dependencies (`framer-motion`, `react-syntax-highlighter`, `DemoCredentialsModal`) for isolated testing. - Verified accessibility features, animations, and interactive behaviors across components. --- frontend/jest.config.js | 3 + frontend/jest.setup.js | 13 + .../organizations/[id]/members/page.test.tsx | 65 +++++ frontend/tests/app/page.test.tsx | 259 +++++++++++++++--- .../charts/UserStatusChart.test.tsx | 72 +++++ .../components/home/AnimatedTerminal.test.tsx | 99 +++++++ .../tests/components/home/CTASection.test.tsx | 132 +++++++++ .../home/DemoCredentialsModal.test.tsx | 170 ++++++++++++ .../tests/components/home/Header.test.tsx | 152 ++++++++++ .../components/home/HeroSection.test.tsx | 137 +++++++++ .../components/home/QuickStartCode.test.tsx | 128 +++++++++ .../components/home/StatsSection.test.tsx | 115 ++++++++ .../hooks/usePrefersReducedMotion.test.ts | 178 ++++++++++++ frontend/tests/lib/chart-colors.test.ts | 117 ++++++++ 14 files changed, 1603 insertions(+), 37 deletions(-) create mode 100644 frontend/tests/app/admin/organizations/[id]/members/page.test.tsx create mode 100644 frontend/tests/components/home/AnimatedTerminal.test.tsx create mode 100644 frontend/tests/components/home/CTASection.test.tsx create mode 100644 frontend/tests/components/home/DemoCredentialsModal.test.tsx create mode 100644 frontend/tests/components/home/Header.test.tsx create mode 100644 frontend/tests/components/home/HeroSection.test.tsx create mode 100644 frontend/tests/components/home/QuickStartCode.test.tsx create mode 100644 frontend/tests/components/home/StatsSection.test.tsx create mode 100644 frontend/tests/hooks/usePrefersReducedMotion.test.ts create mode 100644 frontend/tests/lib/chart-colors.test.ts diff --git a/frontend/jest.config.js b/frontend/jest.config.js index e951e9d..af0c857 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -16,6 +16,9 @@ const customJestConfig = { '/tests/**/*.test.ts', '/tests/**/*.test.tsx', ], + transformIgnorePatterns: [ + 'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)', + ], collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index 6f30ca7..55890fe 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -23,6 +23,19 @@ global.BroadcastChannel = class BroadcastChannel { removeEventListener() {} }; +// Mock IntersectionObserver for components that use viewport detection +global.IntersectionObserver = class IntersectionObserver { + constructor(callback) { + this.callback = callback; + } + observe() { + // Immediately trigger the callback with isIntersecting: true for tests + this.callback([{ isIntersecting: true }]); + } + unobserve() {} + disconnect() {} +}; + // Use real Web Crypto API polyfill for Node environment const cryptoPolyfill = new Crypto(); diff --git a/frontend/tests/app/admin/organizations/[id]/members/page.test.tsx b/frontend/tests/app/admin/organizations/[id]/members/page.test.tsx new file mode 100644 index 0000000..0ef5887 --- /dev/null +++ b/frontend/tests/app/admin/organizations/[id]/members/page.test.tsx @@ -0,0 +1,65 @@ +/** + * Tests for Admin Organization Members Page + */ + +import { render, screen } from '@testing-library/react'; +import OrganizationMembersPage from '@/app/admin/organizations/[id]/members/page'; + +// Mock Next.js Link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +// Mock OrganizationMembersContent component +jest.mock('@/components/admin/organizations/OrganizationMembersContent', () => ({ + OrganizationMembersContent: ({ organizationId }: { organizationId: string }) => ( +
+ Organization ID: {organizationId} +
+ ), +})); + +describe('OrganizationMembersPage', () => { + it('renders back button to organizations', async () => { + const params = Promise.resolve({ id: 'org-123' }); + render(await OrganizationMembersPage({ params })); + + const backLink = screen.getByRole('link'); + expect(backLink).toHaveAttribute('href', '/admin/organizations'); + }); + + it('renders organization members content with correct ID', async () => { + const params = Promise.resolve({ id: 'org-456' }); + render(await OrganizationMembersPage({ params })); + + expect(screen.getByTestId('organization-members-content')).toBeInTheDocument(); + expect(screen.getByText('Organization ID: org-456')).toBeInTheDocument(); + }); + + it('renders container with proper styling', async () => { + const params = Promise.resolve({ id: 'org-789' }); + const { container } = render(await OrganizationMembersPage({ params })); + + const mainContainer = container.querySelector('.container'); + expect(mainContainer).toBeInTheDocument(); + }); + + it('handles different organization IDs', async () => { + const testIds = ['org-001', 'org-abc-123', 'test-org']; + + for (const testId of testIds) { + const params = Promise.resolve({ id: testId }); + const { unmount } = render(await OrganizationMembersPage({ params })); + + expect(screen.getByText(`Organization ID: ${testId}`)).toBeInTheDocument(); + unmount(); + } + }); +}); diff --git a/frontend/tests/app/page.test.tsx b/frontend/tests/app/page.test.tsx index 6160d7e..82624e3 100644 --- a/frontend/tests/app/page.test.tsx +++ b/frontend/tests/app/page.test.tsx @@ -1,12 +1,12 @@ /** * Tests for Home Page - * Smoke tests for static content + * Tests for the new FastNext Template landing page */ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import Home from '@/app/page'; -// Mock Next.js Image component +// Mock Next.js components jest.mock('next/image', () => ({ __esModule: true, default: (props: any) => { @@ -15,56 +15,241 @@ jest.mock('next/image', () => ({ }, })); +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +// Mock framer-motion to avoid animation issues in tests +jest.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + h1: ({ children, ...props }: any) =>

{children}

, + p: ({ children, ...props }: any) =>

{children}

, + section: ({ children, ...props }: any) =>
{children}
, + }, + AnimatePresence: ({ children }: any) => <>{children}, + useInView: () => true, // Always in view for tests +})); + +// Mock react-syntax-highlighter to avoid ESM issues +jest.mock('react-syntax-highlighter', () => ({ + Prism: ({ children, ...props }: any) =>
{children}
, +})); + +jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ + vscDarkPlus: {}, +})); + describe('HomePage', () => { - it('renders without crashing', () => { - render(); - expect(screen.getByText(/get started by editing/i)).toBeInTheDocument(); + describe('Page Structure', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByRole('banner')).toBeInTheDocument(); // header + expect(screen.getByRole('main')).toBeInTheDocument(); + expect(screen.getByRole('contentinfo')).toBeInTheDocument(); // footer + }); + + it('renders header with logo', () => { + render(); + const header = screen.getByRole('banner'); + expect(within(header).getByText('FastNext')).toBeInTheDocument(); + expect(within(header).getByText('Template')).toBeInTheDocument(); + }); + + it('renders footer with copyright', () => { + render(); + const footer = screen.getByRole('contentinfo'); + expect(within(footer).getByText(/FastNext Template. MIT Licensed/i)).toBeInTheDocument(); + }); }); - it('renders Next.js logo', () => { - render(); + describe('Hero Section', () => { + it('renders main headline', () => { + render(); + expect(screen.getAllByText(/Everything You Need to Build/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Modern Web Applications/i)[0]).toBeInTheDocument(); + }); - const logo = screen.getByAltText('Next.js logo'); - expect(logo).toBeInTheDocument(); - expect(logo).toHaveAttribute('src', '/next.svg'); + it('renders production-ready messaging', () => { + render(); + expect(screen.getByText(/Production-ready FastAPI/i)).toBeInTheDocument(); + }); + + it('renders test coverage stats', () => { + render(); + const coverageTexts = screen.getAllByText('97%'); + expect(coverageTexts.length).toBeGreaterThan(0); + expect(screen.getAllByText(/Test Coverage/i)[0]).toBeInTheDocument(); + const testCountTexts = screen.getAllByText('743'); + expect(testCountTexts.length).toBeGreaterThan(0); + expect(screen.getAllByText(/Passing Tests/i)[0]).toBeInTheDocument(); + }); }); - it('renders Vercel logo', () => { - render(); + describe('Context Section', () => { + it('renders what you get message', () => { + render(); + expect(screen.getByText(/What You Get Out of the Box/i)).toBeInTheDocument(); + }); - const logo = screen.getByAltText('Vercel logomark'); - expect(logo).toBeInTheDocument(); - expect(logo).toHaveAttribute('src', '/vercel.svg'); + it('renders key features', () => { + render(); + expect(screen.getAllByText(/Clone & Deploy in < 5 minutes/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/97% Test Coverage \(743 tests\)/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/12\+ Documentation Guides/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Zero Commercial Dependencies/i)[0]).toBeInTheDocument(); + }); }); - it('has correct external links', () => { - render(); + describe('Feature Grid', () => { + it('renders comprehensive features heading', () => { + render(); + expect(screen.getByText(/Comprehensive Features, No Assembly Required/i)).toBeInTheDocument(); + }); - const deployLink = screen.getByRole('link', { name: /deploy now/i }); - expect(deployLink).toHaveAttribute('href', expect.stringContaining('vercel.com')); - expect(deployLink).toHaveAttribute('target', '_blank'); - expect(deployLink).toHaveAttribute('rel', 'noopener noreferrer'); + it('renders all 6 feature cards', () => { + render(); + expect(screen.getAllByText('Authentication & Security')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Multi-Tenant Organizations')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Admin Dashboard')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Complete Documentation')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Production Ready')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Developer Experience')[0]).toBeInTheDocument(); + }); - const docsLink = screen.getByRole('link', { name: /read our docs/i }); - expect(docsLink).toHaveAttribute('href', expect.stringContaining('nextjs.org/docs')); - expect(docsLink).toHaveAttribute('target', '_blank'); + it('has CTAs for each feature', () => { + render(); + expect(screen.getByRole('link', { name: /View Auth Flow/i })).toHaveAttribute('href', '/login'); + expect(screen.getByRole('link', { name: /See Organizations/i })).toHaveAttribute('href', '/admin/organizations'); + expect(screen.getByRole('link', { name: /Try Admin Panel/i })).toHaveAttribute('href', '/admin'); + }); }); - it('renders footer links', () => { - render(); + describe('Demo Section', () => { + it('renders demo section heading', () => { + render(); + expect(screen.getByText(/See It In Action/i)).toBeInTheDocument(); + }); - expect(screen.getByRole('link', { name: /learn/i })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /examples/i })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /go to nextjs\.org/i })).toBeInTheDocument(); + it('renders demo cards', () => { + render(); + expect(screen.getAllByText('Component Showcase')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Authentication Flow')[0]).toBeInTheDocument(); + // Admin Dashboard appears in both Feature Grid and Demo Section, so use getAllByText + const adminDashboards = screen.getAllByText('Admin Dashboard'); + expect(adminDashboards.length).toBeGreaterThanOrEqual(1); + }); + + it('displays demo credentials', () => { + render(); + const credentials = screen.getAllByText(/Demo Credentials:/i); + expect(credentials.length).toBeGreaterThan(0); + }); }); - it('has accessible image alt texts', () => { - render(); + describe('Tech Stack Section', () => { + it('renders tech stack heading', () => { + render(); + expect(screen.getByText(/Modern, Type-Safe, Production-Grade Stack/i)).toBeInTheDocument(); + }); - expect(screen.getByAltText('Next.js logo')).toBeInTheDocument(); - expect(screen.getByAltText('Vercel logomark')).toBeInTheDocument(); - expect(screen.getByAltText('File icon')).toBeInTheDocument(); - expect(screen.getByAltText('Window icon')).toBeInTheDocument(); - expect(screen.getByAltText('Globe icon')).toBeInTheDocument(); + it('renders all technologies', () => { + render(); + expect(screen.getAllByText('FastAPI')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Next.js 15')[0]).toBeInTheDocument(); + expect(screen.getAllByText('PostgreSQL')[0]).toBeInTheDocument(); + expect(screen.getAllByText('TypeScript')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Docker')[0]).toBeInTheDocument(); + expect(screen.getAllByText('TailwindCSS')[0]).toBeInTheDocument(); + expect(screen.getAllByText(/shadcn\/ui/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText('Playwright')[0]).toBeInTheDocument(); + }); + }); + + describe('Philosophy Section', () => { + it('renders why this template exists', () => { + render(); + expect(screen.getByText(/Why This Template Exists/i)).toBeInTheDocument(); + }); + + it('renders what you wont find section', () => { + render(); + expect(screen.getByText(/What You Won't Find Here/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Vendor lock-in/i)[0]).toBeInTheDocument(); + }); + + it('renders what you will find section', () => { + render(); + expect(screen.getByText(/What You Will Find/i)).toBeInTheDocument(); + expect(screen.getByText(/Production patterns that actually work/i)).toBeInTheDocument(); + }); + }); + + describe('Quick Start Section', () => { + it('renders quick start heading', () => { + render(); + expect(screen.getByText(/5-Minute Setup/i)).toBeInTheDocument(); + }); + }); + + describe('CTA Section', () => { + it('renders final CTA', () => { + render(); + expect(screen.getByText(/Start Building,/i)).toBeInTheDocument(); + expect(screen.getByText(/Not Boilerplating/i)).toBeInTheDocument(); + }); + + it('has GitHub link', () => { + render(); + const githubLinks = screen.getAllByRole('link', { name: /GitHub/i }); + expect(githubLinks.length).toBeGreaterThan(0); + expect(githubLinks[0]).toHaveAttribute('href', expect.stringContaining('github.com')); + }); + }); + + describe('Navigation Links', () => { + it('has login link', () => { + render(); + const loginLinks = screen.getAllByRole('link', { name: /Login/i }); + expect(loginLinks.some(link => link.getAttribute('href') === '/login')).toBe(true); + }); + + it('has component showcase link', () => { + render(); + const devLinks = screen.getAllByRole('link', { name: /Component/i }); + expect(devLinks.some(link => link.getAttribute('href') === '/dev')).toBe(true); + }); + + it('has admin demo link', () => { + render(); + const adminLinks = screen.getAllByRole('link', { name: /Admin/i }); + expect(adminLinks.some(link => link.getAttribute('href') === '/admin')).toBe(true); + }); + }); + + describe('Accessibility', () => { + it('has proper heading hierarchy', () => { + render(); + const main = screen.getByRole('main'); + const headings = within(main).getAllByRole('heading'); + expect(headings.length).toBeGreaterThan(0); + }); + + it('has external links with proper attributes', () => { + render(); + const githubLinks = screen.getAllByRole('link', { name: /GitHub/i }); + const externalLink = githubLinks.find(link => + link.getAttribute('href')?.includes('github.com') + ); + expect(externalLink).toHaveAttribute('target', '_blank'); + expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); }); }); diff --git a/frontend/tests/components/charts/UserStatusChart.test.tsx b/frontend/tests/components/charts/UserStatusChart.test.tsx index 06411f5..aabf855 100644 --- a/frontend/tests/components/charts/UserStatusChart.test.tsx +++ b/frontend/tests/components/charts/UserStatusChart.test.tsx @@ -6,14 +6,33 @@ import { render, screen } from '@testing-library/react'; import { UserStatusChart } from '@/components/charts/UserStatusChart'; import type { UserStatusData } from '@/components/charts/UserStatusChart'; +// Capture label function at module level for testing +let capturedLabelFunction: ((entry: any) => string) | null = null; + // Mock recharts to avoid rendering issues in tests jest.mock('recharts', () => { const OriginalModule = jest.requireActual('recharts'); + + const MockPie = (props: any) => { + // Capture the label function for testing + if (props.label && typeof props.label === 'function') { + capturedLabelFunction = props.label; + } + return
{props.children}
; + }; + return { ...OriginalModule, ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
{children}
), + PieChart: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Pie: MockPie, + Cell: ({ fill }: { fill: string }) =>
, + Tooltip: () =>
, + Legend: () =>
, }; }); @@ -69,4 +88,57 @@ describe('UserStatusChart', () => { expect(screen.getByText('User Status Distribution')).toBeInTheDocument(); expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); }); + + describe('renderLabel function', () => { + beforeEach(() => { + capturedLabelFunction = null; + }); + + it('formats label with name and percentage', () => { + render(); + + expect(capturedLabelFunction).toBeTruthy(); + + if (capturedLabelFunction) { + const result = capturedLabelFunction({ name: 'Active', percent: 0.75 }); + expect(result).toBe('Active: 75%'); + } + }); + + it('formats label with zero percent', () => { + render(); + + if (capturedLabelFunction) { + const result = capturedLabelFunction({ name: 'Inactive', percent: 0 }); + expect(result).toBe('Inactive: 0%'); + } + }); + + it('formats label with 100 percent', () => { + render(); + + if (capturedLabelFunction) { + const result = capturedLabelFunction({ name: 'All Users', percent: 1 }); + expect(result).toBe('All Users: 100%'); + } + }); + + it('rounds percentage to nearest whole number', () => { + render(); + + if (capturedLabelFunction) { + const result = capturedLabelFunction({ name: 'Pending', percent: 0.4567 }); + expect(result).toBe('Pending: 46%'); + } + }); + + it('handles small percentages', () => { + render(); + + if (capturedLabelFunction) { + const result = capturedLabelFunction({ name: 'Suspended', percent: 0.025 }); + expect(result).toBe('Suspended: 3%'); + } + }); + }); }); diff --git a/frontend/tests/components/home/AnimatedTerminal.test.tsx b/frontend/tests/components/home/AnimatedTerminal.test.tsx new file mode 100644 index 0000000..e5a61ce --- /dev/null +++ b/frontend/tests/components/home/AnimatedTerminal.test.tsx @@ -0,0 +1,99 @@ +/** + * Tests for AnimatedTerminal component + */ + +import { render, screen } from '@testing-library/react'; +import { AnimatedTerminal } from '@/components/home/AnimatedTerminal'; + +// Mock framer-motion +jest.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, +})); + +// Mock Next.js Link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +// IntersectionObserver is already mocked in jest.setup.js + +describe('AnimatedTerminal', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders the section heading', () => { + render(); + + expect(screen.getByText('Get Started in Seconds')).toBeInTheDocument(); + expect(screen.getByText(/Clone, run, and start building/i)).toBeInTheDocument(); + }); + + it('renders terminal window with header', () => { + render(); + + expect(screen.getByText('bash')).toBeInTheDocument(); + }); + + it('renders Try Live Demo button', () => { + render(); + + const demoLink = screen.getByRole('link', { name: /try live demo/i }); + expect(demoLink).toHaveAttribute('href', '/login'); + }); + + it('displays message about trying demo', () => { + render(); + + expect(screen.getByText(/Or try the live demo without installing/i)).toBeInTheDocument(); + }); + + it('starts animation when component mounts', () => { + render(); + + // Animation should start because IntersectionObserver mock triggers immediately + // Advance timers to show first command + jest.advanceTimersByTime(1000); + + // Check if animated content appears (the mock renders all commands immediately in tests) + const terminalContent = screen.getByText('bash').parentElement?.parentElement; + expect(terminalContent).toBeInTheDocument(); + }); + + it('renders terminal with proper structure', () => { + render(); + + // Verify terminal window has proper structure + const bashIndicator = screen.getByText('bash'); + expect(bashIndicator).toBeInTheDocument(); + expect(bashIndicator.parentElement).toBeInTheDocument(); + }); + + describe('Accessibility', () => { + it('has descriptive text for screen readers', () => { + render(); + + expect(screen.getByText('Get Started in Seconds')).toBeInTheDocument(); + }); + + it('has proper link to demo', () => { + render(); + + const demoLink = screen.getByRole('link', { name: /try live demo/i }); + expect(demoLink).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/components/home/CTASection.test.tsx b/frontend/tests/components/home/CTASection.test.tsx new file mode 100644 index 0000000..d520e70 --- /dev/null +++ b/frontend/tests/components/home/CTASection.test.tsx @@ -0,0 +1,132 @@ +/** + * Tests for CTASection component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { CTASection } from '@/components/home/CTASection'; + +// Mock framer-motion +jest.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, +})); + +// Mock Next.js Link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +// Mock DemoCredentialsModal +jest.mock('@/components/home/DemoCredentialsModal', () => ({ + DemoCredentialsModal: ({ open, onClose }: any) => ( + open ?
+ +
: null + ), +})); + +describe('CTASection', () => { + it('renders main headline', () => { + render(); + + expect(screen.getByText(/Start Building,/i)).toBeInTheDocument(); + expect(screen.getByText(/Not Boilerplating/i)).toBeInTheDocument(); + }); + + it('renders subtext with key messaging', () => { + render(); + + expect(screen.getByText(/Clone the repository, read the docs/i)).toBeInTheDocument(); + expect(screen.getByText(/Free forever, MIT licensed/i)).toBeInTheDocument(); + }); + + it('renders GitHub CTA button', () => { + render(); + + const githubLink = screen.getByRole('link', { name: /get started on github/i }); + expect(githubLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template'); + expect(githubLink).toHaveAttribute('target', '_blank'); + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders Try Live Demo button', () => { + render(); + + const demoButton = screen.getByRole('button', { name: /try live demo/i }); + expect(demoButton).toBeInTheDocument(); + }); + + it('renders Read Documentation link', () => { + render(); + + const docsLink = screen.getByRole('link', { name: /read documentation/i }); + expect(docsLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template#documentation'); + expect(docsLink).toHaveAttribute('target', '_blank'); + expect(docsLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders help text with internal links', () => { + render(); + + expect(screen.getByText(/Need help getting started\?/i)).toBeInTheDocument(); + + const componentShowcaseLink = screen.getByRole('link', { name: /component showcase/i }); + expect(componentShowcaseLink).toHaveAttribute('href', '/dev'); + + const adminDashboardLink = screen.getByRole('link', { name: /admin dashboard demo/i }); + expect(adminDashboardLink).toHaveAttribute('href', '/admin'); + }); + + it('opens demo modal when Try Live Demo button is clicked', () => { + render(); + + const demoButton = screen.getByRole('button', { name: /try live demo/i }); + fireEvent.click(demoButton); + + expect(screen.getByTestId('demo-modal')).toBeInTheDocument(); + }); + + it('closes demo modal when close is called', () => { + render(); + + // Open modal + const demoButton = screen.getByRole('button', { name: /try live demo/i }); + fireEvent.click(demoButton); + expect(screen.getByTestId('demo-modal')).toBeInTheDocument(); + + // Close modal + const closeButton = screen.getByText('Close Modal'); + fireEvent.click(closeButton); + expect(screen.queryByTestId('demo-modal')).not.toBeInTheDocument(); + }); + + describe('Accessibility', () => { + it('has proper external link attributes', () => { + render(); + + const externalLinks = [ + screen.getByRole('link', { name: /get started on github/i }), + screen.getByRole('link', { name: /read documentation/i }), + ]; + + externalLinks.forEach(link => { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + + it('has descriptive button text', () => { + render(); + + expect(screen.getByRole('button', { name: /try live demo/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/components/home/DemoCredentialsModal.test.tsx b/frontend/tests/components/home/DemoCredentialsModal.test.tsx new file mode 100644 index 0000000..7576788 --- /dev/null +++ b/frontend/tests/components/home/DemoCredentialsModal.test.tsx @@ -0,0 +1,170 @@ +/** + * Tests for DemoCredentialsModal component + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DemoCredentialsModal } from '@/components/home/DemoCredentialsModal'; + +// Mock Next.js Link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +describe('DemoCredentialsModal', () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + mockOnClose.mockClear(); + // Mock clipboard API + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.resolve()), + }, + }); + }); + + it('renders when open is true', () => { + render(); + + expect(screen.getByText('Try the Live Demo')).toBeInTheDocument(); + expect(screen.getByText(/Use these credentials to explore/i)).toBeInTheDocument(); + }); + + it('does not render when open is false', () => { + render(); + + expect(screen.queryByText('Try the Live Demo')).not.toBeInTheDocument(); + }); + + it('displays regular user credentials', () => { + render(); + + expect(screen.getByText('Regular User')).toBeInTheDocument(); + expect(screen.getByText('demo@example.com')).toBeInTheDocument(); + expect(screen.getByText('Demo123!')).toBeInTheDocument(); + expect(screen.getByText(/Access settings, organizations/i)).toBeInTheDocument(); + }); + + it('displays admin user credentials', () => { + render(); + + expect(screen.getByText('Admin User (Superuser)')).toBeInTheDocument(); + expect(screen.getByText('admin@example.com')).toBeInTheDocument(); + expect(screen.getByText('Admin123!')).toBeInTheDocument(); + expect(screen.getByText(/Full admin panel access/i)).toBeInTheDocument(); + }); + + it('copies regular user credentials to clipboard', async () => { + render(); + + const copyButtons = screen.getAllByRole('button'); + const regularCopyButton = copyButtons.find(btn => btn.textContent?.includes('Copy')); + + fireEvent.click(regularCopyButton!); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('demo@example.com\nDemo123!'); + const copiedButtons = screen.getAllByRole('button'); + const copiedButton = copiedButtons.find(btn => btn.textContent?.includes('Copied!')); + expect(copiedButton).toBeInTheDocument(); + }); + }); + + it('copies admin user credentials to clipboard', async () => { + render(); + + const copyButtons = screen.getAllByRole('button'); + const adminCopyButton = copyButtons.filter(btn => btn.textContent?.includes('Copy'))[1]; + + fireEvent.click(adminCopyButton!); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('admin@example.com\nAdmin123!'); + const copiedButtons = screen.getAllByRole('button'); + const copiedButton = copiedButtons.find(btn => btn.textContent?.includes('Copied!')); + expect(copiedButton).toBeInTheDocument(); + }); + }); + + it('resets copied state after 2 seconds', async () => { + jest.useFakeTimers(); + render(); + + const copyButtons = screen.getAllByRole('button'); + const copyButton = copyButtons.find(btn => btn.textContent?.includes('Copy')); + fireEvent.click(copyButton!); + + await waitFor(() => { + const copiedButtons = screen.getAllByRole('button'); + const copiedButton = copiedButtons.find(btn => btn.textContent?.includes('Copied!')); + expect(copiedButton).toBeInTheDocument(); + }); + + jest.advanceTimersByTime(2000); + + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + const copiedButton = buttons.find(btn => btn.textContent?.includes('Copied!')); + expect(copiedButton).toBeUndefined(); + }); + + jest.useRealTimers(); + }); + + it('handles clipboard copy failure gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.reject(new Error('Clipboard error'))), + }, + }); + + render(); + + const copyButtons = screen.getAllByRole('button'); + const copyButton = copyButtons.find(btn => btn.textContent?.includes('Copy')); + fireEvent.click(copyButton!); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to copy:', expect.any(Error)); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('calls onClose when close button is clicked', () => { + render(); + + // Find the "Close" button (filter to get the one that's visible and is the footer button) + const closeButtons = screen.getAllByRole('button', { name: 'Close' }); + const footerCloseButton = closeButtons.find(btn => + btn.textContent === 'Close' && !btn.querySelector('.sr-only') + ); + fireEvent.click(footerCloseButton!); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('has a link to login page', () => { + render(); + + const loginLink = screen.getByRole('link', { name: /go to login/i }); + expect(loginLink).toHaveAttribute('href', '/login'); + }); + + it('calls onClose when login link is clicked', () => { + render(); + + const loginLink = screen.getByRole('link', { name: /go to login/i }); + fireEvent.click(loginLink); + + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/components/home/Header.test.tsx b/frontend/tests/components/home/Header.test.tsx new file mode 100644 index 0000000..2cc4439 --- /dev/null +++ b/frontend/tests/components/home/Header.test.tsx @@ -0,0 +1,152 @@ +/** + * Tests for Header component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { Header } from '@/components/home/Header'; + +// Mock Next.js Link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +// Mock DemoCredentialsModal +jest.mock('@/components/home/DemoCredentialsModal', () => ({ + DemoCredentialsModal: ({ open, onClose }: any) => ( + open ?
+ +
: null + ), +})); + +describe('Header', () => { + it('renders logo', () => { + render(
); + + expect(screen.getByText('FastNext')).toBeInTheDocument(); + expect(screen.getByText('Template')).toBeInTheDocument(); + }); + + it('logo links to homepage', () => { + render(
); + + const logoLink = screen.getByRole('link', { name: /fastnext template/i }); + expect(logoLink).toHaveAttribute('href', '/'); + }); + + describe('Desktop Navigation', () => { + it('renders navigation links', () => { + render(
); + + expect(screen.getByRole('link', { name: 'Components' })).toHaveAttribute('href', '/dev'); + expect(screen.getByRole('link', { name: 'Admin Demo' })).toHaveAttribute('href', '/admin'); + }); + + it('renders GitHub link with star badge', () => { + render(
); + + const githubLinks = screen.getAllByRole('link', { name: /github/i }); + const desktopGithubLink = githubLinks.find(link => + link.getAttribute('href')?.includes('github.com') + ); + + expect(desktopGithubLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template'); + expect(desktopGithubLink).toHaveAttribute('target', '_blank'); + expect(desktopGithubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders Try Demo button', () => { + render(
); + + const demoButton = screen.getByRole('button', { name: /try demo/i }); + expect(demoButton).toBeInTheDocument(); + }); + + it('renders Login button', () => { + render(
); + + const loginLinks = screen.getAllByRole('link', { name: /login/i }); + expect(loginLinks.length).toBeGreaterThan(0); + expect(loginLinks[0]).toHaveAttribute('href', '/login'); + }); + + it('opens demo modal when Try Demo button is clicked', () => { + render(
); + + const demoButton = screen.getByRole('button', { name: /try demo/i }); + fireEvent.click(demoButton); + + expect(screen.getByTestId('demo-modal')).toBeInTheDocument(); + }); + }); + + describe('Mobile Menu', () => { + it('renders mobile menu toggle button', () => { + render(
); + + // SheetTrigger wraps the button, so we need to find it by aria-label + const menuButton = screen.getByRole('button', { name: /toggle menu/i }); + expect(menuButton).toBeInTheDocument(); + }); + + it('mobile menu contains navigation links', () => { + render(
); + + // Note: SheetContent is hidden by default in tests, but we can verify the links exist + // The actual mobile menu behavior is tested in E2E tests + const componentsLinks = screen.getAllByRole('link', { name: /components/i }); + expect(componentsLinks.length).toBeGreaterThan(0); + }); + + it('mobile menu contains GitHub link', () => { + render(
); + + const githubLinks = screen.getAllByRole('link', { name: /github/i }); + expect(githubLinks.length).toBeGreaterThan(0); + }); + }); + + describe('Demo Modal Integration', () => { + it('closes demo modal when close is called', () => { + render(
); + + // Open modal + const demoButton = screen.getByRole('button', { name: /try demo/i }); + fireEvent.click(demoButton); + expect(screen.getByTestId('demo-modal')).toBeInTheDocument(); + + // Close modal + const closeButton = screen.getByText('Close Modal'); + fireEvent.click(closeButton); + expect(screen.queryByTestId('demo-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('has proper ARIA labels for icon buttons', () => { + render(
); + + const menuButton = screen.getByRole('button', { name: /toggle menu/i }); + expect(menuButton).toHaveAccessibleName(); + }); + + it('has proper external link attributes', () => { + render(
); + + const githubLinks = screen.getAllByRole('link', { name: /github/i }); + const externalLink = githubLinks.find(link => + link.getAttribute('href')?.includes('github.com') + ); + + expect(externalLink).toHaveAttribute('target', '_blank'); + expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); +}); diff --git a/frontend/tests/components/home/HeroSection.test.tsx b/frontend/tests/components/home/HeroSection.test.tsx new file mode 100644 index 0000000..b8a3e2e --- /dev/null +++ b/frontend/tests/components/home/HeroSection.test.tsx @@ -0,0 +1,137 @@ +/** + * Tests for HeroSection component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { HeroSection } from '@/components/home/HeroSection'; + +// Mock framer-motion +jest.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + h1: ({ children, ...props }: any) =>

{children}

, + p: ({ children, ...props }: any) =>

{children}

, + }, +})); + +// Mock Next.js Link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href, ...props }: any) => { + return ( + + {children} + + ); + }, +})); + +// Mock DemoCredentialsModal +jest.mock('@/components/home/DemoCredentialsModal', () => ({ + DemoCredentialsModal: ({ open, onClose }: any) => ( + open ?
+ +
: null + ), +})); + +describe('HeroSection', () => { + it('renders badge with key highlights', () => { + render(); + + expect(screen.getByText('MIT Licensed')).toBeInTheDocument(); + expect(screen.getAllByText('97% Test Coverage')[0]).toBeInTheDocument(); + expect(screen.getByText('Production Ready')).toBeInTheDocument(); + }); + + it('renders main headline', () => { + render(); + + expect(screen.getAllByText(/Everything You Need to Build/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Modern Web Applications/i)[0]).toBeInTheDocument(); + }); + + it('renders subheadline with key messaging', () => { + render(); + + expect(screen.getByText(/Production-ready FastAPI \+ Next.js template/i)).toBeInTheDocument(); + expect(screen.getByText(/Start building features on day one/i)).toBeInTheDocument(); + }); + + it('renders Try Live Demo button', () => { + render(); + + const demoButton = screen.getByRole('button', { name: /try live demo/i }); + expect(demoButton).toBeInTheDocument(); + }); + + it('renders View on GitHub link', () => { + render(); + + const githubLink = screen.getByRole('link', { name: /view on github/i }); + expect(githubLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template'); + expect(githubLink).toHaveAttribute('target', '_blank'); + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders Explore Components link', () => { + render(); + + const componentsLink = screen.getByRole('link', { name: /explore components/i }); + expect(componentsLink).toHaveAttribute('href', '/dev'); + }); + + it('displays test coverage stats', () => { + render(); + + const coverageTexts = screen.getAllByText('97%'); + expect(coverageTexts.length).toBeGreaterThan(0); + + const testCountTexts = screen.getAllByText('743'); + expect(testCountTexts.length).toBeGreaterThan(0); + expect(screen.getAllByText(/Passing Tests/i)[0]).toBeInTheDocument(); + + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText(/Flaky Tests/i)).toBeInTheDocument(); + }); + + it('opens demo modal when Try Live Demo button is clicked', () => { + render(); + + const demoButton = screen.getByRole('button', { name: /try live demo/i }); + fireEvent.click(demoButton); + + expect(screen.getByTestId('demo-modal')).toBeInTheDocument(); + }); + + it('closes demo modal when close is called', () => { + render(); + + // Open modal + const demoButton = screen.getByRole('button', { name: /try live demo/i }); + fireEvent.click(demoButton); + expect(screen.getByTestId('demo-modal')).toBeInTheDocument(); + + // Close modal + const closeButton = screen.getByText('Close Modal'); + fireEvent.click(closeButton); + expect(screen.queryByTestId('demo-modal')).not.toBeInTheDocument(); + }); + + describe('Accessibility', () => { + it('has proper heading hierarchy', () => { + render(); + + const heading = screen.getAllByRole('heading', { level: 1 })[0]; + expect(heading).toBeInTheDocument(); + }); + + it('has proper external link attributes', () => { + render(); + + const githubLink = screen.getByRole('link', { name: /view on github/i }); + expect(githubLink).toHaveAttribute('target', '_blank'); + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); +}); diff --git a/frontend/tests/components/home/QuickStartCode.test.tsx b/frontend/tests/components/home/QuickStartCode.test.tsx new file mode 100644 index 0000000..f7a823c --- /dev/null +++ b/frontend/tests/components/home/QuickStartCode.test.tsx @@ -0,0 +1,128 @@ +/** + * Tests for QuickStartCode component + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QuickStartCode } from '@/components/home/QuickStartCode'; + +// Mock framer-motion +jest.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, +})); + +// Mock react-syntax-highlighter +jest.mock('react-syntax-highlighter', () => ({ + Prism: ({ children, ...props }: any) =>
{children}
, +})); + +jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ + vscDarkPlus: {}, +})); + +describe('QuickStartCode', () => { + beforeEach(() => { + // Mock clipboard API + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.resolve()), + }, + }); + }); + + it('renders the section heading', () => { + render(); + + expect(screen.getByText('5-Minute Setup')).toBeInTheDocument(); + expect(screen.getByText(/Clone, run, and start building/i)).toBeInTheDocument(); + }); + + it('renders bash indicator', () => { + render(); + + expect(screen.getByText('bash')).toBeInTheDocument(); + }); + + it('renders copy button', () => { + render(); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + expect(copyButton).toBeInTheDocument(); + }); + + it('displays the code snippet', () => { + render(); + + const codeBlock = screen.getByText(/git clone/i); + expect(codeBlock).toBeInTheDocument(); + }); + + it('copies code to clipboard when copy button is clicked', async () => { + render(); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + }); + + const clipboardContent = (navigator.clipboard.writeText as jest.Mock).mock.calls[0][0]; + expect(clipboardContent).toContain('git clone'); + expect(clipboardContent).toContain('docker-compose up'); + expect(clipboardContent).toContain('pip install -r requirements.txt'); + }); + + it('shows "Copied!" message after copying', async () => { + render(); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + }); + + it('resets copied state after 2 seconds', async () => { + jest.useFakeTimers(); + render(); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + + jest.advanceTimersByTime(2000); + + await waitFor(() => { + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + expect(screen.getByText('Copy')).toBeInTheDocument(); + }); + + jest.useRealTimers(); + }); + + it('handles clipboard copy failure gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.reject(new Error('Clipboard error'))), + }, + }); + + render(); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to copy:', expect.any(Error)); + }); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/frontend/tests/components/home/StatsSection.test.tsx b/frontend/tests/components/home/StatsSection.test.tsx new file mode 100644 index 0000000..d44f337 --- /dev/null +++ b/frontend/tests/components/home/StatsSection.test.tsx @@ -0,0 +1,115 @@ +/** + * Tests for StatsSection component + */ + +import { render, screen } from '@testing-library/react'; +import { StatsSection } from '@/components/home/StatsSection'; + +// Mock framer-motion +jest.mock('framer-motion', () => { + const React = require('react'); + return { + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, + useInView: () => true, // Always in view for tests + }; +}); + +describe('StatsSection', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders section heading', () => { + render(); + + expect(screen.getByText('Built with Quality in Mind')).toBeInTheDocument(); + expect(screen.getByText(/Not just another template/i)).toBeInTheDocument(); + }); + + it('renders all stat cards', () => { + render(); + + expect(screen.getByText('Test Coverage')).toBeInTheDocument(); + expect(screen.getByText('Passing Tests')).toBeInTheDocument(); + expect(screen.getByText('Flaky Tests')).toBeInTheDocument(); + expect(screen.getByText('API Endpoints')).toBeInTheDocument(); + }); + + it('displays stat descriptions', () => { + render(); + + expect(screen.getByText(/Comprehensive testing across backend and frontend/i)).toBeInTheDocument(); + expect(screen.getByText(/Backend, frontend unit, and E2E tests/i)).toBeInTheDocument(); + expect(screen.getByText(/Production-stable test suite/i)).toBeInTheDocument(); + expect(screen.getByText(/Fully documented with OpenAPI/i)).toBeInTheDocument(); + }); + + it('renders animated counters with correct suffixes', () => { + render(); + + // Counters start at 0, so we should see 0 initially + const counters = screen.getAllByText(/^[0-9]+[%+]?$/); + expect(counters.length).toBeGreaterThan(0); + }); + + it('animates counters when in view', () => { + render(); + + // The useInView mock returns true, so animation should start + // Advance timers to let the counter animation run + jest.advanceTimersByTime(2000); + + // After animation, we should see the final values + // The component should eventually show the stat values + const statsSection = screen.getByText('Test Coverage').parentElement; + expect(statsSection).toBeInTheDocument(); + }); + + it('displays icons for each stat', () => { + render(); + + // Icons are rendered via lucide-react components + // We can verify the stat cards are rendered with proper structure + const testCoverageCard = screen.getByText('Test Coverage').closest('div'); + expect(testCoverageCard).toBeInTheDocument(); + + const passingTestsCard = screen.getByText('Passing Tests').closest('div'); + expect(passingTestsCard).toBeInTheDocument(); + + const flakyTestsCard = screen.getByText('Flaky Tests').closest('div'); + expect(flakyTestsCard).toBeInTheDocument(); + + const apiEndpointsCard = screen.getByText('API Endpoints').closest('div'); + expect(apiEndpointsCard).toBeInTheDocument(); + }); + + describe('Accessibility', () => { + it('has proper heading hierarchy', () => { + render(); + + const heading = screen.getByRole('heading', { name: /built with quality in mind/i }); + expect(heading).toBeInTheDocument(); + }); + + it('has descriptive labels for stats', () => { + render(); + + const statLabels = [ + 'Test Coverage', + 'Passing Tests', + 'Flaky Tests', + 'API Endpoints', + ]; + + statLabels.forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/tests/hooks/usePrefersReducedMotion.test.ts b/frontend/tests/hooks/usePrefersReducedMotion.test.ts new file mode 100644 index 0000000..b5dc5eb --- /dev/null +++ b/frontend/tests/hooks/usePrefersReducedMotion.test.ts @@ -0,0 +1,178 @@ +/** + * Tests for usePrefersReducedMotion hook + * Tests media query detection for accessibility preferences + */ + +import { renderHook, act } from '@testing-library/react'; +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; + +describe('usePrefersReducedMotion', () => { + let mockMatchMedia: jest.Mock; + let mockListeners: ((event: MediaQueryListEvent) => void)[]; + + beforeEach(() => { + mockListeners = []; + + mockMatchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + mockListeners.push(listener); + } + }), + removeEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + const index = mockListeners.indexOf(listener); + if (index > -1) { + mockListeners.splice(index, 1); + } + } + }), + dispatchEvent: jest.fn(), + })); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: mockMatchMedia, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns false when user does not prefer reduced motion', () => { + mockMatchMedia.mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + const { result } = renderHook(() => usePrefersReducedMotion()); + + expect(result.current).toBe(false); + expect(mockMatchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + }); + + it('returns true when user prefers reduced motion', () => { + mockMatchMedia.mockImplementation((query: string) => ({ + matches: true, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + const { result } = renderHook(() => usePrefersReducedMotion()); + + expect(result.current).toBe(true); + }); + + it('updates when media query preference changes to true', () => { + const mockMediaQuery = { + matches: false, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + mockListeners.push(listener); + } + }), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }; + + mockMatchMedia.mockReturnValue(mockMediaQuery); + + const { result } = renderHook(() => usePrefersReducedMotion()); + + expect(result.current).toBe(false); + + // Simulate media query change + act(() => { + mockListeners.forEach(listener => { + listener({ matches: true } as MediaQueryListEvent); + }); + }); + + expect(result.current).toBe(true); + }); + + it('updates when media query preference changes to false', () => { + const mockMediaQuery = { + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + mockListeners.push(listener); + } + }), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }; + + mockMatchMedia.mockReturnValue(mockMediaQuery); + + const { result } = renderHook(() => usePrefersReducedMotion()); + + expect(result.current).toBe(true); + + // Simulate media query change + act(() => { + mockListeners.forEach(listener => { + listener({ matches: false } as MediaQueryListEvent); + }); + }); + + expect(result.current).toBe(false); + }); + + it('cleans up event listener on unmount', () => { + const removeEventListenerSpy = jest.fn(); + + const mockMediaQuery = { + matches: false, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + mockListeners.push(listener); + } + }), + removeEventListener: removeEventListenerSpy, + dispatchEvent: jest.fn(), + }; + + mockMatchMedia.mockReturnValue(mockMediaQuery); + + const { unmount } = renderHook(() => usePrefersReducedMotion()); + + expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('handles SSR environment safely', () => { + const originalWindow = global.window; + + // @ts-ignore - Simulating SSR + delete global.window; + + const { result } = renderHook(() => usePrefersReducedMotion()); + + // Should return false in SSR environment + expect(result.current).toBe(false); + + // Restore window + global.window = originalWindow; + }); +}); diff --git a/frontend/tests/lib/chart-colors.test.ts b/frontend/tests/lib/chart-colors.test.ts new file mode 100644 index 0000000..b3dbf3e --- /dev/null +++ b/frontend/tests/lib/chart-colors.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for chart-colors utility + * Tests color configuration and helper functions for data visualization + */ + +import { withOpacity, CHART_COLORS, CHART_PALETTES, CHART_GRADIENTS } from '@/lib/chart-colors'; + +describe('chart-colors', () => { + describe('withOpacity', () => { + it('converts opacity 0 to hex 00', () => { + const result = withOpacity('#3b82f6', 0); + expect(result).toBe('#3b82f600'); + }); + + it('converts opacity 1 to hex ff', () => { + const result = withOpacity('#3b82f6', 1); + expect(result).toBe('#3b82f6ff'); + }); + + it('converts opacity 0.5 to hex 80', () => { + const result = withOpacity('#3b82f6', 0.5); + expect(result).toBe('#3b82f680'); + }); + + it('converts opacity 0.25 to hex 40', () => { + const result = withOpacity('#3b82f6', 0.25); + expect(result).toBe('#3b82f640'); + }); + + it('converts opacity 0.75 to hex bf', () => { + const result = withOpacity('#3b82f6', 0.75); + expect(result).toBe('#3b82f6bf'); + }); + + it('pads single digit hex values with zero', () => { + const result = withOpacity('#3b82f6', 0.01); + expect(result).toBe('#3b82f603'); + }); + + it('works with 3-digit hex colors', () => { + const result = withOpacity('#fff', 0.5); + expect(result).toBe('#fff80'); + }); + + it('works with uppercase hex colors', () => { + const result = withOpacity('#3B82F6', 0.5); + expect(result).toBe('#3B82F680'); + }); + + it('handles edge case of very small opacity', () => { + const result = withOpacity('#000000', 0.004); + expect(result).toBe('#00000001'); + }); + + it('handles edge case of very high opacity', () => { + const result = withOpacity('#ffffff', 0.996); + expect(result).toBe('#fffffffe'); + }); + }); + + describe('CHART_COLORS', () => { + it('exports primary color palette', () => { + expect(CHART_COLORS.primary).toBe('#3b82f6'); + expect(CHART_COLORS.primaryLight).toBe('#60a5fa'); + expect(CHART_COLORS.primaryDark).toBe('#2563eb'); + }); + + it('exports accent colors', () => { + expect(CHART_COLORS.accent1).toBeDefined(); + expect(CHART_COLORS.accent2).toBeDefined(); + expect(CHART_COLORS.accent3).toBeDefined(); + expect(CHART_COLORS.accent4).toBeDefined(); + expect(CHART_COLORS.accent5).toBeDefined(); + }); + + it('exports status colors', () => { + expect(CHART_COLORS.success).toBeDefined(); + expect(CHART_COLORS.warning).toBeDefined(); + expect(CHART_COLORS.error).toBeDefined(); + expect(CHART_COLORS.info).toBeDefined(); + }); + }); + + describe('CHART_PALETTES', () => { + it('exports line chart palette', () => { + expect(CHART_PALETTES.line).toHaveLength(2); + expect(CHART_PALETTES.line).toContain(CHART_COLORS.primary); + expect(CHART_PALETTES.line).toContain(CHART_COLORS.accent1); + }); + + it('exports bar chart palette', () => { + expect(CHART_PALETTES.bar).toHaveLength(2); + expect(CHART_PALETTES.bar).toContain(CHART_COLORS.primary); + expect(CHART_PALETTES.bar).toContain(CHART_COLORS.accent2); + }); + + it('exports pie chart palette with 4 colors', () => { + expect(CHART_PALETTES.pie).toHaveLength(4); + }); + + it('exports multi-series palette with 6 colors', () => { + expect(CHART_PALETTES.multi).toHaveLength(6); + }); + }); + + describe('CHART_GRADIENTS', () => { + it('exports primary gradient definition', () => { + expect(CHART_GRADIENTS.primary.start).toBe(CHART_COLORS.primary); + expect(CHART_GRADIENTS.primary.end).toMatch(/#3b82f6[a-f0-9]{2}/); + }); + + it('exports accent gradient definition', () => { + expect(CHART_GRADIENTS.accent.start).toBe(CHART_COLORS.accent1); + expect(CHART_GRADIENTS.accent.end).toMatch(/#8b5cf6[a-f0-9]{2}/); + }); + }); +});