feat(test-mode): implement test mode functionality for local match verification
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s

This commit is contained in:
Torsten Schulz (local)
2026-09-02 14:23:43 +02:00
parent 94bd81b833
commit f753bf43fb

View File

@@ -20,6 +20,29 @@
</div>
</div>
<label class="test-mode-control" :class="{ active: isTestMode }">
<input v-model="isTestMode" type="checkbox" @change="onTestModeChange" />
<span>
<strong>Testmodus</strong>
<small>Nur lokal ausprobieren keine Speicherung oder Übermittlung an nuScore</small>
</span>
</label>
<div v-if="isTestMode && (meetingDetails?.isCompleted || meetingData?.isCompleted)" class="test-verification">
<button type="button" class="btn-secondary" @click="verifyCompletedMatchProjection">
🧪 Gespeicherte Paarungen prüfen
</button>
<p v-if="testVerification" :class="testVerification.matches ? 'test-success' : 'test-error'">
{{ testVerification.summary }}
</p>
<ul v-if="testVerification && !testVerification.matches" class="test-mismatches">
<li v-for="item in testVerification.items.filter(entry => !entry.matches)" :key="item.matchNumber">
Spiel {{ item.matchNumber }}: erwartet {{ item.expectedHome }} {{ item.expectedGuest }},
gespeichert {{ item.actualHome }} {{ item.actualGuest }}"
</li>
</ul>
</div>
<div class="nuscore-sections">
<div class="section-grid">
<button class="section-btn" :class="{ active: activeSection === 'general' }" @click="setActiveSection('general')">
@@ -715,6 +738,8 @@ export default {
return {
loading: true,
error: null,
isTestMode: false,
testVerification: null,
meetingData: null,
meetingDetails: null,
activeSection: 'general',
@@ -1631,6 +1656,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
getSubmitButtonText() {
if (this.isMatchCompleted) {
return '✅ Spielbericht bereits abgesendet';
} else if (this.isTestMode) {
return '🧪 Testübermittlung simulieren';
} else if (!this.areAllMatchResultsValid()) {
const playThrough = this.getPlayThrough();
if (playThrough && playThrough.toUpperCase().includes('ALLGAMES')) {
@@ -1750,6 +1777,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
async submitMatchReport() {
try {
console.log('🚀 Starte Spielbericht-Absendung...');
if (this.isTestMode) {
alert('🧪 Testmodus: Der Spielbericht wurde nur lokal geprüft und nicht an nuScore übermittelt.');
return;
}
// Wenn eine Mannschaft nicht angetreten ist, keine Match-Ergebnisse oder Zeiten validieren
const isTeamNotAppeared = this.teamNotAppeared !== null;
@@ -2025,9 +2057,12 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
return;
}
// 1. Spieleranzahl aktualisieren (aus Aufstellung)
matchData.playerCountHome = this.getSelectedPlayerCount('home');
matchData.playerCountGuest = this.getSelectedPlayerCount('guest');
// playerCount zählt nur Einzelspieler. Ein zusätzlicher,
// ausschließlich im Doppel eingesetzter Spieler darf die
// Braunschweig-Matrix nicht auf eine falsche Konstellation
// (etwa 5:3) umstellen.
matchData.playerCountHome = this.getSinglePlayerCount('home');
matchData.playerCountGuest = this.getSinglePlayerCount('guest');
// 1.1 Lokalen Aufstellungszustand in den Payload übernehmen
this.applyCurrentLineupStateToPayload(matchData);
@@ -2161,10 +2196,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
matchData.teamLineupHomePlayers.forEach(player => {
const positions = [];
// Einzel-Position NUR hinzufügen wenn Spieler ausgewählt ist
// Einzelposition nur übernehmen, wenn sie tatsächlich
// gesetzt ist. Ersatzspieler im Doppel dürfen nicht über
// ihren Rang zu Einzelspielern werden.
if (player.isSelected) {
// Verwende positionSingle falls verfügbar, sonst rank für Einzel-Position
const singlePos = player.positionSingle || player.rank;
const singlePos = player.positionSingle;
if (singlePos) {
player.positionSingle = singlePos;
positions.push(`E${singlePos}`);
@@ -2190,8 +2226,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Einzel-Position NUR hinzufügen wenn Spieler ausgewählt ist
if (player.isSelected) {
// Verwende positionSingle falls verfügbar, sonst rank für Einzel-Position
const singlePos = player.positionSingle || player.rank;
const singlePos = player.positionSingle;
if (singlePos) {
player.positionSingle = singlePos;
positions.push(`E${singlePos}`);
@@ -2277,6 +2312,18 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
// Anzahl der tatsächlich im Einzel aufgestellten Spieler. Bei alten,
// noch nicht positionierten Entwürfen bleibt die Auswahlzahl der
// kompatible Fallback.
getSinglePlayerCount(team) {
const key = team === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers';
const players = this.meetingDetails?.[key] || this[team === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers'] || [];
const selected = players.filter(player => player?.isSelected && player?.nuLigaPersonId > 0);
const singles = selected.filter(player => Number(player?.positionSingle) > 0);
return singles.length > 0 ? singles.length : selected.length;
},
// Verwende die Matches aus dem Match-Objekt oder lasse vorhandene Ergebnisse bestehen
initializeResults() {
if (this.match.matches && Array.isArray(this.match.matches) && this.match.matches.length > 0) {
@@ -2470,6 +2517,25 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
prepareResults() {
// 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
// Reihenfolge und Paarungen maßgeblich.
const serverMatches = Array.isArray(this.meetingDetails?.matches)
? this.meetingDetails.matches
: [];
if (serverMatches.length > 0) {
this.results = serverMatches.map((match, index) => ({
label: (typeof match?.name === 'string' && match.name.trim()) || `Spiel ${index + 1}`,
homeName: this.formatMeetingMatchSide(match, 'home') || match?.homePlayer || '',
guestName: this.formatMeetingMatchSide(match, 'guest') || match?.guestPlayer || '',
sets: ['', '', '', '', ''],
completed: false,
result: '1:0'
}));
return;
}
const matrix = this.currentPlayModeMatrix;
this.results = (matrix || []).map((label) => ({
label,
@@ -2735,6 +2801,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
};
},
persistLocalDraft() {
if (this.isTestMode) return;
const key = this.getDraftStorageKey();
if (!key) return;
try {
@@ -2745,6 +2812,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
persistLocalDraftDebounced() {
if (this.isTestMode) return;
if (this.draftPersistTimer) {
clearTimeout(this.draftPersistTimer);
}
@@ -2803,6 +2871,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
syncDraftToServerDebounced(delayMs = 600) {
if (this.isTestMode) return;
if (this.draftSyncTimer) {
clearTimeout(this.draftSyncTimer);
}
@@ -2812,7 +2881,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}, delayMs);
},
async syncDraftToServer() {
if (this.isDraftSyncing || !this.meetingData?.nuLigaMeetingUuid) {
if (this.isTestMode || this.isDraftSyncing || !this.meetingData?.nuLigaMeetingUuid) {
return;
}
this.isDraftSyncing = true;
@@ -2863,6 +2932,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
};
},
async broadcastDraft() {
if (this.isTestMode) return;
const clubId = this.$store?.getters?.currentClub;
const gameCode = this.match?.code;
const matchData = this.getDraftMatchData();
@@ -2874,6 +2944,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
broadcastDraftDebounced() {
if (this.isTestMode) return;
if (this.broadcastDraftTimer) clearTimeout(this.broadcastDraftTimer);
this.broadcastDraftTimer = setTimeout(() => {
this.broadcastDraftTimer = null;
@@ -2881,6 +2952,80 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}, 500);
},
normalizeVerificationName(value) {
return String(value || '')
.toLocaleLowerCase('de-DE')
.replace(/\s*\/\s*/g, '/')
.replace(/\s+/g, ' ')
.trim();
},
verifyCompletedMatchProjection() {
const formations = this.meetingDetails?.meetingPlayMode?.matchFormations || [];
const matches = this.meetingDetails?.matches || [];
if (!Array.isArray(formations) || formations.length === 0 || !Array.isArray(matches) || matches.length === 0) {
this.testVerification = {
matches: false,
summary: 'Keine vollständigen nuScore-Daten zum Vergleichen verfügbar.',
items: []
};
return;
}
const items = formations.map((formation, index) => {
const homePosition = this.formatLineupPositionLabel(formation.homeLineupPosition, 'home');
const guestPosition = this.formatLineupPositionLabel(formation.guestLineupPosition, 'guest');
const label = `${homePosition} ${guestPosition}`;
const sourceMatch = matches.find(match => Number(match?.matchNr) === Number(formation.matchNr)) || matches[index];
const expectedHome = this.resolveSide(label, 'home');
const expectedGuest = this.resolveSide(label, 'guest');
const actualHome = this.formatMeetingMatchSide(sourceMatch, 'home');
const actualGuest = this.formatMeetingMatchSide(sourceMatch, 'guest');
return {
matchNumber: formation.matchNr || index + 1,
expectedHome,
expectedGuest,
actualHome,
actualGuest,
matches: this.normalizeVerificationName(expectedHome) === this.normalizeVerificationName(actualHome) &&
this.normalizeVerificationName(expectedGuest) === this.normalizeVerificationName(actualGuest)
};
});
const mismatchCount = items.filter(item => !item.matches).length;
this.testVerification = {
matches: mismatchCount === 0,
summary: mismatchCount === 0
? `✓ Alle ${items.length} gespeicherten Paarungen stimmen mit der lokalen Matrixauflösung überein.`
: `⚠️ ${mismatchCount} von ${items.length} Paarungen weichen von der lokalen Matrixauflösung ab.`,
items
};
},
async onTestModeChange() {
if (!this.isTestMode) return;
// Bereits eingeplante Nebenwirkungen abbrechen. Die Eingaben
// existieren danach ausschließlich im aktuellen Dialog.
['broadcastDraftTimer', 'draftPersistTimer', 'draftSyncTimer'].forEach((timerKey) => {
if (this[timerKey]) {
clearTimeout(this[timerKey]);
this[timerKey] = null;
}
});
this.isDraftSyncing = false;
this.lastDraftSyncError = '';
// Ein Test beginnt mit dem aktuellen offiziellen Stand, damit ein
// alter lokaler Entwurf nicht versehentlich getestet wird.
await this.loadData();
this.prepareResults();
this.populateResultsFromMeetingDetails();
this.testVerification = null;
},
/**
* Übernimmt per WebSocket empfangene Match-Daten (von anderem Gerät) in den Dialog.
* Aktualisiert meetingDetails und meetingData, dann Ergebnisse und Aufstellungs-Bestätigung.
@@ -4590,6 +4735,67 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
flex-direction: column;
}
.test-mode-control {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 16px;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--surface-muted);
cursor: pointer;
}
.test-mode-control.active {
border-color: #d78a00;
background: #fff3cd;
color: #795300;
}
.test-mode-control input {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.test-mode-control span {
display: grid;
gap: 2px;
}
.test-mode-control small {
font-size: 12px;
}
.test-verification {
margin: -8px 0 16px;
padding: 12px 14px;
border: 1px dashed #d78a00;
border-radius: 10px;
background: #fffaf0;
}
.test-verification p {
margin: 10px 0 0;
font-size: 13px;
font-weight: 600;
}
.test-success {
color: #216e39;
}
.test-error {
color: #9f3a38;
}
.test-mismatches {
margin: 8px 0 0;
padding-left: 20px;
font-size: 12px;
}
.report-content {
flex: 1;
padding: 20px;