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:
Torsten Schulz (local)
2025-12-14 06:46:00 +01:00
parent e83bc250a8
commit 945ec0d48c
23 changed files with 1688 additions and 50 deletions

View File

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

View File

@@ -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 () => {

View File

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