Skip to content
Closed
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
6 changes: 3 additions & 3 deletions frontend/src/components/ContributorProfile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,16 @@ describe('ContributorProfile', () => {

it('does not render copy button when wallet is empty', () => {
render(<ContributorProfile {...defaultProps} walletAddress="" />);
expect(screen.queryByTestId('copy-wallet-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('copy-wallet')).not.toBeInTheDocument();
});

it('calls clipboard API when copy button is clicked', () => {
it('calls clipboard API when copy button is clicked', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

render(<ContributorProfile {...defaultProps} />);
fireEvent.click(screen.getByTestId('copy-wallet-btn'));
expect(writeText).toHaveBeenCalledWith(defaultProps.walletAddress);
await expect(writeText).toHaveBeenCalledWith(defaultProps.walletAddress);
});

// ── Recent bounties tests ──────────────────────────────────────────────────
Expand Down
47 changes: 12 additions & 35 deletions frontend/src/components/ContributorProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use client';

import React, { useState } from 'react';
import React from 'react';
import type { ContributorBadgeStats } from '../types/badges';
import { computeBadges } from '../types/badges';
import { BadgeGrid } from './badges';
import { TimeAgo } from './common/TimeAgo';
import { CopyAddress } from './common/CopyAddress';

interface RecentBounty {
title: string;
Expand Down Expand Up @@ -145,28 +146,11 @@ export const ContributorProfile: React.FC<ContributorProfileProps> = ({
creatorTotalEscrowed = 0,
creatorCompletionRate = 0,
}) => {
const [copied, setCopied] = useState(false);

const truncatedWallet = walletAddress
? `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`
: 'Not connected';

const badges = badgeStats ? computeBadges(badgeStats) : [];
const earnedCount = badges.filter((b) => b.earned).length;

const mostRecentPrTimestamp = badgeStats?.prSubmissionTimestampsUtc?.[badgeStats.prSubmissionTimestampsUtc.length - 1];

const handleCopyWallet = async () => {
if (!walletAddress) return;
try {
await navigator.clipboard.writeText(walletAddress);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard API may not be available in all contexts
}
};

return (
<div className="bg-gray-900 rounded-lg p-4 sm:p-6 text-white space-y-6">
{/* Profile Header */}
Expand Down Expand Up @@ -197,23 +181,16 @@ export const ContributorProfile: React.FC<ContributorProfileProps> = ({
)}
</div>
<div className="flex items-center gap-1.5 justify-center sm:justify-start">
<p className="text-gray-400 text-xs sm:text-sm font-mono">{truncatedWallet}</p>
{walletAddress && (
<button
data-testid="copy-wallet-btn"
onClick={handleCopyWallet}
className="text-gray-500 hover:text-gray-300 transition-colors"
title="Copy wallet address"
>
{copied ? (
<span className="text-green-400 text-xs">✓</span>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
)}
</button>
{walletAddress ? (
<CopyAddress
address={walletAddress}
startChars={6}
endChars={4}
data-testid="copy-wallet"
ariaLabel="Copy wallet address"
/>
) : (
<p className="text-gray-400 text-xs sm:text-sm font-mono">Not connected</p>
)}
</div>
{joinDate && (
Expand Down
233 changes: 233 additions & 0 deletions frontend/src/components/common/CopyAddress.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* @jest-environment jsdom
*/
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CopyAddress, truncateAddress } from './CopyAddress';

// Mock navigator.clipboard
const mockWriteText = vi.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});

describe('truncateAddress', () => {
it('truncates address with default length', () => {
const address = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z';
expect(truncateAddress(address)).toBe('C2Tv...7Z');
});

it('truncates with custom start/end chars', () => {
const address = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z';
expect(truncateAddress(address, 6, 4)).toBe('C2Tv7g...7Z');
});

it('returns full address if too short to truncate', () => {
const address = 'ABC123';
expect(truncateAddress(address)).toBe('ABC123');
});

it('returns empty string for empty address', () => {
expect(truncateAddress('')).toBe('');
});
});

describe('CopyAddress', () => {
const testAddress = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z8W1BAGS';

beforeEach(() => {
vi.clearAllMocks();
mockWriteText.mockResolvedValue(undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('renders truncated address', () => {
render(<CopyAddress address={testAddress} />);
expect(screen.getByTestId('copy-address-text').textContent).toBe('C2Tv...AGS');
});

it('shows full address in title attribute', () => {
render(<CopyAddress address={testAddress} />);
expect(screen.getByTestId('copy-address-text').getAttribute('title')).toBe(testAddress);
});

it('renders copy icon by default', () => {
render(<CopyAddress address={testAddress} />);
expect(screen.getByTestId('copy-address-icon')).toBeTruthy();
});

it('hides icon when showIcon=false', () => {
render(<CopyAddress address={testAddress} showIcon={false} />);
expect(screen.queryByTestId('copy-address-btn')).toBeNull();
});

it('copies address to clipboard on click', async () => {
render(<CopyAddress address={testAddress} />);

await act(async () => {
fireEvent.click(screen.getByTestId('copy-address-btn'));
});

expect(mockWriteText).toHaveBeenCalledWith(testAddress);
});

it('shows checkmark after successful copy', async () => {
render(<CopyAddress address={testAddress} />);

await act(async () => {
fireEvent.click(screen.getByTestId('copy-address-btn'));
});

expect(screen.getByTestId('copy-address-checkmark')).toBeTruthy();
});

it('returns to copy icon after 2 seconds', async () => {
vi.useFakeTimers();
render(<CopyAddress address={testAddress} />);

await act(async () => {
fireEvent.click(screen.getByTestId('copy-address-btn'));
});

expect(screen.getByTestId('copy-address-checkmark')).toBeTruthy();

act(() => {
vi.advanceTimersByTime(2100);
});

await waitFor(() => {
expect(screen.queryByTestId('copy-address-checkmark')).toBeNull();
});

vi.useRealTimers();
});

it('shows error icon on copy failure', async () => {
mockWriteText.mockRejectedValue(new Error('Clipboard error'));
render(<CopyAddress address={testAddress} />);

await act(async () => {
fireEvent.click(screen.getByTestId('copy-address-btn'));
});

expect(screen.getByTestId('copy-address-error')).toBeTruthy();
});

it('calls onCopy callback after successful copy', async () => {
const onCopy = vi.fn();
render(<CopyAddress address={testAddress} onCopy={onCopy} />);

await act(async () => {
fireEvent.click(screen.getByTestId('copy-address-btn'));
});

expect(onCopy).toHaveBeenCalled();
});

it('has correct aria-label', () => {
render(<CopyAddress address={testAddress} />);
expect(screen.getByTestId('copy-address-btn').getAttribute('aria-label')).toBe(
'Copy C2Tv...AGS to clipboard'
);
});

it('uses custom ariaLabel when provided', () => {
render(<CopyAddress address={testAddress} ariaLabel="Copy wallet address" />);
expect(screen.getByTestId('copy-address-btn').getAttribute('aria-label')).toBe(
'Copy wallet address'
);
});

it('handles Enter key press', async () => {
render(<CopyAddress address={testAddress} />);

await act(async () => {
fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Enter' });
});

expect(mockWriteText).toHaveBeenCalledWith(testAddress);
});

it('handles Space key press', async () => {
render(<CopyAddress address={testAddress} />);

await act(async () => {
fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: ' ' });
});

expect(mockWriteText).toHaveBeenCalledWith(testAddress);
});

it('does not copy on other key presses', async () => {
render(<CopyAddress address={testAddress} />);

fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Tab' });

expect(mockWriteText).not.toHaveBeenCalled();
});

it('prevents default on Enter/Space key', async () => {
render(<CopyAddress address={testAddress} />);

const event = fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Enter' });
// The handler calls preventDefault, but fireEvent doesn't easily test this
// The fact that it doesn't throw is a basic test
});

it('returns null when address is empty', () => {
const { container } = render(<CopyAddress address="" />);
expect(container.firstChild).toBeNull();
});

it('applies custom className', () => {
render(<CopyAddress address={testAddress} className="my-custom-class" />);
expect(screen.getByTestId('copy-address').className).toContain('my-custom-class');
});

it('applies custom data-testid', () => {
render(<CopyAddress address={testAddress} data-testid="wallet-address" />);
expect(screen.getByTestId('wallet-address')).toBeTruthy();
expect(screen.getByTestId('wallet-address-text')).toBeTruthy();
expect(screen.getByTestId('wallet-address-btn')).toBeTruthy();
});

it('updates title attribute after copy', async () => {
render(<CopyAddress address={testAddress} />);
const btn = screen.getByTestId('copy-address-btn');

expect(btn.getAttribute('title')).toBe('Copy to clipboard');

await act(async () => {
fireEvent.click(btn);
});

expect(btn.getAttribute('title')).toBe('Copied!');
});

it('has screen reader status element', () => {
render(<CopyAddress address={testAddress} />);
const status = screen.getByTestId('copy-address-status');
expect(status.getAttribute('role')).toBe('status');
expect(status.getAttribute('aria-live')).toBe('polite');
});

it('has sr-only class on status element', () => {
render(<CopyAddress address={testAddress} />);
expect(screen.getByTestId('copy-address-status').className).toContain('sr-only');
});

it('button has type="button"', () => {
render(<CopyAddress address={testAddress} />);
expect(screen.getByTestId('copy-address-btn').getAttribute('type')).toBe('button');
});

it('uses custom startChars and endChars', () => {
render(<CopyAddress address={testAddress} startChars={6} endChars={6} />);
expect(screen.getByTestId('copy-address-text').textContent).toBe('C2Tv7g...1BAGS');
});
});
Loading
Loading