From 78cafaf10c2bac327bc8e2ee0bd8da6cdc06bb50 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 3 Aug 2026 15:16:28 +1000 Subject: [PATCH] PM-5793: Restore downloaded profile ratings What was broken Downloaded profiles could omit all Topcoder Activity stats after the first PM-5793 change, and rated Data Science paths were not included even when Profiles displayed them. Root cause (if identifiable) The service used Promise.allSettled while app-bootstrap replaces the global Promise with Bluebird. Bluebird returns PromiseInspection objects rather than native status/value records, so the mapper received no stats. The first mapper also handled only the legacy tracks and SRM. What was changed Resolve required stats and optional history with Bluebird-compatible Promise.all behavior, map native and configured Data Science ratings with Profiles history/count/order rules, and render ratings in PDF activity rows. Any added/updated tests Expanded ProfileStats regression coverage for the supplied totals, Data Science and SRM separation, rating selection and ordering, AI de-duplication/tie-breaks, Bluebird/native promise resolution, optional-history fallback, and PDF rating text. --- src/common/profileStats.ts | 136 +++++++++++++++++++++-- src/common/profileTemplate.ts | 5 +- src/services/MemberService.ts | 23 ++-- test/unit/ProfileStats.test.js | 197 ++++++++++++++++++++++++++++++++- 4 files changed, 329 insertions(+), 32 deletions(-) diff --git a/src/common/profileStats.ts b/src/common/profileStats.ts index 226d7e3..5a61f70 100644 --- a/src/common/profileStats.ts +++ b/src/common/profileStats.ts @@ -16,6 +16,19 @@ const AI_ENGINEERING_TRACK_NAMES = new Set([ 'AI_ENGINEER', 'AI_ENGINEERING' ]) +const NATIVE_DATA_SCIENCE_SUBTRACK_NAMES = [ + 'Challenge', + 'MARATHON_MATCH' +] +const NATIVE_DATA_SCIENCE_STATS_KEYS = new Set([ + ...NATIVE_DATA_SCIENCE_SUBTRACK_NAMES, + 'SRM', + 'challenges', + 'mostRecentEventDate', + 'mostRecentEventName', + 'mostRecentSubmission', + 'wins' +]) /** * Return a finite numeric value without coercing strings or nullish values. @@ -134,13 +147,9 @@ function getAIEngineeringSource (stats) { 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] + return getDataScienceSummarySource(dataScienceCandidates) } const topLevelName = ['AI_ENGINEERING', 'AI', 'AI_ENGINEER'] @@ -264,11 +273,68 @@ function getDevelopmentTrackSummary (sources, statsHistory) { } } +/** + * Pick the Data Science subtrack whose rating Profiles displays. + * Rating, percentile, and challenge count are descending tie breakers. + * @param {Array} sources active rated Data Science sources + * @returns {Object|undefined} source with the strongest visible rating + */ +function getDataScienceSummarySource (sources) { + return [...sources].sort((left, right) => { + const leftRank = left.subTrack.rank || {} + const rightRank = right.subTrack.rank || {} + + return (getFiniteNumber(rightRank.rating) ?? 0) - (getFiniteNumber(leftRank.rating) ?? 0) || + (getFiniteNumber(rightRank.percentile) ?? 0) - (getFiniteNumber(leftRank.percentile) ?? 0) || + (getFiniteNumber(right.subTrack.challenges) ?? 0) - (getFiniteNumber(left.subTrack.challenges) ?? 0) + })[0] +} + +/** + * Build the independently rated, non-native Data Science rows shown by Profiles. + * Native Challenge, Marathon Match, and SRM rows are handled by their parent + * tracks, while AI Engineering aliases remain grouped under Development. + * @param {Object} stats member stats response for one public group + * @param {Object|undefined} statsHistory member stats history response for the same group + * @returns {Array<{trackName: string, rating: number, wins: number, submissions: number, challenges: number}>} custom rated rows + */ +function getDataScienceRatingPathRows (stats, statsHistory) { + const dataScienceStats: Record = stats.DATA_SCIENCE + if (!dataScienceStats || typeof dataScienceStats !== 'object') { + return [] + } + + return Object.entries(dataScienceStats) + .filter(([name, subTrack]) => ( + !NATIVE_DATA_SCIENCE_STATS_KEYS.has(name) && + !isAIEngineeringTrackName(name) && + subTrack && typeof subTrack === 'object' && + getFiniteNumber(subTrack.rank && subTrack.rank.rating) !== undefined + )) + .sort(([, left], [, right]) => ( + (getFiniteNumber(right.wins) ?? 0) - (getFiniteNumber(left.wins) ?? 0) || + (getSubTrackDisplaySubmissionCount(right) ?? 0) - (getSubTrackDisplaySubmissionCount(left) ?? 0) + )) + .map(([name, subTrack]) => { + const source = { + subTrack: { ...subTrack, name }, + trackName: 'DATA_SCIENCE' + } + + return { + trackName: name, + rating: getFiniteNumber(subTrack.rank && subTrack.rank.rating) ?? 0, + ...getSubTrackSummary(source, statsHistory), + challenges: getFiniteNumber(subTrack.challenges) ?? 0 + } + }) +} + /** * 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. + * The PDF uses this mapper to match the Development, Design, Testing, Data + * Science, configured rating-path, and Competitive Programming values shown by + * Profiles. Competitive Programming is emitted only for active SRM stats. * 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 @@ -325,6 +391,26 @@ function buildProfileActivityStats (stats, statsHistory) { }) } + const dataScienceStats: Record = stats.DATA_SCIENCE || {} + const dataScienceSources = NATIVE_DATA_SCIENCE_SUBTRACK_NAMES + .filter(name => ( + dataScienceStats[name] && + typeof dataScienceStats[name] === 'object' && + (getFiniteNumber(dataScienceStats[name].challenges) ?? 0) > 0 + )) + .map(name => ({ + subTrack: { ...dataScienceStats[name], name }, + trackName: 'DATA_SCIENCE' + })) + if (dataScienceSources.length > 0) { + const summarySource = getDataScienceSummarySource(dataScienceSources) + result.push({ + trackName: 'Data Science', + rating: getFiniteNumber(summarySource && summarySource.subTrack.rank && summarySource.subTrack.rank.rating) ?? 0, + ...getStandardTrackSummary(dataScienceSources, statsHistory) + }) + } + const srmStats = stats.DATA_SCIENCE && stats.DATA_SCIENCE.SRM const competitions = getFiniteNumber(srmStats && srmStats.challenges) ?? 0 if (competitions > 0) { @@ -336,9 +422,41 @@ function buildProfileActivityStats (stats, statsHistory) { }) } + result.push(...getDataScienceRatingPathRows(stats, statsHistory)) + return result } +/** + * Resolve stats requests and build downloaded-profile activity rows. + * Member stats are required; history is optional and falls back to aggregate + * counters when its request fails. Promise.all is compatible with the Bluebird + * global used by the application bootstrap. + * @param {Promise>} statsRequest member stats request + * @param {Promise>} historyRequest member stats history request + * @param {Function} [onHistoryFailure] optional history error callback + * @returns {Promise>} PDF activity rows + * @throws {*} when the required member stats request fails + */ +async function buildProfileActivityStatsFromRequests (statsRequest, historyRequest, onHistoryFailure) { + const safeHistoryRequest = Promise.resolve(historyRequest).catch((error) => { + if (typeof onHistoryFailure === 'function') { + onHistoryFailure(error) + } + return [] + }) + const [statsResult, historyResult] = await Promise.all([ + statsRequest, + safeHistoryRequest + ]) + + return buildProfileActivityStats( + Array.isArray(statsResult) ? statsResult[0] : undefined, + Array.isArray(historyResult) ? historyResult[0] : undefined + ) +} + module.exports = { - buildProfileActivityStats + buildProfileActivityStats, + buildProfileActivityStatsFromRequests } diff --git a/src/common/profileTemplate.ts b/src/common/profileTemplate.ts index 20e9141..11b2651 100644 --- a/src/common/profileTemplate.ts +++ b/src/common/profileTemplate.ts @@ -297,7 +297,7 @@ function createCategorySkillsBlock (categoryName, skillNames) { } /** - * Build the PDF template for member profile + * Build the PDF template for a member profile, including rated activity rows. * @param {Object} pdfData the aggregated PDF data * @returns {Object} React element tree */ @@ -515,13 +515,14 @@ function buildProfileTemplate (pdfData) { const statsItems = topcoderActivity.statsByTrack.map((stat, index) => { const isCompetitiveProgramming = stat.trackName === 'Competitive Programming' const rating = stat.rating == null ? 0 : stat.rating + const hasTrackRating = !isCompetitiveProgramming && rating > 0 const wins = stat.wins == null ? 0 : stat.wins const competitions = stat.competitions == null ? 0 : stat.competitions const submissions = stat.submissions == null ? 0 : stat.submissions const challenges = stat.challenges == null ? 0 : stat.challenges const valueText = isCompetitiveProgramming ? `${rating} rating, ${wins} wins, ${competitions} competitions` - : `${wins} ${wins === 1 ? 'win' : 'wins'}, ${submissions} ${submissions === 1 ? 'submission' : 'submissions'}, ${challenges} ${challenges === 1 ? 'challenge' : 'challenges'}` + : `${hasTrackRating ? `${rating} rating, ` : ''}${wins} ${wins === 1 ? 'win' : 'wins'}, ${submissions} ${submissions === 1 ? 'submission' : 'submissions'}, ${challenges} ${challenges === 1 ? 'challenge' : 'challenges'}` return React.createElement( Text, { key: `stats-track-${index}`, style: styles.activityItem }, diff --git a/src/services/MemberService.ts b/src/services/MemberService.ts index 10de735..f0dc0d9 100644 --- a/src/services/MemberService.ts +++ b/src/services/MemberService.ts @@ -23,7 +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 { buildProfileActivityStatsFromRequests } = require('../common/profileStats') const countryCallingCodes = require('country-calling-code') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') @@ -1758,7 +1758,8 @@ async function getMemberRoles (userId) { /** * 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. + * Required stats failures are logged and return no activity rows so profile generation + * can continue; optional history failures fall back to aggregate counters. * @param {Object} currentUser the user who performs the profile download * @param {String} handle member handle * @returns {Promise>} @@ -1766,22 +1767,12 @@ async function getMemberRoles (userId) { async function fetchMemberStatsByTrack (currentUser, handle) { try { const StatisticsService = require('./StatisticsService') - const [statsOutcome, historyOutcome] = await Promise.allSettled([ + const statsByTrack = await buildProfileActivityStatsFromRequests( StatisticsService.getMemberStats(currentUser, handle, {}), - StatisticsService.getHistoryStats(currentUser, handle, {}) - ]) - if (statsOutcome.status === 'rejected') { - throw statsOutcome.reason - } - if (historyOutcome.status === 'rejected') { - logger.warn(`fetchMemberStatsByTrack history lookup failed for ${handle}: ${historyOutcome.reason.message}`) - } - 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 + StatisticsService.getHistoryStats(currentUser, handle, {}), + error => logger.warn(`fetchMemberStatsByTrack history lookup failed for ${handle}: ${error.message}`) ) + return statsByTrack } catch (err) { logger.warn(`fetchMemberStatsByTrack failed for ${handle}: ${err.message}`) return [] diff --git a/test/unit/ProfileStats.test.js b/test/unit/ProfileStats.test.js index f992b28..a1a37ad 100644 --- a/test/unit/ProfileStats.test.js +++ b/test/unit/ProfileStats.test.js @@ -2,12 +2,35 @@ * Unit tests for downloaded-profile member stats aggregation. */ +require('../../app-bootstrap') const chai = require('chai') -const { buildProfileActivityStats } = require('../../src/common/profileStats') +const { + buildProfileActivityStats, + buildProfileActivityStatsFromRequests +} = require('../../src/common/profileStats') +const { buildProfileTemplate } = require('../../src/common/profileTemplate') const should = chai.should() +/** + * Flatten text children from a React element tree for template assertions. + * @param {*} node React element, child array, or primitive value + * @returns {string} concatenated visible text + */ +function getTemplateText (node) { + if (node === null || node === undefined || typeof node === 'boolean') { + return '' + } + if (Array.isArray(node)) { + return node.map(getTemplateText).join('') + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + return getTemplateText(node.props && node.props.children) +} + describe('profile stats helper unit tests', () => { it('should match Profiles track totals for the PM-5793 member data', () => { const stats = { @@ -100,14 +123,19 @@ describe('profile stats helper unit tests', () => { ]) }) - it('should create Competitive Programming only from active SRM stats', () => { - buildProfileActivityStats({ + it('should keep active SRM stats separate from Data Science activity', () => { + const inactiveStats = 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([]) + }, {}) + + inactiveStats.should.deep.equal([ + { trackName: 'Data Science', rating: 1800, challenges: 13, wins: 3, submissions: 13 } + ]) + inactiveStats.map(stat => stat.trackName).should.not.include('Competitive Programming') const activeStats = buildProfileActivityStats({ DATA_SCIENCE: { @@ -118,9 +146,102 @@ describe('profile stats helper unit tests', () => { }, {}) activeStats.should.deep.equal([ + { trackName: 'Data Science', rating: 1800, challenges: 13, wins: 3, submissions: 13 }, { trackName: 'Competitive Programming', rating: 2741, wins: 1, competitions: 170 } ]) - should.equal(activeStats[0].rating, 2741) + should.equal(activeStats[1].rating, 2741) + }) + + it('should include the strongest native Data Science rating and combined activity', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + Challenge: { + challenges: 2, + wins: 2, + submissions: { submissions: 1 }, + rank: { rating: 1499, percentile: 10 } + }, + MARATHON_MATCH: { + challenges: 1, + wins: 2, + rank: { rating: 763, percentile: 20 } + } + } + }, { + DATA_SCIENCE: { + Challenge: { + history: [ + { challengeId: 1, placement: 1 }, + { challengeId: 2, placement: 2 } + ] + }, + MARATHON_MATCH: { + history: [ + { challengeId: 3, placement: 1 }, + { challengeId: 4, placement: 3 } + ] + } + } + }).should.deep.equal([ + { trackName: 'Data Science', rating: 1499, challenges: 3, wins: 2, submissions: 4 } + ]) + }) + + it('should include history-aware custom ratings after Competitive Programming', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + 'Java MySQL': { + challenges: 3, + wins: 1, + rank: { rating: 1422, overallPercentile: 12 } + }, + Python: { + challenges: 2, + wins: 2, + rank: { rating: 1500, overallPercentile: 20 } + }, + NO_RATING: { + challenges: 2, + wins: 1, + rank: {} + }, + SRM: { challenges: 1, wins: 0, rank: { rating: 900 } } + } + }, { + DATA_SCIENCE: { + 'Java MySQL': { + history: [ + { challengeId: 1, placement: 2 }, + { challengeId: 2, placement: 1 }, + { challengeId: 3, placement: 3 }, + { challengeId: 4, placement: 2 } + ] + } + } + }).should.deep.equal([ + { trackName: 'Competitive Programming', rating: 900, wins: 0, competitions: 1 }, + { trackName: 'Python', rating: 1500, wins: 2, submissions: 2, challenges: 2 }, + { trackName: 'Java MySQL', rating: 1422, wins: 1, submissions: 4, challenges: 3 } + ]) + }) + + it('should use Profiles tie breakers for AI Engineering rating aliases', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + AI: { + challenges: 1, + wins: 1, + rank: { rating: 1200, percentile: 10 } + }, + AI_ENGINEERING: { + challenges: 3, + wins: 2, + rank: { rating: 1200, percentile: 20 } + } + } + }, {}).should.deep.equal([ + { trackName: 'Development', challenges: 3, wins: 2, submissions: 3 } + ]) }) it('should include and de-duplicate rated AI Engineering activity under Development', () => { @@ -164,4 +285,70 @@ describe('profile stats helper unit tests', () => { { trackName: 'Development', challenges: 6, wins: 6, submissions: 6 } ]) }) + + it('should resolve activity requests with the application Bluebird Promise', async () => { + const stats = { + DEVELOP: { + subTracks: [ + { name: 'Task', challenges: 2, wins: 1, submissions: { submissions: 2 } } + ] + } + } + + const result = await buildProfileActivityStatsFromRequests( + (async () => [stats])(), + (async () => [{}])() + ) + + result.should.deep.equal([ + { trackName: 'Development', challenges: 2, wins: 1, submissions: 2 } + ]) + }) + + it('should retain aggregate activity when the optional history request fails', async () => { + const historyError = new Error('history unavailable') + let reportedError + + const result = await buildProfileActivityStatsFromRequests( + (async () => [{ + DESIGN: { + subTracks: [ + { name: 'Challenge', challenges: 3, wins: 1 } + ] + } + }])(), + (async () => { throw historyError })(), + error => { reportedError = error } + ) + + result.should.deep.equal([ + { trackName: 'Design', challenges: 3, wins: 1, submissions: 3 } + ]) + should.equal(reportedError, historyError) + }) + + it('should render Data Science ratings in the downloaded-profile activity row', () => { + const template = buildProfileTemplate({ + member: { + generatedOn: 'August 3, 2026', + handle: 'rated-member' + }, + workExperience: [], + education: [], + languages: [], + skills: { principal: { verified: [], notVerified: [] } }, + skillsByCategory: [], + topcoderActivity: { + statsByTrack: [ + { trackName: 'Data Science', rating: 1499, wins: 1, submissions: 2, challenges: 2 } + ] + }, + certifications: [], + courses: [] + }) + + getTemplateText(template).should.include( + 'Data Science: 1499 rating, 1 win, 2 submissions, 2 challenges' + ) + }) })