diff --git a/src/common/profileStats.ts b/src/common/profileStats.ts new file mode 100644 index 0000000..226d7e3 --- /dev/null +++ b/src/common/profileStats.ts @@ -0,0 +1,344 @@ +/** + * Build the Topcoder activity summary used by downloaded member profiles. + * + * Profiles derives its displayed track totals from member stats and stats history. + * This helper applies the same grouping and history fallbacks so the PDF does not + * invent a second set of counters from challenge registrations or winner rows. + */ + +const TESTING_SUBTRACK_NAMES = new Set([ + 'BUG_HUNT', + 'TEST_SCENARIOS', + 'TEST_SUITES' +]) +const AI_ENGINEERING_TRACK_NAMES = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING' +]) + +/** + * Return a finite numeric value without coercing strings or nullish values. + * PDF profile summaries use this to distinguish missing counters from zero. + * @param {*} value candidate numeric value from the member stats response + * @returns {number|undefined} the finite number, or undefined when unavailable + */ +function getFiniteNumber (value) { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +/** + * Normalize a track name for matching Profiles aliases such as AI Engineering. + * @param {*} value raw track or rating-path name + * @returns {string} uppercase underscore-delimited track token + */ +function normalizeTrackName (value) { + return String(value || '').trim().toUpperCase().replace(/[\s-]+/g, '_') +} + +/** + * Determine whether a track or rating-path name represents AI Engineering. + * Profiles groups these aliases into Development activity. + * @param {*} value raw track or rating-path name + * @returns {boolean} true for a supported AI Engineering alias + */ +function isAIEngineeringTrackName (value) { + return AI_ENGINEERING_TRACK_NAMES.has(normalizeTrackName(value)) +} + +/** + * Read an explicit submission count from a member stats subtrack. + * Both legacy numeric values and unified nested submission objects are supported. + * @param {Object} subTrack member stats subtrack + * @returns {number|undefined} explicit submission count when present + */ +function getSubTrackSubmissionCount (subTrack) { + const nestedCount = subTrack && subTrack.submissions && typeof subTrack.submissions === 'object' + ? subTrack.submissions.submissions + : undefined + return getFiniteNumber(nestedCount) ?? getFiniteNumber(subTrack && subTrack.submissions) +} + +/** + * Resolve the submission count displayed by Profiles for a subtrack. + * Positive explicit counts win; otherwise challenge participation is the fallback. + * @param {Object} subTrack member stats subtrack + * @returns {number|undefined} displayable submission count when available + */ +function getSubTrackDisplaySubmissionCount (subTrack) { + const submissionCount = getSubTrackSubmissionCount(subTrack) + if (submissionCount !== undefined && submissionCount > 0) { + return submissionCount + } + + const challengeCount = getFiniteNumber(subTrack && subTrack.challenges) + return challengeCount !== undefined && challengeCount > 0 + ? challengeCount + : submissionCount +} + +/** + * Determine whether a member stats subtrack has activity visible in Profiles. + * @param {Object} subTrack member stats subtrack + * @returns {boolean} true when submissions or challenges are positive + */ +function isActiveSubTrack (subTrack) { + return (getSubTrackDisplaySubmissionCount(subTrack) ?? 0) > 0 || + (getFiniteNumber(subTrack && subTrack.challenges) ?? 0) > 0 +} + +/** + * Load history rows for a stats subtrack from the member history response. + * Unified DEVELOPMENT, DESIGN, and QA history is stored in named subtrack arrays. + * @param {Object|undefined} statsHistory member stats history response + * @param {string} trackName API track key containing the subtrack + * @param {string} subTrackName API subtrack name + * @returns {Array} matching history rows, or an empty array + */ +function getSubTrackHistory (statsHistory, trackName, subTrackName) { + const trackHistory = statsHistory && statsHistory[trackName] + if (!trackHistory) { + return [] + } + + if (Array.isArray(trackHistory.history)) { + return trackHistory.history + } + + if (Array.isArray(trackHistory.subTracks)) { + const matchingSubTrack = trackHistory.subTracks.find(subTrack => subTrack && subTrack.name === subTrackName) + return matchingSubTrack && Array.isArray(matchingSubTrack.history) + ? matchingSubTrack.history + : [] + } + + const keyedHistory = trackHistory[subTrackName] + return keyedHistory && Array.isArray(keyedHistory.history) ? keyedHistory.history : [] +} + +/** + * Pick the rated AI Engineering stats source that Profiles groups under Development. + * A DATA_SCIENCE rating path is preferred over compatible top-level AI payloads. + * @param {Object} stats member stats response for one public group + * @returns {Object|undefined} subtrack plus its API history track key + */ +function getAIEngineeringSource (stats) { + const dataScienceStats: Record = stats.DATA_SCIENCE || {} + const dataScienceCandidates = Object.entries(dataScienceStats) + .filter(([name, value]) => ( + isAIEngineeringTrackName(name) && + value && typeof value === 'object' && + getFiniteNumber(value.rank && value.rank.rating) !== undefined + )) + .map(([name, subTrack]) => ({ + subTrack: { ...subTrack, name }, + trackName: 'DATA_SCIENCE' + })) + .sort((left, right) => ( + (getFiniteNumber(right.subTrack.rank && right.subTrack.rank.rating) ?? 0) - + (getFiniteNumber(left.subTrack.rank && left.subTrack.rank.rating) ?? 0) + )) + + if (dataScienceCandidates.length > 0) { + return dataScienceCandidates[0] + } + + const topLevelName = ['AI_ENGINEERING', 'AI', 'AI_ENGINEER'] + .find(name => stats[name] && typeof stats[name] === 'object') + if (!topLevelName) { + return undefined + } + + return { + subTrack: { ...stats[topLevelName], name: topLevelName }, + trackName: topLevelName + } +} + +/** + * Build the win and submission totals displayed for one Profiles subtrack. + * Placement-bearing history is authoritative for wins, while history length is + * the minimum submission count when aggregate counters are stale or incomplete. + * @param {Object} source subtrack plus the API track key used to find its history + * @param {Object|undefined} statsHistory member stats history response + * @returns {{wins: number, submissions: number}} display-safe subtrack totals + */ +function getSubTrackSummary (source, statsHistory) { + const history = getSubTrackHistory(statsHistory, source.trackName, source.subTrack.name) + const historyWithPlacements = history.filter(row => getFiniteNumber(row && row.placement) !== undefined) + + return { + submissions: Math.max(getSubTrackDisplaySubmissionCount(source.subTrack) ?? 0, history.length), + wins: historyWithPlacements.length > 0 + ? historyWithPlacements.filter(row => row.placement === 1).length + : getFiniteNumber(source.subTrack.wins) ?? 0 + } +} + +/** + * Build the stable challenge identity used by Profiles to de-duplicate history. + * @param {Object} historyRow member stats history row + * @returns {string} composite challenge identity + */ +function getHistoryChallengeKey (historyRow) { + return [ + historyRow && historyRow.challengeId, + historyRow && historyRow.challengeName, + historyRow && (historyRow.ratingDate ?? historyRow.date) + ].map(value => String(value ?? '')).join('::') +} + +/** + * Sum a track without de-duplicating activity across its subtracks. + * Design and Testing detail views use raw challenge totals plus history-aware + * win and submission summaries for each child card. + * @param {Array} sources active subtracks with their API track keys + * @param {Object|undefined} statsHistory member stats history response + * @returns {{challenges: number, wins: number, submissions: number}} track totals + */ +function getStandardTrackSummary (sources, statsHistory) { + return sources.reduce((summary, source) => { + const subTrackSummary = getSubTrackSummary(source, statsHistory) + summary.challenges += getFiniteNumber(source.subTrack.challenges) ?? 0 + summary.wins += subTrackSummary.wins + summary.submissions += subTrackSummary.submissions + return summary + }, { challenges: 0, wins: 0, submissions: 0 }) +} + +/** + * Build Development totals using the Profiles history de-duplication rules. + * This prevents overlapping rating paths from counting the same challenge twice + * while retaining aggregate-only CODE and First2Finish activity. + * @param {Array} sources active Development subtracks + * @param {Object|undefined} statsHistory member stats history response + * @returns {{challenges: number, wins: number, submissions: number}} Development totals + */ +function getDevelopmentTrackSummary (sources, statsHistory) { + const summaries = sources.map(source => ({ + history: getSubTrackHistory(statsHistory, source.trackName, source.subTrack.name), + stats: getSubTrackSummary(source, statsHistory), + subTrack: source.subTrack + })) + const historySummaries = summaries.filter(summary => summary.history.length > 0) + + if (historySummaries.length === 0) { + return getStandardTrackSummary(sources, statsHistory) + } + + const uniqueHistoryByChallenge = new Map() + let hasDuplicateHistory = false + historySummaries.forEach((summary) => { + summary.history.forEach((historyRow) => { + const key = getHistoryChallengeKey(historyRow) + const existingHistory = uniqueHistoryByChallenge.get(key) + if (existingHistory) { + hasDuplicateHistory = true + } + if (!existingHistory || existingHistory.placement !== 1) { + uniqueHistoryByChallenge.set(key, historyRow) + } + }) + }) + + const uniqueHistory = Array.from(uniqueHistoryByChallenge.values()) + const noHistorySources = sources.filter((source) => ( + getSubTrackHistory(statsHistory, source.trackName, source.subTrack.name).length === 0 + )) + const noHistoryStats = getStandardTrackSummary(noHistorySources, statsHistory) + const historyChallengeExtras = historySummaries.reduce((total, summary) => ( + total + Math.max(0, (getFiniteNumber(summary.subTrack.challenges) ?? 0) - summary.history.length) + ), 0) + const historySubmissionExtras = historySummaries.reduce((total, summary) => ( + total + Math.max(0, summary.stats.submissions - summary.history.length) + ), 0) + const uniqueHistoryWins = uniqueHistory.filter(historyRow => historyRow.placement === 1).length + const historyStatsWins = hasDuplicateHistory + ? Math.max(...historySummaries.map(summary => summary.stats.wins)) + : historySummaries.reduce((total, summary) => total + summary.stats.wins, 0) + + return { + challenges: uniqueHistory.length + historyChallengeExtras + noHistoryStats.challenges, + submissions: uniqueHistory.length + historySubmissionExtras + noHistoryStats.submissions, + wins: (uniqueHistoryWins > 0 ? uniqueHistoryWins : historyStatsWins) + noHistoryStats.wins + } +} + +/** + * Convert member stats and history responses into downloaded-profile activity rows. + * The PDF uses this mapper to match the Development, Design, Testing, and + * Competitive Programming values shown by Profiles. Competitive Programming is + * emitted only for active SRM stats; other Data Science activity is not relabeled. + * This function does not mutate its inputs or throw for missing response fields. + * @param {Object|undefined} stats member stats response for one public group + * @param {Object|undefined} statsHistory member stats history response for the same group + * @returns {Array<{trackName: string, wins: number, submissions?: number, challenges?: number, rating?: number, competitions?: number}>} PDF activity rows + */ +function buildProfileActivityStats (stats, statsHistory) { + if (!stats) { + return [] + } + + const developSources = Array.isArray(stats.DEVELOP && stats.DEVELOP.subTracks) + ? stats.DEVELOP.subTracks.map(subTrack => ({ subTrack, trackName: 'DEVELOP' })) + : [] + const developmentSources = developSources.filter(source => ( + !TESTING_SUBTRACK_NAMES.has(source.subTrack.name) && isActiveSubTrack(source.subTrack) + )) + if (!developmentSources.some(source => isAIEngineeringTrackName(source.subTrack.name))) { + const aiEngineeringSource = getAIEngineeringSource(stats) + if (aiEngineeringSource && isActiveSubTrack(aiEngineeringSource.subTrack)) { + developmentSources.push(aiEngineeringSource) + } + } + const designSources = Array.isArray(stats.DESIGN && stats.DESIGN.subTracks) + ? stats.DESIGN.subTracks + .filter(isActiveSubTrack) + .map(subTrack => ({ subTrack, trackName: 'DESIGN' })) + : [] + const qaSources = Array.isArray(stats.QA && stats.QA.subTracks) + ? stats.QA.subTracks + .filter(isActiveSubTrack) + .map(subTrack => ({ subTrack, trackName: 'QA' })) + : [] + const testingSources = developSources + .filter(source => TESTING_SUBTRACK_NAMES.has(source.subTrack.name) && isActiveSubTrack(source.subTrack)) + .concat(qaSources) + const result = [] + + if (developmentSources.length > 0) { + result.push({ + trackName: 'Development', + ...getDevelopmentTrackSummary(developmentSources, statsHistory) + }) + } + if (designSources.length > 0) { + result.push({ + trackName: 'Design', + ...getStandardTrackSummary(designSources, statsHistory) + }) + } + if (testingSources.length > 0) { + result.push({ + trackName: 'Testing', + ...getStandardTrackSummary(testingSources, statsHistory) + }) + } + + const srmStats = stats.DATA_SCIENCE && stats.DATA_SCIENCE.SRM + const competitions = getFiniteNumber(srmStats && srmStats.challenges) ?? 0 + if (competitions > 0) { + result.push({ + trackName: 'Competitive Programming', + rating: getFiniteNumber(srmStats && srmStats.rank && srmStats.rank.rating) ?? 0, + wins: getFiniteNumber(srmStats && srmStats.wins) ?? 0, + competitions + }) + } + + return result +} + +module.exports = { + buildProfileActivityStats +} diff --git a/src/services/MemberService.ts b/src/services/MemberService.ts index 3c9fa93..10de735 100644 --- a/src/services/MemberService.ts +++ b/src/services/MemberService.ts @@ -23,6 +23,7 @@ const fileTypeChecker = require('file-type-checker') const sharp = require('sharp') const { bufferContainsScript } = require('../common/image') const { htmlToText } = require('../common/htmlUtils') +const { buildProfileActivityStats } = require('../common/profileStats') const countryCallingCodes = require('country-calling-code') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') @@ -1755,126 +1756,34 @@ async function getMemberRoles (userId) { } } -/** Track enum to display name for member PDF activity stats. */ -const TRACK_DISPLAY_NAMES = { - DEVELOPMENT: 'Development', - DESIGN: 'Design', - DATA_SCIENCE: 'Competitive Programming', - QUALITY_ASSURANCE: 'Quality Assurance' -} - /** - * Fetch member stats by challenge track for PDF: wins and submissions from ChallengeWinner, - * registrations (challenges count) from resources schema, grouped by track. - * @param {Number} userId member userId - * @param {Object} challengesPrisma challenges Prisma client - * @param {Object} resourcesPrisma resources Prisma client + * Fetch the member stats and history used by Profiles and map them for the PDF. + * Failures are logged and return no activity rows so profile generation can continue. + * @param {Object} currentUser the user who performs the profile download + * @param {String} handle member handle * @returns {Promise>} */ -async function fetchMemberStatsByTrack (userId, challengesPrisma, resourcesPrisma) { - const trackMap: Record = {} // track enum -> standard counts or competitive programming counts - +async function fetchMemberStatsByTrack (currentUser, handle) { try { - const numUserId = Object.prototype.toString.call(userId) === '[object BigInt]' - ? helper.bigIntToNumber(userId) - : userId - - const winnerRows = await challengesPrisma.ChallengeWinner.findMany({ - where: { - userId: numUserId, - type: { in: ['PLACEMENT', 'PASSED_REVIEW'] } - }, - include: { - challenge: { - include: { track: true } - } - } - }) - - for (const w of winnerRows) { - const trackEnum = _.get(w, 'challenge.track.track') - if (!trackEnum) continue - if (!trackMap[trackEnum]) { - const isCompetitiveProgramming = trackEnum === 'DATA_SCIENCE' - trackMap[trackEnum] = isCompetitiveProgramming - ? { wins: 0, competitions: 0, rating: undefined } - : { wins: 0, submissions: 0, challenges: 0 } - } - const row = trackMap[trackEnum] - if (w.type === 'PLACEMENT') row.wins += 1 - if (w.type === 'PASSED_REVIEW' && row.submissions !== undefined) row.submissions += 1 - } - - // 2) Resources: registrations (distinct challenges) by track - const memberIdStr = String(userId) - const resources = await resourcesPrisma.resource.findMany({ - where: { - memberId: memberIdStr, - resourceRole: { - nameLower: 'submitter' - } - }, - select: { challengeId: true } - }) - const challengeIds: any[] = [...new Set(resources.map(r => r.challengeId).filter(Boolean))] - if (challengeIds.length > 0) { - const challenges = await challengesPrisma.Challenge.findMany({ - where: { id: { in: challengeIds } }, - include: { track: true } - }) - const challengeIdToTrack: Record = {} - for (const c of challenges) { - const trackEnum = _.get(c, 'track.track') - if (trackEnum) challengeIdToTrack[c.id] = trackEnum - } - const challengesPerTrack: Record = {} - for (const cid of challengeIds) { - const trackEnum = challengeIdToTrack[cid] - if (!trackEnum) continue - if (!challengesPerTrack[trackEnum]) challengesPerTrack[trackEnum] = 0 - challengesPerTrack[trackEnum] += 1 - } - for (const [trackEnum, count] of Object.entries(challengesPerTrack)) { - if (!trackMap[trackEnum]) { - const isCompetitiveProgramming = trackEnum === 'DATA_SCIENCE' - trackMap[trackEnum] = isCompetitiveProgramming - ? { wins: 0, competitions: count, rating: undefined } - : { wins: 0, submissions: 0, challenges: count } - } else { - if (trackMap[trackEnum].challenges !== undefined) { - trackMap[trackEnum].challenges = count - } - if (trackMap[trackEnum].competitions !== undefined) { - trackMap[trackEnum].competitions = count - } - } - } + const StatisticsService = require('./StatisticsService') + const [statsOutcome, historyOutcome] = await Promise.allSettled([ + StatisticsService.getMemberStats(currentUser, handle, {}), + StatisticsService.getHistoryStats(currentUser, handle, {}) + ]) + if (statsOutcome.status === 'rejected') { + throw statsOutcome.reason } - - const statsByTrack = [] - for (const [trackEnum, counts] of Object.entries(trackMap)) { - const trackName = TRACK_DISPLAY_NAMES[trackEnum] || trackEnum - const hasAny = Object.values(counts).some(v => typeof v === 'number' && v > 0) - if (!hasAny && (counts.rating == null || counts.rating === 0)) continue - if (trackEnum === 'DATA_SCIENCE') { - statsByTrack.push({ - trackName, - rating: counts.rating == null ? 0 : counts.rating, - wins: counts.wins == null ? 0 : counts.wins, - competitions: counts.competitions == null ? 0 : counts.competitions - }) - } else { - statsByTrack.push({ - trackName, - wins: counts.wins == null ? 0 : counts.wins, - submissions: counts.submissions == null ? 0 : counts.submissions, - challenges: counts.challenges == null ? 0 : counts.challenges - }) - } + if (historyOutcome.status === 'rejected') { + logger.warn(`fetchMemberStatsByTrack history lookup failed for ${handle}: ${historyOutcome.reason.message}`) } - return statsByTrack + const statsResult = statsOutcome.value + const historyResult = historyOutcome.status === 'fulfilled' ? historyOutcome.value : [] + return buildProfileActivityStats( + Array.isArray(statsResult) ? statsResult[0] : undefined, + Array.isArray(historyResult) ? historyResult[0] : undefined + ) } catch (err) { - logger.warn(`fetchMemberStatsByTrack failed for user ${userId}: ${err.message}`) + logger.warn(`fetchMemberStatsByTrack failed for ${handle}: ${err.message}`) return [] } } @@ -1979,36 +1888,8 @@ async function aggregatePDFData (currentUser, handle) { // Fetch gamification achievements const achievements = await fetchGamificationAchievements(userId) - // Fetch member stats by track (wins, submissions, challenges from ChallengeWinner + resources) - let statsByTrack = [] - try { - statsByTrack = await fetchMemberStatsByTrack(userId, challengesPrisma, resourcesPrisma) - } catch (err) { - logger.warn(`aggregatePDFData: statsByTrack failed for ${handle}: ${err.message}`) - } - - // Merge Competitive Programming rating from the stats service into PDF activity data. - try { - const StatisticsService = require('./StatisticsService') - const statsResult = await StatisticsService.getMemberStats(currentUser, handle, {}) - const statsResponse = Array.isArray(statsResult) && statsResult.length > 0 ? statsResult[0] : null - if (statsResponse && statsResponse.DATA_SCIENCE) { - const ds = statsResponse.DATA_SCIENCE - const rating = (ds.SRM && ds.SRM.rank && ds.SRM.rank.rating != null) - ? ds.SRM.rank.rating - : (ds.MARATHON_MATCH && ds.MARATHON_MATCH.rank && ds.MARATHON_MATCH.rank.rating != null) - ? ds.MARATHON_MATCH.rank.rating - : 0 - const cpEntry = statsByTrack.find(entry => entry.trackName === 'Competitive Programming') - if (cpEntry) { - cpEntry.rating = rating - } else if (rating > 0) { - statsByTrack.push({ trackName: 'Competitive Programming', rating, wins: 0, competitions: 0 }) - } - } - } catch (err) { - logger.warn(`aggregatePDFData: getMemberStats for rating failed for ${handle}: ${err.message}`) - } + // Use the same member stats and history sources as the Profiles UI. + const statsByTrack = await fetchMemberStatsByTrack(currentUser, handle) // Fetch certifications and courses const { certifications, courses } = await fetchCertificationsAndCourses(userId) diff --git a/test/unit/ProfileStats.test.js b/test/unit/ProfileStats.test.js new file mode 100644 index 0000000..f992b28 --- /dev/null +++ b/test/unit/ProfileStats.test.js @@ -0,0 +1,167 @@ +/* + * Unit tests for downloaded-profile member stats aggregation. + */ + +const chai = require('chai') + +const { buildProfileActivityStats } = require('../../src/common/profileStats') + +const should = chai.should() + +describe('profile stats helper unit tests', () => { + it('should match Profiles track totals for the PM-5793 member data', () => { + const stats = { + DEVELOP: { + subTracks: [ + { name: 'Task', challenges: 178, wins: 178, submissions: { submissions: 178 } }, + { name: 'BUG_HUNT', challenges: 5, wins: 0, submissions: { submissions: 2 } }, + { name: 'TEST_SUITES', challenges: 6, wins: 2, submissions: { submissions: 4 } }, + { name: 'First2Finish', challenges: 17, wins: 7, submissions: { submissions: 7 } }, + { name: 'CODE', challenges: 29, wins: 7, submissions: { submissions: 21 } }, + { name: 'Challenge', challenges: 22, wins: 8, submissions: { submissions: 22 } } + ] + }, + DESIGN: { + subTracks: [ + { name: 'Challenge', challenges: 6, wins: null } + ] + }, + QA: { + subTracks: [ + { name: 'Challenge', challenges: 5, wins: 2, submissions: { submissions: 5 } }, + { name: 'Task', challenges: 4, wins: 4, submissions: { submissions: 4 } } + ] + }, + DATA_SCIENCE: { + SRM: { challenges: null, wins: null, rank: { rating: 0 } }, + Task: { challenges: 1, wins: 1, submissions: { submissions: 1 } } + } + } + const history = { + DEVELOP: { + subTracks: [ + { + name: 'Task', + history: Array.from({ length: 178 }, (_, index) => ({ + challengeId: `task-${index}`, + challengeName: `Task ${index}`, + placement: 1, + ratingDate: index + })) + }, + { + name: 'Challenge', + history: Array.from({ length: 23 }, (_, index) => ({ + challengeId: `challenge-${index}`, + challengeName: `Challenge ${index}`, + placement: index < 8 ? 1 : index < 22 ? 2 : undefined, + ratingDate: index + })) + } + ] + }, + DESIGN: { + subTracks: [{ + name: 'Challenge', + history: [ + { challengeId: 'design-1', challengeName: 'Design 1', placement: 50, ratingDate: 1 }, + { challengeId: 'design-2', challengeName: 'Design 2', placement: 1, ratingDate: 2 } + ] + }] + }, + QA: { + subTracks: [ + { + name: 'Challenge', + history: Array.from({ length: 5 }, (_, index) => ({ + challengeId: `qa-challenge-${index}`, + challengeName: `QA Challenge ${index}`, + placement: index < 2 ? 1 : 2, + ratingDate: index + })) + }, + { + name: 'Task', + history: Array.from({ length: 4 }, (_, index) => ({ + challengeId: `qa-task-${index}`, + challengeName: `QA Task ${index}`, + placement: 1, + ratingDate: index + })) + } + ] + } + } + + buildProfileActivityStats(stats, history).should.deep.equal([ + { trackName: 'Development', challenges: 247, wins: 200, submissions: 229 }, + { trackName: 'Design', challenges: 6, wins: 1, submissions: 6 }, + { trackName: 'Testing', challenges: 20, wins: 8, submissions: 15 } + ]) + }) + + it('should create Competitive Programming only from active SRM stats', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + Challenge: { challenges: 5, wins: 2, rank: { rating: 1500 } }, + MARATHON_MATCH: { challenges: 8, wins: 1, rank: { rating: 1800 } }, + SRM: { challenges: 0, wins: 0, rank: { rating: 0 } } + } + }, {}).should.deep.equal([]) + + const activeStats = buildProfileActivityStats({ + DATA_SCIENCE: { + Challenge: { challenges: 5, wins: 2, rank: { rating: 1500 } }, + MARATHON_MATCH: { challenges: 8, wins: 1, rank: { rating: 1800 } }, + SRM: { challenges: 170, wins: 1, rank: { rating: 2741 } } + } + }, {}) + + activeStats.should.deep.equal([ + { trackName: 'Competitive Programming', rating: 2741, wins: 1, competitions: 170 } + ]) + should.equal(activeStats[0].rating, 2741) + }) + + it('should include and de-duplicate rated AI Engineering activity under Development', () => { + const sharedHistory = [ + { challengeId: 'shared-1', challengeName: 'Shared 1', placement: 1, ratingDate: 1 }, + { challengeId: 'shared-2', challengeName: 'Shared 2', placement: 1, ratingDate: 2 } + ] + const aiHistory = [ + { challengeId: 'ai-1', challengeName: 'AI 1', placement: 1, ratingDate: 3 }, + { challengeId: 'ai-2', challengeName: 'AI 2', placement: 1, ratingDate: 4 }, + { challengeId: 'ai-3', challengeName: 'AI 3', placement: 1, ratingDate: 5 }, + { challengeId: 'ai-4', challengeName: 'AI 4', placement: 1, ratingDate: 6 }, + ...sharedHistory + ] + + buildProfileActivityStats({ + DEVELOP: { + subTracks: [{ + name: 'Challenge', + challenges: 2, + wins: 2, + submissions: { submissions: 2 } + }] + }, + DATA_SCIENCE: { + 'AI Engineering': { + challenges: 6, + wins: 6, + submissions: { submissions: 6 }, + rank: { rating: 1200 } + } + } + }, { + DEVELOP: { + subTracks: [{ name: 'Challenge', history: sharedHistory }] + }, + DATA_SCIENCE: { + 'AI Engineering': { history: aiHistory } + } + }).should.deep.equal([ + { trackName: 'Development', challenges: 6, wins: 6, submissions: 6 } + ]) + }) +})