@@ -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' ;
2225import 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 - Z a - 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+
7391019describe ( 'getFullDashboardData' , ( ) => {
7401020 beforeEach ( ( ) => vi . spyOn ( global , 'fetch' ) ) ;
7411021 afterEach ( ( ) => vi . restoreAllMocks ( ) ) ;
0 commit comments