diff --git a/__tests__/datalib/judgeToTeam.ts b/__tests__/datalib/judgeToTeam.ts new file mode 100644 index 00000000..fb685794 --- /dev/null +++ b/__tests__/datalib/judgeToTeam.ts @@ -0,0 +1,132 @@ +import { db } from '../../jest.setup'; +import { GetJudgeToTeamPairings } from '@datalib/judgeToTeam/getJudgeToTeamPairings'; +import { ObjectId } from 'mongodb'; +import JudgeToTeam from '@typeDefs/judgeToTeam'; + +beforeEach(async () => { + await db.collection('submissions').deleteMany({}); +}); + +// Helper to create valid submission documents +function createSubmission(judgeId: ObjectId, teamId: ObjectId) { + return { + judge_id: judgeId, + team_id: teamId, + social_good: null, + creativity: null, + presentation: null, + scores: [], + is_scored: false, + queuePosition: null, + }; +} + +describe('GetJudgeToTeamPairings', () => { + it('should return an empty array when no submissions exist', async () => { + const result = await GetJudgeToTeamPairings(); + expect(result.ok).toBe(true); + expect(result.body).toEqual([]); + expect(result.error).toBe(null); + }); + + it('should return pairings with string IDs converted from ObjectIds', async () => { + const judgeId = new ObjectId(); + const teamId = new ObjectId(); + + await db + .collection('submissions') + .insertOne(createSubmission(judgeId, teamId)); + + const result = await GetJudgeToTeamPairings(); + expect(result.ok).toBe(true); + expect(result.body).toHaveLength(1); + expect(result.error).toBe(null); + + const pairing = result.body?.[0]; + expect(pairing).toEqual({ + judge_id: judgeId.toString(), + team_id: teamId.toString(), + }); + }); + + it('should return multiple pairings correctly', async () => { + const judgeId1 = new ObjectId(); + const judgeId2 = new ObjectId(); + const teamId1 = new ObjectId(); + const teamId2 = new ObjectId(); + const teamId3 = new ObjectId(); + + await db + .collection('submissions') + .insertMany([ + createSubmission(judgeId1, teamId1), + createSubmission(judgeId1, teamId2), + createSubmission(judgeId2, teamId3), + ]); + + const result = await GetJudgeToTeamPairings(); + expect(result.ok).toBe(true); + expect(result.body).toHaveLength(3); + expect(result.error).toBe(null); + + const pairings = result.body as JudgeToTeam[]; + expect(pairings[0]).toEqual({ + judge_id: judgeId1.toString(), + team_id: teamId1.toString(), + }); + expect(pairings[1]).toEqual({ + judge_id: judgeId1.toString(), + team_id: teamId2.toString(), + }); + expect(pairings[2]).toEqual({ + judge_id: judgeId2.toString(), + team_id: teamId3.toString(), + }); + }); + + it('should handle duplicate judge-team pairings', async () => { + const judgeId = new ObjectId(); + const teamId = new ObjectId(); + + await db + .collection('submissions') + .insertMany([ + createSubmission(judgeId, teamId), + createSubmission(judgeId, teamId), + ]); + + const result = await GetJudgeToTeamPairings(); + expect(result.ok).toBe(true); + expect(result.body).toHaveLength(2); + + const pairings = result.body as JudgeToTeam[]; + expect(pairings[0]).toEqual(pairings[1]); + }); + + it('should convert ObjectIds to strings for duplicate prevention comparison', async () => { + const judgeId = new ObjectId(); + const teamId = new ObjectId(); + const judgeIdString = judgeId.toString(); + const teamIdString = teamId.toString(); + + await db + .collection('submissions') + .insertOne(createSubmission(judgeId, teamId)); + + const result = await GetJudgeToTeamPairings(); + const pairings = result.body as JudgeToTeam[]; + + // This test verifies that String() conversion is necessary for comparison + // in algorithms like judgesToTeamsAlgorithm.ts + expect(String(pairings[0].judge_id)).toBe(judgeIdString); + expect(String(pairings[0].team_id)).toBe(teamIdString); + + // Simulate the duplicate check from judgesToTeamsAlgorithm.ts (lines 183-189) + const duplicateExists = pairings.some( + (entry) => + String(entry.judge_id) === judgeIdString && + String(entry.team_id) === teamIdString + ); + expect(duplicateExists).toBe(true); + }); +}); diff --git a/app/(api)/_actions/logic/applyDiagnosticAlpha.ts b/app/(api)/_actions/logic/applyDiagnosticAlpha.ts index cb26d70b..b77aa4f8 100644 --- a/app/(api)/_actions/logic/applyDiagnosticAlpha.ts +++ b/app/(api)/_actions/logic/applyDiagnosticAlpha.ts @@ -14,25 +14,35 @@ import checkMatches from '@actions/logic/checkMatches'; export default async function applyDiagnosticAlpha(options: { alpha: number; judgeToTeam: JudgeToTeam[]; -}): Promise<{ ok: boolean; body: JudgeToTeam[] | null; error: string | null }> { +}): Promise<{ + ok: boolean; + body: JudgeToTeam[] | null; + error: string | null; + message?: string; +}> { const existing = await GetManySubmissions(); - if (existing.ok && existing.body && existing.body.length > 0) { + const teamsRes = await GetManyTeams(); + if (!teamsRes.ok) { return { ok: false, body: null, - error: - 'Submissions collection is not empty. Please clear before applying diagnostics.', + error: `GetManyTeams error: ${teamsRes.error}`, }; } - const teamsRes = await GetManyTeams(); - if (!teamsRes.ok) { + const teams = teamsRes.body; + const existingCount = existing.ok && existing.body ? existing.body.length : 0; + const maxTotalAssignmentsPerTeam = 4; + const maxSubmissions = teams.length * maxTotalAssignmentsPerTeam; + const isSecondRound = existingCount > 0; + + if (existingCount >= maxSubmissions) { return { ok: false, body: null, - error: `GetManyTeams error: ${teamsRes.error}`, + error: + 'Maximum judge assignments reached (4 judges per team). Clear submissions to rerun.', }; } - const teams = teamsRes.body; const parsedSubmissions = await parseAndReplace(options.judgeToTeam); if (!checkMatches(parsedSubmissions, teams.length)) { return { @@ -62,5 +72,12 @@ export default async function applyDiagnosticAlpha(options: { // return { ok: false, body: null, error: res.error }; // } - return { ok: true, body: options.judgeToTeam, error: null }; + return { + ok: true, + body: options.judgeToTeam, + error: null, + message: isSecondRound + ? 'Second round detected: new pairings were added on top of existing submissions.' + : undefined, + }; } diff --git a/app/(api)/_actions/logic/checkMatches.ts b/app/(api)/_actions/logic/checkMatches.ts index 54fe18f8..6068cc87 100644 --- a/app/(api)/_actions/logic/checkMatches.ts +++ b/app/(api)/_actions/logic/checkMatches.ts @@ -4,7 +4,7 @@ export default function checkMatches( matches: Submission[], teamsLength: number ) { - if (matches.length < 3 * teamsLength) return false; + if (matches.length < 2 * teamsLength) return false; let valid = true; const mp: Map = new Map(); @@ -18,7 +18,7 @@ export default function checkMatches( } mp.forEach((count) => { - if (count !== 3) valid = false; + if (count !== 2) valid = false; }); return valid; diff --git a/app/(api)/_actions/logic/matchTeams.ts b/app/(api)/_actions/logic/matchTeams.ts index 157245ce..b58fa5a5 100644 --- a/app/(api)/_actions/logic/matchTeams.ts +++ b/app/(api)/_actions/logic/matchTeams.ts @@ -5,13 +5,13 @@ import matchAllTeams from '@utils/matching/judgesToTeamsAlgorithm'; import parseAndReplace from '@utils/request/parseAndReplace'; import { GetManyTeams } from '@datalib/teams/getTeam'; import { CreateManySubmissions } from '@datalib/submissions/createSubmission'; -import { GetManySubmissions } from '@datalib/submissions/getSubmissions'; +//import { GetManySubmissions } from '@datalib/submissions/getSubmissions'; import checkMatches from '@actions/logic/checkMatches'; export default async function matchTeams( options: { alpha: number } = { alpha: 4 } ) { - const submissionsResponse = await GetManySubmissions(); + /*const submissionsResponse = await GetManySubmissions(); if ( submissionsResponse.ok && submissionsResponse.body && @@ -23,7 +23,7 @@ export default async function matchTeams( error: 'Submissions collection is not empty. Please clear submissions before matching teams.', }; - } + }*/ // Generate submissions based on judge-team assignments. const teamsRes = await GetManyTeams(); @@ -46,10 +46,13 @@ export default async function matchTeams( } const res = await CreateManySubmissions(parsedJudgeToTeam); if (!res.ok) { + console.log(`${res.error}`); return { ok: false, body: null, - error: 'Invalid submissions.', + error: `Failed to create submissions in database: ${ + res.error ?? 'Unknown error' + }`, }; } // for (const submission of parsedJudgeToTeam) { @@ -65,7 +68,7 @@ export default async function matchTeams( return { ok: false, body: null, - error: 'Invalid submissions.', + error: 'Invalid submissions: assignment validation failed.', }; } return { diff --git a/app/(api)/_datalib/judgeToTeam/getJudgeToTeamPairings.ts b/app/(api)/_datalib/judgeToTeam/getJudgeToTeamPairings.ts new file mode 100644 index 00000000..86bb5fc5 --- /dev/null +++ b/app/(api)/_datalib/judgeToTeam/getJudgeToTeamPairings.ts @@ -0,0 +1,28 @@ +import JudgeToTeam from '@typeDefs/judgeToTeam'; +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { HttpError } from '@utils/response/Errors'; +import Submission from '@typeDefs/submission'; +import { ObjectId, Db } from 'mongodb'; + +type MongoSubmission = Omit & { + judge_id: ObjectId; + team_id: ObjectId; +}; + +export const GetJudgeToTeamPairings = async () => { + try { + const db = (await getDatabase()) as Db; + const submissions = await db + .collection('submissions') + .find() + .toArray(); + const pairings = submissions.map((submission) => ({ + judge_id: String(submission.judge_id), + team_id: String(submission.team_id), + })); + return { ok: true, body: pairings as JudgeToTeam[], error: null }; + } catch (e) { + const error = e as HttpError; + return { ok: false, body: null, error: error.message }; + } +}; diff --git a/app/(api)/_utils/matching/judgesToTeamsAlgorithm.ts b/app/(api)/_utils/matching/judgesToTeamsAlgorithm.ts index ddad3c8f..9d029252 100644 --- a/app/(api)/_utils/matching/judgesToTeamsAlgorithm.ts +++ b/app/(api)/_utils/matching/judgesToTeamsAlgorithm.ts @@ -6,6 +6,7 @@ import { optedHDTracks, nonHDTracks } from '@data/tracks'; import { GetManyUsers } from '@datalib/users/getUser'; import { GetManyTeams } from '@datalib/teams/getTeam'; +import { GetJudgeToTeamPairings } from '@datalib/judgeToTeam/getJudgeToTeamPairings'; interface Judge { user: User; @@ -66,7 +67,7 @@ export default async function matchAllTeams(options?: { alpha?: number }) { const teamMatchQualities: { [teamId: string]: number[] } = {}; const teamJudgeDomainTypes: { [teamId: string]: string[] } = {}; - const rounds = 3; + const rounds = 2; const ALPHA = options?.alpha ?? 4; // Fetch all checked in judges. const judgesResponse = await GetManyUsers({ @@ -157,6 +158,19 @@ export default async function matchAllTeams(options?: { alpha?: number }) { .filter((team) => team.tracks.length < rounds) .map((team) => [team._id ?? '', rounds - team.tracks.length]) ); + + // Get previous pairings and push it to the judgeToTeam array (so that !duplicateExists is true) + const previousPairings = await GetJudgeToTeamPairings(); + if (!previousPairings.ok || !previousPairings.body) { + throw new Error( + `Failed to load existing judge-to-team pairings: ${ + previousPairings.error ?? 'Unknown error' + }` + ); + } + const previousPairingsBody = previousPairings.body; + judgeToTeam.push(...previousPairingsBody); + // Main loop: process each team for each round. for (let domainIndex = 0; domainIndex < rounds; domainIndex++) { for (const team of modifiedTeams) { @@ -168,10 +182,14 @@ export default async function matchAllTeams(options?: { alpha?: number }) { let selectedJudge: Judge | undefined = undefined; for (const judge of judgesQueue) { + // String() conversion is necessary because: + // - previousPairings from GetJudgeToTeamPairings converts ObjectIds to strings + // - judge.user._id and team._id are ObjectIds that need .toString() + // - Comparing without String() would cause false negatives in duplicate detection const duplicateExists = judgeToTeam.some( (entry) => - entry.judge_id === judge.user._id?.toString() && - entry.team_id === team._id?.toString() + String(entry.judge_id) === judge.user._id?.toString() && + String(entry.team_id) === team._id?.toString() ); if (!duplicateExists) { selectedJudge = judge; @@ -212,6 +230,25 @@ export default async function matchAllTeams(options?: { alpha?: number }) { shuffleArray(modifiedTeams); } + // Remove the previous pairings without relying on array insertion order. + if (previousPairingsBody.length > 0) { + const previousPairingKeySet = new Set( + previousPairingsBody.map((pairing) => { + const judgeId = String(pairing.judge_id); + const teamId = String(pairing.team_id); + return `${judgeId}::${teamId}`; + }) + ); + const filteredJudgeToTeam = judgeToTeam.filter((entry) => { + const judgeId = String(entry.judge_id); + const teamId = String(entry.team_id); + const key = `${judgeId}::${teamId}`; + return !previousPairingKeySet.has(key); + }); + judgeToTeam.length = 0; + judgeToTeam.push(...filteredJudgeToTeam); + } + console.log('No. of judgeToTeam:', judgeToTeam.length); const judgeAssignments = judgesQueue.map((judge) => judge.teamsAssigned); diff --git a/app/(pages)/admin/_components/JudgeTeamGrouping/JudgeTeamGrouping.tsx b/app/(pages)/admin/_components/JudgeTeamGrouping/JudgeTeamGrouping.tsx index aabb46a0..18c021ab 100644 --- a/app/(pages)/admin/_components/JudgeTeamGrouping/JudgeTeamGrouping.tsx +++ b/app/(pages)/admin/_components/JudgeTeamGrouping/JudgeTeamGrouping.tsx @@ -130,6 +130,9 @@ export default function JudgeTeamGrouping() { setShowSubmissions(true); setApplyAlphaSuccess(true); setError(''); + if (res.message) { + alert(res.message); + } } else { setError(res.error!); setShowMatching(false); @@ -326,14 +329,14 @@ export default function JudgeTeamGrouping() { return (
-
-

Error

- {error && ( + {error && ( +
+

Error

{error}
- )} -
+
+ )} {/* Diagnostics inputs */}