Fix nuScore double lineup synchronization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s

This commit is contained in:
Torsten Schulz (local)
2026-10-28 10:12:28 +01:00
parent 34807adf3b
commit 6f4f865b4a
3 changed files with 244 additions and 130 deletions

View File

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

View File

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

View File

@@ -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(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/^import .*;\n/gm, '')
.replace('export default', 'globalThis.component =');
const context = vm.createContext({ resolveLineupToken, console });
vm.runInContext(script, context);
const methods = context.component.methods;
const player = (id, name, single, double, selected = true) => ({
nuLigaPersonId: id, firstname: name, isSelected: selected,
positionSingle: single, positionDouble: double,
});
const home = [
player(1, 'Fred Swyter', null, null, false),
player(2, 'Lukas Wypior', 1, 1),
player(3, 'Magdalena Schwalbach', 2, 1),
player(4, 'Timo Wolf', 3, 2),
player(5, 'Joshua Koch', 4, 2),
];
const guest = [
player(11, 'Anton Schottdorf', 1, 1),
player(12, 'Anna Wolf', 2, 2),
player(13, 'Raffaele Garofano', 3, 2),
player(14, 'Edis Toprak Basaraner', 4, 1),
];
function dialog(players = structuredClone(home)) {
return Object.assign({}, methods, {
meetingDetails: {
teamLineupHomePlayers: players,
teamLineupGuestPlayers: structuredClone(guest),
meetingPlayMode: { matchFormations: [
{ matchNr: 1, homeLineupPosition: 'D1', guestLineupPosition: 'D1' },
{ matchNr: 2, homeLineupPosition: 'D2', guestLineupPosition: 'D2' },
{ matchNr: 3, homeLineupPosition: 'E1', guestLineupPosition: 'E2' },
] },
},
currentPlayModeMatrix: [],
});
}
test('screenshot lineup resolves both explicit doubles, including the crossed guest pairs', () => {
const report = dialog();
assert.equal(report.resolveMatchSide({}, 0, 'home'), 'Lukas Wypior / Magdalena Schwalbach');
assert.equal(report.resolveMatchSide({}, 0, 'guest'), 'Anton Schottdorf / Edis Toprak Basaraner');
assert.equal(report.resolveMatchSide({}, 1, 'home'), 'Timo Wolf / Joshua Koch');
assert.equal(report.resolveMatchSide({}, 1, 'guest'), 'Anna Wolf / Raffaele Garofano');
});
test('current positions override obsolete match names and unselected singles', () => {
const report = dialog();
const stale = { playerA1: { firstname: 'Fred Swyter' }, homePlayer: 'Fred Swyter' };
assert.equal(report.resolveMatchSide(stale, 2, 'home'), 'Lukas Wypior');
assert.equal(report.resolveMatchSide(stale, 0, 'home'), 'Lukas Wypior / Magdalena Schwalbach');
assert.equal(report.resolveSide('A4 – B4', 'home'), 'Joshua Koch');
});
test('clearing a double does not resurrect server names or invent a pair from rank', () => {
const report = dialog();
for (const p of report.meetingDetails.teamLineupHomePlayers) p.positionDouble = null;
assert.equal(report.resolveMatchSide({ homePlayer: 'Old / Pair' }, 0, 'home'), '');
assert.equal(report.resolveSide('DA1 – DB1', 'home'), '');
assert.equal(dialog([{ nuLigaPersonId: 1, firstname: 'One', isSelected: true },
{ nuLigaPersonId: 2, firstname: 'Two', isSelected: true }]).resolveSide('DA1 – DB1', 'home'), '');
});
test('nuScore participants remain available when lineup positions are absent', () => {
const report = dialog([]);
assert.equal(report.resolveMatchSide({ playerA1: { firstname: 'One' }, playerA2: { firstname: 'Two' } }, 0, 'home'), 'One / Two');
assert.equal(report.resolveMatchSide({ homePlayer: 'One / Two' }, 0, 'home'), 'One / Two');
assert.equal(report.resolveMatchSide({ homePlayer: 'nicht angegeben' }, 0, 'home'), '');
});
test('numeric strings and legacy positions work; double-only players stay out of singles', () => {
const players = [player(1, 'One', '1', '2'), player(2, 'Two', null, '2', false)];
assert.equal(resolveLineupToken(players, 'DA2'), 'One / Two');
assert.equal(resolveLineupToken(players, 'A2'), '');
assert.equal(resolveLineupToken([{ nuLigaPersonId: 1, firstname: 'Legacy', isSelected: true, positions: ['E1', 'D2'] }], 'DB2'), 'Legacy');
assert.equal(resolveLineupToken([{ ...players[0], positionDouble: null, positions: ['D2'] }], 'DA2'), '');
});
test('formation lookup uses match number rather than array order', () => {
assert.equal(dialog().resolveMatchSide({ matchNr: 2 }, 0, 'guest'), 'Anna Wolf / Raffaele Garofano');
});
test('a stale three-player matrix gains D2 for four against four without moving singles scores into it', () => {
const report = dialog();
report.getPlayMode = () => 'braunschweiger system';
report.currentPlayModeMatrix = [
'DA1 – DB1', 'DA2 – DB2', 'A1 – B1', 'A2 – B2', 'A3 – B3',
'A4 – B4', 'A1 – B2', 'A2 – B1', 'A3 – B4', 'A4 – B3',
];
report.meetingDetails.meetingPlayMode.matchFormations = [
{ matchNr: 1, homeLineupPosition: 'D1', guestLineupPosition: 'D1' },
{ matchNr: 2, homeLineupPosition: 'E1', guestLineupPosition: 'E2' },
];
report.meetingDetails.matches = [
{ matchNr: 1, set1A: 11, set1B: 5 },
{ matchNr: 2, set1A: 11, set1B: 8, playerA1: { firstname: 'Fred Swyter' } },
];
report.ensureTtrSnapshots = () => {};
report.prepareResults();
assert.equal(report.results.length, 10);
assert.equal(report.results[1].homeName, 'Timo Wolf / Joshua Koch');
assert.equal(report.results[1].guestName, 'Anna Wolf / Raffaele Garofano');
assert.equal(report.meetingDetails.matches[1].set1A, 0);
assert.equal(report.meetingDetails.matches[6].set1B, 8);
assert.equal(report.meetingDetails.matches[6].matchNr, 7);
assert.equal(report.meetingDetails.matches[0].set1B, 5);
});
test('an extra double-only player does not change the Braunschweig team-size matrix', () => {
const report = dialog([...structuredClone(home), player(100, 'Reserve', null, 2)]);
report.getPlayMode = () => 'braunschweiger system';
assert.equal(report.getEffectivePlayerCount('home'), 4);
});
test('a player may move between doubles without having to be the final selection', () => {
const report = dialog();
const lukas = report.meetingDetails.teamLineupHomePlayers[1];
const timo = report.meetingDetails.teamLineupHomePlayers[3];
// D2 contains Timo and Joshua, so it is full; after freeing one place,
// Lukas can be moved directly from D1 to D2.
timo.positionDouble = null;
report.persistLocalDraftDebounced = () => {};
report.broadcastDraftDebounced = () => {};
report.syncDraftToServerDebounced = () => {};
assert.equal(report.canSelectDoublePosition(lukas, 'home', 2), true);
report.toggleDoublePosition(lukas, 'home', 2);
assert.equal(lukas.positionDouble, 2);
assert.deepEqual(Array.from(lukas.positions), ['E1', 'D2']);
});
test('position arrays retain both doubles after any single-position recalculation', () => {
const report = dialog();
report.recalculatePositions('teamLineupHomePlayers');
assert.deepEqual(Array.from(report.meetingDetails.teamLineupHomePlayers[1].positions), ['E1', 'D1']);
assert.deepEqual(Array.from(report.meetingDetails.teamLineupHomePlayers[2].positions), ['E2', 'D1']);
assert.deepEqual(Array.from(report.meetingDetails.teamLineupHomePlayers[3].positions), ['E3', 'D2']);
assert.deepEqual(Array.from(report.meetingDetails.teamLineupHomePlayers[4].positions), ['E4', 'D2']);
});