test(matching): add comprehensive integration tests for matching algorithm

- Implement all 19 test scenarios from matching-scenarios.md
- Phase 1: Fundamentals (TC1-3) - basic flow and NOT_FOUND cases
- Phase 2: Collision Detection (TC4-9) - heat buffers and slot mapping
- Phase 3: Limits & Workload (TC10-11) - MAX_RECORDINGS and collision bugs
- Phase 4: Fairness & Tiers (TC12-16) - debt calculation and tier penalties
- Phase 5: Edge Cases (TC17-19) - multiple heats and incremental matching
- All 19 tests passing with 76.92% coverage on matching.js
This commit is contained in:
Radosław Gierwiało
2025-11-30 19:37:36 +01:00
parent 065e77fd4e
commit 72275835f1

View File

@@ -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);
});
});
});