Fix empty TTR preview and retain singles with missing ratings
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s
This commit is contained in:
@@ -626,7 +626,18 @@
|
||||
<div v-else-if="ttrLoading" class="ttr-preview-empty">TTR-Werte werden geladen …</div>
|
||||
<div v-else class="ttr-preview-table-wrap">
|
||||
<table class="ttr-preview-table"><thead><tr><th>Einzel</th><th>Heim</th><th>Gast</th><th>Erwartung Heim</th><th>Vorschau / Ergebnis</th></tr></thead>
|
||||
<tbody><tr v-for="entry in ttrSingles" :key="`ttr-${entry.index}`"><td>{{ entry.index + 1 }}</td><td>{{ entry.homeName }} · {{ ttrSnapshot(entry.index).homeTtr }}</td><td>{{ entry.guestName }} · {{ ttrSnapshot(entry.index).guestTtr }}</td><td>{{ formatTtrProbability(entry.preview.homeWin.expectedWinProbability) }}</td><td v-if="entry.actual">{{ entry.actual.winner === 'home' ? 'Heimsieg' : 'Gastsieg' }}: Heim {{ formatTtrChange(entry.actual.home.displayChange) }}, Gast {{ formatTtrChange(entry.actual.guest.displayChange) }}</td><td v-else>Heimsieg: {{ formatTtrChange(entry.preview.homeWin.displayChange) }} · Heimpleite: {{ formatTtrChange(entry.preview.homeLoss.displayChange) }}</td></tr></tbody></table>
|
||||
<tbody>
|
||||
<tr v-for="entry in ttrSingles" :key="`ttr-${entry.index}`">
|
||||
<td>{{ entry.index + 1 }}</td>
|
||||
<td>{{ entry.homeName }} · {{ entry.homeTtr ?? 'TTR fehlt' }}</td>
|
||||
<td>{{ entry.guestName }} · {{ entry.guestTtr ?? 'TTR fehlt' }}</td>
|
||||
<td>{{ entry.preview ? formatTtrProbability(entry.preview.homeWin.expectedWinProbability) : '—' }}</td>
|
||||
<td v-if="entry.actual">{{ entry.actual.winner === 'home' ? 'Heimsieg' : 'Gastsieg' }}: Heim {{ formatTtrChange(entry.actual.home.displayChange) }}, Gast {{ formatTtrChange(entry.actual.guest.displayChange) }}</td>
|
||||
<td v-else-if="entry.preview">Heimsieg: {{ formatTtrChange(entry.preview.homeWin.displayChange) }} · Heimniederlage: {{ formatTtrChange(entry.preview.homeLoss.displayChange) }}</td>
|
||||
<td v-else>Berechnung erst mit beiden TTR-Werten möglich.</td>
|
||||
</tr>
|
||||
<tr v-if="!ttrSingles.length"><td colspan="5">Noch keine Einzelpaarungen vorhanden. Bitte die Einzelaufstellungen prüfen.</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<p v-if="ttrUnavailablePlayers.length" class="ttr-preview-empty">Noch ohne Wert: {{ ttrUnavailablePlayers.join(', ') }}.</p>
|
||||
</div>
|
||||
@@ -1134,16 +1145,23 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
.filter(({ match }) => !this.isTtrDouble(match))
|
||||
.map(({ match, index }) => {
|
||||
const snapshot = this.ttrSnapshot(index);
|
||||
if (!Number.isFinite(Number(snapshot?.homeTtr)) || !Number.isFinite(Number(snapshot?.guestTtr))) return null;
|
||||
const entry = {
|
||||
index,
|
||||
homeName: match.homeName || 'Heim',
|
||||
guestName: match.guestName || 'Gast',
|
||||
homeTtr: this.isAvailableTtr(snapshot?.homeTtr) ? Number(snapshot.homeTtr) : null,
|
||||
guestTtr: this.isAvailableTtr(snapshot?.guestTtr) ? Number(snapshot.guestTtr) : null,
|
||||
preview: null,
|
||||
actual: null,
|
||||
};
|
||||
if (entry.homeTtr === null || entry.guestTtr === null) return entry;
|
||||
const homeWin = calculateTtrChange(snapshot.homeTtr, snapshot.guestTtr, 'win', snapshot.homeAdjustmentConstant);
|
||||
const homeLoss = calculateTtrChange(snapshot.homeTtr, snapshot.guestTtr, 'loss', snapshot.homeAdjustmentConstant);
|
||||
const guestWin = calculateTtrChange(snapshot.guestTtr, snapshot.homeTtr, 'win', snapshot.guestAdjustmentConstant);
|
||||
const guestLoss = calculateTtrChange(snapshot.guestTtr, snapshot.homeTtr, 'loss', snapshot.guestAdjustmentConstant);
|
||||
const winner = this.ttrWinner(match);
|
||||
return {
|
||||
index,
|
||||
homeName: match.homeName || 'Heim',
|
||||
guestName: match.guestName || 'Gast',
|
||||
...entry,
|
||||
preview: { homeWin, homeLoss, guestWin, guestLoss },
|
||||
actual: winner ? {
|
||||
winner,
|
||||
@@ -1151,10 +1169,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
guest: winner === 'guest' ? guestWin : guestLoss,
|
||||
} : null,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
});
|
||||
},
|
||||
ttrUnavailablePlayers() {
|
||||
return this.ttrPlayers.filter((player) => !Number.isFinite(Number(player.ttr))).map((player) => player.name);
|
||||
return this.ttrPlayers.filter((player) => !this.isAvailableTtr(player.ttr)).map((player) => player.name);
|
||||
},
|
||||
draftStatusState() {
|
||||
if (this.isDraftSyncing) {
|
||||
@@ -3291,6 +3309,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
: [];
|
||||
if (serverMatches.length > 0) {
|
||||
this.results = serverMatches.map((match, index) => ({
|
||||
matchType: match.matchType,
|
||||
label: (typeof match?.name === 'string' && match.name.trim()) || this.getMatchFormationLabel(match, index) || `Spiel ${index + 1}`,
|
||||
homeName: this.resolveMatchSide(match, index, 'home'),
|
||||
guestName: this.resolveMatchSide(match, index, 'guest'),
|
||||
@@ -3324,6 +3343,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
ttrSnapshot(index) {
|
||||
return this.ttrSnapshots[index] || null;
|
||||
},
|
||||
isAvailableTtr(value) {
|
||||
return value !== null && value !== undefined && String(value).trim() !== '' && Number.isFinite(Number(value));
|
||||
},
|
||||
normalizeTtrName(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE');
|
||||
},
|
||||
@@ -3349,21 +3371,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
homeTeamName: this.meetingData?.homeTeamname,
|
||||
guestTeamName: this.meetingData?.guestTeamname,
|
||||
});
|
||||
const resolvedByKey = new Map((response.data?.players || []).map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player]));
|
||||
const players = ['home', 'guest'].flatMap((side) => this.ttrLineupPlayers(side).map((player, index) => ({
|
||||
side,
|
||||
name: [player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '),
|
||||
nuLigaPersonId: player.nuLigaPersonId || null,
|
||||
...resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`),
|
||||
// Test-Aufstellungen sind Platzhalternamen. Nur nicht
|
||||
// über die API auflösbare Spieler erhalten Testwerte.
|
||||
ttr: Number.isFinite(Number(resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.ttr))
|
||||
? Number(resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.ttr)
|
||||
: (side === 'home' ? 1320 - index * 23 : 1345 - index * 19),
|
||||
adjustmentConstant: resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.adjustmentConstant || 16,
|
||||
source: resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.source || 'test',
|
||||
})));
|
||||
this.applyTtrPreviewPlayers(players);
|
||||
this.applyTtrPreviewPlayers(Array.isArray(response.data?.players) ? response.data.players : []);
|
||||
return;
|
||||
}
|
||||
if (!this.match?.id) {
|
||||
@@ -3391,14 +3399,22 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
if (this.isTtrDouble(match)) return;
|
||||
const home = index.get(`home:${this.normalizeTtrName(match.homeName)}`);
|
||||
const guest = index.get(`guest:${this.normalizeTtrName(match.guestName)}`);
|
||||
if (!Number.isFinite(Number(home?.ttr)) || !Number.isFinite(Number(guest?.ttr))) return;
|
||||
snapshots[resultIndex] = { homeTtr: Number(home.ttr), guestTtr: Number(guest.ttr), homeAdjustmentConstant: Number(home.adjustmentConstant || 16), guestAdjustmentConstant: Number(guest.adjustmentConstant || 16) };
|
||||
snapshots[resultIndex] = {
|
||||
homeTtr: this.isAvailableTtr(home?.ttr) ? Number(home.ttr) : null,
|
||||
guestTtr: this.isAvailableTtr(guest?.ttr) ? Number(guest.ttr) : null,
|
||||
homeAdjustmentConstant: Number(home?.adjustmentConstant || 16),
|
||||
guestAdjustmentConstant: Number(guest?.adjustmentConstant || 16),
|
||||
};
|
||||
});
|
||||
this.ttrSnapshots = snapshots;
|
||||
},
|
||||
isTtrDouble(match) {
|
||||
const label = String(match?.label || '').toUpperCase();
|
||||
return label.includes('D') || String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / ');
|
||||
const type = String(match?.matchType || '').toUpperCase();
|
||||
if (type === 'DOUBLE' || type === 'DOUBLES') return true;
|
||||
if (type === 'SINGLE' || type === 'SINGLES') return false;
|
||||
const label = String(match?.label || '');
|
||||
return /\b(?:DOPPEL|DOUBLES?)\b|(?:^|[\s–-])D[AB]?\d+(?=$|[\s–-])/i.test(label) ||
|
||||
String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / ');
|
||||
},
|
||||
ttrWinner(match) {
|
||||
if (!match?.completed) return null;
|
||||
|
||||
86
frontend/tests/ttrPreview.test.mjs
Normal file
86
frontend/tests/ttrPreview.test.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { calculateTtrChange } from '../src/services/ttrCalculator.mjs';
|
||||
|
||||
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 requests = [];
|
||||
const context = vm.createContext({ console, calculateTtrChange, apiClient: {
|
||||
async post(url, body) {
|
||||
requests.push({ url, body });
|
||||
return { data: { players: [] } };
|
||||
},
|
||||
} });
|
||||
vm.runInContext(script, context);
|
||||
const { methods, computed } = context.component;
|
||||
|
||||
function dialog() {
|
||||
return Object.assign({}, methods, {
|
||||
results: [
|
||||
{ label: 'D1-D1', homeName: 'Anton Schottdorf / Anna Wolf', guestName: 'Yumin Kim / Timo Engler' },
|
||||
{ label: '1. Einzel der Runde', homeName: 'Anton Schottdorf', guestName: 'Yumin Kim', completed: false },
|
||||
],
|
||||
ttrSnapshots: {},
|
||||
ttrPlayers: [],
|
||||
});
|
||||
}
|
||||
|
||||
test('singles with a D in their title are retained while double formation labels are excluded', () => {
|
||||
const report = dialog();
|
||||
assert.equal(report.isTtrDouble(report.results[0]), true);
|
||||
assert.equal(report.isTtrDouble(report.results[1]), false);
|
||||
assert.equal(report.isTtrDouble({ label: 'DA2 – DB2' }), true);
|
||||
assert.equal(report.isTtrDouble({ label: 'Doppel 1' }), true);
|
||||
assert.equal(report.isTtrDouble({ label: '3. Runde', matchType: 'SINGLE' }), false);
|
||||
});
|
||||
|
||||
test('missing TTR values retain the single and are not treated as zero', () => {
|
||||
const report = dialog();
|
||||
report.applyTtrPreviewPlayers([
|
||||
{ side: 'home', name: 'Anton Schottdorf', ttr: 1300 },
|
||||
{ side: 'guest', name: 'Yumin Kim', ttr: null },
|
||||
]);
|
||||
const rows = computed.ttrSingles.call(report);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].homeTtr, 1300);
|
||||
assert.equal(rows[0].guestTtr, null);
|
||||
assert.equal(rows[0].preview, null);
|
||||
assert.equal(computed.ttrUnavailablePlayers.call(report)[0], 'Yumin Kim');
|
||||
});
|
||||
|
||||
test('available ratings produce the preview and a completed single uses the entered result', () => {
|
||||
const report = dialog();
|
||||
report.applyTtrPreviewPlayers([
|
||||
{ side: 'home', name: 'Anton Schottdorf', ttr: 1300 },
|
||||
{ side: 'guest', name: 'Yumin Kim', ttr: 1300 },
|
||||
]);
|
||||
assert.equal(computed.ttrSingles.call(report)[0].preview.homeWin.displayChange, 8);
|
||||
report.results[1].completed = true;
|
||||
report.getPlayerSetWins = (_match, side) => side === 'home' ? 3 : 0;
|
||||
const row = computed.ttrSingles.call(report)[0];
|
||||
assert.equal(row.actual.home.displayChange, 8);
|
||||
assert.equal(row.actual.guest.displayChange, -8);
|
||||
});
|
||||
|
||||
test('standalone test mode calls the API and keeps real pairings without inventing ratings', async () => {
|
||||
const report = dialog();
|
||||
Object.assign(report, {
|
||||
standaloneTest: true,
|
||||
canOpenTtrPreview: true,
|
||||
meetingDetails: {},
|
||||
meetingData: {},
|
||||
$store: { getters: { currentClub: 1 } },
|
||||
ttrLineupPlayers: () => [],
|
||||
});
|
||||
await report.loadTtrPreview();
|
||||
assert.equal(requests.at(-1).url, '/matches/ttr-preview/standalone');
|
||||
assert.equal(report.ttrError, '');
|
||||
const rows = computed.ttrSingles.call(report);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].homeTtr, null);
|
||||
assert.equal(rows[0].preview, null);
|
||||
});
|
||||
Reference in New Issue
Block a user