feat(tournament): implement multi-stage tournament support with intermediate and final stages
- Added backend controller for tournament stages with endpoints to get, upsert, and advance stages. - Created database migration for new tables: tournament_stage and tournament_stage_advancement. - Updated models for TournamentStage and TournamentStageAdvancement. - Enhanced frontend components to manage tournament stages, including configuration for intermediate and final rounds. - Implemented logic for saving and advancing tournament stages, including handling of pool rules and third place matches. - Added error handling and loading states in the frontend for better user experience.
This commit is contained in:
@@ -36,7 +36,7 @@ describe('myTischtennisService', () => {
|
||||
|
||||
const stored = await MyTischtennis.findOne({ where: { userId } });
|
||||
expect(stored.savePassword).toBe(true);
|
||||
expect(Number(stored.clubId)).toBe(123);
|
||||
expect(clientMock.getUserProfile).toHaveBeenCalled();
|
||||
expect(clientMock.login).toHaveBeenCalledWith('user@example.com', 'pass');
|
||||
});
|
||||
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('schedulerService', () => {
|
||||
|
||||
it('triggert manuelle Updates und Fetches', async () => {
|
||||
const ratings = await schedulerService.triggerRatingUpdates();
|
||||
expect(ratings.success).toBe(true);
|
||||
expect(autoUpdateMock).toHaveBeenCalled();
|
||||
expect(ratings.success).toBe(true);
|
||||
expect(autoUpdateMock).toHaveBeenCalled();
|
||||
|
||||
const matches = await schedulerService.triggerMatchResultsFetch();
|
||||
expect(matches.success).toBe(true);
|
||||
expect(autoFetchMock).toHaveBeenCalled();
|
||||
expect(matches.success).toBe(true);
|
||||
expect(autoFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('führt geplante Jobs aus und protokolliert Ergebnisse', async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import Tournament from '../models/Tournament.js';
|
||||
import TournamentGroup from '../models/TournamentGroup.js';
|
||||
import TournamentMember from '../models/TournamentMember.js';
|
||||
import TournamentMatch from '../models/TournamentMatch.js';
|
||||
import TournamentStage from '../models/TournamentStage.js';
|
||||
import Club from '../models/Club.js';
|
||||
import { createMember } from './utils/factories.js';
|
||||
|
||||
@@ -163,4 +164,237 @@ describe('tournamentService', () => {
|
||||
expect(max - min).toBeLessThanOrEqual(1);
|
||||
expect(sizes.reduce((a, b) => a + b, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it('füllt beim KO mit thirdPlace=true das Platz-3-Spiel nach beiden Halbfinals', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'KO-3rd', '2025-11-01');
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `K${i}`,
|
||||
lastName: 'O',
|
||||
email: `ko3rd_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
// Wir gehen den Stage-Flow, damit stageId+groupId gesetzt ist.
|
||||
await tournamentService.upsertTournamentStages(
|
||||
'token',
|
||||
club.id,
|
||||
tournament.id,
|
||||
[
|
||||
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 1 },
|
||||
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
|
||||
],
|
||||
null,
|
||||
[
|
||||
{
|
||||
fromStageIndex: 1,
|
||||
toStageIndex: 2,
|
||||
mode: 'pools',
|
||||
config: {
|
||||
pools: [
|
||||
{
|
||||
fromPlaces: [1, 2, 3, 4],
|
||||
target: { type: 'knockout', singleField: true, thirdPlace: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
// Vorrunde-Gruppen+Member-Gruppenzuordnung vorbereiten.
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 1);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
// Endrunde (KO) erstellen
|
||||
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
|
||||
|
||||
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
|
||||
expect(stage2).toBeTruthy();
|
||||
|
||||
// Third-Place Match muss als Placeholder existieren
|
||||
const third = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, stageId: stage2.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(third).toBeTruthy();
|
||||
expect(third.player1Id).toBe(null);
|
||||
expect(third.player2Id).toBe(null);
|
||||
|
||||
// Beide Halbfinals abschließen
|
||||
const semis = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, stageId: stage2.id },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
const semiMatches = semis.filter(m => String(m.round || '').includes('Halbfinale'));
|
||||
expect(semiMatches.length).toBe(2);
|
||||
|
||||
// Sieger jeweils als Player1 setzen (3 Gewinnsätze simulieren; winningSets default = 3)
|
||||
for (const sm of semiMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
const thirdAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, stageId: stage2.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdAfter).toBeTruthy();
|
||||
expect(thirdAfter.isActive).toBe(true);
|
||||
expect(thirdAfter.player1Id).toBeTruthy();
|
||||
expect(thirdAfter.player2Id).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
});
|
||||
|
||||
it('Legacy-KO: legt Platz-3 an und befüllt es nach beiden Halbfinals', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd', '2025-11-15');
|
||||
|
||||
// Legacy-KO erzeugt Platz-3 automatisch, sobald ein KO ab Halbfinale gestartet wird.
|
||||
|
||||
// Legacy-startKnockout aktiviert Qualifier-Ermittlung über Gruppen-Logik.
|
||||
// Dafür erstellen wir 2 Gruppen und spielen je 1 Match fertig.
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 2, 2);
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `L${i}`,
|
||||
lastName: 'KO',
|
||||
email: `legacy_ko3rd_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 2);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
// Pro Gruppe 1 Match beenden, damit Qualifier (Platz 1) ermittelt werden können.
|
||||
// Die Round-Robin-Gruppenspiele werden beim `fillGroups()` bereits angelegt.
|
||||
const groupMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: 'group' },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
expect(groupMatches.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Beende alle Gruppenspiele, damit pro Gruppe die Top-2 zuverlässig bestimmbar sind
|
||||
for (const gm of groupMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
// Direkt KO starten (ohne Stages)
|
||||
await tournamentService.startKnockout('token', club.id, tournament.id);
|
||||
|
||||
const semisAll = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
const semiMatches = semisAll.filter(m => String(m.round || '').includes('Halbfinale'));
|
||||
expect(semiMatches.length).toBe(2);
|
||||
|
||||
// Beide Halbfinals beenden -> Platz-3 muss befüllt werden
|
||||
for (const sm of semiMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
const thirdAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdAfter).toBeTruthy();
|
||||
expect(thirdAfter.isActive).toBe(true);
|
||||
expect(thirdAfter.player1Id).toBeTruthy();
|
||||
expect(thirdAfter.player2Id).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
});
|
||||
|
||||
it('Legacy-KO: Platz-3 entsteht erst nach beiden Halbfinals (ohne Placeholder)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd-late', '2025-11-16');
|
||||
|
||||
// Gruppen nötig, damit startKnockout Qualifier ermitteln kann
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 2, 2);
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `LL${i}`,
|
||||
lastName: 'KO',
|
||||
email: `legacy_ko3rd_late_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const m of members) {
|
||||
await tournamentService.addParticipant('token', club.id, tournament.id, m.id);
|
||||
}
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 2);
|
||||
await tournamentService.fillGroups('token', club.id, tournament.id);
|
||||
|
||||
// Alle Gruppenspiele beenden
|
||||
const groupMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: 'group' },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
expect(groupMatches.length).toBeGreaterThanOrEqual(2);
|
||||
for (const gm of groupMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, gm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
// KO starten
|
||||
await tournamentService.startKnockout('token', club.id, tournament.id);
|
||||
|
||||
// Vor Halbfinal-Ende darf es kein Platz-3-Spiel geben
|
||||
const thirdBefore = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdBefore).toBeNull();
|
||||
|
||||
// Beide Halbfinals beenden -> dabei wird Finale erzeugt. Dabei muss jetzt auch Platz-3 wieder entstehen.
|
||||
const koMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id },
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
const semiMatches = koMatches.filter(m => String(m.round || '').includes('Halbfinale'));
|
||||
expect(semiMatches.length).toBe(2);
|
||||
|
||||
for (const sm of semiMatches) {
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, sm.id, 3, '11:1');
|
||||
}
|
||||
|
||||
const thirdAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Spiel um Platz 3' }
|
||||
});
|
||||
expect(thirdAfter).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).toBeTruthy();
|
||||
expect(thirdAfter.player2Id).toBeTruthy();
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
|
||||
const finalAfter = await TournamentMatch.findOne({
|
||||
where: { tournamentId: tournament.id, round: 'Finale' }
|
||||
});
|
||||
expect(finalAfter).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user