From 6f4f865b4a113642f0f56cfd48534bfd4011e00b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 28 Oct 2026 10:12:28 +0100 Subject: [PATCH] Fix nuScore double lineup synchronization --- .../src/components/MatchReportApiDialog.vue | 201 +++++++----------- frontend/src/services/lineupResolver.mjs | 27 +++ frontend/tests/lineupResolver.test.mjs | 146 +++++++++++++ 3 files changed, 244 insertions(+), 130 deletions(-) create mode 100644 frontend/src/services/lineupResolver.mjs create mode 100644 frontend/tests/lineupResolver.test.mjs diff --git a/frontend/src/components/MatchReportApiDialog.vue b/frontend/src/components/MatchReportApiDialog.vue index a0487089..4eccfe09 100755 --- a/frontend/src/components/MatchReportApiDialog.vue +++ b/frontend/src/components/MatchReportApiDialog.vue @@ -860,6 +860,7 @@ import CryptoJS from 'crypto-js'; import apiClient, { backendBaseUrl } from '../apiClient'; import { onMatchReportSubmitted, offMatchReportSubmitted } from '../services/socketService.js'; import { calculateTtrChange } from '../services/ttrCalculator.mjs'; +import { resolveLineupToken } from '../services/lineupResolver.mjs'; export default { name: 'MatchReportDialog', @@ -1500,7 +1501,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr // Effektive Spieleranzahl (berücksichtigt Braunschweiger-Regel) getEffectivePlayerCount(team) { - const actualCount = this.getSelectedPlayerCount(team); + const actualCount = this.getSinglePlayerCount(team); const playMode = this.getPlayMode(); // Spezieller Fall für Braunschweiger System: 2er-Mannschaften werden als 3er-Mannschaften gewertet @@ -2396,13 +2397,20 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr if (result?.object) { const hydratedResult = JSON.parse(JSON.stringify(result.object)); - this.applyCurrentLineupStateToPayload(hydratedResult); this.meetingDetails = hydratedResult; + // /validate may return its default (for example 4:3) + // match order even though all four players and both + // doubles are locally assigned. Restore the local lineup + // and derive the formations again before rendering. + this.applyCurrentLineupStateToPayload(this.meetingDetails); + this.updatePlayerPositions(this.meetingDetails); + this.applyBraunschweigerMatchFormations(this.meetingDetails); this.meetingData = { ...(this.meetingData || {}), ...JSON.parse(JSON.stringify(result.object)) }; this.applyLineupCertificationFromMeetingDetails(); + this.prepareResults(); } if (response.ok) { @@ -2905,6 +2913,14 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr }, resolveMatchSide(match, index, side) { + const label = this.getMatchFormationLabel(match, index); + const token = String(label).split('–')[side === 'home' ? 0 : 1]?.trim(); + const key = side === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers'; + const lineupName = resolveLineupToken(this.meetingDetails?.[key], token); + // Current positions override cached nuScore match participants, + // including explicitly cleared positions. + if (lineupName !== null) return lineupName; + const assignedName = this.formatMeetingMatchSide(match, side); if (this.isUsableMatchSide(assignedName)) { return assignedName; @@ -3263,6 +3279,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr } }, prepareResults() { + if (this.getSinglePlayerCount('home') > 0 && this.getSinglePlayerCount('guest') > 0) { + this.applyBraunschweigerMatchFormations(this.meetingDetails); + } // nuScore kann Aufstellungen liefern, die sich nicht aus der Zahl // ausgewählter Spieler ableiten lassen (z.B. ein Spieler nur im // Doppel). Die vom Server gelieferten Begegnungen sind dann für @@ -3367,6 +3386,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr return; } + if (this.getSinglePlayerCount('home') > 0 && this.getSinglePlayerCount('guest') > 0 && + this.applyBraunschweigerMatchFormations(this.meetingDetails)) { + this.prepareResults(); + } const sourceMatches = this.meetingDetails.matches; const count = Math.min((this.results && this.results.length) || 0, sourceMatches.length); @@ -3380,12 +3403,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr if (typeof src.name === 'string' && src.name.trim() !== '') { dst.label = src.name.trim(); } - if (resolvedHomeName) { - dst.homeName = resolvedHomeName; - } - if (resolvedGuestName) { - dst.guestName = resolvedGuestName; - } + dst.homeName = resolvedHomeName; + dst.guestName = resolvedGuestName; if (!dst.sets || !Array.isArray(dst.sets)) { this.$set(dst, 'sets', ['', '', '', '', '']); @@ -3924,123 +3943,17 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr const token = side === 'home' ? parts[0] : parts[1]; if (!token) return ''; - if (token.startsWith('DA') || token.startsWith('DB')) { - // Doppel-Name aus den gesetzten Doppeln ableiten - const pos = parseInt(token.replace(/[^0-9]/g, '')); // 1 oder 2 - const key = side === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers'; - const list = (this.meetingDetails && this.meetingDetails[key]) ? this.meetingDetails[key] : []; - const pair = list.filter(p => p.positionDouble === pos && p.nuLigaPersonId > 0); - const n = (p) => [p.firstname || p.firstName || '', p.lastname || p.lastName || ''].filter(Boolean).join(' ').trim(); - - // Wenn ein Doppel-Paar gefunden wurde, verwende es - if (pair.length >= 2) { - const result = `${n(pair[0])} / ${n(pair[1])}`; - return result; - } - if (pair.length === 1) { - const result = `${n(pair[0])}`; - return result; - } - - // Falls kein Doppel gefunden, verwende die entsprechenden Spieler basierend auf der Position - if (pair.length === 0) { - const selectedPlayers = list.filter(p => p.isSelected && p.nuLigaPersonId > 0); - - - // Versuche verschiedene Sortierungen - // 1. Zuerst nach positionDouble (falls vorhanden) - // 2. Dann nach rank - // 3. Dann nach der Reihenfolge in der Liste - const sortedPlayers = selectedPlayers.sort((a, b) => { - // Spieler mit positionDouble zuerst - if (a.positionDouble && !b.positionDouble) return -1; - if (!a.positionDouble && b.positionDouble) return 1; - if (a.positionDouble && b.positionDouble) { - return a.positionDouble - b.positionDouble; - } - - // Dann nach rank - if (a.rank !== b.rank) { - return a.rank - b.rank; - } - - // Dann nach positions - const aPos = (a.positions && a.positions.length > 0) ? a.positions[0] : 'Z'; - const bPos = (b.positions && b.positions.length > 0) ? b.positions[0] : 'Z'; - return aPos.localeCompare(bPos); - }); - - - // Für DA1/DB1: Spieler 0+1, für DA2/DB2: Spieler 2+3, etc. - const startIndex = (pos - 1) * 2; - if (sortedPlayers.length > startIndex + 1) { - const result = `${n(sortedPlayers[startIndex])} / ${n(sortedPlayers[startIndex + 1])}`; - return result; - } else if (sortedPlayers.length > startIndex) { - const result = n(sortedPlayers[startIndex]); - return result; - } else if (sortedPlayers.length >= 2) { - // Fallback: zirkulieren - const index1 = startIndex % sortedPlayers.length; - const index2 = (startIndex + 1) % sortedPlayers.length; - const result = `${n(sortedPlayers[index1])} / ${n(sortedPlayers[index2])}`; - return result; - } else if (sortedPlayers.length === 1) { - const result = n(sortedPlayers[0]); - return result; - } - } - - return token; - } else { - // Einzelposition A1..A6 / B1..B6 - const isHome = token.startsWith('A'); - const rank = parseInt(token.replace(/[^0-9]/g, '')); - const team = (isHome ? 'home' : 'guest'); - const key = team === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers'; - const list = (this.meetingDetails && this.meetingDetails[key]) ? this.meetingDetails[key] : []; - - // Prüfe zuerst, ob überhaupt genügend Spieler für diese Position vorhanden sind - const selectedPlayers = list.filter(p => p.isSelected && p.nuLigaPersonId > 0); - - // Für Braunschweiger System: 2er-Mannschaften werden als 3er-Mannschaften gewertet - let effectivePlayerCount = selectedPlayers.length; - const playMode = this.getPlayMode(); - if (playMode && playMode.toLowerCase().includes('braunschweiger') && selectedPlayers.length === 2) { - effectivePlayerCount = 3; - } - - if (effectivePlayerCount < rank) { - // Nicht genügend Spieler für diese Position → Position bleibt leer - return ''; - } - - // Suche nach Spieler mit entsprechender Position - // Zuerst nach A1, A2, etc. in positions Array (für Standard-Aufstellungen) - let player = list.find(p => p.isSelected && (p.positions || []).includes(`${isHome ? 'A' : 'B'}${rank}`)); - - // Falls immer noch nicht gefunden, suche nach Spielern mit E1, E2, etc. in positions - if (!player) { - player = list.find(p => p.isSelected && (p.positions || []).includes(`E${rank}`)); - } - - // Falls immer noch nicht gefunden, nach Reihenfolge in der Liste der ausgewählten Spieler - if (!player) { - // Da wir bereits oben geprüft haben, dass genügend Spieler vorhanden sind, - // können wir sicher den entsprechenden Spieler verwenden - const sortedPlayers = selectedPlayers.sort((a, b) => { - const aPos = (a.positions && a.positions.length > 0) ? a.positions[0] : 'Z'; - const bPos = (b.positions && b.positions.length > 0) ? b.positions[0] : 'Z'; - return aPos.localeCompare(bPos); - }); - - player = sortedPlayers[rank - 1]; - } - - if (!player) return ''; - const result = [player.firstname || player.firstName || '', player.lastname || player.lastName || ''].filter(Boolean).join(' ').trim(); - return result; - } + const key = side === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers'; + const list = this.meetingDetails?.[key] || []; + const resolved = resolveLineupToken(list, token); + if (resolved !== null) return resolved; + + // Legacy drafts without positions may use selection order for + // singles only. Doubles require an explicit pair. + if (/^D/i.test(token)) return ''; + const position = Number(token.replace(/\D/g, '')); + const selected = list.filter(player => player.isSelected && Number(player.nuLigaPersonId) > 0); + return this.formatMatchPlayerName(selected[position - 1]); } catch (e) { console.warn('Fehler beim Auflösen der Spielerseite:', e, 'label:', label, 'side:', side); return ''; @@ -4830,6 +4743,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr selectedPlayer.positionDouble = doublePosition; } + // nuScore evaluates both positionDouble and positions. Keep + // both representations in sync immediately, rather than only + // when the final report is submitted. + this.updatePlayerPositions(this.meetingDetails); + // Sowohl Setzen als auch Entfernen muss die Doppelzeilen // unmittelbar aus den aktuellen Paaren neu auflösen. if (!this.isHomeLineupCertified || !this.isGuestLineupCertified) { @@ -4853,11 +4771,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr return false; } - // Check if player is already selected for a different double position - if (player.positionDouble && player.positionDouble !== doublePosition) { - return false; // Player is already assigned to a different double position - } - // Pro Doppelposition sind genau zwei aufgestellte Spieler erlaubt. const playersInDoublePosition = this.meetingDetails[teamKey].filter(p => p.isSelected && p.positionDouble === doublePosition && p.nuLigaPersonId !== player.nuLigaPersonId && p.nuLigaPersonId > 0 @@ -4890,6 +4803,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr this.meetingDetails[teamKey][playerIndex].positions = [`E${index + 1}`]; } }); + + this.updatePlayerPositions(this.meetingDetails); }, // PINs automatisch laden @@ -5354,10 +5269,36 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr return; } + const previousFormations = target.meetingPlayMode?.matchFormations || []; + const formationKey = formation => `${formation?.homeLineupPosition}-${formation?.guestLineupPosition}`; + const changed = previousFormations.length !== formations.length || + formations.some((formation, index) => formationKey(formation) !== formationKey(previousFormations[index])); + if (changed && Array.isArray(target.matches) && target.matches.length > 0) { + // Keep scores with their formation when the team size changes; + // a new D2 must never inherit the former second singles match. + const previousByFormation = new Map(previousFormations.map((formation, index) => [ + formationKey(formation), + target.matches.find(match => Number(match.matchNr) === Number(formation.matchNr)) || target.matches[index] + ])); + target.matches = formations.map(formation => { + const previous = previousByFormation.get(formationKey(formation)); + return { + ...(previous || { + set1A: 0, set1B: 0, set2A: 0, set2B: 0, set3A: 0, set3B: 0, + set4A: 0, set4B: 0, set5A: 0, set5B: 0, + matchesA: 0, matchesB: 0, setsA: 0, setsB: 0, isCompleted: false + }), + matchNr: formation.matchNr, + name: `${formation.homeLineupPosition}-${formation.guestLineupPosition}`, + matchType: formation.homeLineupPosition.startsWith('D') ? 'DOUBLE' : 'SINGLE' + }; + }); + } target.meetingPlayMode = { ...(target.meetingPlayMode && typeof target.meetingPlayMode === 'object' ? target.meetingPlayMode : {}), matchFormations: formations }; + return changed; }, getBraunschweigerServerMatrix() { const mapFormationsToLabels = (formations) => { diff --git a/frontend/src/services/lineupResolver.mjs b/frontend/src/services/lineupResolver.mjs new file mode 100644 index 00000000..ec3f19d0 --- /dev/null +++ b/frontend/src/services/lineupResolver.mjs @@ -0,0 +1,27 @@ +const nameOf = player => [player.firstname || player.firstName, player.lastname || player.lastName] + .filter(Boolean).join(' ').trim(); + +// null means the response has no position data; an empty string means that +// position is explicitly unoccupied. Never fill doubles from ranking order. +export function resolveLineupToken(players, token) { + const match = /^(D[AB]|[AB])(\d+)$/i.exec(String(token || '').trim()); + if (!match) return null; + const double = match[1].length === 2; + const position = Number(match[2]); + const field = double ? 'positionDouble' : 'positionSingle'; + const positionPattern = double ? /^D\d+$/i : /^(E|A|B)\d+$/i; + const list = (players || []).filter(player => Number(player?.nuLigaPersonId) > 0); + const hasPositionData = list.some(player => + Object.hasOwn(player, field) || (player.positions || []).some(value => positionPattern.test(value))); + if (!hasPositionData) return null; + + const assigned = list.filter(player => { + if (!double && !player.isSelected) return false; + // An explicit null also overrides obsolete entries in positions. + if (Object.hasOwn(player, field)) return Number(player[field]) === position; + return (player.positions || []).some(value => + positionPattern.test(value) && Number(String(value).replace(/\D/g, '')) === position); + }); + if (double) return assigned.map(nameOf).filter(Boolean).join(' / '); + return assigned.length === 1 ? nameOf(assigned[0]) : ''; +} diff --git a/frontend/tests/lineupResolver.test.mjs b/frontend/tests/lineupResolver.test.mjs new file mode 100644 index 00000000..36b1a871 --- /dev/null +++ b/frontend/tests/lineupResolver.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { resolveLineupToken } from '../src/services/lineupResolver.mjs'; + +// Exercise the actual dialog methods without mounting its network/UI lifecycle. +const source = readFileSync(new URL('../src/components/MatchReportApiDialog.vue', import.meta.url), 'utf8'); +const script = source.match(/