diff --git a/src/apps/opportunities/src/utils/marathon-match.utils.spec.ts b/src/apps/opportunities/src/utils/marathon-match.utils.spec.ts index 817a08422..28948f0dc 100644 --- a/src/apps/opportunities/src/utils/marathon-match.utils.spec.ts +++ b/src/apps/opportunities/src/utils/marathon-match.utils.spec.ts @@ -225,6 +225,35 @@ describe('Marathon Match challenge detail utilities', () => { .toEqual({ finalScore: 80, provisionalScore: undefined }) }) + it.each([ + { testStatus: 'IN_PROGRESS' }, + { testStatus: 'PENDING' }, + { testStatus: 'RUNNING' }, + { testProgress: 0.5 }, + { testProgress: 50 }, + { testProgressDetails: { progress: 0.2, status: 'PROCESSING' } }, + ])('withholds provisional placeholders and stale fallbacks for %j', metadata => { + expect(marathonSubmissionScores({ + id: 'pending', + initialScore: 88, + provisionalScore: 99, + reviewSummation: [{ aggregateScore: 0, isProvisional: true, metadata }], + }).provisionalScore) + .toBeUndefined() + }) + + it.each([0, 42.5, -1])('preserves completed provisional score %s', score => { + expect(marathonSubmissionScores({ + id: 'finished', + reviewSummation: [{ + aggregateScore: score, + isProvisional: true, + metadata: { testProgress: score < 0 ? 0 : 1, testStatus: score < 0 ? 'FAILED' : 'COMPLETED' }, + }], + }).provisionalScore) + .toBe(score) + }) + it('matches community-app final-score release timing and completed non-MM gating', () => { const finalSubmission = { finalScore: 98.98, id: 'final' } expect(shouldShowFinalSubmissionScores({ diff --git a/src/apps/opportunities/src/utils/marathon-match.utils.ts b/src/apps/opportunities/src/utils/marathon-match.utils.ts index 1203afc36..6e4499de3 100644 --- a/src/apps/opportunities/src/utils/marathon-match.utils.ts +++ b/src/apps/opportunities/src/utils/marathon-match.utils.ts @@ -285,6 +285,26 @@ function systemSummationIsComplete(summation: ChallengeReviewSummation): boolean return true } +/** + * Withholds provisional placeholders while the latest scorer run is unfinished. + * Failed scores remain visible; completed legacy rows may omit progress metadata. + * @param summation Latest provisional review summation, if available. + * @returns Whether the aggregate and legacy score fallbacks must be hidden. + * @throws Does not throw. + */ +function provisionalScoreIsPending(summation?: ChallengeReviewSummation): boolean { + const metadata = summation?.metadata ?? {} + const details = metadata.testProgressDetails + const detailRecord = details && typeof details === 'object' && !Array.isArray(details) + ? details as Record + : {} + const status = testStatusValue(metadata.testStatus ?? detailRecord.status) + const progress = testProgressValue(metadata.testProgress ?? detailRecord.progress) + + return status === 'Cancelled' || status === 'In progress' + || (status !== 'Failed' && progress !== undefined && progress < 100) +} + /** * Identifies Marathon Match challenges across v6 names, catalog IDs, and tags. * @@ -342,7 +362,7 @@ export function marathonDashboardIsEnabled(challenge: ChallengeOpportunity): boo /** * Resolves provisional and final Marathon Match scores, preferring the latest * phase-specific review summation over legacy submission-level fields. - * Cancelled phase scores, including placeholder aggregates, are not displayed. + * Unfinished provisional and cancelled phase scores, including legacy fallbacks, are not displayed. * * @param submission Review API submission with modern or legacy score fields. * @returns resolved provisional and final scores. @@ -353,7 +373,7 @@ export function marathonSubmissionScores( ): MarathonSubmissionScores { const provisional = latestPhaseSummation(submission, 'provisional') const final = latestPhaseSummation(submission, 'final') - const provisionalScore = testStatusValue(provisional?.metadata?.testStatus) === 'Cancelled' + const provisionalScore = provisionalScoreIsPending(provisional) ? undefined : finiteScore(provisional?.aggregateScore) ?? finiteScore(submission.provisionalScore)