Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions components/dashboard/ActivityLandscape.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('framer-motion')>('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'>) => (
<div {...props}>{children}</div>
),
div: ({
children,
initial: _initial,
animate: _animate,
exit: _exit,
transition: _transition,
...props
}: MotionDivProps) => <div {...props}>{children}</div>,
},
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
};
});
const mockData = Array.from({ length: 100 }, (_, i) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});

Expand Down
225 changes: 225 additions & 0 deletions components/dashboard/ProfileCard.accessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement> & { fill?: boolean }) => (
// eslint-disable-next-line @next/next/no-img-element
<img {...props} />
),
}));

vi.mock('framer-motion', () => ({
motion: {
div: ({
children,
initial: _initial,
animate: _animate,
exit: _exit,
transition: _transition,
...props
}: MotionDivProps) => <div {...props}>{children}</div>,
button: ({
children,
initial: _initial,
animate: _animate,
exit: _exit,
transition: _transition,
whileHover: _whileHover,
whileTap: _whileTap,
...props
}: MotionButtonProps) => <button {...props}>{children}</button>,
},
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
}));

vi.mock('react-qr-code', () => ({
default: ({ value }: { value: string }) => (
<svg aria-hidden="true" data-testid="qr-code" data-value={value} />
),
}));

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(
<ProfileCard
user={mockUser}
exportData={mockExportData}
badges={['Top Contributor', 'Fast Responder']}
/>
);
}

function getFocusableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
'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(
<div>
<button aria-describedby="profile-score-tooltip">Developer Score</button>
<VisualizationTooltip title="Developer Score" x={120} y={80}>
<span id="profile-score-tooltip">Developer score is 88 out of 100.</span>
</VisualizationTooltip>
</div>
);

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<HTMLHeadingElement>('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);
}
});
});
6 changes: 3 additions & 3 deletions context/TranslationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ export function TranslationProvider({ children }: { children: ReactNode }) {
const translationSet = translations[currentLang] || translations.en;
let value = getNestedValue(translationSet as Record<string, unknown>, path);

if (typeof value !== 'string') {
if (value === path && currentLang !== 'en') {
value = getNestedValue(translations.en as Record<string, unknown>, path);
}

if (typeof value !== 'string') {
if (value === path) {
if (params && 'defaultValue' in params) {
return params.defaultValue;
}
Expand Down Expand Up @@ -144,7 +144,7 @@ export function useTranslation() {
changeLanguage: () => {},
t: (path: string, params?: Record<string, string>): string => {
const value = getNestedValue(en, path);
if (typeof value !== 'string') {
if (value === path) {
if (params && 'defaultValue' in params) {
return params.defaultValue;
}
Expand Down
2 changes: 1 addition & 1 deletion proxy.accessibility.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down
20 changes: 10 additions & 10 deletions proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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*',
Expand Down
10 changes: 5 additions & 5 deletions middleware.ts → proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse> {
export async function proxy(request: NextRequest): Promise<NextResponse> {
const ip = getClientIp(request);

const isRefresh =
Expand Down
Loading