import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestCompetition, createTestRound, createTestProject, createTestProjectRoundState, createTestAssignment, createTestEvaluation, createTestEvaluationForm, cleanupTestData, uid, } from '../helpers' import { analyticsRouter } from '../../src/server/routers/analytics' import { projectRouter } from '../../src/server/routers/project' describe('analytics.getProjectDetail round scoping', () => { let programId: string let admin: { id: string; email: string; role: 'SUPER_ADMIN' } let projectId: string let roundAId: string let roundBId: string const userIds: string[] = [] beforeAll(async () => { const program = await createTestProgram({ name: `bal-scope-${uid()}` }) programId = program.id const competition = await createTestCompetition(programId) const roundA = await createTestRound(competition.id, { name: 'Round A', sortOrder: 0, status: 'ROUND_CLOSED' }) const roundB = await createTestRound(competition.id, { name: 'Round B', sortOrder: 1, status: 'ROUND_ACTIVE' }) roundAId = roundA.id roundBId = roundB.id const formA = await createTestEvaluationForm(roundA.id) const formB = await createTestEvaluationForm(roundB.id) const project = await createTestProject(programId) projectId = project.id await createTestProjectRoundState(projectId, roundA.id, { state: 'PASSED' }) await createTestProjectRoundState(projectId, roundB.id, { state: 'IN_PROGRESS' }) // 2 evaluations on Round A: 7.0, 8.0 (mean 7.5) for (const score of [7, 8]) { const juror = await createTestUser('JURY_MEMBER') userIds.push(juror.id) const a = await createTestAssignment(juror.id, projectId, roundA.id) await createTestEvaluation(a.id, formA.id, { status: 'SUBMITTED', globalScore: score, submittedAt: new Date() }) } // 3 evaluations on Round B: 9.0, 8.0, 8.0 (mean 8.333…) for (const score of [9, 8, 8]) { const juror = await createTestUser('JURY_MEMBER') userIds.push(juror.id) const a = await createTestAssignment(juror.id, projectId, roundB.id) await createTestEvaluation(a.id, formB.id, { status: 'SUBMITTED', globalScore: score, submittedAt: new Date() }) } const adminUser = await createTestUser('SUPER_ADMIN') userIds.push(adminUser.id) admin = { id: adminUser.id, email: adminUser.email, role: 'SUPER_ADMIN' } }) afterAll(async () => { await cleanupTestData(programId, userIds) }) it('returns only round-B stats when roundId=roundB is passed', async () => { const caller = createCaller(analyticsRouter, admin) const result = await caller.getProjectDetail({ id: projectId, roundId: roundBId }) expect(result.stats).not.toBeNull() expect(result.stats!.totalEvaluations).toBe(3) expect(result.stats!.averageGlobalScore).toBeCloseTo(8.333, 2) }) it('returns aggregated stats across all rounds when roundId is omitted', async () => { const caller = createCaller(analyticsRouter, admin) const result = await caller.getProjectDetail({ id: projectId }) expect(result.stats!.totalEvaluations).toBe(5) }) it('project.getFullDetail also scopes stats to roundId when provided', async () => { const caller = createCaller(projectRouter, admin) const scoped = await caller.getFullDetail({ id: projectId, roundId: roundBId }) expect(scoped.stats!.totalEvaluations).toBe(3) expect(scoped.stats!.averageGlobalScore).toBeCloseTo(8.333, 2) const aggregate = await caller.getFullDetail({ id: projectId }) expect(aggregate.stats!.totalEvaluations).toBe(5) }) }) describe('analytics.getProjectRankings per-round z-context (edition mode)', () => { let programId: string let admin: { id: string; email: string; role: 'SUPER_ADMIN' } let projectXId: string let projectYId: string const userIds: string[] = [] beforeAll(async () => { const program = await createTestProgram({ name: `rank-edition-${uid()}` }) programId = program.id const competition = await createTestCompetition(programId) const roundA = await createTestRound(competition.id, { name: 'A', sortOrder: 0 }) const roundB = await createTestRound(competition.id, { name: 'B', sortOrder: 1 }) const formA = await createTestEvaluationForm(roundA.id, [ { id: 'c1', label: 'X', scale: '1-10', weight: 1 }, ]) const formB = await createTestEvaluationForm(roundB.id, [ { id: 'c1', label: 'X', scale: '1-10', weight: 1 }, ]) const projX = await createTestProject(programId, { title: 'X' }) const projY = await createTestProject(programId, { title: 'Y' }) projectXId = projX.id projectYId = projY.id await createTestProjectRoundState(projX.id, roundA.id) await createTestProjectRoundState(projY.id, roundA.id) await createTestProjectRoundState(projX.id, roundB.id) await createTestProjectRoundState(projY.id, roundB.id) const lenient = await createTestUser('JURY_MEMBER') const harsh = await createTestUser('JURY_MEMBER') userIds.push(lenient.id, harsh.id) const writeEval = async (jurorId: string, projId: string, roundId: string, formId: string, c1: number) => { const a = await createTestAssignment(jurorId, projId, roundId) await prisma.evaluation.create({ data: { assignmentId: a.id, formId, status: 'SUBMITTED', submittedAt: new Date(), criterionScoresJson: { c1 }, }, }) } // Round A await writeEval(lenient.id, projX.id, roundA.id, formA.id, 9) await writeEval(lenient.id, projY.id, roundA.id, formA.id, 9) await writeEval(harsh.id, projX.id, roundA.id, formA.id, 6) await writeEval(harsh.id, projY.id, roundA.id, formA.id, 4) // Round B (different scoring profile) await writeEval(lenient.id, projX.id, roundB.id, formB.id, 8) await writeEval(lenient.id, projY.id, roundB.id, formB.id, 8) await writeEval(harsh.id, projX.id, roundB.id, formB.id, 7) await writeEval(harsh.id, projY.id, roundB.id, formB.id, 5) const adminUser = await createTestUser('SUPER_ADMIN') userIds.push(adminUser.id) admin = { id: adminUser.id, email: adminUser.email, role: 'SUPER_ADMIN' } }) afterAll(async () => { await cleanupTestData(programId, userIds) }) it('aggregates per-project balanced score as the mean of per-round balanced averages', async () => { const caller = createCaller(analyticsRouter, admin) const result = await caller.getProjectRankings({ programId }) const x = result.find((p: { id: string }) => p.id === projectXId)! const y = result.find((p: { id: string }) => p.id === projectYId)! // Per-round balanced (computed by hand using the algorithm in juror-balance.ts): // Round A overall mean=7, stddev=√4.5; lenient stddev=0 (fallback), harsh stddev=1 // X balanced ≈ 9.06, Y balanced ≈ 6.94 // Round B overall mean=7, stddev=√1.5; lenient stddev=0 (fallback), harsh stddev=1 // X balanced ≈ 8.11, Y balanced ≈ 6.89 // Edition rollup = mean of per-round balanced averages: // X ≈ 8.59, Y ≈ 6.91 expect(x.balancedScore!).toBeCloseTo(8.59, 1) expect(y.balancedScore!).toBeCloseTo(6.91, 1) // Crucially, X must rank above Y after the per-round correction. expect(x.balancedScore!).toBeGreaterThan(y.balancedScore!) }) })