From 0fa1038529b8d297dff74314dae0656f0012b7bb Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sat, 1 Aug 2026 10:53:29 +1000 Subject: [PATCH] PM-5793: Align downloaded profile activity stats What was broken Downloaded profiles showed activity totals that differed from Profiles, including a separate Quality Assurance row and Competitive Programming activity that the UI did not display. Root cause (if identifiable) The download flow recalculated activity from challenge winner and resource rows. That logic used different win, submission, grouping, and Data Science rules from the member stats and history data used by Profiles. What was changed The download now reads member stats and history through StatisticsService and maps the Development, Design, Testing, and SRM-based Competitive Programming rows with the same grouping and history fallbacks used by Profiles. A history lookup failure falls back to aggregate stats so profile generation can continue. Any added/updated tests Added unit coverage for the PM-5793 profile totals, SRM-only Competitive Programming visibility, and overlapping AI Engineering history. The focused tests, lint, and build pass. The full suite has 218 passing tests and the same 17 pre-existing Joi message assertion failures as origin/develop, which has 215 passing tests and those 17 failures. --- src/common/profileStats.ts | 344 +++++++++++++++++++++++++++++++++ src/services/MemberService.ts | 167 +++------------- test/unit/ProfileStats.test.js | 167 ++++++++++++++++ 3 files changed, 535 insertions(+), 143 deletions(-) create mode 100644 src/common/profileStats.ts create mode 100644 test/unit/ProfileStats.test.js 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 } + ]) + }) +})