Skip to content

Commit

Permalink
Adds tests to make sure we generate valid pairings
Browse files Browse the repository at this point in the history
  • Loading branch information
arcanis committed Nov 29, 2024
1 parent 178b11b commit 129c2dd
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 42 deletions.
75 changes: 70 additions & 5 deletions src/utils/generatePairs.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import { generatePairs } from './generatePairs';
import { GeneratedPairs, generatePairs } from './generatePairs';
import { Participant, Rule } from '../types';
import { parseParticipantsText } from './parseParticipants';
import { parseParticipantsText, ParseSuccess } from './parseParticipants';

describe('generatePairs', () => {
// Arbitrary to generate valid participant names (non-empty strings)
Expand Down Expand Up @@ -193,7 +193,7 @@ describe('generatePairs', () => {
});

it('should support generating complex configurations', () => {
const participants = parseParticipantsText(`
const parseResult = parseParticipantsText(`
Alice !Brian !Claire
Brian !Alice !Claire
Claire !Brian !Alice
Expand All @@ -214,9 +214,74 @@ describe('generatePairs', () => {
Ryan !Quinn !Matthew !Nina !Olivia !Paige
`);

expect(participants.ok).toBe(true);
expect(parseResult.ok).toBe(true);
const parseOk = parseResult as ParseSuccess;

const result = generatePairs((participants as any).participants);
const result = generatePairs(parseOk.participants);
expect(result).not.toBeNull();
});

it('should generate valid pairings for a given complex configuration', () => {
const parseResult = parseParticipantsText(`
Alice !Brian !Claire
Brian !Alice !Claire
Claire !Brian !Alice
Ethan !Fiona !Grace !Hannah !Ivy !Jack !Kyle
Fiona !Ethan !Grace !Hannah !Ivy !Jack !Kyle
Grace !Fiona !Ethan !Hannah !Ivy !Jack !Kyle
Hannah !Fiona !Grace !Ethan !Ivy !Jack !Kyle
Ivy !Fiona !Grace !Hannah !Ethan !Jack !Kyle
Kyle !Fiona !Grace !Hannah !Ethan !Jack !Ivy
Logan !Sophie
Sophie !Logan
Matthew !Nina !Olivia !Paige !Quinn !Ryan
Nina !Matthew !Olivia !Paige !Quinn !Ryan
Olivia !Nina !Matthew !Paige !Quinn !Ryan
Paige !Nina !Olivia !Matthew !Quinn !Ryan
Jack !Fiona !Grace !Hannah !Ethan !Ivy !Kyle
Quinn !Matthew !Nina !Olivia !Paige !Ryan
Ryan !Quinn !Matthew !Nina !Olivia !Paige
`);

expect(parseResult.ok).toBe(true);
const parseOk = parseResult as ParseSuccess;

for (let t = 0; t < 100; t++) {
const generationResult = generatePairs(parseOk.participants);

expect(generationResult).not.toBeNull();
const {pairings} = generationResult as GeneratedPairs;

// Verify each participant gives and receives exactly once
const givers = new Set(pairings.map(p => p.giver.id));
const receivers = new Set(pairings.map(p => p.receiver.id));
expect(givers.size).toBe(Object.keys(parseOk.participants).length);
expect(receivers.size).toBe(Object.keys(parseOk.participants).length);

// Verify no self-assignments
for (const {giver, receiver} of pairings) {
expect(giver.id).not.toBe(receiver.id);
}

// Verify all MUST NOT rules are respected
for (const {giver, receiver} of pairings) {
const participant = parseOk.participants[giver.id];
const mustNotRules = participant.rules.filter(r => r.type === 'mustNot');

for (const rule of mustNotRules) {
expect(receiver.id).not.toBe(rule.targetParticipantId);
}
}

// Verify all MUST rules are respected
for (const {giver, receiver} of pairings) {
const participant = parseOk.participants[giver.id];
const mustRules = participant.rules.filter(r => r.type === 'must');

for (const rule of mustRules) {
expect(receiver.id).toBe(rule.targetParticipantId);
}
}
}
});
});
80 changes: 43 additions & 37 deletions src/utils/generatePairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function generatePairs(participants: Record<string, Participant>): Genera
}

// Initialize candidate receivers for each giver
const candidateReceivers = new Map<string, Set<string>>();
const initialCandidateReceivers = new Map<string, Set<string>>();

for (const giverId of participantIds) {
const giver = participants[giverId];
Expand All @@ -61,11 +61,11 @@ export function generatePairs(participants: Record<string, Participant>): Genera
.forEach(r => candidates.delete(r.targetParticipantId));
}

candidateReceivers.set(giverId, candidates);
initialCandidateReceivers.set(giverId, candidates);
}

// Find next giver with fewest options
const findNextGiver = (): string | null => {
const findNextGiver = (candidateReceivers: Map<string, Set<string>>): string | null => {
let minOptions = Infinity;
let result: string | null = null;

Expand All @@ -79,46 +79,52 @@ export function generatePairs(participants: Record<string, Participant>): Genera
return result;
};

// Generate pairings
const finalPairs = new Map<string, string>();
pairingGenerations:
for (let t = 0; t < 10; t++) {
// Generate pairings
const finalPairs = new Map<string, string>();
const candidateReceivers = structuredClone(initialCandidateReceivers);

while (candidateReceivers.size > 0) {
const giverId = findNextGiver();
if (!giverId) break;
while (candidateReceivers.size > 0) {
const giverId = findNextGiver(candidateReceivers);
if (!giverId) break;

const candidates = candidateReceivers.get(giverId)!;
if (candidates.size === 0) {
return null; // No valid receivers left for this giver
}
const candidates = candidateReceivers.get(giverId)!;
if (candidates.size === 0) {
continue pairingGenerations; // No valid receivers left for this giver
}

// Randomly select a receiver from candidates
const receiverId = Array.from(candidates)[Math.floor(Math.random() * candidates.size)];
finalPairs.set(giverId, receiverId);
// Randomly select a receiver from candidates
const receiverId = Array.from(candidates)[Math.floor(Math.random() * candidates.size)];
finalPairs.set(giverId, receiverId);

// Remove this receiver as an option for all remaining givers
candidateReceivers.delete(giverId);
for (const candidates of candidateReceivers.values()) {
candidates.delete(receiverId);
// Remove this receiver as an option for all remaining givers
candidateReceivers.delete(giverId);
for (const candidates of candidateReceivers.values()) {
candidates.delete(receiverId);
}
}
}

if (finalPairs.size !== participantIds.length) {
return null; // Not everyone got paired
}

const pairings = Array.from(finalPairs).map(([giverId, receiverId]) => ({
giver: {
id: giverId,
name: participants[giverId].name
},
receiver: {
id: receiverId,
name: participants[receiverId].name
if (finalPairs.size !== participantIds.length) {
continue pairingGenerations; // Not everyone got paired
}
}));

return {
hash: generateGenerationHash(participants),
pairings
};
const pairings = Array.from(finalPairs).map(([giverId, receiverId]) => ({
giver: {
id: giverId,
name: participants[giverId].name
},
receiver: {
id: receiverId,
name: participants[receiverId].name
}
}));

return {
hash: generateGenerationHash(participants),
pairings
};
}

return null;
}

0 comments on commit 129c2dd

Please sign in to comment.