From ecee9e0a006b56d3c8a3b4ae2068d4114a334ad2 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 28 Oct 2026 11:03:46 +0100 Subject: [PATCH] Resolve external opponent TTR ratings and show lookup diagnostics --- backend/clients/myTischtennisClient.js | 11 ++- backend/controllers/matchController.js | 84 +++------------- backend/services/ttrPreviewService.js | 69 +++++++++++++ backend/tests/ttrPreview.node.test.mjs | 98 +++++++++++++++++++ .../src/components/MatchReportApiDialog.vue | 20 +++- frontend/tests/ttrPreview.test.mjs | 37 +++++++ 6 files changed, 249 insertions(+), 70 deletions(-) create mode 100644 backend/services/ttrPreviewService.js create mode 100644 backend/tests/ttrPreview.node.test.mjs diff --git a/backend/clients/myTischtennisClient.js b/backend/clients/myTischtennisClient.js index d4c6cc7c..b3955b9f 100755 --- a/backend/clients/myTischtennisClient.js +++ b/backend/clients/myTischtennisClient.js @@ -869,6 +869,15 @@ class MyTischtennisClient { * @param {string} fedNickname - Federation nickname (e.g., "HeTTV") * @returns {Promise} Rankings with player entries (all pages) */ + async searchClubs(cookie, query) { + const result = await this.authenticatedRequest('/api/search/clubs', cookie, { + method: 'POST', data: new URLSearchParams({ query }).toString() + }); + if (!result.success) return result; + if (!Array.isArray(result.data?.results)) return { success: false, error: 'Ungültige Antwort der myTischtennis-Vereinssuche' }; + return { success: true, clubs: result.data.results }; + } + async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) { const { includeHistoryPlayerIds = false } = options; const allEntries = []; @@ -877,7 +886,7 @@ class MyTischtennisClient { while (hasMorePages) { - const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}¤t-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`; + const endpoint = `/rankings/andro-rangliste?all-players=on&continent=all&country=all&clubnr=${encodeURIComponent(clubId)}&fednickname=${encodeURIComponent(fedNickname)}¤t-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`; const result = await this.authenticatedRequest(endpoint, cookie, { method: 'GET' }); diff --git a/backend/controllers/matchController.js b/backend/controllers/matchController.js index 64ea2e43..34899a65 100755 --- a/backend/controllers/matchController.js +++ b/backend/controllers/matchController.js @@ -10,21 +10,8 @@ import myTischtennisService from '../services/myTischtennisService.js'; import myTischtennisClient from '../clients/myTischtennisClient.js'; import { Op } from 'sequelize'; import sequelize from '../database.js'; +import { resolveTtrPreview } from '../services/ttrPreviewService.js'; -const normalizeTtrName = (value) => String(value || '') - .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') - .replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE'); - -const playerName = (player) => [player?.firstname || player?.firstName, player?.lastname || player?.lastName] - .filter(Boolean).join(' ').trim(); - -const findClubForTeam = (clubs, teamName) => { - const teamKey = normalizeTtrName(teamName); - return clubs.find((club) => { - const clubKey = normalizeTtrName(club.name); - return clubKey && (teamKey === clubKey || teamKey.startsWith(`${clubKey} `) || clubKey.startsWith(`${teamKey} `)); - }) || null; -}; export const uploadCSV = async (req, res) => { try { const clubId = req.body?.clubId ?? req.query?.clubId; @@ -210,8 +197,7 @@ export const getMatchPlayers = async (req, res) => { /** * Resolves the current TTR snapshots for a nuScore lineup. Local member data - * is preferred. Missing opponent data is fetched from the club ranking when - * the opposing club is also known in the database. + * is preferred. Missing opponents are resolved through myTischtennis club search. */ export const getTtrPreviewPlayers = async (req, res) => { try { @@ -224,6 +210,7 @@ export const getTtrPreviewPlayers = async (req, res) => { await checkAccess(userToken, clubId); const snapshotDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date()); + let cachedPlayers = []; if (match) { const [cachedRows] = await sequelize.query( 'SELECT payload FROM match_ttr_preview_cache WHERE match_id = :matchId AND snapshot_date = :snapshotDate LIMIT 1', @@ -231,65 +218,26 @@ export const getTtrPreviewPlayers = async (req, res) => { ); if (cachedRows.length) { const payload = typeof cachedRows[0].payload === 'string' ? JSON.parse(cachedRows[0].payload) : cachedRows[0].payload; - return res.json({ ...payload, cached: true, snapshotDate }); + // Keep successful daily lookups, but retry missing players instead + // of caching an unsuccessful response until tomorrow. + cachedPlayers = Array.isArray(payload.players) ? payload.players : []; } } - const sides = ['home', 'guest']; - const lineup = Object.fromEntries(sides.map((side) => [side, Array.isArray(req.body?.[`${side}Players`]) ? req.body[`${side}Players`] : []])); - const allPlayers = sides.flatMap((side) => lineup[side].map((player) => ({ side, player }))); const members = await Member.findAll({ where: { ttr: { [Op.ne]: null } } }); - const byExternalId = new Map(); - const byName = new Map(); - members.forEach((member) => { - if (member.myTischtennisPlayerId) byExternalId.set(String(member.myTischtennisPlayerId), member); - const key = normalizeTtrName(`${member.firstName} ${member.lastName}`); - if (key && !byName.has(key)) byName.set(key, member); - else if (key) byName.set(key, null); - }); - - const resolved = new Map(); - for (const { side, player } of allPlayers) { - const id = player?.nuLigaPersonId || player?.personId || player?.id; - const member = (id && byExternalId.get(String(id))) || byName.get(normalizeTtrName(playerName(player))); - if (member?.ttr != null) { - resolved.set(`${side}:${normalizeTtrName(playerName(player))}`, { - ttr: Number(member.ttr), adjustmentConstant: Number(member.ttrAdjustmentConstant || 16), source: 'database' - }); - } - } - - const clubs = await Club.findAll({ where: { associationMemberNumber: { [Op.ne]: null }, myTischtennisFedNickname: { [Op.ne]: null } } }); - let session = null; - for (const side of sides) { - const pending = lineup[side].filter((player) => !resolved.has(`${side}:${normalizeTtrName(playerName(player))}`)); - if (!pending.length) continue; - const club = findClubForTeam(clubs, req.body?.[`${side}TeamName`]); - if (!club) continue; - try { - session ||= await myTischtennisService.getSession(req.user.id); - const rankings = await myTischtennisClient.getClubRankings(session.cookie, club.associationMemberNumber, club.myTischtennisFedNickname); - if (!rankings.success) continue; - const rankingByName = new Map(rankings.entries.map((entry) => [normalizeTtrName(`${entry.firstname} ${entry.lastname}`), entry])); - pending.forEach((player) => { - const entry = rankingByName.get(normalizeTtrName(playerName(player))); - if (Number.isFinite(Number(entry?.fedRank))) { - resolved.set(`${side}:${normalizeTtrName(playerName(player))}`, { ttr: Number(entry.fedRank), adjustmentConstant: 16, source: 'myTischtennis' }); - } - }); - } catch (_error) { - // Keep the player explicitly unresolved; the report remains usable. - } - } - - const payload = { players: allPlayers.map(({ side, player }) => { - const value = resolved.get(`${side}:${normalizeTtrName(playerName(player))}`); - return { side, name: playerName(player), nuLigaPersonId: player?.nuLigaPersonId || null, ttr: value?.ttr ?? null, adjustmentConstant: value?.adjustmentConstant ?? null, source: value?.source || 'unavailable' }; - }) }; + const clubs = await Club.findAll(); + const payload = await resolveTtrPreview({ + homePlayers: Array.isArray(req.body?.homePlayers) ? req.body.homePlayers : [], + guestPlayers: Array.isArray(req.body?.guestPlayers) ? req.body.guestPlayers : [], + homeTeamName: req.body?.homeTeamName, guestTeamName: req.body?.guestTeamName, + homeClubName: req.body?.homeClubName, guestClubName: req.body?.guestClubName, + members, clubs, cachedPlayers, + }, { getSession: () => myTischtennisService.getSession(req.user.id), client: myTischtennisClient }); if (match) { await sequelize.query( `INSERT INTO match_ttr_preview_cache (match_id, snapshot_date, payload, created_at, updated_at) - VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())`, + VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW()) + ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at = NOW()`, { replacements: { matchId: match.id, snapshotDate, payload: JSON.stringify(payload) } } ); } diff --git a/backend/services/ttrPreviewService.js b/backend/services/ttrPreviewService.js new file mode 100644 index 00000000..945720c9 --- /dev/null +++ b/backend/services/ttrPreviewService.js @@ -0,0 +1,69 @@ +export const normalizeTtrName = value => String(value || '').normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss') + .replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE'); + +const nameOf = player => [player?.firstname || player?.firstName, player?.lastname || player?.lastName].filter(Boolean).join(' ').trim() || String(player?.name || '').trim(); +const hasRating = value => value !== null && value !== undefined && String(value).trim() !== '' && Number.isFinite(Number(value)); +export const clubNameFromTeam = name => String(name || '').replace(/\s*\([^)]*\)/g, '') + .replace(/\s+(?:[IVXL]+|[1-9])\s*$/i, '').trim(); +const matchesClub = (name, club) => normalizeTtrName(clubNameFromTeam(name)) === normalizeTtrName(club); + +/** Dependencies are injected so the real lookup path can be tested without credentials. */ +export async function resolveTtrPreview({ homePlayers = [], guestPlayers = [], homeTeamName, guestTeamName, + homeClubName, guestClubName, members = [], clubs = [], cachedPlayers = [] }, { getSession, client }) { + const players = []; + const diagnostics = []; + let sessionPromise; + const session = () => (sessionPromise ||= getSession()); + for (const side of ['home', 'guest']) { + const lineup = side === 'home' ? homePlayers : guestPlayers; + const teamName = side === 'home' ? homeTeamName : guestTeamName; + const clubName = (side === 'home' ? homeClubName : guestClubName) || clubNameFromTeam(teamName); + const localClub = clubs.find(club => matchesClub(teamName, club.name) || matchesClub(clubName, club.name)); + const sidePlayers = lineup.map(player => { + const name = nameOf(player); + // nuLigaPersonId and myTischtennisPlayerId are different ID namespaces. + const candidates = members.filter(member => hasRating(member.ttr) && + normalizeTtrName(`${member.firstName} ${member.lastName}`) === normalizeTtrName(name) && + (!localClub || Number(member.clubId) === Number(localClub.id))); + const member = candidates.length === 1 ? candidates[0] : null; + const cached = cachedPlayers.find(entry => entry.side === side && normalizeTtrName(entry.name) === normalizeTtrName(name) && + String(entry.nuLigaPersonId || '') === String(player.nuLigaPersonId || '') && hasRating(entry.ttr)); + return { side, name, nuLigaPersonId: player.nuLigaPersonId || null, + ttr: member ? Number(member.ttr) : cached ? Number(cached.ttr) : null, + adjustmentConstant: Number(member?.ttrAdjustmentConstant || cached?.adjustmentConstant || 16), + source: member ? 'database' : cached ? cached.source : 'unavailable' }; + }); + const pending = sidePlayers.filter(player => !hasRating(player.ttr)); + if (pending.length) { + try { + const activeSession = await session(); + let clubNr = localClub?.associationMemberNumber; + let federation = localClub?.myTischtennisFedNickname; + if (!clubNr || !federation) { + if (!clubName) throw new Error('Vereinsname fehlt in den Spielberichtsdaten.'); + const search = await client.searchClubs(activeSession.cookie, clubNameFromTeam(clubName)); + if (!search.success) throw new Error(search.error || 'Vereinssuche fehlgeschlagen.'); + const matches = search.clubs.filter(club => matchesClub(clubName, club.clubname) || matchesClub(teamName, club.clubname)); + if (matches.length !== 1) throw new Error(matches.length ? 'Verein nicht eindeutig gefunden.' : 'Verein bei myTischtennis nicht gefunden.'); + clubNr = matches[0].clubnr; + federation = matches[0].organization_short; + if (!clubNr || !federation) throw new Error('Vereinsnummer oder Verbandskürzel fehlt im Suchergebnis.'); + } + const ranking = await client.getClubRankings(activeSession.cookie, clubNr, federation); + if (!ranking.success) throw new Error(ranking.error || 'Ranglistenabruf fehlgeschlagen.'); + for (const player of pending) { + const entries = ranking.entries.filter(entry => normalizeTtrName(nameOf(entry)) === normalizeTtrName(player.name) && hasRating(entry.fedRank)); + if (entries.length === 1) Object.assign(player, { ttr: Number(entries[0].fedRank), source: 'myTischtennis' }); + } + const missing = pending.filter(player => !hasRating(player.ttr)); + if (missing.length) diagnostics.push({ side, code: 'players_not_found', + message: `${clubName || teamName}: Kein eindeutiger aktueller TTR für ${missing.map(player => player.name).join(', ')} in der Rangliste gefunden.` }); + } catch (error) { + diagnostics.push({ side, code: 'lookup_failed', message: `${clubName || teamName || side}: ${error.message}` }); + } + } + players.push(...sidePlayers); + } + return { players, diagnostics }; +} diff --git a/backend/tests/ttrPreview.node.test.mjs b/backend/tests/ttrPreview.node.test.mjs new file mode 100644 index 00000000..9394d439 --- /dev/null +++ b/backend/tests/ttrPreview.node.test.mjs @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveTtrPreview, clubNameFromTeam } from '../services/ttrPreviewService.js'; + +const anna = { firstname: 'Anna', lastname: 'Wolf', nuLigaPersonId: 123 }; +function setup(overrides = {}) { + const calls = []; + const input = { homePlayers: [anna], guestPlayers: [], homeTeamName: 'TSV 1875 Bonames (J15)', ...overrides }; + const dependencies = { + getSession: async () => ({ cookie: 'test-cookie' }), + client: { + searchClubs: async (_cookie, query) => { + calls.push(['search', query]); + return { success: true, clubs: [{ clubname: 'TSV 1875 Bonames', clubnr: '43002', organization_short: 'HeTTV' }] }; + }, + getClubRankings: async (_cookie, club, fed) => { + calls.push(['rankings', club, fed]); + return { success: true, entries: [{ ...anna, fedRank: 680 }] }; + }, + }, + }; + return { input, dependencies, calls }; +} + +test('resolves an external opponent without any local club through search and ranking', async () => { + const { input, dependencies, calls } = setup(); + const result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, 680); + assert.equal(result.players[0].source, 'myTischtennis'); + assert.deepEqual(calls, [['search', 'TSV 1875 Bonames'], ['rankings', '43002', 'HeTTV']]); + assert.deepEqual(result.diagnostics, []); + assert.equal(clubNameFromTeam('Eintracht Frankfurt VI (J15)'), 'Eintracht Frankfurt'); +}); + +test('uses stored ratings from both clubs without requiring a myTischtennis session', async () => { + const { input, dependencies, calls } = setup({ guestPlayers: [{ firstname: 'Yumin', lastname: 'Kim' }], + guestTeamName: 'Eintracht Frankfurt VI (J15)', + clubs: [{ id: 1, name: 'TSV 1875 Bonames' }, { id: 2, name: 'Eintracht Frankfurt' }], + members: [{ clubId: 1, firstName: 'Anna', lastName: 'Wolf', ttr: 680 }, + { clubId: 2, firstName: 'Yumin', lastName: 'Kim', ttr: 900 }] }); + dependencies.getSession = () => { throw new Error('must not log in'); }; + const result = await resolveTtrPreview(input, dependencies); + assert.deepEqual(result.players.map(p => p.ttr), [680, 900]); + assert.equal(calls.length, 0); +}); + +test('an expired session is visible and does not erase already resolved local players', async () => { + const { input, dependencies } = setup({ guestPlayers: [{ firstname: 'Yumin', lastname: 'Kim' }], + members: [{ firstName: 'Anna', lastName: 'Wolf', ttr: 680 }] }); + dependencies.getSession = async () => { throw new Error('Session abgelaufen. Bitte erneut einloggen.'); }; + const result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, 680); + assert.equal(result.players[1].ttr, null); + assert.match(result.diagnostics[0].message, /Session abgelaufen/); +}); + +test('missing or ambiguous ranking values are not converted to zero or guessed', async () => { + const { input, dependencies } = setup(); + dependencies.client.getClubRankings = async () => ({ success: true, entries: [{ ...anna, fedRank: null }] }); + let result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, null); + assert.equal(result.diagnostics[0].code, 'players_not_found'); + dependencies.client.getClubRankings = async () => ({ success: true, entries: [{ ...anna, fedRank: 680 }, { ...anna, fedRank: 900 }] }); + result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, null); +}); + +test('daily cached values skip external calls but missing cached players can be retried', async () => { + const { input, dependencies, calls } = setup({ cachedPlayers: [{ side: 'home', name: 'Anna Wolf', nuLigaPersonId: 123, ttr: 670, source: 'myTischtennis' }] }); + let result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, 670); + assert.equal(calls.length, 0); + input.cachedPlayers[0].ttr = null; + result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, 680); + assert.equal(calls.length, 2); +}); + +test('nuLiga and myTischtennis numeric IDs cannot accidentally match different players', async () => { + const { input, dependencies } = setup({ members: [{ myTischtennisPlayerId: 123, firstName: 'Other', lastName: 'Player', ttr: 2000 }] }); + const result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, 680); +}); + +test('configured clubs bypass search and failures report their actual cause', async () => { + const { input, dependencies, calls } = setup({ clubs: [{ name: 'TSV 1875 Bonames', associationMemberNumber: '43002', myTischtennisFedNickname: 'HeTTV' }] }); + dependencies.client.getClubRankings = async () => ({ success: false, error: 'Zugriff auf aktuelle Rangliste verweigert' }); + const result = await resolveTtrPreview(input, dependencies); + assert.equal(calls.length, 0); + assert.match(result.diagnostics[0].message, /Zugriff auf aktuelle Rangliste verweigert/); +}); + +test('displayed pairings can resolve ratings without nuScore lineup metadata', async () => { + const { input, dependencies } = setup({ homePlayers: [{ name: 'Anna Wolf' }] }); + const result = await resolveTtrPreview(input, dependencies); + assert.equal(result.players[0].ttr, 680); + assert.equal(result.players[0].name, 'Anna Wolf'); +}); diff --git a/frontend/src/components/MatchReportApiDialog.vue b/frontend/src/components/MatchReportApiDialog.vue index d263a8fb..c1713c5d 100755 --- a/frontend/src/components/MatchReportApiDialog.vue +++ b/frontend/src/components/MatchReportApiDialog.vue @@ -640,6 +640,8 @@

Noch ohne Wert: {{ ttrUnavailablePlayers.join(', ') }}.

+

{{ ttrPlayers.length - ttrUnavailablePlayers.length }} von {{ ttrPlayers.length }} TTR-Werten geladen.

+

{{ diagnostic.message }}

@@ -954,6 +956,7 @@ export default { ttrPlayers: [], ttrLoading: false, ttrError: '', + ttrDiagnostics: [], // Fehlermeldungen für Satzeingaben errors: [], // Aktive Zelle der schwebenden Satz-Tastatur: { matchIndex, setIndex } oder null @@ -3351,7 +3354,15 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr }, ttrLineupPlayers(side) { const key = side === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers'; - return (this.meetingDetails?.[key] || []).filter((player) => player?.isSelected && player?.nuLigaPersonId > 0 && this.isSingleLineupPlayer(player)); + const lineup = this.meetingDetails?.[key] || []; + // The displayed singles are authoritative, including older nuScore + // lineups without positionSingle/isSelected metadata. + const names = [...new Set(this.results.filter(match => !this.isTtrDouble(match)) + .map(match => side === 'home' ? match.homeName : match.guestName).filter(Boolean))]; + if (names.length) return names.map(name => lineup.find(player => + this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' ')) === this.normalizeTtrName(name) + ) || { name }); + return lineup.filter((player) => player?.isSelected && player?.nuLigaPersonId > 0 && this.isSingleLineupPlayer(player)); }, async openTtrSection() { if (!this.canOpenTtrPreview) return; @@ -3362,6 +3373,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr if (!this.canOpenTtrPreview || !this.meetingDetails) return; this.ttrLoading = true; this.ttrError = ''; + this.ttrDiagnostics = []; try { if (this.standaloneTest) { const response = await apiClient.post('/matches/ttr-preview/standalone', { @@ -3370,7 +3382,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr guestPlayers: this.ttrLineupPlayers('guest'), homeTeamName: this.meetingData?.homeTeamname, guestTeamName: this.meetingData?.guestTeamname, + homeClubName: this.meetingData?.homeClub, + guestClubName: this.meetingData?.guestClub, }); + this.ttrDiagnostics = response.data?.diagnostics || []; this.applyTtrPreviewPlayers(Array.isArray(response.data?.players) ? response.data.players : []); return; } @@ -3383,7 +3398,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr guestPlayers: this.ttrLineupPlayers('guest'), homeTeamName: this.meetingData?.homeTeamname, guestTeamName: this.meetingData?.guestTeamname, + homeClubName: this.meetingData?.homeClub, + guestClubName: this.meetingData?.guestClub, }); + this.ttrDiagnostics = response.data?.diagnostics || []; this.applyTtrPreviewPlayers(Array.isArray(response.data?.players) ? response.data.players : []); } catch (error) { this.ttrError = error.response?.data?.error || 'TTR-Werte konnten nicht geladen werden.'; diff --git a/frontend/tests/ttrPreview.test.mjs b/frontend/tests/ttrPreview.test.mjs index 1ea83bb6..77991e2c 100644 --- a/frontend/tests/ttrPreview.test.mjs +++ b/frontend/tests/ttrPreview.test.mjs @@ -84,3 +84,40 @@ test('standalone test mode calls the API and keeps real pairings without inventi assert.equal(rows[0].homeTtr, null); assert.equal(rows[0].preview, null); }); + +test('TTR requests include displayed singles even when legacy lineup metadata is missing', () => { + const report = dialog(); + report.meetingDetails = { teamLineupHomePlayers: [{ firstname: 'Anton', lastname: 'Schottdorf', nuLigaPersonId: 123 }] }; + const home = report.ttrLineupPlayers('home'); + assert.equal(home.length, 1); + assert.equal(home[0].nuLigaPersonId, 123); + const guest = report.ttrLineupPlayers('guest'); + assert.equal(guest.length, 1); + assert.equal(guest[0].name, 'Yumin Kim'); +}); + +test('test mode on a saved report calls the real endpoint and exposes lookup diagnostics', async () => { + const report = dialog(); + Object.assign(report, { + standaloneTest: false, + isTestMode: true, + canOpenTtrPreview: true, + match: { id: 42 }, + meetingDetails: {}, + meetingData: { homeClub: 'TSV 1875 Bonames', guestClub: 'Eintracht Frankfurt' }, + }); + const originalPost = context.apiClient.post; + context.apiClient.post = async (url, body) => { + assert.equal(url, '/matches/42/ttr-preview'); + assert.equal(body.homeClubName, 'TSV 1875 Bonames'); + assert.equal(body.homePlayers[0].name, 'Anton Schottdorf'); + return { data: { players: [], diagnostics: [{ side: 'home', message: 'Session abgelaufen' }] } }; + }; + try { + await report.loadTtrPreview(); + assert.equal(report.ttrDiagnostics[0].message, 'Session abgelaufen'); + assert.equal(report.ttrLoading, false); + } finally { + context.apiClient.post = originalPost; + } +});