feat(tournament): enhance tournament configuration and results handling

- Updated TournamentConfigTab.vue to conditionally disable target type selection based on final stage type.
- Improved logic for determining target type and group count based on stage configuration.
- Refactored TournamentPlacementsTab.vue to streamline class and group placements display, including better handling of class visibility and player names.
- Enhanced TournamentResultsTab.vue to handle 'BYE' results and limit displayed entries to top three.
- Modified TournamentTab.vue to robustly determine match winners and losers, including handling 'BYE' scenarios and ensuring accurate knockout progression.
- Added logic to reset knockout matches with optional class filtering.
This commit is contained in:
Torsten Schulz (local)
2025-12-15 15:36:18 +01:00
parent 945ec0d48c
commit 047b1801b3
7 changed files with 1044 additions and 399 deletions

View File

@@ -17,7 +17,9 @@ 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 TournamentResult from '../models/TournamentResult.js';
import TournamentStage from '../models/TournamentStage.js';
import TournamentClass from '../models/TournamentClass.js';
import Club from '../models/Club.js';
import { createMember } from './utils/factories.js';
@@ -254,6 +256,112 @@ describe('tournamentService', () => {
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
});
it('Stage-KO: 3 Gruppen × Plätze 1,2 => 6 Qualifier (keine falschen IDs, keine Duplikate)', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Stage-KO-6', '2025-11-20');
// Stages: Vorrunde (Groups) -> Endrunde (KO)
await tournamentService.upsertTournamentStages(
'token',
club.id,
tournament.id,
[
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 3 },
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
],
null,
[
{
fromStageIndex: 1,
toStageIndex: 2,
mode: 'pools',
config: {
pools: [
{
fromPlaces: [1, 2],
target: { type: 'knockout', singleField: true, thirdPlace: false },
},
],
},
},
]
);
// 3 Gruppen anlegen
await tournamentService.createGroups('token', club.id, tournament.id, 3);
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
expect(groups).toHaveLength(3);
// Je Gruppe 2 Teilnehmer -> insgesamt 6
const members = [];
for (let i = 0; i < 6; i++) {
members.push(
await createMember(club.id, {
firstName: `S${i}`,
lastName: 'KO',
email: `stage_ko6_${i}@example.com`,
gender: i % 2 === 0 ? 'male' : 'female',
})
);
}
// Gruppe 1
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[0].id, classId: null, groupId: groups[0].id });
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[1].id, classId: null, groupId: groups[0].id });
// Gruppe 2
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[2].id, classId: null, groupId: groups[1].id });
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[3].id, classId: null, groupId: groups[1].id });
// Gruppe 3
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[4].id, classId: null, groupId: groups[2].id });
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[5].id, classId: null, groupId: groups[2].id });
// Gruppenspiele erzeugen+beenden (damit Ranking/Platz 1/2 stabil ist)
// Wir erzeugen minimal pro Gruppe ein 1v1-Match und schließen es ab.
for (const g of groups) {
const [tm1, tm2] = await TournamentMember.findAll({ where: { tournamentId: tournament.id, groupId: g.id }, order: [['id', 'ASC']] });
const gm = await TournamentMatch.create({
tournamentId: tournament.id,
round: 'group',
groupId: g.id,
classId: null,
player1Id: tm1.id,
player2Id: tm2.id,
isFinished: true,
isActive: true,
result: '3:0',
});
await TournamentResult.bulkCreate([
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 1 },
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 2 },
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 3 },
]);
}
// KO-Endrunde 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();
const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id }, order: [['id', 'ASC']] });
const round1 = stage2Matches.filter(m => String(m.round || '').includes('Viertelfinale') || String(m.round || '').includes('Achtelfinale') || String(m.round || '').includes('Halbfinale (3)'));
// Bei 6 Entrants muss ein 8er-Bracket entstehen => 3 Matches in der ersten Runde.
// (Die Byes werden nicht als Matches angelegt.)
expect(round1.length).toBe(3);
for (const m of round1) {
expect(m.player1Id).toBeTruthy();
expect(m.player2Id).toBeTruthy();
expect(m.player1Id).not.toBe(m.player2Id);
}
// Spieler-IDs müssen Member-IDs (clubMemberId) sein, nicht TournamentMember.id
const memberIdSet = new Set(members.map(x => x.id));
for (const m of round1) {
expect(memberIdSet.has(m.player1Id)).toBe(true);
expect(memberIdSet.has(m.player2Id)).toBe(true);
}
});
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');
@@ -324,6 +432,204 @@ describe('tournamentService', () => {
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
});
it('Legacy-KO: bei ungerader Qualifier-Zahl wird ein Freilos vergeben (kein Duplikat / kein Self-Match)', async () => {
const club = await Club.create({ name: 'Tournament Club' });
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-Bye', '2025-11-17');
// 3 Gruppen, jeweils 1 Spieler -> advancingPerGroup=1 => 3 Qualifier
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 3, 1);
await tournamentService.createGroups('token', club.id, tournament.id, 3);
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
expect(groups).toHaveLength(3);
const members = [];
for (let i = 0; i < 3; i++) {
members.push(
await createMember(club.id, {
firstName: `B${i}`,
lastName: 'YE',
email: `legacy_bye_${i}@example.com`,
gender: i % 2 === 0 ? 'male' : 'female',
})
);
}
// Je Gruppe genau 1 Teilnehmer, und keine Gruppenspiele nötig (es gibt keine Paarungen)
await TournamentMember.create({
tournamentId: tournament.id,
clubMemberId: members[0].id,
classId: null,
groupId: groups[0].id,
});
await TournamentMember.create({
tournamentId: tournament.id,
clubMemberId: members[1].id,
classId: null,
groupId: groups[1].id,
});
await TournamentMember.create({
tournamentId: tournament.id,
clubMemberId: members[2].id,
classId: null,
groupId: groups[2].id,
});
// KO starten: Erwartung = genau 1 Match (2 Spieler) + 1 Freilos (ohne extra Match)
await tournamentService.startKnockout('token', club.id, tournament.id);
const koMatches = await TournamentMatch.findAll({
where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } },
order: [['id', 'ASC']],
});
// Bei 3 Qualifiern muss GENAU EIN Halbfinale (3) existieren.
const semi3 = koMatches.filter(m => m.round === 'Halbfinale (3)');
expect(semi3).toHaveLength(1);
expect(semi3[0].player1Id).toBeTruthy();
expect(semi3[0].player2Id).toBeTruthy();
expect(semi3[0].player1Id).not.toBe(semi3[0].player2Id);
// Self-match darf nirgends vorkommen.
for (const m of koMatches) {
if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id);
}
// Hinweis: Bei 3 Qualifiern wird im Legacy-Flow aktuell ein "Halbfinale (3)" erzeugt.
// Ein automatisches Weitertragen des Freiloses bis in ein fertiges Finale ist nicht Teil dieses Tests.
// Wichtig ist hier die Regression: kein Duplikat und kein Self-Match.
// Halbfinale beenden (soll keine kaputten Folge-Matches erzeugen)
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 1, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 2, '11:1');
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 3, '11:1');
const after = await TournamentMatch.findAll({
where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } },
order: [['id', 'ASC']],
});
// Egal ob ein Folge-Match entsteht oder nicht: es darf kein Self-Match geben.
for (const m of after) {
if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id);
}
});
it('Stage advancement ist klassenisoliert (Zwischen-/Endrunde hängt nur von der jeweiligen Klasse ab)', async () => {
const club = await Club.create({ name: 'Club', accessToken: 'token' });
const tournament = await Tournament.create({
clubId: club.id,
name: 'Stages Multi-Class',
date: '2025-12-14',
type: 'groups',
numberOfGroups: 2,
advancingPerGroup: 1,
winningSets: 3,
allowsExternal: false,
});
const classA = await TournamentClass.create({ tournamentId: tournament.id, name: 'A' });
const classB = await TournamentClass.create({ tournamentId: tournament.id, name: 'B' });
await tournamentService.upsertTournamentStages(
'token',
club.id,
tournament.id,
[
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 2 },
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
],
null,
[
{
fromStageIndex: 1,
toStageIndex: 2,
mode: 'pools',
config: {
pools: [
{ fromPlaces: [1], target: { type: 'knockout', singleField: true, thirdPlace: false } },
],
},
},
]
);
await tournamentService.createGroups('token', club.id, tournament.id, 2);
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
expect(groups.length).toBe(2);
// Klasse A fertig
const memberA1 = await createMember(club.id, {
firstName: 'A1',
lastName: 'Test',
email: 'stage_class_a1@example.com',
gender: 'male',
});
const memberA2 = await createMember(club.id, {
firstName: 'A2',
lastName: 'Test',
email: 'stage_class_a2@example.com',
gender: 'female',
});
const a1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA1.id, classId: classA.id, groupId: groups[0].id });
const a2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA2.id, classId: classA.id, groupId: groups[0].id });
const aMatch = await TournamentMatch.create({
tournamentId: tournament.id,
round: 'group',
groupId: groups[0].id,
classId: classA.id,
player1Id: a1.id,
player2Id: a2.id,
isFinished: true,
isActive: true,
result: '3:0',
});
await TournamentResult.bulkCreate([
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 1 },
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 2 },
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 3 },
]);
// Klasse B unfertig
const memberB1 = await createMember(club.id, {
firstName: 'B1',
lastName: 'Test',
email: 'stage_class_b1@example.com',
gender: 'male',
});
const memberB2 = await createMember(club.id, {
firstName: 'B2',
lastName: 'Test',
email: 'stage_class_b2@example.com',
gender: 'female',
});
const b1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB1.id, classId: classB.id, groupId: groups[1].id });
const b2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB2.id, classId: classB.id, groupId: groups[1].id });
await TournamentMatch.create({
tournamentId: tournament.id,
round: 'group',
groupId: groups[1].id,
classId: classB.id,
player1Id: b1.id,
player2Id: b2.id,
isFinished: false,
isActive: true,
result: null,
});
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
expect(stage2).toBeTruthy();
const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } });
expect(stage2Matches.some(m => m.classId === classB.id)).toBe(false);
// Und es wurden keine Stage2-Gruppen für Klasse B erzeugt.
// (classless Container-Gruppen sind möglich entscheidend ist, dass Klasse B nicht blockiert/vermengt wird.)
const stage2Groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } });
expect(stage2Groups.some(g => g.classId === classB.id)).toBe(false);
});
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');