From eb17177f771767213a340a3f8af67078e8382493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 18 May 2026 13:07:02 +0900 Subject: [PATCH] Add starting lineup support and enhance pre-game data handling. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KBO 경기 상세 정보에 선발 및 예상 라인업 정보를 포함하고 CLI에서 이를 확인할 수 있는 출력 기능을 추가했습니다. - 경기가 시작되기 전 KBO API가 빈 데이터를 반환하는 상황(code 200)을 예외 처리하고, 경기 상태에 따라 null을 반환하도록 개선했습니다. - 종료된 경기는 박스스코어에서 선발 명단을 추출하고, 시작 전 경기는 라인업 분석 API를 호출하는 이원화된 데이터 수집 로직을 구현했습니다. - 예상 라인업이 확정으로 전환되는 시점을 빠르게 반영하기 위해 라인업 출처에 따라 캐시 TTL을 동적으로 조절하는 기능을 도입했습니다. - 라인업 파싱 및 경기 시작 전후의 데이터 처리 로직에 대한 검증을 위해 서비스 레이어 테스트를 업데이트했습니다. --- src/kbo/cli.ts | 71 ++++-- src/kbo/game-detail.ts | 296 ++++++++++++++++++++--- src/services/gameDetailService.ts | 30 ++- src/types/kbo.ts | 4 + tests/services/gameDetailService.test.ts | 53 ++-- 5 files changed, 387 insertions(+), 67 deletions(-) diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts index d82bbe2..643e68b 100644 --- a/src/kbo/cli.ts +++ b/src/kbo/cli.ts @@ -49,7 +49,7 @@ import { type GameListRecord, type GameListResult, } from "./game-list"; -import { fetchGameDetail, type GameDetail } from "./game-detail"; +import { fetchGameDetail, type GameDetail, type Lineup } from "./game-detail"; const PLAYER_CONFIGS: Record = { hitter: HITTER_CONFIG, @@ -303,6 +303,15 @@ function printGameListTable(result: GameListResult) { } function printGameDetail(d: GameDetail) { + if (!d.scoreBoard) { + console.log(`\n${"═".repeat(80)}`); + console.log(` ${d.gameId} (시작 전 — KBO 점수판 데이터 없음)`); + console.log(`${"═".repeat(80)}`); + if (d.lineup) { + printLineup(d.lineup); + } + return; + } const { meta, innings, totals, maxInning } = d.scoreBoard; console.log(`\n${"═".repeat(80)}`); console.log( @@ -341,20 +350,44 @@ function printGameDetail(d: GameDetail) { console.log(" " + awayRow.map((c) => padCell(c, 4)).join("")); console.log(" " + homeRow.map((c) => padCell(c, 4)).join("")); - if (d.boxScore.events.length > 0) { - console.log(`\n [기록]`); + if (d.boxScore && d.boxScore.events.length > 0) { + console.log("\n [기록]"); for (const e of d.boxScore.events) { console.log(` ${e.label}: ${e.value}`); } } - console.log(`\n [키플레이어 - 타자 (WPA)]`); - for (const p of d.keyPlayer.hitter.items) { - console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`); + if (d.keyPlayer.hitter) { + console.log("\n [키플레이어 - 타자 (WPA)]"); + for (const p of d.keyPlayer.hitter.items) { + console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`); + } } - console.log(`\n [키플레이어 - 투수 (WPA)]`); - for (const p of d.keyPlayer.pitcher.items) { - console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`); + if (d.keyPlayer.pitcher) { + console.log("\n [키플레이어 - 투수 (WPA)]"); + for (const p of d.keyPlayer.pitcher.items) { + console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`); + } + } + + if (d.lineup) { + printLineup(d.lineup); + } +} + +function printLineup(l: Lineup) { + const srcLabel = + l.source === "boxscore" ? "출장 라인업" : + l.source === "preview-announced" ? "선발 라인업" : + "예상 라인업(직전 경기 기준)"; + console.log(`\n [라인업 — ${srcLabel}]`); + for (const team of [l.away, l.home] as const) { + const tag = team === l.away ? "원정" : "홈"; + console.log(` ${tag} ${team.teamName} (출처 경기 ${team.sourceGameId})`); + for (const s of team.slots) { + const war = s.seasonWar == null ? "" : ` WAR ${s.seasonWar.toFixed(2)}`; + console.log(` ${s.batOrder}. ${padCell(s.position, 6)} ${s.name}${war}`); + } } } @@ -383,9 +416,9 @@ async function gamesCommand( console.log(`Fetching KBO game list for ${date}...\n`); const resolvedSeries = series ? (SERIES_CODES[series] ?? series) : undefined; - const resolvedLeague = league - ? (LEAGUE_CODES[league] ?? (league as LeagueCode)) - : undefined; + const resolvedLeague = league ? + (LEAGUE_CODES[league] ?? (league as LeagueCode)) : + undefined; const result = await fetchGameList({ date, @@ -411,9 +444,9 @@ async function scheduleCommand( day?: number ) { const monthStr = String(month).padStart(2, "0"); - const label = day != null - ? `${year}-${monthStr}-${String(day).padStart(2, "0")}` - : `${year}-${monthStr}`; + const label = day != null ? + `${year}-${monthStr}-${String(day).padStart(2, "0")}` : + `${year}-${monthStr}`; console.log(`Fetching KBO schedule for ${label}...\n`); const resolvedTeam = team ? (TEAM_CODES[team] ?? team) : undefined; @@ -426,16 +459,16 @@ async function scheduleCommand( team: resolvedTeam, }); - const result = day != null - ? { + const result = day != null ? + { ...monthResult, day, games: monthResult.games.filter((g) => { const m = g.date.match(/^(\d{2})\.(\d{2})$/); return m ? parseInt(m[2], 10) === day : false; }), - } - : monthResult; + } : + monthResult; console.log(` ${result.games.length} games found`); diff --git a/src/kbo/game-detail.ts b/src/kbo/game-detail.ts index 7c6a991..1a2908d 100644 --- a/src/kbo/game-detail.ts +++ b/src/kbo/game-detail.ts @@ -34,15 +34,15 @@ interface ResolvedFilters { function resolveFilters(f: GameDetailFilters): ResolvedFilters { const leId = - typeof f.league === "number" - ? f.league - : Number(LEAGUE_CODES[f.league ?? ""] ?? f.league ?? LeagueCode.KBO); + typeof f.league === "number" ? + f.league : + Number(LEAGUE_CODES[f.league ?? ""] ?? f.league ?? LeagueCode.KBO); const srId = - typeof f.series === "number" - ? f.series - : f.series != null && f.series !== "" - ? (SERIES_ID[String(f.series)] ?? Number(f.series)) - : 0; + typeof f.series === "number" ? + f.series : + f.series != null && f.series !== "" ? + (SERIES_ID[String(f.series)] ?? Number(f.series)) : + 0; const seasonId = f.season ?? Number(f.gameId.slice(0, 4)); if (!Number.isFinite(leId) || !Number.isFinite(srId) || !Number.isFinite(seasonId)) { throw new Error( @@ -126,6 +126,11 @@ function toInt(s: string | undefined): number | null { return Number.isFinite(n) ? n : null; } +// 시작 전 경기는 KBO가 code:"200"로 빈 응답을 내려준다. 진행/종료는 "100". +function isEmptyResponse(raw: { code?: string } | null | undefined): boolean { + return raw?.code === "200"; +} + // ── 1. ScoreBoardScroll ── interface RawScoreBoardScroll { @@ -158,7 +163,9 @@ interface RawScoreBoardScroll { table1?: string; table2?: string; table3?: string; - maxInning: number; + maxInning?: number; + code?: string; + msg?: string; } export interface ScoreBoardMeta { @@ -270,18 +277,19 @@ export function parseScoreBoardScroll(raw: RawScoreBoardScroll): ScoreBoardScrol away: parseTotalsRow(t3.rows[0]?.cells ?? []), home: parseTotalsRow(t3.rows[1]?.cells ?? []), }, - maxInning: raw.maxInning, + maxInning: raw.maxInning ?? 0, }; } export async function fetchScoreBoardScroll( filters: GameDetailFilters -): Promise { +): Promise { const { gameId, leId, srId, seasonId } = resolveFilters(filters); const raw = await postJson( "GetScoreBoardScroll", bodyOf({ leId, srId, seasonId, gameId }) ); + if (isEmptyResponse(raw)) return null; return parseScoreBoardScroll(raw); } @@ -291,8 +299,10 @@ interface RawBoxScoreScroll { tableEtc?: string; arrHitter?: { table1?: string; table2?: string; table3?: string }[]; arrPitcher?: { table?: string }[]; - maxInning: number; - realMaxInning: number; + maxInning?: number; + realMaxInning?: number; + code?: string; + msg?: string; } export interface BoxScoreEtc { @@ -467,19 +477,20 @@ export function parseBoxScoreScroll(raw: RawBoxScoreScroll): BoxScoreScroll { events: parseEvents(raw.tableEtc), away: buildTeam(hitters[0], pitchers[0], maxInning), home: buildTeam(hitters[1], pitchers[1], maxInning), - maxInning: raw.maxInning, - realMaxInning: raw.realMaxInning, + maxInning: raw.maxInning ?? 0, + realMaxInning: raw.realMaxInning ?? 0, }; } export async function fetchBoxScoreScroll( filters: GameDetailFilters -): Promise { +): Promise { const { gameId, leId, srId, seasonId } = resolveFilters(filters); const raw = await postJson( "GetBoxScoreScroll", bodyOf({ leId, srId, seasonId, gameId }) ); + if (isEmptyResponse(raw)) return null; return parseBoxScoreScroll(raw); } @@ -544,7 +555,7 @@ function parseKeyPlayer(raw: RawKeyPlayer, group: KeyPlayerGroup): KeyPlayerRank async function fetchKeyPlayer( path: "GetKeyPlayerHitter" | "GetKeyPlayerPitcher", options: KeyPlayerOptions -): Promise { +): Promise { const { gameId, leId, srId } = resolveFilters(options); const groupSc = options.groupSc ?? "GAME_WPA_RT"; const sort = options.sort ?? "DESC"; @@ -552,27 +563,239 @@ async function fetchKeyPlayer( path, bodyOf({ leId, srId, gameId, groupSc, sort }) ); + if (isEmptyResponse(raw)) return null; return parseKeyPlayer(raw, groupSc); } -export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise { +export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise { return fetchKeyPlayer("GetKeyPlayerHitter", options); } -export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise { +export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise { return fetchKeyPlayer("GetKeyPlayerPitcher", options); } -// ── 4. 통합 GameDetail ── +// ── 4. LineUp Analysis (시작 전 라인업 / 직전 경기 fallback) ── + +interface RawLineupCk { + LINEUP_CK?: boolean; +} + +interface RawLineupTeamMeta { + LE_ID?: number; + SR_ID?: number; + SEASON_ID?: number; + T_ID?: string; + T_NM?: string; + T_EMBLEM_LK?: string; + T_INITIAL_LK?: string; + HITTER_12_WAR_RT?: string; + HITTER_35_WAR_RT?: string; + HITTER_69_WAR_RT?: string; + G_ID?: string; +} + +// 응답은 인덱스 키("0".."4")를 가진 객체. 각 슬롯은 1행짜리 배열로 감싸져 있다. +// "0": [{ LINEUP_CK }] +// "1": [홈팀 메타] +// "2": [원정팀 메타] +// "3": [홈 라인업 테이블 JSON 문자열] +// "4": [원정 라인업 테이블 JSON 문자열] +type RawLineupAnalysis = { + "0"?: RawLineupCk[]; + "1"?: RawLineupTeamMeta[]; + "2"?: RawLineupTeamMeta[]; + "3"?: string[]; + "4"?: string[]; + code?: string; + msg?: string; +}; + +export type LineupSource = "boxscore" | "preview-announced" | "preview-estimated"; + +export interface LineupSlot { + /** 타순 1~9. */ + batOrder: number; + /** 풀네임 ("유격수", "좌익수"). BoxScore 출처는 약어를 풀네임으로 확장한 값. */ + position: string; + name: string; + /** 시즌 WAR. preview 출처에서만 채워짐. boxscore 출처는 null. */ + seasonWar: number | null; +} + +export interface LineupTeam { + teamId: string; + teamName: string; + /** 작은 엠블럼 URL. */ + emblem: string; + /** announced=false면 직전 경기 ID(라인업 출처). 그 외엔 요청한 경기 자체. */ + sourceGameId: string; + slots: LineupSlot[]; +} + +export interface Lineup { + /** 데이터 출처. 클라이언트 UI 라벨 결정용. */ + source: LineupSource; + /** true=확정 라인업, false=직전 경기 fallback 추정. boxscore 출처는 항상 true. */ + announced: boolean; + home: LineupTeam; + away: LineupTeam; +} + +// 약어 → 풀네임. BoxScore의 시작 라인업 추출 시 사용. 약어 앞에 붙은 교체 prefix +// (타/주/대/교) 가 보이면 떼고 매핑한다. 매핑 실패는 원본 그대로 반환. +const POSITION_FULL_NAMES: Record = { + 유: "유격수", + 좌: "좌익수", + 중: "중견수", + 우: "우익수", + 一: "1루수", + 二: "2루수", + 三: "3루수", + 포: "포수", + 지: "지명타자", + 투: "투수", +}; + +function expandPosition(abbr: string): string { + if (!abbr) return abbr; + const cleaned = abbr.replace(/^[타주대교]/, ""); + return POSITION_FULL_NAMES[cleaned] ?? abbr; +} + +function parseLineupTable(tableJsonStr: string | undefined): LineupSlot[] { + const t = parseTableJson(tableJsonStr); + const slots: LineupSlot[] = []; + for (const r of t.rows) { + const cells = r.cells; + const order = parseInt(cells[0] ?? "", 10); + if (!Number.isFinite(order)) continue; + slots.push({ + batOrder: order, + position: cells[1] ?? "", + name: cells[2] ?? "", + seasonWar: toFloat(cells[3]), + }); + } + return slots; +} + +export function parseLineupAnalysis(raw: RawLineupAnalysis): Lineup | null { + const flag = raw?.["0"]?.[0]?.LINEUP_CK; + const homeMeta = raw?.["1"]?.[0]; + const awayMeta = raw?.["2"]?.[0]; + if (flag === undefined || !homeMeta || !awayMeta) return null; + const homeSlots = parseLineupTable(raw["3"]?.[0]); + const awaySlots = parseLineupTable(raw["4"]?.[0]); + const announced = flag; + return { + source: announced ? "preview-announced" : "preview-estimated", + announced, + home: { + teamId: homeMeta.T_ID ?? "", + teamName: homeMeta.T_NM ?? "", + emblem: homeMeta.T_INITIAL_LK ?? "", + sourceGameId: homeMeta.G_ID ?? "", + slots: homeSlots, + }, + away: { + teamId: awayMeta.T_ID ?? "", + teamName: awayMeta.T_NM ?? "", + emblem: awayMeta.T_INITIAL_LK ?? "", + sourceGameId: awayMeta.G_ID ?? "", + slots: awaySlots, + }, + }; +} + +export async function fetchLineupAnalysis( + filters: GameDetailFilters +): Promise { + const { gameId, leId, srId, seasonId } = resolveFilters(filters); + const raw = await postJson( + "GetLineUpAnalysis", + bodyOf({ leId, srId, seasonId, gameId }) + ); + return parseLineupAnalysis(raw); +} + +// BoxScore.hitters는 교체 출장까지 포함한 전체 출장자 목록. 동일 batOrder 슬롯의 +// 첫 등장이 선발. 9명까지 수집해 타순 오름차순으로 반환한다. +function extractStartersFromHitters(hitters: BoxHitter[]): LineupSlot[] { + const seen = new Set(); + const slots: LineupSlot[] = []; + for (const h of hitters) { + const order = parseInt(h.order, 10); + if (!Number.isFinite(order) || seen.has(order)) continue; + seen.add(order); + slots.push({ + batOrder: order, + position: expandPosition(h.position), + name: h.name, + seasonWar: null, + }); + } + return slots.sort((a, b) => a.batOrder - b.batOrder); +} + +function buildLineupFromBoxScore( + boxScore: BoxScoreScroll, + scoreBoard: ScoreBoardScroll | null, + gameId: string +): Lineup { + const sb = scoreBoard?.meta; + return { + source: "boxscore", + announced: true, + home: { + teamId: sb?.homeTeam.id ?? "", + teamName: sb?.homeTeam.name ?? "", + emblem: sb?.homeTeam.emblem ?? "", + sourceGameId: gameId, + slots: extractStartersFromHitters(boxScore.home.hitters), + }, + away: { + teamId: sb?.awayTeam.id ?? "", + teamName: sb?.awayTeam.name ?? "", + emblem: sb?.awayTeam.emblem ?? "", + sourceGameId: gameId, + slots: extractStartersFromHitters(boxScore.away.hitters), + }, + }; +} + +// gameId 앞 8자리(YYYYMMDD, KST)가 오늘(KST)보다 미래인지. +function isGameDateInFuture(gameId: string): boolean { + const m = gameId.match(/^(\d{4})(\d{2})(\d{2})/); + if (!m) return false; + const gameDate = `${m[1]}-${m[2]}-${m[3]}`; + const nowKst = new Date(Date.now() + 9 * 60 * 60 * 1000); + const today = + `${nowKst.getUTCFullYear()}-` + + `${String(nowKst.getUTCMonth() + 1).padStart(2, "0")}-` + + `${String(nowKst.getUTCDate()).padStart(2, "0")}`; + return gameDate > today; +} + +// ── 5. 통합 GameDetail ── export interface GameDetail { gameId: string; - scoreBoard: ScoreBoardScroll; - boxScore: BoxScoreScroll; + /** 시작 전 경기는 null (KBO code:"200"). */ + scoreBoard: ScoreBoardScroll | null; + /** 시작 전 경기는 null. */ + boxScore: BoxScoreScroll | null; keyPlayer: { - hitter: KeyPlayerRanking; - pitcher: KeyPlayerRanking; + hitter: KeyPlayerRanking | null; + pitcher: KeyPlayerRanking | null; }; + /** + * 선발 라인업. 사실 우선 + 프리뷰 fallback: + * - boxScore가 있으면 거기서 추출 (source: "boxscore") + * - 시작 전이면 GetLineUpAnalysis 호출 (source: "preview-announced" 또는 "preview-estimated") + * - 모두 실패하면 null + */ + lineup: Lineup | null; /** Naver highLight에서 추출한 득점 타석 목록. 호출 실패 시 null. */ scoringPlays: GameScoringPlays | null; } @@ -580,19 +803,36 @@ export interface GameDetail { export async function fetchGameDetail( filters: GameDetailFilters ): Promise { - const [scoreBoard, boxScore, keyHitter, keyPitcher, scoringPlays] = + // 게임 날짜가 미래면 BoxScore가 빈 것이 확정이므로 프리뷰를 병렬로 묶어 + // 둘째 라운드를 생략한다. 오늘/과거는 BoxScore가 빈 경우에만 추가 호출. + const future = isGameDateInFuture(filters.gameId); + + const [scoreBoard, boxScore, keyHitter, keyPitcher, scoringPlays, futureLineup] = await Promise.all([ fetchScoreBoardScroll(filters), - fetchBoxScoreScroll(filters), - fetchKeyPlayerHitter(filters), - fetchKeyPlayerPitcher(filters), + fetchBoxScoreScroll(filters).catch(() => null), + fetchKeyPlayerHitter(filters).catch(() => null), + fetchKeyPlayerPitcher(filters).catch(() => null), fetchScoringPlays(filters.gameId).catch(() => null), + future ? fetchLineupAnalysis(filters).catch(() => null) : Promise.resolve(null), ]); + + let lineup: Lineup | null = futureLineup; + if (!lineup) { + if (boxScore !== null) { + lineup = buildLineupFromBoxScore(boxScore, scoreBoard, filters.gameId); + } else { + // 오늘이지만 아직 시작 안 한 경기: BoxScore가 비었으므로 프리뷰 보조 호출. + lineup = await fetchLineupAnalysis(filters).catch(() => null); + } + } + return { gameId: filters.gameId, scoreBoard, boxScore, keyPlayer: { hitter: keyHitter, pitcher: keyPitcher }, + lineup, scoringPlays, }; } diff --git a/src/services/gameDetailService.ts b/src/services/gameDetailService.ts index ca8de8a..1853d23 100644 --- a/src/services/gameDetailService.ts +++ b/src/services/gameDetailService.ts @@ -2,7 +2,6 @@ import { fetchGameDetail, type GameDetail, type GameDetailFilters, - type ScoreBoardMeta, } from "../kbo/game-detail"; import { encodeKey, @@ -14,6 +13,7 @@ import { const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000; +const TEN_MIN_MS = 10 * 60 * 1000; const THIRTY_SEC_MS = 30_000; /** @@ -22,17 +22,27 @@ const THIRTY_SEC_MS = 30_000; * 시작 전이면 시작 시각까지 (최대 1h). * * 종료 판정: scoreBoard.meta.endTime이 채워져 있으면 종료. + * scoreBoard가 null이면 KBO가 아직 경기 데이터를 만들지 않은 시작 전 상태 → + * 라인업이 발표 전(`preview-estimated`)이면 곧 바뀔 수 있으니 10분, + * 그 외엔 gameId에서 추정한 KST 18:30 시작 시각까지 캐시. */ -function gameDetailTtlMs(meta: ScoreBoardMeta): number { - if (meta.endTime && meta.endTime.trim() !== "") { +function gameDetailTtlMs(detail: GameDetail): number { + const sb = detail.scoreBoard; + if (sb?.meta.endTime && sb.meta.endTime.trim() !== "") { return SEVEN_DAYS_MS; } - const startTs = parseGameStartTs(meta.gameDate, meta.startTime); + const startTs = sb ? + parseGameStartTs(sb.meta.gameDate, sb.meta.startTime) : + parseGameStartFromGameId(detail.gameId); if (startTs == null) return THIRTY_SEC_MS; const now = Date.now(); if (now < startTs) { + // 발표 전 fallback 라인업은 짧게 폴링 — false → true 전환 시점을 빠르게 잡기 위해. + if (detail.lineup?.source === "preview-estimated") { + return Math.min(TEN_MIN_MS, Math.max(startTs - now, THIRTY_SEC_MS)); + } return Math.min(ONE_HOUR_MS, Math.max(startTs - now, THIRTY_SEC_MS)); } return THIRTY_SEC_MS; @@ -57,6 +67,16 @@ function parseGameStartTs( ); } +// scoreBoard가 없을 때(시작 전) gameId의 날짜만으로 시작 시각을 추정한다. +// 정확한 시각은 모르지만 KBO 정규 경기의 평일 18:30 / 주말 14:00·17:00을 +// 일률적으로 18:30 KST로 가정해 캐시 TTL 상한선용으로 쓴다. +function parseGameStartFromGameId(gameId: string): number | null { + const m = gameId.match(/^(\d{4})(\d{2})(\d{2})/); + if (!m) return null; + const [, y, mo, d] = m; + return Date.UTC(Number(y), Number(mo) - 1, Number(d), 18 - 9, 30); +} + export async function getGameDetail( filters: GameDetailFilters ): Promise { @@ -68,7 +88,7 @@ export async function getGameDetail( return getOrFetchDynamic(key, async () => { const detail = await fetchGameDetail(filters); - const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta); + const ttlMs = gameDetailTtlMs(detail); return { value: detail, ttlMs }; }); } diff --git a/src/types/kbo.ts b/src/types/kbo.ts index a03b45a..41c3f0b 100644 --- a/src/types/kbo.ts +++ b/src/types/kbo.ts @@ -50,6 +50,10 @@ export type { KeyPlayerRanking, KeyPlayerItem, KeyPlayerGroup, + Lineup, + LineupTeam, + LineupSlot, + LineupSource, } from "../kbo/game-detail"; export type { diff --git a/tests/services/gameDetailService.test.ts b/tests/services/gameDetailService.test.ts index b356d00..2058b32 100644 --- a/tests/services/gameDetailService.test.ts +++ b/tests/services/gameDetailService.test.ts @@ -20,23 +20,29 @@ describe("gameDetailService (실제 KBO + Firestore 에뮬레이터)", () => { console.log(`[detail] fetch 완료 (${Date.now() - t0}ms)`); expect(result.gameId).toBe(ENDED_GAME_ID); - expect(result.scoreBoard.meta.gameId).toBe(ENDED_GAME_ID); - expect(result.scoreBoard.meta.endTime).toBeTruthy(); - expect(result.scoreBoard.meta.awayTeam.id).toBe("SS"); - expect(result.scoreBoard.meta.homeTeam.id).toBe("LG"); - expect(result.scoreBoard.innings.length).toBeGreaterThan(0); - expect(result.scoreBoard.totals.away.runs).toBeTypeOf("number"); - expect(result.boxScore.events.length).toBeGreaterThan(0); - expect(result.boxScore.away.hitters.length).toBeGreaterThan(0); - expect(result.boxScore.away.pitchers.length).toBeGreaterThan(0); - expect(result.keyPlayer.hitter.items.length).toBeGreaterThan(0); - expect(result.keyPlayer.pitcher.items.length).toBeGreaterThan(0); + expect(result.scoreBoard).not.toBeNull(); + expect(result.boxScore).not.toBeNull(); + const scoreBoard = result.scoreBoard!; + const boxScore = result.boxScore!; + expect(scoreBoard.meta.gameId).toBe(ENDED_GAME_ID); + expect(scoreBoard.meta.endTime).toBeTruthy(); + expect(scoreBoard.meta.awayTeam.id).toBe("SS"); + expect(scoreBoard.meta.homeTeam.id).toBe("LG"); + expect(scoreBoard.innings.length).toBeGreaterThan(0); + expect(scoreBoard.totals.away.runs).toBeTypeOf("number"); + expect(boxScore.events.length).toBeGreaterThan(0); + expect(boxScore.away.hitters.length).toBeGreaterThan(0); + expect(boxScore.away.pitchers.length).toBeGreaterThan(0); + expect(result.keyPlayer.hitter).not.toBeNull(); + expect(result.keyPlayer.pitcher).not.toBeNull(); + expect(result.keyPlayer.hitter!.items.length).toBeGreaterThan(0); + expect(result.keyPlayer.pitcher!.items.length).toBeGreaterThan(0); // 도메인 타입 — 첫 타자: 순번/위치/이름 + 이닝 결과 길이가 maxInning과 일치 - const firstHitter = result.boxScore.away.hitters[0]; + const firstHitter = boxScore.away.hitters[0]; expect(firstHitter.name).toBeTruthy(); expect(firstHitter.order).toBeTruthy(); - expect(firstHitter.inningResults.length).toBe(result.scoreBoard.maxInning); + expect(firstHitter.inningResults.length).toBe(scoreBoard.maxInning); // 빈 이닝(타석에 안 들어선 칸)은 "" — KBO의  가 디코딩되어 사라져야 함 for (const cell of firstHitter.inningResults) { expect(cell).not.toMatch(/ /); @@ -45,17 +51,34 @@ describe("gameDetailService (실제 KBO + Firestore 에뮬레이터)", () => { expect(firstHitter.seasonAvg).toBeTypeOf("number"); // 첫 투수: 시즌 W/L/S + 이닝/실점/자책 모두 채워짐 - const firstPitcher = result.boxScore.away.pitchers[0]; + const firstPitcher = boxScore.away.pitchers[0]; expect(firstPitcher.name).toBeTruthy(); expect(firstPitcher.innings).toBeTruthy(); expect(firstPitcher.earnedRuns).toBeTypeOf("number"); expect(firstPitcher.seasonEra).toBeTypeOf("number"); // ParsedTableRow 형태 확인 (Firestore가 받을 수 있는 모양: 배열 안 객체) - const sampleRawRow = result.boxScore.away.raw.hitterByInning.rows[0]; + const sampleRawRow = boxScore.away.raw.hitterByInning.rows[0]; expect(sampleRawRow).toHaveProperty("cells"); expect(Array.isArray(sampleRawRow.cells)).toBe(true); + // 종료 경기 라인업: BoxScore에서 추출됨 (사실 우선 원칙) + expect(result.lineup).not.toBeNull(); + const lineup = result.lineup!; + expect(lineup.source).toBe("boxscore"); + expect(lineup.announced).toBe(true); + expect(lineup.home.slots.length).toBe(9); + expect(lineup.away.slots.length).toBe(9); + expect(lineup.home.teamId).toBe("LG"); + expect(lineup.away.teamId).toBe("SS"); + // 9명이 1~9번 타순 1회씩 채워짐 + const homeOrders = lineup.home.slots.map((s) => s.batOrder).sort(); + expect(homeOrders).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + // 약어 → 풀네임 확장 (BoxScore "유" 등이 "유격수"로) + for (const s of lineup.home.slots) { + expect(s.position.length).toBeGreaterThan(1); + } + // Firestore에 캐시 doc이 실제로 써졌는지 확인 (nested-array 회귀 방지) const snap = await firestore .collection("kboCache")