Skip to content

Commit 1386c0f

Browse files
Merge branch 'main' into fix/og-bypass-cache
2 parents 54f63c9 + 981c8b6 commit 1386c0f

6 files changed

Lines changed: 607 additions & 0 deletions
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import React from 'react';
4+
import Leaderboard, { Contributor } from './Leaderboard';
5+
6+
// Mock Next.js Image
7+
vi.mock('next/image', () => ({
8+
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img alt="mock" {...props} />,
9+
}));
10+
11+
// Mock Framer Motion
12+
vi.mock('framer-motion', async () => {
13+
const actual = await vi.importActual('framer-motion');
14+
return {
15+
...actual,
16+
motion: {
17+
div: ({
18+
children,
19+
className,
20+
onClick,
21+
style,
22+
}: {
23+
children?: React.ReactNode;
24+
className?: string;
25+
onClick?: React.MouseEventHandler<HTMLDivElement>;
26+
style?: React.CSSProperties;
27+
}) => (
28+
<div className={className} onClick={onClick} style={style} data-testid="motion-div">
29+
{children}
30+
</div>
31+
),
32+
},
33+
};
34+
});
35+
36+
// IntersectionObserver mock
37+
beforeEach(() => {
38+
const mockIntersectionObserver = vi.fn();
39+
mockIntersectionObserver.mockReturnValue({
40+
observe: () => null,
41+
unobserve: () => null,
42+
disconnect: () => null,
43+
});
44+
window.IntersectionObserver =
45+
mockIntersectionObserver as unknown as typeof window.IntersectionObserver;
46+
});
47+
48+
describe('Leaderboard - Massive Scaling & High Bounds (Issue #2754 Equivalent)', () => {
49+
it('Massive Array Population (Render Bounds): handles processing arrays of 1000+ contributors cleanly without crashing', () => {
50+
const massiveContributors = Array.from({ length: 1005 }).map((_, i) => ({
51+
id: i,
52+
login: `user${i}`,
53+
avatar_url: `https://avatars.githubusercontent.com/u/${i}?v=4`,
54+
contributions: 100 - i, // fake sort
55+
html_url: `https://github.com/user${i}`,
56+
}));
57+
58+
const start = performance.now();
59+
const { container } = render(<Leaderboard contributors={massiveContributors} />);
60+
const end = performance.now();
61+
62+
// Verify it extracts podium and leaves 1002 in the list
63+
const listEntries = container.querySelectorAll('.flex.items-center.justify-between.p-4');
64+
expect(listEntries.length).toBe(1002);
65+
66+
// Performance bounds check (bumped to 5000ms to account for slower CI runners)
67+
expect(end - start).toBeLessThan(5000);
68+
});
69+
70+
it('Extreme High Contribution Metric Bounds: safely renders millions of commits without integer layout overflow', () => {
71+
const highMetricData: Contributor[] = [
72+
{ id: 1, login: 'whale', avatar_url: '', html_url: '', contributions: 999999999 },
73+
{ id: 2, login: 'shark', avatar_url: '', html_url: '', contributions: 888888888 },
74+
{ id: 3, login: 'dolphin', avatar_url: '', html_url: '', contributions: 777777777 },
75+
{ id: 4, login: 'fish', avatar_url: '', html_url: '', contributions: 666666666 },
76+
];
77+
78+
const { getByText } = render(<Leaderboard contributors={highMetricData} />);
79+
80+
// Podium text
81+
expect(getByText('999999999')).toBeTruthy();
82+
expect(getByText('888888888')).toBeTruthy();
83+
expect(getByText('777777777')).toBeTruthy();
84+
85+
// List text
86+
expect(getByText('666666666')).toBeTruthy();
87+
});
88+
89+
it('Rank Formatting Overflow Prevention: ensures extremely high index numbers do not break grid bounds', () => {
90+
// Check rank #1000+ string bounds
91+
const highRankData = Array.from({ length: 1500 }).map((_, i) => ({
92+
id: i,
93+
login: `user${i}`,
94+
avatar_url: '',
95+
contributions: 10,
96+
html_url: '',
97+
}));
98+
99+
const { getByText } = render(<Leaderboard contributors={highRankData} />);
100+
101+
// Verify #1000 renders cleanly inside the bounding flex box
102+
const highRankItem = getByText('#1000');
103+
expect(highRankItem).toBeTruthy();
104+
expect(highRankItem.className).toContain('font-mono');
105+
});
106+
107+
it('Podium Extraction Memory Allocation: strictly segments exactly the top 3 nodes regardless of massive data input sizes', () => {
108+
const massiveData = Array.from({ length: 500 }).map((_, i) => ({
109+
id: i,
110+
login: `user${i}`,
111+
avatar_url: '',
112+
contributions: 10,
113+
html_url: '',
114+
}));
115+
116+
const { container } = render(<Leaderboard contributors={massiveData} />);
117+
118+
// Check that we only rendered 3 PodiumItems (Rank 1, 2, 3)
119+
const crowns = container.querySelectorAll('.absolute.-top-8.z-30'); // Crown icons exist in podiums only
120+
expect(crowns.length).toBe(3);
121+
});
122+
123+
it('Structural Text Wrapping Safety: extreme long usernames truncate without clipping or blowing out DOM boundaries', () => {
124+
const extremeNameData: Contributor[] = [
125+
{ id: 1, login: 'a'.repeat(500), avatar_url: '', html_url: '', contributions: 100 },
126+
{ id: 2, login: 'b'.repeat(500), avatar_url: '', html_url: '', contributions: 90 },
127+
{ id: 3, login: 'c'.repeat(500), avatar_url: '', html_url: '', contributions: 80 },
128+
];
129+
130+
const { getByText } = render(<Leaderboard contributors={extremeNameData} />);
131+
132+
const longNameUser = getByText('a'.repeat(500));
133+
// Verify it uses truncate to stop boundary breakages
134+
expect(longNameUser.className).toContain('truncate');
135+
});
136+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, fireEvent } from '@testing-library/react';
3+
import React from 'react';
4+
import Leaderboard, { Contributor } from './Leaderboard';
5+
6+
// Mock Next.js Image
7+
vi.mock('next/image', () => ({
8+
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img alt="mock" {...props} />,
9+
}));
10+
11+
// Mock framer-motion to render children with classes and event handlers
12+
vi.mock('framer-motion', async () => {
13+
const actual = await vi.importActual('framer-motion');
14+
return {
15+
...actual,
16+
motion: {
17+
div: ({
18+
children,
19+
className,
20+
onClick,
21+
style,
22+
}: {
23+
children?: React.ReactNode;
24+
className?: string;
25+
onClick?: React.MouseEventHandler<HTMLDivElement>;
26+
style?: React.CSSProperties;
27+
}) => (
28+
<div className={className} onClick={onClick} style={style} data-testid="motion-div">
29+
{children}
30+
</div>
31+
),
32+
},
33+
};
34+
});
35+
36+
describe('Leaderboard - Mouse Interactivity & Touch Events (Issue #2757 Equivalent)', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks();
39+
});
40+
41+
const mockData: Contributor[] = [
42+
{ id: 1, login: 'user1', avatar_url: '', html_url: '', contributions: 100 },
43+
{ id: 2, login: 'user2', avatar_url: '', html_url: '', contributions: 90 },
44+
{ id: 3, login: 'user3', avatar_url: '', html_url: '', contributions: 80 },
45+
{ id: 4, login: 'user4', avatar_url: '', html_url: '', contributions: 70 },
46+
];
47+
48+
it('Interactive Pointer Detection (Cursor Hovers Equivalent): applies explicit cursor-pointer classes on interactive elements', () => {
49+
const { container } = render(<Leaderboard contributors={mockData} />);
50+
51+
// Check podiums
52+
const podiums = container.querySelectorAll('.w-28.sm\\:w-36.cursor-pointer');
53+
expect(podiums.length).toBeGreaterThan(0);
54+
55+
// Check list entries
56+
const listEntries = container.querySelectorAll(
57+
'.flex.items-center.justify-between.p-4.cursor-pointer'
58+
);
59+
expect(listEntries.length).toBe(1); // user4
60+
});
61+
62+
it('Event Propagation to DOM Targets (Touch Propagation Equivalent): fires document scroll bounds safely on click', () => {
63+
const scrollIntoViewMock = vi.fn();
64+
document.getElementById = vi.fn().mockReturnValue({
65+
scrollIntoView: scrollIntoViewMock,
66+
});
67+
68+
const { container } = render(<Leaderboard contributors={mockData} />);
69+
70+
const listItem = container.querySelector(
71+
'.flex.items-center.justify-between.p-4.cursor-pointer'
72+
) as HTMLElement;
73+
fireEvent.click(listItem);
74+
75+
// Verifies the target scroll view was intercepted and fired
76+
expect(document.getElementById).toHaveBeenCalledWith('contributors');
77+
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' });
78+
});
79+
80+
it('Hover State Visibility (Tooltips Equivalent): transitions text and background colors via group-hover structurally', () => {
81+
const { container } = render(<Leaderboard contributors={mockData} />);
82+
83+
const listItem = container.querySelector(
84+
'.flex.items-center.justify-between.p-4.cursor-pointer'
85+
);
86+
// Ensure the Tailwind structural classes that handle the state change are natively present
87+
expect(listItem?.className).toContain('hover:bg-black/[0.06]');
88+
expect(listItem?.className).toContain('group');
89+
});
90+
91+
it('Mouse Leave Recovery (Hiding Overlay visuals Equivalent): ensures transition duration limits are bounded safely', () => {
92+
const { container } = render(<Leaderboard contributors={mockData} />);
93+
94+
const listItems = container.querySelectorAll('.transition-all.duration-300');
95+
// Validates the recovery speeds don't trap the user in floating states
96+
expect(listItems.length).toBeGreaterThan(0);
97+
});
98+
99+
it('Nested Interactive Scopes: validates avatar glow triggers specifically isolate under parental group hover boundaries', () => {
100+
const { container } = render(<Leaderboard contributors={mockData} />);
101+
102+
// Verify the avatar borders only activate under group-hover targeting
103+
const listAvatar = container.querySelector('.group-hover\\:border-cyan-400\\/40');
104+
expect(listAvatar).toBeTruthy();
105+
});
106+
});

lib/svg/glacier.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { themes } from './themes';
3+
import { generateSVG } from './generator';
4+
import type { StreakStats, ContributionCalendar, BadgeParams } from '../../types/index';
5+
6+
const mockStats: StreakStats = {
7+
currentStreak: 5,
8+
longestStreak: 10,
9+
totalContributions: 120,
10+
todayDate: '2024-06-01',
11+
};
12+
13+
const mockCalendar: ContributionCalendar = {
14+
totalContributions: 120,
15+
weeks: [
16+
{
17+
contributionDays: [
18+
{ contributionCount: 3, date: '2024-05-26' },
19+
{ contributionCount: 0, date: '2024-05-27' },
20+
{ contributionCount: 5, date: '2024-05-28' },
21+
{ contributionCount: 2, date: '2024-05-29' },
22+
{ contributionCount: 4, date: '2024-05-30' },
23+
{ contributionCount: 1, date: '2024-05-31' },
24+
{ contributionCount: 6, date: '2024-06-01' },
25+
],
26+
},
27+
],
28+
};
29+
30+
const glacierAccent = Array.isArray(themes.glacier.accent)
31+
? themes.glacier.accent[0]
32+
: themes.glacier.accent;
33+
34+
const mockParams = {
35+
user: 'octocat',
36+
bg: themes.glacier.bg,
37+
text: themes.glacier.text,
38+
accent: glacierAccent,
39+
size: 'medium',
40+
scale: 'linear',
41+
grace: 1,
42+
refresh: false,
43+
hide_title: false,
44+
hide_background: false,
45+
hide_stats: false,
46+
lang: 'en',
47+
view: 'default',
48+
delta_format: 'percent',
49+
mode: 'commits',
50+
entrance: 'rise',
51+
format: 'svg',
52+
radius: 8,
53+
speed: '8s',
54+
opacity: 1.0,
55+
labels: false,
56+
shading: false,
57+
gradient: false,
58+
disable_particles: false,
59+
glow: true,
60+
} as BadgeParams;
61+
62+
const HEX_REGEX = /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/;
63+
64+
function hexToRgb(hex: string): [number, number, number] {
65+
const h = hex.replace('#', '');
66+
const full =
67+
h.length === 3
68+
? h
69+
.split('')
70+
.map((c) => c + c)
71+
.join('')
72+
: h;
73+
const n = parseInt(full, 16);
74+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
75+
}
76+
77+
function relativeLuminance(hex: string): number {
78+
const [r, g, b] = hexToRgb(hex).map((c) => {
79+
const s = c / 255;
80+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
81+
});
82+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
83+
}
84+
85+
function contrastRatio(hex1: string, hex2: string): number {
86+
const l1 = relativeLuminance(hex1);
87+
const l2 = relativeLuminance(hex2);
88+
const lighter = Math.max(l1, l2);
89+
const darker = Math.min(l1, l2);
90+
return (lighter + 0.05) / (darker + 0.05);
91+
}
92+
93+
describe('glacier theme', () => {
94+
it('exists as a key in the themes object', () => {
95+
expect(Object.hasOwn(themes, 'glacier')).toBe(true);
96+
});
97+
98+
it('has valid hex color strings for bg, text, and accent', () => {
99+
const { bg, text, accent } = themes.glacier;
100+
const accentStr = Array.isArray(accent) ? accent[0] : accent;
101+
expect(HEX_REGEX.test(bg)).toBe(true);
102+
expect(HEX_REGEX.test(text)).toBe(true);
103+
expect(HEX_REGEX.test(accentStr)).toBe(true);
104+
});
105+
106+
it('has the correct specific color values', () => {
107+
expect(themes.glacier.bg).toBe('e0f2fe');
108+
expect(themes.glacier.text).toBe('0369a1');
109+
expect(glacierAccent).toBe('06b6d4');
110+
});
111+
112+
it('meets WCAG AA contrast ratio (≥ 4.5) between bg and text', () => {
113+
const ratio = contrastRatio(themes.glacier.bg, themes.glacier.text);
114+
expect(ratio).toBeGreaterThanOrEqual(4.5);
115+
});
116+
117+
it('generates SVG output containing the glacier theme hex colors', () => {
118+
const svg = generateSVG(mockStats, mockParams, mockCalendar);
119+
expect(typeof svg).toBe('string');
120+
expect(svg.length).toBeGreaterThan(100);
121+
expect(svg).toMatch(new RegExp(`#?${glacierAccent}`, 'i'));
122+
expect(svg).toContain('<svg');
123+
});
124+
});

0 commit comments

Comments
 (0)