Skip to content

Commit 4941ce4

Browse files
authored
refactor(github): extract dashboard helpers into focused pure functions (JhaSourav07#988)
2 parents a4dcf09 + 62b0f5c commit 4941ce4

2 files changed

Lines changed: 421 additions & 171 deletions

File tree

lib/github.test.ts

Lines changed: 281 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import {
1212
GITHUB_CACHE_TTL_MS,
1313
validateGitHubUsername,
1414
cacheKey,
15-
buildInsights,
1615
displayName,
1716
fetchOrgMembers,
1817
getOrgDashboardData,
1918
getWrappedData,
2019
computeDeveloperScore,
20+
buildProfileData,
21+
aggregateLanguages,
22+
buildInsights,
23+
buildActivityMap,
2124
} from './github';
2225
import type { ContributionCalendar } from '../types';
2326

@@ -736,6 +739,283 @@ describe('fetchContributedRepos', () => {
736739
});
737740
});
738741

742+
describe('computeDeveloperScore', () => {
743+
it('returns 0 for a brand new account with no activity', () => {
744+
expect(
745+
computeDeveloperScore({
746+
repos: 0,
747+
followers: 0,
748+
stars: 0,
749+
contributions: 0,
750+
longestStreak: 0,
751+
})
752+
).toBe(0);
753+
});
754+
755+
it('caps the score at 100 for an extremely active account', () => {
756+
expect(
757+
computeDeveloperScore({
758+
repos: 999,
759+
followers: 999,
760+
stars: 999,
761+
contributions: 99999,
762+
longestStreak: 999,
763+
})
764+
).toBe(100);
765+
});
766+
767+
it('saturates each factor independently at its ceiling', () => {
768+
expect(
769+
computeDeveloperScore({
770+
repos: 50,
771+
followers: 50,
772+
stars: 100,
773+
contributions: 400,
774+
longestStreak: 50,
775+
})
776+
).toBe(100);
777+
});
778+
779+
it('computes a partial score correctly when only repos and followers are non-zero', () => {
780+
expect(
781+
computeDeveloperScore({
782+
repos: 10,
783+
followers: 20,
784+
stars: 0,
785+
contributions: 0,
786+
longestStreak: 0,
787+
})
788+
).toBe(15);
789+
});
790+
791+
it('rounds fractional scores to the nearest integer', () => {
792+
expect(
793+
computeDeveloperScore({
794+
repos: 1,
795+
followers: 0,
796+
stars: 0,
797+
contributions: 0,
798+
longestStreak: 0,
799+
})
800+
).toBe(1);
801+
});
802+
});
803+
804+
describe('buildProfileData', () => {
805+
const baseProfile = {
806+
login: 'octocat',
807+
name: 'The Octocat',
808+
avatar_url: 'https://example.com/avatar.png',
809+
public_repos: 10,
810+
followers: 20,
811+
following: 5,
812+
created_at: '2020-06-01T00:00:00Z',
813+
bio: 'Hello world',
814+
location: 'San Francisco',
815+
plan: { name: 'pro' },
816+
};
817+
818+
it('maps all fields from the raw profile correctly', () => {
819+
const result = buildProfileData(baseProfile, 42, 75);
820+
821+
expect(result.username).toBe('octocat');
822+
expect(result.name).toBe('The Octocat');
823+
expect(result.avatarUrl).toBe('https://example.com/avatar.png');
824+
expect(result.isPro).toBe(true);
825+
expect(result.bio).toBe('Hello world');
826+
expect(result.location).toBe('San Francisco');
827+
expect(result.developerScore).toBe(75);
828+
expect(result.stats.stars).toBe(42);
829+
expect(result.stats.repositories).toBe(10);
830+
expect(result.stats.followers).toBe(20);
831+
expect(result.stats.following).toBe(5);
832+
});
833+
834+
it('falls back to login when name is null', () => {
835+
const result = buildProfileData({ ...baseProfile, name: null }, 0, 0);
836+
expect(result.name).toBe('octocat');
837+
});
838+
839+
it('falls back to login when name is an empty string', () => {
840+
const result = buildProfileData({ ...baseProfile, name: ' ' }, 0, 0);
841+
expect(result.name).toBe('octocat');
842+
});
843+
844+
it('uses default bio when bio is null', () => {
845+
const result = buildProfileData({ ...baseProfile, bio: null }, 0, 0);
846+
expect(result.bio).toBe('No bio available');
847+
});
848+
849+
it('uses default location when location is null', () => {
850+
const result = buildProfileData({ ...baseProfile, location: null }, 0, 0);
851+
expect(result.location).toBe('Earth');
852+
});
853+
854+
it('sets isPro to false when plan is absent', () => {
855+
const result = buildProfileData({ ...baseProfile, plan: null }, 0, 0);
856+
expect(result.isPro).toBe(false);
857+
});
858+
859+
it('sets isPro to false when plan name is not "pro"', () => {
860+
const result = buildProfileData({ ...baseProfile, plan: { name: 'free' } }, 0, 0);
861+
expect(result.isPro).toBe(false);
862+
});
863+
864+
it('formats joinedDate as "MMM YYYY" from an ISO created_at string', () => {
865+
const result = buildProfileData({ ...baseProfile, created_at: '2020-01-15T00:00:00Z' }, 0, 0);
866+
expect(result.joinedDate).toMatch(/^[A-Za-z]+ \d{4}$/);
867+
});
868+
});
869+
870+
describe('aggregateLanguages', () => {
871+
it('returns an empty array when no repos have a language set', () => {
872+
expect(aggregateLanguages([{ language: null }, { language: null }])).toEqual([]);
873+
});
874+
875+
it('returns an empty array for an empty repo list', () => {
876+
expect(aggregateLanguages([])).toEqual([]);
877+
});
878+
879+
it('computes percentages and sorts by frequency descending', () => {
880+
const repos = [{ language: 'TypeScript' }, { language: 'TypeScript' }, { language: 'Rust' }];
881+
const result = aggregateLanguages(repos);
882+
883+
expect(result[0].name).toBe('TypeScript');
884+
expect(result[0].percentage).toBe(67);
885+
expect(result[1].name).toBe('Rust');
886+
expect(result[1].percentage).toBe(33);
887+
});
888+
889+
it('caps results at the top 5 languages', () => {
890+
const repos = ['TypeScript', 'Rust', 'Go', 'Python', 'Java', 'C++'].map((language) => ({
891+
language,
892+
}));
893+
expect(aggregateLanguages(repos)).toHaveLength(5);
894+
});
895+
896+
it('assigns a fallback color for languages not in LANGUAGE_COLORS', () => {
897+
const result = aggregateLanguages([{ language: 'BrainfuckLang9000' }]);
898+
expect(result[0].color).toBe('#a855f7');
899+
});
900+
901+
it('skips repos with null language without crashing', () => {
902+
const repos = [{ language: 'TypeScript' }, { language: null }, { language: 'TypeScript' }];
903+
const result = aggregateLanguages(repos);
904+
expect(result).toHaveLength(1);
905+
expect(result[0].name).toBe('TypeScript');
906+
});
907+
});
908+
909+
describe('buildInsights', () => {
910+
const baseStreak = { totalContributions: 120, currentStreak: 0, longestStreak: 14 };
911+
912+
it('always returns exactly 3 insight cards', () => {
913+
const result = buildInsights(baseStreak, [{ name: 'TypeScript' }]);
914+
expect(result).toHaveLength(3);
915+
});
916+
917+
it('card 1 includes the total contribution count', () => {
918+
const result = buildInsights({ ...baseStreak, totalContributions: 42 }, []);
919+
expect(result[0].text).toContain('42');
920+
});
921+
922+
it('card 2 shows the top language name', () => {
923+
const result = buildInsights(baseStreak, [{ name: 'Rust' }, { name: 'Go' }]);
924+
expect(result[1].text).toContain('Rust');
925+
});
926+
927+
it('card 2 falls back to "Unknown" when languages array is empty', () => {
928+
const result = buildInsights(baseStreak, []);
929+
expect(result[1].text).toContain('Unknown');
930+
});
931+
932+
it('card 3 shows active streak copy when currentStreak is above 3', () => {
933+
const result = buildInsights({ ...baseStreak, currentStreak: 10 }, []);
934+
expect(result[2].icon).toBe('Zap');
935+
expect(result[2].text).toContain('10');
936+
});
937+
938+
it('card 3 shows longest streak copy when currentStreak is 3 or below', () => {
939+
const result = buildInsights({ ...baseStreak, currentStreak: 3, longestStreak: 30 }, []);
940+
expect(result[2].icon).toBe('Star');
941+
expect(result[2].text).toContain('30');
942+
});
943+
944+
it('card 3 boundary: currentStreak of exactly 3 uses longest-streak copy', () => {
945+
const result = buildInsights({ ...baseStreak, currentStreak: 3 }, []);
946+
expect(result[2].icon).toBe('Star');
947+
});
948+
949+
it('card 3 boundary: currentStreak of exactly 4 uses active-streak copy', () => {
950+
const result = buildInsights({ ...baseStreak, currentStreak: 4 }, []);
951+
expect(result[2].icon).toBe('Zap');
952+
});
953+
});
954+
955+
describe('buildActivityMap', () => {
956+
it('returns an empty array for no days', () => {
957+
expect(buildActivityMap([])).toEqual([]);
958+
});
959+
960+
it('maps intensity 0 for zero contributions', () => {
961+
const result = buildActivityMap([{ date: '2024-01-01', contributionCount: 0 }]);
962+
expect(result[0].intensity).toBe(0);
963+
});
964+
965+
it('maps intensity 1 for counts 1-3', () => {
966+
const results = buildActivityMap([
967+
{ date: '2024-01-01', contributionCount: 1 },
968+
{ date: '2024-01-02', contributionCount: 3 },
969+
]);
970+
expect(results[0].intensity).toBe(1);
971+
expect(results[1].intensity).toBe(1);
972+
});
973+
974+
it('maps intensity 2 for counts 4-6', () => {
975+
const results = buildActivityMap([
976+
{ date: '2024-01-01', contributionCount: 4 },
977+
{ date: '2024-01-02', contributionCount: 6 },
978+
]);
979+
expect(results[0].intensity).toBe(2);
980+
expect(results[1].intensity).toBe(2);
981+
});
982+
983+
it('maps intensity 3 for counts 7-10', () => {
984+
const results = buildActivityMap([
985+
{ date: '2024-01-01', contributionCount: 7 },
986+
{ date: '2024-01-02', contributionCount: 10 },
987+
]);
988+
expect(results[0].intensity).toBe(3);
989+
expect(results[1].intensity).toBe(3);
990+
});
991+
992+
it('maps intensity 4 for counts above 10', () => {
993+
const result = buildActivityMap([{ date: '2024-01-01', contributionCount: 11 }]);
994+
expect(result[0].intensity).toBe(4);
995+
});
996+
997+
it('preserves the date and count fields unchanged', () => {
998+
const result = buildActivityMap([{ date: '2024-06-15', contributionCount: 5 }]);
999+
expect(result[0].date).toBe('2024-06-15');
1000+
expect(result[0].count).toBe(5);
1001+
});
1002+
1003+
it('covers all five intensity buckets across threshold boundaries', () => {
1004+
const days = [0, 1, 4, 7, 11].map((n, i) => ({
1005+
date: `2024-01-0${i + 1}`,
1006+
contributionCount: n,
1007+
}));
1008+
const intensities = buildActivityMap(days).map((d) => d.intensity);
1009+
expect(intensities).toEqual([0, 1, 2, 3, 4]);
1010+
});
1011+
it('passes through locAdditions and locDeletions from the LoC injection step', () => {
1012+
const day = { date: '2024-01-01', contributionCount: 5, locAdditions: 120, locDeletions: 30 };
1013+
const result = buildActivityMap([day]);
1014+
expect(result[0].locAdditions).toBe(120);
1015+
expect(result[0].locDeletions).toBe(30);
1016+
});
1017+
});
1018+
7391019
describe('getFullDashboardData', () => {
7401020
beforeEach(() => vi.spyOn(global, 'fetch'));
7411021
afterEach(() => vi.restoreAllMocks());

0 commit comments

Comments
 (0)