diff --git a/components/dashboard/ActivityLandscape.test.tsx b/components/dashboard/ActivityLandscape.test.tsx index e2b44e2c5..d579c11c2 100644 --- a/components/dashboard/ActivityLandscape.test.tsx +++ b/components/dashboard/ActivityLandscape.test.tsx @@ -1,18 +1,28 @@ +import type { ComponentProps, ReactNode } from 'react'; import { it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import ActivityLandscape from './ActivityLandscape'; -vi.mock('framer-motion', async () => { - const actual = await vi.importActual('framer-motion'); +type MotionDivProps = ComponentProps<'div'> & { + initial?: unknown; + animate?: unknown; + exit?: unknown; + transition?: unknown; +}; +vi.mock('framer-motion', () => { return { - ...actual, motion: { - ...actual.motion, - div: ({ children, ...props }: React.ComponentProps<'div'>) => ( -
{children}
- ), + div: ({ + children, + initial: _initial, + animate: _animate, + exit: _exit, + transition: _transition, + ...props + }: MotionDivProps) =>
{children}
, }, + AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}, }; }); const mockData = Array.from({ length: 100 }, (_, i) => ({ diff --git a/components/dashboard/HistoricalTrendView.empty-fallback.test.tsx b/components/dashboard/HistoricalTrendView.empty-fallback.test.tsx index 3b32d30fb..dec4a8fcf 100644 --- a/components/dashboard/HistoricalTrendView.empty-fallback.test.tsx +++ b/components/dashboard/HistoricalTrendView.empty-fallback.test.tsx @@ -74,16 +74,11 @@ describe('HistoricalTrendView - Empty & Missing Input Fallbacks', () => { beforeEach(() => { vi.clearAllMocks(); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-01-15T12:00:00Z')); consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-01-15T12:00:00Z')); }); afterEach(() => { consoleError.mockRestore(); - vi.useRealTimers(); }); it('renders clear empty-state messaging when activity is an empty array', () => { @@ -113,7 +108,7 @@ describe('HistoricalTrendView - Empty & Missing Input Fallbacks', () => { expect(screen.getByText('Contributions')).toBeInTheDocument(); expect(screen.getByText('Active Days')).toBeInTheDocument(); - expect(screen.getByText('Current Streak')).toBeInTheDocument(); + expect(screen.getByText(/^(?:Current|Upcoming|Ending) Streak$/)).toBeInTheDocument(); expect(screen.getByText('Longest Streak')).toBeInTheDocument(); }); diff --git a/components/dashboard/ProfileCard.accessibility.test.tsx b/components/dashboard/ProfileCard.accessibility.test.tsx new file mode 100644 index 000000000..27237195d --- /dev/null +++ b/components/dashboard/ProfileCard.accessibility.test.tsx @@ -0,0 +1,225 @@ +import type { ComponentProps, ImgHTMLAttributes, ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import ProfileCard from './ProfileCard'; +import VisualizationTooltip from './VisualizationTooltip'; +import type { DashboardExportData, UserProfile } from '@/types/dashboard'; + +type MotionProps = { + initial?: unknown; + animate?: unknown; + exit?: unknown; + transition?: unknown; + whileHover?: unknown; + whileTap?: unknown; +}; + +type MotionDivProps = ComponentProps<'div'> & MotionProps & { children?: ReactNode }; +type MotionButtonProps = ComponentProps<'button'> & MotionProps & { children?: ReactNode }; + +vi.mock('next/image', () => ({ + default: ({ + fill: _fill, + ...props + }: ImgHTMLAttributes & { fill?: boolean }) => ( + // eslint-disable-next-line @next/next/no-img-element + + ), +})); + +vi.mock('framer-motion', () => ({ + motion: { + div: ({ + children, + initial: _initial, + animate: _animate, + exit: _exit, + transition: _transition, + ...props + }: MotionDivProps) =>
{children}
, + button: ({ + children, + initial: _initial, + animate: _animate, + exit: _exit, + transition: _transition, + whileHover: _whileHover, + whileTap: _whileTap, + ...props + }: MotionButtonProps) => , + }, + AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('react-qr-code', () => ({ + default: ({ value }: { value: string }) => ( + + ), +})); + +vi.mock('@/hooks/useShareActions', () => ({ + useShareActions: () => ({ + states: {}, + handleTwitter: vi.fn(), + handleLinkedIn: vi.fn(), + handleReddit: vi.fn(), + handleDownloadPNG: vi.fn(), + handleDownloadWEBP: vi.fn(), + handleDownloadSVG: vi.fn(), + handleCopyMarkdown: vi.fn(), + handleDownloadJSON: vi.fn(), + handleNativeShare: vi.fn(), + }), +})); + +const mockUser: UserProfile = { + name: 'Jane Doe', + username: 'janedoe', + bio: 'Fullstack Engineer and open source maintainer', + location: 'San Francisco, CA', + joinedDate: 'Joined Oct 2021', + developerScore: 88, + avatarUrl: 'https://example.com/jane-avatar.png', + isPro: true, + stats: { + repositories: 45, + stars: 230, + followers: 1200, + following: 150, + }, +}; + +const mockExportData: DashboardExportData = { + stats: { + currentStreak: 8, + peakStreak: 45, + totalContributions: 382, + }, + languages: [ + { name: 'TypeScript', color: '#3178c6', percentage: 70 }, + { name: 'CSS', color: '#563d7c', percentage: 30 }, + ], + activity: [], +}; + +function renderProfileCard() { + return render( + + ); +} + +function getFocusableElements(container: HTMLElement) { + return Array.from( + container.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ); +} + +describe('ProfileCard - Accessibility Standards & Screen Reader Aria Compliance', () => { + it('uses accessible label coordinates for the avatar, share button, and share dialog', () => { + renderProfileCard(); + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute( + 'src', + 'https://example.com/jane-avatar.png?s=120' + ); + + fireEvent.click(screen.getByRole('button', { name: /share your pulse/i })); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'share-sheet-title'); + expect(screen.getByText('janedoe')).toHaveAttribute('id', 'share-sheet-title'); + expect(screen.getByRole('button', { name: /close share panel/i })).toHaveAttribute( + 'aria-label', + 'Close share panel' + ); + }); + + it('keeps focusable button controls keyboard reachable without suppressing visible outlines', () => { + renderProfileCard(); + + const shareButton = screen.getByRole('button', { name: /share your pulse/i }); + shareButton.focus(); + + expect(document.activeElement).toBe(shareButton); + expect(shareButton.className).not.toMatch(/(?:^|\s)(?:focus:)?outline-none(?:\s|$)/); + + fireEvent.click(shareButton); + + const closeButton = screen.getByRole('button', { name: /close share panel/i }); + closeButton.focus(); + + expect(document.activeElement).toBe(closeButton); + expect(closeButton.className).not.toMatch(/(?:^|\s)(?:focus:)?outline-none(?:\s|$)/); + }); + + it('announces tooltip labels through tooltip semantics and readable text content', () => { + render( +
+ + + Developer score is 88 out of 100. + +
+ ); + + const trigger = screen.getByRole('button', { name: /developer score/i }); + const tooltip = screen.getByRole('tooltip'); + + expect(trigger).toHaveAccessibleDescription('Developer score is 88 out of 100.'); + expect(tooltip).toHaveTextContent('Developer Score'); + expect(tooltip).toHaveTextContent('Developer score is 88 out of 100.'); + }); + + it('preserves normal tab ordering and wraps keyboard focus inside the share dialog', () => { + renderProfileCard(); + + fireEvent.click(screen.getByRole('button', { name: /share your pulse/i })); + + const dialog = screen.getByRole('dialog'); + const focusableElements = getFocusableElements(dialog); + const closeButton = screen.getByRole('button', { name: /close share panel/i }); + const copyImageButton = screen.getByRole('button', { name: /copy image/i }); + const saveFileButton = screen.getByRole('button', { name: /save file/i }); + const urlInput = screen.getByDisplayValue('https://commitpulse.vercel.app/dashboard/janedoe'); + + expect(focusableElements[0]).toBe(closeButton); + expect(focusableElements[1]).toBe(copyImageButton); + expect(focusableElements[2]).toBe(saveFileButton); + expect(focusableElements[3]).toBe(urlInput); + expect(focusableElements.length).toBeGreaterThan(4); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + lastElement.focus(); + fireEvent.keyDown(dialog, { key: 'Tab' }); + expect(document.activeElement).toBe(firstElement); + + firstElement.focus(); + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(lastElement); + }); + + it('renders headings in logical hierarchical order without skipped levels', () => { + const { container } = renderProfileCard(); + + const headings = Array.from( + container.querySelectorAll('h1, h2, h3, h4, h5, h6') + ); + const levels = headings.map((heading) => Number(heading.tagName.slice(1))); + + expect(screen.getByRole('heading', { name: 'Jane Doe', level: 2 })).toBeInTheDocument(); + expect(levels).toEqual([2]); + + for (let index = 0; index < levels.length - 1; index += 1) { + expect(levels[index + 1] - levels[index]).toBeLessThanOrEqual(1); + } + }); +}); diff --git a/context/TranslationContext.tsx b/context/TranslationContext.tsx index c45559553..66318cbb8 100644 --- a/context/TranslationContext.tsx +++ b/context/TranslationContext.tsx @@ -107,11 +107,11 @@ export function TranslationProvider({ children }: { children: ReactNode }) { const translationSet = translations[currentLang] || translations.en; let value = getNestedValue(translationSet as Record, path); - if (typeof value !== 'string') { + if (value === path && currentLang !== 'en') { value = getNestedValue(translations.en as Record, path); } - if (typeof value !== 'string') { + if (value === path) { if (params && 'defaultValue' in params) { return params.defaultValue; } @@ -144,7 +144,7 @@ export function useTranslation() { changeLanguage: () => {}, t: (path: string, params?: Record): string => { const value = getNestedValue(en, path); - if (typeof value !== 'string') { + if (value === path) { if (params && 'defaultValue' in params) { return params.defaultValue; } diff --git a/proxy.accessibility.test.ts b/proxy.accessibility.test.ts index 85556c181..2664a4a3d 100644 --- a/proxy.accessibility.test.ts +++ b/proxy.accessibility.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest, NextResponse } from 'next/server'; -import { middleware as proxy } from './middleware'; +import { proxy } from './proxy'; import { rateLimit } from './lib/rate-limit'; vi.mock('./lib/rate-limit', () => ({ diff --git a/proxy.test.ts b/proxy.test.ts index ba839b6fa..5a224563e 100644 --- a/proxy.test.ts +++ b/proxy.test.ts @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { NextRequest, NextResponse } from 'next/server'; -import { middleware as proxy, config } from './middleware'; +import { proxy, config } from './proxy'; import { rateLimit } from '@/lib/rate-limit'; vi.mock('@/lib/rate-limit', () => ({ @@ -386,24 +386,24 @@ describe('proxy', () => { }); }); -describe('middleware.ts wiring', () => { - it('middleware.ts exports a function named middleware', async () => { - const mod = await import('./middleware'); +describe('proxy.ts wiring', () => { + it('proxy.ts exports a function named proxy', async () => { + const mod = await import('./proxy'); - // Next.js looks for a named export called `middleware` - expect(typeof mod.middleware).toBe('function'); + // Next.js looks for a named export called `proxy` + expect(typeof mod.proxy).toBe('function'); }); - it('middleware.ts exports config with a non-empty matcher array', async () => { - const mod = await import('./middleware'); + it('proxy.ts exports config with a non-empty matcher array', async () => { + const mod = await import('./proxy'); expect(mod.config).toBeDefined(); expect(Array.isArray(mod.config.matcher)).toBe(true); expect(mod.config.matcher.length).toBeGreaterThan(0); }); - it('middleware covers all expected API routes', async () => { - const { config: mwConfig } = await import('./middleware'); + it('proxy covers all expected API routes', async () => { + const { config: mwConfig } = await import('./proxy'); const expected = [ '/api/streak/:path*', '/api/github/:path*', diff --git a/middleware.ts b/proxy.ts similarity index 84% rename from middleware.ts rename to proxy.ts index a14616778..a61f8c1d1 100644 --- a/middleware.ts +++ b/proxy.ts @@ -4,14 +4,14 @@ import { rateLimit } from './lib/rate-limit'; import { getClientIp } from './utils/getClientIp'; /** - * Next.js middleware — rate-limits all matched API routes. + * Next.js proxy — rate-limits all matched API routes. * - * Next.js requires this file to be named `middleware.ts` at the project root - * and to export a function named `middleware` (and optionally `config`). + * Next.js requires this file to be named `proxy.ts` at the project root + * and to export a function named `proxy` (and optionally `config`). * - * @see https://nextjs.org/docs/app/building-your-application/routing/middleware + * @see https://nextjs.org/docs/messages/middleware-to-proxy */ -export async function middleware(request: NextRequest): Promise { +export async function proxy(request: NextRequest): Promise { const ip = getClientIp(request); const isRefresh =