diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0c64c3c..3f06edf 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1709,10 +1709,12 @@ paths: Copilot `trackCounts` includes only non-zero Development, Design, Quality Assurance, and Data Science keys. Fulfillment is - `COMPLETED / (COMPLETED + CANCELLED*) * 100`; `CANCELLED` and every - `CANCELLED_*` status count as cancelled, non-terminal statuses are - excluded, a zero denominator returns 0, and the percentage is rounded - to two decimal places on a 0-100 scale. + `COMPLETED / (COMPLETED + qualifying CANCELLED*) * 100`. + `CANCELLED_CLIENT_REQUEST` remains in challenge history and track counts + but is excluded from fulfillment because it is not a Copilot failure. + Other `CANCELLED` and `CANCELLED_*` statuses count as cancelled, + non-terminal statuses are excluded, a zero denominator returns 0, and + the percentage is rounded to two decimal places on a 0-100 scale. Cross-schema special-role queries require the `challenges` and `resources` schemas to be co-located in the challenge database, as in the supported @@ -2842,13 +2844,14 @@ definitions: format: int32 minimum: 0 description: > - Anonymous-visible Copilot challenges in CANCELLED or any CANCELLED_* - status. + Anonymous-visible Copilot challenges in CANCELLED or CANCELLED_* + statuses included as Copilot failures. CANCELLED_CLIENT_REQUEST is + excluded. total: type: integer format: int32 minimum: 0 - description: Completed plus cancelled terminal challenges. + description: Completed plus included cancelled terminal challenges. rate: type: number format: double @@ -2856,7 +2859,7 @@ definitions: maximum: 100 description: > Completed percentage on a 0-100 scale, rounded to two decimals; zero - when there are no terminal outcomes. + when there are no included terminal outcomes. SpecialRoleChallengeDimension: type: object required: diff --git a/src/services/SpecialRoleService.ts b/src/services/SpecialRoleService.ts index 7088ad8..43a9299 100644 --- a/src/services/SpecialRoleService.ts +++ b/src/services/SpecialRoleService.ts @@ -17,6 +17,9 @@ const { ChallengesPrisma } = prismaManager const COPILOT_ROLE = 'copilot' const REVIEWER_ROLE = 'reviewer' const SPECIAL_ROLES = [COPILOT_ROLE, REVIEWER_ROLE] +const FULFILLMENT_EXCLUDED_STATUSES = new Set([ + 'CANCELLED_CLIENT_REQUEST' +]) const REVIEWER_ROLE_NAMES_LOWER = [ 'iterative reviewer', @@ -107,12 +110,14 @@ function getProfileTrackKey (track) { /** * Calculate Copilot fulfillment from terminal outcome counts. The percentage - * is completed divided by completed plus every `CANCELLED`/`CANCELLED_*` - * status, rounded to two decimal places on a 0-100 scale. It returns zero when - * no terminal challenge exists and does not raise. + * is completed divided by completed plus qualifying `CANCELLED`/ + * `CANCELLED_*` statuses, rounded to two decimal places on a 0-100 scale. + * Client-request cancellations are excluded because they are not Copilot + * failures. It returns zero when no included terminal challenge exists and + * does not raise. * @param {Number} completed number of completed public Copilot challenges - * @param {Number} cancelled number of cancelled public Copilot challenges - * @returns {Object} completed, cancelled, terminal total, and percentage rate + * @param {Number} cancelled number of included cancelled public Copilot challenges + * @returns {Object} completed, cancelled, included terminal total, and rate */ function buildFulfillment (completed, cancelled) { const total = completed + cancelled @@ -266,6 +271,7 @@ async function loadVisibleCopilotMetrics (userId) { let cancelled = 0 _.forEach(rows, row => { const challengeCount = Number(row.challengeCount) || 0 + const status = String(row.status || '') const trackKey = getProfileTrackKey({ track: row.track, name: row.trackName, @@ -274,9 +280,12 @@ async function loadVisibleCopilotMetrics (userId) { if (trackKey && challengeCount > 0) { trackCounts[trackKey] = (trackCounts[trackKey] || 0) + challengeCount } - if (row.status === 'COMPLETED') { + if (status === 'COMPLETED') { completed += challengeCount - } else if (String(row.status || '').startsWith('CANCELLED')) { + } else if ( + status.startsWith('CANCELLED') && + !FULFILLMENT_EXCLUDED_STATUSES.has(status) + ) { cancelled += challengeCount } }) diff --git a/test/unit/SpecialRoleService.test.js b/test/unit/SpecialRoleService.test.js index 6c10a15..13d82d4 100644 --- a/test/unit/SpecialRoleService.test.js +++ b/test/unit/SpecialRoleService.test.js @@ -297,12 +297,24 @@ describe('special role service unit tests', () => { } }) - it('getMemberRoleChallenges should return all Copilot rows and aggregate visible terminal metrics', async () => { + it('getMemberRoleChallenges should exclude client-request cancellations only from Copilot fulfillment', async () => { const { service, restore } = loadSpecialRoleService({ onResourceQuery: async () => [], onChallengeQuery: async (sql) => { if (sql.includes('challengeType."name" AS "typeName"')) { return [ + { + id: 'copilot-4', + name: 'Client-Cancelled Copilot Challenge', + status: 'CANCELLED_CLIENT_REQUEST', + startDate: new Date('2024-04-01T00:00:00Z'), + endDate: new Date('2024-04-02T00:00:00Z'), + resourceCreatedAt: new Date('2024-04-01T12:00:00Z'), + trackId: 'data-science', + trackName: 'Data Science', + typeId: 'challenge', + typeName: 'Challenge' + }, { id: 'copilot-3', name: 'Newest Copilot Challenge', @@ -363,6 +375,13 @@ describe('special role service unit tests', () => { trackName: 'Design', trackAbbreviation: 'DES', challengeCount: 1 + }, + { + status: 'CANCELLED_CLIENT_REQUEST', + track: 'DATA_SCIENCE', + trackName: 'Data Science', + trackAbbreviation: 'DS', + challengeCount: 1 } ] } @@ -373,21 +392,30 @@ describe('special role service unit tests', () => { try { const result = await service.getMemberRoleChallenges('devtest1400', 'copilot') - result.total.should.equal(3) - result.trackCounts.should.deep.equal({ DEVELOPMENT: 2, DESIGN: 1 }) + result.total.should.equal(4) + result.trackCounts.should.deep.equal({ + DEVELOPMENT: 2, + DESIGN: 1, + DATA_SCIENCE: 1 + }) result.fulfillment.should.deep.equal({ completed: 1, cancelled: 1, total: 2, rate: 50 }) - result.challenges.should.have.length(3) + result.challenges.should.have.length(4) result.challenges.map(challenge => challenge.id).should.deep.equal([ + 'copilot-4', 'copilot-3', 'copilot-2', 'copilot-1' ]) - result.challenges[1].should.deep.equal({ + result.challenges[0].should.include({ + id: 'copilot-4', + status: 'CANCELLED_CLIENT_REQUEST' + }) + result.challenges[2].should.deep.equal({ id: 'copilot-2', name: 'Second Copilot Challenge', status: 'CANCELLED_ZERO_SUBMISSIONS',