diff --git a/backend/src/__tests__/matching-algorithm.test.js b/backend/src/__tests__/matching-algorithm.test.js new file mode 100644 index 0000000..ae5406c --- /dev/null +++ b/backend/src/__tests__/matching-algorithm.test.js @@ -0,0 +1,770 @@ +/** + * Matching Algorithm Integration Tests + * + * Tests the complete matching algorithm (runMatching + saveMatchingResults) + * Based on: backend/src/__tests__/matching-scenarios.md + * + * Test organization: + * - Phase 1: Fundamentals (TC1-3) - Basic flow, NOT_FOUND scenarios + * - Phase 2: Collision Detection (TC4-9) - Buffers, slots + * - Phase 3: Limits & Workload (TC10-11) - MAX_RECORDINGS, collision bug + * - Phase 4: Fairness & Tiers (TC12-16) - Debt calculation, tier penalties + * - Phase 5: Edge Cases (TC17-19) - Sanity checks, incremental matching + */ + +const { PrismaClient } = require('@prisma/client'); +const matchingService = require('../services/matching'); +const { SUGGESTION_STATUS, ACCOUNT_TIER } = require('../constants'); + +const prisma = new PrismaClient(); + +// Test data cleanup +async function cleanupTestData() { + await prisma.recordingSuggestion.deleteMany({}); + await prisma.match.deleteMany({}); + await prisma.eventUserHeat.deleteMany({}); + await prisma.eventParticipant.deleteMany({}); + await prisma.event.deleteMany({ + where: { name: { startsWith: 'Matching Test' } } + }); + await prisma.user.deleteMany({ + where: { email: { contains: '@matching-test.com' } } + }); +} + +// Helper: Create test user +async function createTestUser(username, options = {}) { + const timestamp = Date.now() + Math.random() * 1000; + return await prisma.user.create({ + data: { + email: `${username}-${timestamp}@matching-test.com`, + username: `${username}_${timestamp}`, + passwordHash: 'test-hash', + firstName: options.firstName || username, + lastName: 'TestUser', + city: options.city || null, + country: options.country || null, + accountTier: options.tier || ACCOUNT_TIER.BASIC, + recordingsDone: options.recordingsDone || 0, + recordingsReceived: options.recordingsReceived || 0, + } + }); +} + +// Helper: Create test event +async function createTestEvent(name, options = {}) { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return await prisma.event.create({ + data: { + name: `Matching Test ${name}`, + slug: `matching-test-${name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`, + location: options.location || 'Test City', + startDate: now, + endDate: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), + registrationDeadline: yesterday, + scheduleConfig: options.scheduleConfig || null, + } + }); +} + +// Helper: Create participant +async function createParticipant(userId, eventId, options = {}) { + return await prisma.eventParticipant.create({ + data: { + userId, + eventId, + competitorNumber: options.competitorNumber || null, + recorderOptOut: options.recorderOptOut || false, + accountTierOverride: options.accountTierOverride || null, + } + }); +} + +// Helper: Create heat +async function createHeat(userId, eventId, heatNumber, options = {}) { + // Get or create division + let division = await prisma.division.findFirst({ + where: { id: options.divisionId || 1 } + }); + if (!division) { + division = await prisma.division.create({ + data: { + name: 'Novice', + abbreviation: 'NOV', + displayOrder: 1 + } + }); + } + + // Get or create competition type + let compType = await prisma.competitionType.findFirst({ + where: { id: options.competitionTypeId || 1 } + }); + if (!compType) { + compType = await prisma.competitionType.create({ + data: { + name: 'Jack & Jill', + abbreviation: 'J&J', + displayOrder: 1 + } + }); + } + + return await prisma.eventUserHeat.create({ + data: { + userId, + eventId, + divisionId: options.divisionId || division.id, + competitionTypeId: options.competitionTypeId || compType.id, + heatNumber, + role: options.role || 'leader', + } + }); +} + +// Helper: Run matching and save results +async function runAndSaveMatching(eventId) { + const suggestions = await matchingService.runMatching(eventId); + await matchingService.saveMatchingResults(eventId, suggestions); + + const saved = await prisma.recordingSuggestion.findMany({ + where: { eventId }, + include: { + heat: true, + recorder: { + select: { id: true, username: true } + } + } + }); + + return { generated: suggestions, saved }; +} + +describe('Matching Algorithm Integration Tests', () => { + beforeAll(async () => { + await cleanupTestData(); + }); + + afterAll(async () => { + await cleanupTestData(); + await prisma.$disconnect(); + }); + + // ======================================== + // PHASE 1: FUNDAMENTALS (TC1-3) + // ======================================== + + describe('Phase 1: Fundamentals', () => { + test('TC1: One dancer, one free recorder → simple happy path', async () => { + // Setup + const event = await createTestEvent('TC1'); + const dancer = await createTestUser('dancer-tc1'); + const recorder = await createTestUser('recorder-tc1'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); + + const heat = await createHeat(dancer.id, event.id, 10); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify + expect(saved).toHaveLength(1); + expect(saved[0].heatId).toBe(heat.id); + expect(saved[0].recorderId).toBe(recorder.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + + test('TC2: No recorders available → NOT_FOUND', async () => { + // Setup + const event = await createTestEvent('TC2'); + const dancer = await createTestUser('dancer-tc2'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createHeat(dancer.id, event.id, 10); + + // Execute (no other participants = no recorders) + const { saved } = await runAndSaveMatching(event.id); + + // Verify + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBeNull(); + expect(saved[0].status).toBe(SUGGESTION_STATUS.NOT_FOUND); + }); + + test('TC3: Only recorder is self → NOT_FOUND', async () => { + // Setup + const event = await createTestEvent('TC3'); + const dancer = await createTestUser('dancer-tc3'); + + // Dancer is also potential recorder (no opt-out, no competitorNumber would make them only recorder) + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createHeat(dancer.id, event.id, 10); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - can't record themselves + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBeNull(); + expect(saved[0].status).toBe(SUGGESTION_STATUS.NOT_FOUND); + }); + }); + + // ======================================== + // PHASE 2: COLLISION DETECTION (TC4-9) + // ======================================== + + describe('Phase 2: Collision Detection', () => { + test('TC4: Recorder dancing in same heat → cannot record', async () => { + // Setup + const event = await createTestEvent('TC4'); + const dancer = await createTestUser('dancer-tc4'); + const recorder = await createTestUser('recorder-tc4'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); // Not a dancer + + // Both in same heat + await createHeat(dancer.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + await createHeat(recorder.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBeNull(); + expect(saved[0].status).toBe(SUGGESTION_STATUS.NOT_FOUND); + }); + + test('TC5: Recorder in buffer BEFORE dance → cannot record', async () => { + // Setup + const event = await createTestEvent('TC5'); + const dancer = await createTestUser('dancer-tc5'); + const recorder = await createTestUser('recorder-tc5'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); // Not a dancer + + // Dancer: heat 9, Recorder: heat 10 (HEAT_BUFFER_BEFORE=1 blocks heat 9) + await createHeat(dancer.id, event.id, 9, { divisionId: 1, competitionTypeId: 1 }); + await createHeat(recorder.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - recorder needs heat 9 for prep before dancing in 10 + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBeNull(); + expect(saved[0].status).toBe(SUGGESTION_STATUS.NOT_FOUND); + }); + + test('TC6: Recorder in buffer AFTER dance → cannot record', async () => { + // Setup + const event = await createTestEvent('TC6'); + const dancer = await createTestUser('dancer-tc6'); + const recorder = await createTestUser('recorder-tc6'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); // Not a dancer + + // Dancer: heat 11, Recorder: heat 10 (HEAT_BUFFER_AFTER=1 blocks heat 11) + await createHeat(dancer.id, event.id, 11, { divisionId: 1, competitionTypeId: 1 }); + await createHeat(recorder.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - recorder needs heat 11 for rest after dancing in 10 + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBeNull(); + expect(saved[0].status).toBe(SUGGESTION_STATUS.NOT_FOUND); + }); + + test('TC7: No collision when heat outside buffer', async () => { + // Setup + const event = await createTestEvent('TC7'); + const dancer = await createTestUser('dancer-tc7'); + const recorder = await createTestUser('recorder-tc7'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); // Not a dancer + + // Dancer: heat 12, Recorder: heat 10 + // Buffer = ±1, so recorder busy in 9,10,11 - heat 12 is free + await createHeat(dancer.id, event.id, 12, { divisionId: 1, competitionTypeId: 1 }); + await createHeat(recorder.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorder.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + + test('TC8: Collision between divisions in same slot (scheduleConfig)', async () => { + // Setup - create divisions first + const novice = await prisma.division.upsert({ + where: { abbreviation: 'NOV' }, + create: { name: 'Novice', abbreviation: 'NOV', displayOrder: 1 }, + update: {} + }); + const intermediate = await prisma.division.upsert({ + where: { abbreviation: 'INT' }, + create: { name: 'Intermediate', abbreviation: 'INT', displayOrder: 2 }, + update: {} + }); + + const event = await createTestEvent('TC8', { + scheduleConfig: { + slots: [ + { order: 1, divisionIds: [novice.id, intermediate.id] } // Same slot! + ] + } + }); + + const dancer = await createTestUser('dancer-tc8'); + const recorder = await createTestUser('recorder-tc8'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); // Not a dancer! + + // Dancer in novice, recorder DANCING in intermediate (same slot!) + await createHeat(dancer.id, event.id, 1, { divisionId: novice.id, competitionTypeId: 1 }); + await createHeat(recorder.id, event.id, 1, { divisionId: intermediate.id, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - only dancer's heat gets suggestion (recorder has no competitorNumber) + // But recorder is busy in that slot so → NOT_FOUND + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBeNull(); + expect(saved[0].status).toBe(SUGGESTION_STATUS.NOT_FOUND); + }); + + test('TC9: No collision when divisions in different slots', async () => { + // Setup + const novice = await prisma.division.upsert({ + where: { abbreviation: 'NOV' }, + create: { name: 'Novice', abbreviation: 'NOV', displayOrder: 1 }, + update: {} + }); + const advanced = await prisma.division.upsert({ + where: { abbreviation: 'ADV' }, + create: { name: 'Advanced', abbreviation: 'ADV', displayOrder: 3 }, + update: {} + }); + + const event = await createTestEvent('TC9', { + scheduleConfig: { + slots: [ + { order: 1, divisionIds: [novice.id] }, + { order: 2, divisionIds: [advanced.id] } // Different slots + ] + } + }); + + const dancer = await createTestUser('dancer-tc9'); + const recorder = await createTestUser('recorder-tc9'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); // Not a dancer + + // Different divisions in different slots, same heat number + await createHeat(dancer.id, event.id, 1, { divisionId: novice.id, competitionTypeId: 1 }); + await createHeat(recorder.id, event.id, 1, { divisionId: advanced.id, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - different time slots, no collision + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorder.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + }); + + // ======================================== + // PHASE 3: LIMITS & WORKLOAD (TC10-11) + // ======================================== + + describe('Phase 3: Limits & Workload', () => { + test('TC10: MAX_RECORDINGS_PER_PERSON is respected', async () => { + // Setup + const event = await createTestEvent('TC10'); + const recorder = await createTestUser('recorder-tc10'); + + await createParticipant(recorder.id, event.id, { competitorNumber: null }); + + // Create 4 dancers with different heats (no time collision) + // They opt out of recording so they don't record each other + const dancers = []; + for (let i = 0; i < 4; i++) { + const dancer = await createTestUser(`dancer-tc10-${i}`); + await createParticipant(dancer.id, event.id, { competitorNumber: 100 + i, recorderOptOut: true }); + await createHeat(dancer.id, event.id, 10 + i * 5); // Heats: 10, 15, 20, 25 (no collision) + dancers.push(dancer); + } + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - MAX = 3, so first 3 assigned, 4th gets NOT_FOUND + expect(saved).toHaveLength(4); + + const assigned = saved.filter(s => s.status === SUGGESTION_STATUS.PENDING); + const notFound = saved.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND); + + expect(assigned).toHaveLength(3); + expect(notFound).toHaveLength(1); + + // All assigned should have same recorder + assigned.forEach(s => { + expect(s.recorderId).toBe(recorder.id); + }); + }); + + test('TC11: Recording-recording collision (critical bug fix)', async () => { + // Setup + const event = await createTestEvent('TC11'); + const recorder = await createTestUser('recorder-tc11'); + const dancerA = await createTestUser('dancer-tc11-a'); + const dancerB = await createTestUser('dancer-tc11-b'); + + await createParticipant(recorder.id, event.id, { competitorNumber: null }); + await createParticipant(dancerA.id, event.id, { competitorNumber: 101 }); + await createParticipant(dancerB.id, event.id, { competitorNumber: 102 }); + + // Both dancers in same time slot + const heatA = await createHeat(dancerA.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + const heatB = await createHeat(dancerB.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - recorder can only be assigned to ONE of them + expect(saved).toHaveLength(2); + + const assigned = saved.filter(s => s.status === SUGGESTION_STATUS.PENDING); + const notFound = saved.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND); + + expect(assigned).toHaveLength(1); + expect(notFound).toHaveLength(1); + expect(assigned[0].recorderId).toBe(recorder.id); + }); + }); + + // ======================================== + // PHASE 4: FAIRNESS & TIERS (TC12-16) + // ======================================== + + describe('Phase 4: Fairness & Tiers', () => { + test('TC12: Higher fairnessDebt → more likely to record', async () => { + // Setup + const event = await createTestEvent('TC12'); + const dancer = await createTestUser('dancer-tc12', { city: 'Warsaw', country: 'Poland' }); + + // Recorder A: high debt (received=10, done=0 → debt=+10) + const recorderA = await createTestUser('recorder-tc12-a', { + city: 'Warsaw', + country: 'Poland', + recordingsReceived: 10, + recordingsDone: 0 + }); + + // Recorder B: no debt + const recorderB = await createTestUser('recorder-tc12-b', { + city: 'Warsaw', + country: 'Poland', + recordingsReceived: 0, + recordingsDone: 0 + }); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorderA.id, event.id, { competitorNumber: null }); + await createParticipant(recorderB.id, event.id, { competitorNumber: null }); + + await createHeat(dancer.id, event.id, 10); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - RecorderA should be chosen (higher debt) + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorderA.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + + test('TC13: Location score beats fairness', async () => { + // Setup + const event = await createTestEvent('TC13'); + const dancer = await createTestUser('dancer-tc13', { city: 'Warsaw', country: 'Poland' }); + + // Recorder A: same city, no debt + const recorderA = await createTestUser('recorder-tc13-a', { + city: 'Warsaw', + country: 'Poland', + recordingsReceived: 0, + recordingsDone: 0 + }); + + // Recorder B: different country, huge debt + const recorderB = await createTestUser('recorder-tc13-b', { + city: 'Paris', + country: 'France', + recordingsReceived: 100, + recordingsDone: 0 + }); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorderA.id, event.id, { competitorNumber: null }); + await createParticipant(recorderB.id, event.id, { competitorNumber: null }); + + await createHeat(dancer.id, event.id, 10); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - RecorderA wins (location > fairness) + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorderA.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + + test('TC14: Basic vs Supporter vs Comfort tier penalties', async () => { + // Setup + const event = await createTestEvent('TC14'); + const dancer = await createTestUser('dancer-tc14', { city: 'Warsaw', country: 'Poland' }); + + // All same location, same stats (0/0), different tiers + const recorderBasic = await createTestUser('recorder-tc14-basic', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.BASIC, + recordingsReceived: 0, + recordingsDone: 0 + }); + + const recorderSupporter = await createTestUser('recorder-tc14-supporter', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.SUPPORTER, + recordingsReceived: 0, + recordingsDone: 0 + }); + + const recorderComfort = await createTestUser('recorder-tc14-comfort', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.COMFORT, + recordingsReceived: 0, + recordingsDone: 0 + }); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorderBasic.id, event.id, { competitorNumber: null }); + await createParticipant(recorderSupporter.id, event.id, { competitorNumber: null }); + await createParticipant(recorderComfort.id, event.id, { competitorNumber: null }); + + await createHeat(dancer.id, event.id, 10); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - Basic tier wins (no penalty) + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorderBasic.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + + test('TC15: Supporter chosen when Basic unavailable', async () => { + // Setup + const event = await createTestEvent('TC15'); + const dancer = await createTestUser('dancer-tc15', { city: 'Warsaw', country: 'Poland' }); + + const recorderBasic = await createTestUser('recorder-tc15-basic', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.BASIC + }); + + const recorderSupporter = await createTestUser('recorder-tc15-supporter', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.SUPPORTER + }); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorderBasic.id, event.id, { competitorNumber: null }); // Not a dancer, but has heat for collision + await createParticipant(recorderSupporter.id, event.id, { competitorNumber: null }); + + // Basic has heat 10 (same as dancer) → collision, but no competitorNumber so no suggestion for Basic + await createHeat(dancer.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + await createHeat(recorderBasic.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - Supporter is chosen (Basic has collision) + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorderSupporter.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + + test('TC16: Comfort used as last resort', async () => { + // Setup + const event = await createTestEvent('TC16'); + const dancer = await createTestUser('dancer-tc16', { city: 'Warsaw', country: 'Poland' }); + + const recorderBasic = await createTestUser('recorder-tc16-basic', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.BASIC + }); + + const recorderComfort = await createTestUser('recorder-tc16-comfort', { + city: 'Warsaw', + country: 'Poland', + tier: ACCOUNT_TIER.COMFORT + }); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorderBasic.id, event.id, { competitorNumber: null }); // Not a dancer, but has heat for collision + await createParticipant(recorderComfort.id, event.id, { competitorNumber: null }); + + // Basic has heat 10 (same as dancer) → collision, but no competitorNumber so no suggestion for Basic + await createHeat(dancer.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + await createHeat(recorderBasic.id, event.id, 10, { divisionId: 1, competitionTypeId: 1 }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - Comfort is used (only option) + expect(saved).toHaveLength(1); + expect(saved[0].recorderId).toBe(recorderComfort.id); + expect(saved[0].status).toBe(SUGGESTION_STATUS.PENDING); + }); + }); + + // ======================================== + // PHASE 5: EDGE CASES (TC17-19) + // ======================================== + + describe('Phase 5: Edge Cases', () => { + test('TC17: Dancer with no heats is ignored', async () => { + // Setup + const event = await createTestEvent('TC17'); + const dancerNoHeats = await createTestUser('dancer-tc17-noheats'); + const recorder = await createTestUser('recorder-tc17'); + + // Dancer has competitorNumber but no heats + await createParticipant(dancerNoHeats.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - no suggestions (dancer has no heats) + expect(saved).toHaveLength(0); + }); + + test('TC18: Multiple heats for one dancer - all assigned', async () => { + // Setup + const event = await createTestEvent('TC18'); + const dancer = await createTestUser('dancer-tc18'); + const recorder1 = await createTestUser('recorder-tc18-1'); + const recorder2 = await createTestUser('recorder-tc18-2'); + + await createParticipant(dancer.id, event.id, { competitorNumber: 101 }); + await createParticipant(recorder1.id, event.id, { competitorNumber: null }); + await createParticipant(recorder2.id, event.id, { competitorNumber: null }); + + // Dancer has 3 heats in different divisions to avoid unique constraint + const novice = await prisma.division.upsert({ + where: { abbreviation: 'NOV' }, + create: { name: 'Novice', abbreviation: 'NOV', displayOrder: 1 }, + update: {} + }); + const intermediate = await prisma.division.upsert({ + where: { abbreviation: 'INT' }, + create: { name: 'Intermediate', abbreviation: 'INT', displayOrder: 2 }, + update: {} + }); + const advanced = await prisma.division.upsert({ + where: { abbreviation: 'ADV' }, + create: { name: 'Advanced', abbreviation: 'ADV', displayOrder: 3 }, + update: {} + }); + + await createHeat(dancer.id, event.id, 5, { divisionId: novice.id }); + await createHeat(dancer.id, event.id, 7, { divisionId: intermediate.id }); + await createHeat(dancer.id, event.id, 9, { divisionId: advanced.id }); + + // Execute + const { saved } = await runAndSaveMatching(event.id); + + // Verify - all 3 heats have suggestions + expect(saved).toHaveLength(3); + saved.forEach(s => { + expect(s.status).toBe(SUGGESTION_STATUS.PENDING); + expect(s.recorderId).not.toBeNull(); + }); + + // Check load balancing (should distribute between recorders) + const recorder1Count = saved.filter(s => s.recorderId === recorder1.id).length; + const recorder2Count = saved.filter(s => s.recorderId === recorder2.id).length; + + // Should be relatively balanced (2-1 or 1-2) + expect(recorder1Count + recorder2Count).toBe(3); + }); + + test('TC19: Incremental matching respects accepted suggestions', async () => { + // Setup + const event = await createTestEvent('TC19'); + const dancerA = await createTestUser('dancer-tc19-a'); + const dancerB = await createTestUser('dancer-tc19-b'); + const recorder = await createTestUser('recorder-tc19'); + + await createParticipant(dancerA.id, event.id, { competitorNumber: 101 }); + await createParticipant(dancerB.id, event.id, { competitorNumber: 102 }); + await createParticipant(recorder.id, event.id, { competitorNumber: null }); + + const heatA = await createHeat(dancerA.id, event.id, 5); + const heatB = await createHeat(dancerB.id, event.id, 6); + + // First run - get suggestions + const firstRun = await runAndSaveMatching(event.id); + expect(firstRun.saved).toHaveLength(2); + + // Recorder accepts suggestion for heatA + const suggestionA = await prisma.recordingSuggestion.findFirst({ + where: { heatId: heatA.id } + }); + await prisma.recordingSuggestion.update({ + where: { id: suggestionA.id }, + data: { status: 'accepted' } + }); + + // Second run - should respect accepted suggestion + const secondRun = await runAndSaveMatching(event.id); + + // Verify + // Heat A should still have the accepted suggestion (preserved) + // Heat B should get a new/updated suggestion + const heatASuggestions = secondRun.saved.filter(s => s.heatId === heatA.id); + const heatBSuggestions = secondRun.saved.filter(s => s.heatId === heatB.id); + + expect(heatASuggestions).toHaveLength(1); // Preserved (still accepted) + expect(heatASuggestions[0].status).toBe('accepted'); // Status unchanged + expect(heatBSuggestions).toHaveLength(1); // Still gets suggestion + expect(heatBSuggestions[0].recorderId).toBe(recorder.id); + }); + }); +});