Skip to content

Commit 6d4bdc6

Browse files
Merge branch 'main' into test-2949
2 parents 4cee552 + bb7c726 commit 6d4bdc6

4 files changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import CopyRepoButton from './CopyRepoButton';
5+
6+
vi.mock('lucide-react', () => ({
7+
Copy: () => <svg aria-hidden="true" data-testid="copy-icon" />,
8+
}));
9+
10+
Object.assign(navigator, {
11+
clipboard: {
12+
writeText: vi.fn().mockResolvedValue(undefined),
13+
},
14+
});
15+
16+
describe('CopyRepoButton accessibility behavior', () => {
17+
it('renders as an accessible button with visible label', () => {
18+
render(<CopyRepoButton />);
19+
20+
expect(screen.getByRole('button', { name: /copy url/i })).toBeDefined();
21+
});
22+
23+
it('keeps decorative copy icon hidden from assistive technology', () => {
24+
render(<CopyRepoButton />);
25+
26+
expect(screen.getByTestId('copy-icon').getAttribute('aria-hidden')).toBe('true');
27+
});
28+
29+
it('is keyboard focusable through the native button element', () => {
30+
render(<CopyRepoButton />);
31+
32+
const button = screen.getByRole('button', { name: /copy url/i });
33+
button.focus();
34+
35+
expect(document.activeElement).toBe(button);
36+
});
37+
38+
it('updates accessible button name after successful copy action', async () => {
39+
render(<CopyRepoButton />);
40+
41+
const button = screen.getByRole('button', { name: /copy url/i });
42+
fireEvent.click(button);
43+
44+
expect(await screen.findByRole('button', { name: /copied/i })).toBeDefined();
45+
});
46+
47+
it('preserves semantic button role after interaction', async () => {
48+
render(<CopyRepoButton />);
49+
50+
fireEvent.click(screen.getByRole('button', { name: /copy url/i }));
51+
52+
expect(await screen.findByRole('button', { name: /copied/i })).toBeDefined();
53+
expect(screen.getAllByRole('button')).toHaveLength(1);
54+
});
55+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import ContributorsPage from './page';
3+
import '@testing-library/jest-dom';
4+
5+
describe('ContributorsPage - Timezone Boundaries & Calendar Alignment', () => {
6+
beforeEach(() => {
7+
vi.restoreAllMocks();
8+
});
9+
10+
it('verifies standard timestamp formatting in rate limit message', async () => {
11+
const timestamp = 1704067200; // 2024-01-01T00:00:00.000Z
12+
global.fetch = vi.fn(() =>
13+
Promise.resolve({
14+
ok: false,
15+
status: 403,
16+
headers: {
17+
get: (name: string) => {
18+
if (name === 'x-ratelimit-remaining') return '0';
19+
if (name === 'x-ratelimit-reset') return String(timestamp);
20+
return null;
21+
},
22+
},
23+
json: async () => [],
24+
})
25+
) as unknown as typeof fetch;
26+
27+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
28+
29+
const element = await ContributorsPage();
30+
// Element renders fallback UI because of the API rate limit error, fetch fails gracefully
31+
expect(element).toBeDefined();
32+
expect(consoleSpy).toHaveBeenCalledWith(
33+
'Failed to fetch contributors:',
34+
expect.objectContaining({
35+
message: expect.stringContaining('Please try again after 2024-01-01T00:00:00.000Z.'),
36+
})
37+
);
38+
});
39+
40+
it('verifies timezone/calendar alignment during leap year boundaries', async () => {
41+
const timestamp = 1709164800; // 2024-02-29T00:00:00.000Z (Leap day)
42+
global.fetch = vi.fn(() =>
43+
Promise.resolve({
44+
ok: false,
45+
status: 403,
46+
headers: {
47+
get: (name: string) => {
48+
if (name === 'x-ratelimit-remaining') return '0';
49+
if (name === 'x-ratelimit-reset') return String(timestamp);
50+
return null;
51+
},
52+
},
53+
json: async () => [],
54+
})
55+
) as unknown as typeof fetch;
56+
57+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
58+
59+
await ContributorsPage();
60+
expect(consoleSpy).toHaveBeenCalledWith(
61+
'Failed to fetch contributors:',
62+
expect.objectContaining({
63+
message: expect.stringContaining('Please try again after 2024-02-29T00:00:00.000Z.'),
64+
})
65+
);
66+
});
67+
68+
it('verifies timezone/calendar alignment during year-end boundary rollovers', async () => {
69+
const timestamp = 1798761599; // 2026-12-31T23:59:59.000Z
70+
global.fetch = vi.fn(() =>
71+
Promise.resolve({
72+
ok: false,
73+
status: 403,
74+
headers: {
75+
get: (name: string) => {
76+
if (name === 'x-ratelimit-remaining') return '0';
77+
if (name === 'x-ratelimit-reset') return String(timestamp);
78+
return null;
79+
},
80+
},
81+
json: async () => [],
82+
})
83+
) as unknown as typeof fetch;
84+
85+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
86+
87+
await ContributorsPage();
88+
expect(consoleSpy).toHaveBeenCalledWith(
89+
'Failed to fetch contributors:',
90+
expect.objectContaining({
91+
message: expect.stringContaining('Please try again after 2026-12-31T23:59:59.000Z.'),
92+
})
93+
);
94+
});
95+
96+
it('handles invalid non-numeric or empty rate limit reset headers gracefully without throwing formatter exceptions', async () => {
97+
global.fetch = vi.fn(() =>
98+
Promise.resolve({
99+
ok: false,
100+
status: 429,
101+
headers: {
102+
get: (name: string) => {
103+
if (name === 'x-ratelimit-reset') return 'invalid-timestamp';
104+
return null;
105+
},
106+
},
107+
json: async () => [],
108+
})
109+
) as unknown as typeof fetch;
110+
111+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
112+
113+
await ContributorsPage();
114+
// It should omit the date reset message entirely when timestamp is invalid
115+
expect(consoleSpy).toHaveBeenCalledWith(
116+
'Failed to fetch contributors:',
117+
expect.objectContaining({
118+
message: 'GitHub API rate limit exceeded. Please try again later.',
119+
})
120+
);
121+
});
122+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, expectTypeOf } from 'vitest';
2+
import { z } from 'zod';
3+
4+
// Type definitions used by the contributors page
5+
type ExpectedContributor = {
6+
id: number;
7+
username: string;
8+
avatarUrl: string;
9+
contributions: number;
10+
};
11+
12+
type PageProps = {
13+
contributors: ExpectedContributor[];
14+
showAvatars?: boolean;
15+
topN?: number;
16+
};
17+
18+
describe('ContributorsPage type & schema compiler checks (Variation 10)', () => {
19+
it('Case 1: Validate core property shapes match design boundaries', () => {
20+
type Matching = {
21+
id: number;
22+
username: string;
23+
avatarUrl: string;
24+
contributions: number;
25+
};
26+
27+
expectTypeOf<Matching>().toEqualTypeOf<ExpectedContributor>();
28+
});
29+
30+
it('Case 2: Ensure invalid parameters are blocked via static assignability', () => {
31+
type Invalid = {
32+
id: string; // wrong type
33+
username: number; // wrong type
34+
contributions?: string; // wrong and optional
35+
};
36+
37+
expectTypeOf<Invalid>().not.toMatchTypeOf<ExpectedContributor>();
38+
});
39+
40+
it('Case 3: Optional fields accepted safely without compile-time warnings', () => {
41+
const minimal: PageProps = {
42+
contributors: [{ id: 1, username: 'bob', avatarUrl: '/img/bob.png', contributions: 3 }],
43+
};
44+
45+
expect(minimal.contributors.length).toBe(1);
46+
type MinimalType = typeof minimal;
47+
expectTypeOf<MinimalType>().toMatchTypeOf<PageProps>();
48+
});
49+
50+
it('Case 4: Zod runtime schema flags out-of-bound structural types with flat reports', () => {
51+
const contributorSchema = z
52+
.object({
53+
id: z.number().int().positive(),
54+
username: z.string().min(1),
55+
avatarUrl: z.string().min(1),
56+
contributions: z.number().int().nonnegative(),
57+
})
58+
.strict();
59+
60+
const bad = {
61+
id: -5,
62+
username: '',
63+
avatarUrl: 12345,
64+
contributions: -1,
65+
extra: 'unexpected',
66+
} as unknown;
67+
68+
try {
69+
contributorSchema.parse(bad);
70+
expect(false).toBe(true);
71+
} catch (err) {
72+
const zErr = err as z.ZodError;
73+
expect(zErr.issues.length).toBeGreaterThan(0);
74+
// flat validation: all issue paths are shallow (no deep nesting)
75+
const allShallow = zErr.issues.every((i) => i.path.length <= 1);
76+
expect(allShallow).toBe(true);
77+
}
78+
});
79+
80+
it('Case 5: Correct payloads safely clear validation limits', () => {
81+
const contributorSchema = z.object({
82+
id: z.number().int().positive(),
83+
username: z.string().min(1),
84+
avatarUrl: z.string().min(1),
85+
contributions: z.number().int().nonnegative(),
86+
});
87+
88+
const good = {
89+
id: 2,
90+
username: 'alice',
91+
avatarUrl: 'https://cdn.test/a.png',
92+
contributions: 10,
93+
};
94+
95+
const parsed = contributorSchema.parse(good);
96+
expect(parsed).toEqual(good);
97+
type ParsedType = z.infer<typeof contributorSchema>;
98+
expectTypeOf<ParsedType>().toEqualTypeOf<ExpectedContributor>();
99+
});
100+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import ProfileOptimizerModal from './ProfileOptimizerModal';
4+
import type { ReactNode, HTMLAttributes } from 'react';
5+
import '@testing-library/jest-dom';
6+
7+
vi.mock('framer-motion', () => ({
8+
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
9+
10+
motion: {
11+
div: ({
12+
children,
13+
...props
14+
}: HTMLAttributes<HTMLDivElement> & {
15+
children?: ReactNode;
16+
}) => <div {...props}>{children}</div>,
17+
18+
p: ({
19+
children,
20+
...props
21+
}: HTMLAttributes<HTMLParagraphElement> & {
22+
children?: ReactNode;
23+
}) => <p {...props}>{children}</p>,
24+
},
25+
}));
26+
27+
const mockUserData = {
28+
profile: {
29+
developerScore: 75,
30+
bio: 'Full Stack Developer',
31+
stats: {
32+
repositories: 12,
33+
followers: 20,
34+
},
35+
},
36+
languages: ['TypeScript', 'JavaScript'],
37+
stats: {
38+
totalContributions: 500,
39+
},
40+
};
41+
42+
describe('ProfileOptimizerModal', () => {
43+
const onClose = vi.fn();
44+
45+
beforeEach(() => {
46+
vi.clearAllMocks();
47+
});
48+
49+
it('does not render when isOpen is false', () => {
50+
render(<ProfileOptimizerModal isOpen={false} onClose={onClose} userData={mockUserData} />);
51+
52+
expect(screen.queryByText('Profile Optimizer')).not.toBeInTheDocument();
53+
});
54+
55+
it('renders modal when isOpen is true', () => {
56+
render(<ProfileOptimizerModal isOpen onClose={onClose} userData={mockUserData} />);
57+
58+
expect(screen.getByText('Profile Optimizer')).toBeInTheDocument();
59+
});
60+
61+
it('calls onClose when close button is clicked', () => {
62+
render(<ProfileOptimizerModal isOpen onClose={onClose} userData={mockUserData} />);
63+
64+
const buttons = screen.getAllByRole('button');
65+
fireEvent.click(buttons[0]);
66+
67+
expect(onClose).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it('shows loading state initially', () => {
71+
render(<ProfileOptimizerModal isOpen onClose={onClose} userData={mockUserData} />);
72+
73+
expect(screen.getByText('Analysing GitHub profile...')).toBeInTheDocument();
74+
});
75+
76+
it('renders loading container when modal opens', () => {
77+
render(<ProfileOptimizerModal isOpen onClose={onClose} userData={mockUserData} />);
78+
79+
expect(screen.getByText('Analysing GitHub profile...')).toBeInTheDocument();
80+
});
81+
});

0 commit comments

Comments
 (0)