Add starting lineup support and enhance pre-game data handling.

- KBO 경기 상세 정보에 선발 및 예상 라인업 정보를 포함하고 CLI에서 이를 확인할 수 있는 출력 기능을 추가했습니다.
- 경기가 시작되기 전 KBO API가 빈 데이터를 반환하는 상황(code 200)을 예외 처리하고, 경기 상태에 따라 null을 반환하도록 개선했습니다.
- 종료된 경기는 박스스코어에서 선발 명단을 추출하고, 시작 전 경기는 라인업 분석 API를 호출하는 이원화된 데이터 수집 로직을 구현했습니다.
- 예상 라인업이 확정으로 전환되는 시점을 빠르게 반영하기 위해 라인업 출처에 따라 캐시 TTL을 동적으로 조절하는 기능을 도입했습니다.
- 라인업 파싱 및 경기 시작 전후의 데이터 처리 로직에 대한 검증을 위해 서비스 레이어 테스트를 업데이트했습니다.
This commit is contained in:
윤정민 2026-05-18 13:07:02 +09:00
parent 1101aedeb7
commit eb17177f77
5 changed files with 387 additions and 67 deletions

View File

@ -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<string, PlayerPageConfig> = {
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`);

View File

@ -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<ScoreBoardScroll> {
): Promise<ScoreBoardScroll | null> {
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
const raw = await postJson<RawScoreBoardScroll>(
"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<BoxScoreScroll> {
): Promise<BoxScoreScroll | null> {
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
const raw = await postJson<RawBoxScoreScroll>(
"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<KeyPlayerRanking> {
): Promise<KeyPlayerRanking | null> {
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<KeyPlayerRanking> {
export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise<KeyPlayerRanking | null> {
return fetchKeyPlayer("GetKeyPlayerHitter", options);
}
export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise<KeyPlayerRanking> {
export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise<KeyPlayerRanking | null> {
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<string, string> = {
: "유격수",
: "좌익수",
: "중견수",
: "우익수",
: "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<Lineup | null> {
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
const raw = await postJson<RawLineupAnalysis>(
"GetLineUpAnalysis",
bodyOf({ leId, srId, seasonId, gameId })
);
return parseLineupAnalysis(raw);
}
// BoxScore.hitters는 교체 출장까지 포함한 전체 출장자 목록. 동일 batOrder 슬롯의
// 첫 등장이 선발. 9명까지 수집해 타순 오름차순으로 반환한다.
function extractStartersFromHitters(hitters: BoxHitter[]): LineupSlot[] {
const seen = new Set<number>();
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<GameDetail> {
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,
};
}

View File

@ -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<GameDetail> {
@ -68,7 +88,7 @@ export async function getGameDetail(
return getOrFetchDynamic<GameDetail>(key, async () => {
const detail = await fetchGameDetail(filters);
const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta);
const ttlMs = gameDetailTtlMs(detail);
return { value: detail, ttlMs };
});
}

View File

@ -50,6 +50,10 @@ export type {
KeyPlayerRanking,
KeyPlayerItem,
KeyPlayerGroup,
Lineup,
LineupTeam,
LineupSlot,
LineupSource,
} from "../kbo/game-detail";
export type {

View File

@ -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의 &nbsp;가 디코딩되어 사라져야 함
for (const cell of firstHitter.inningResults) {
expect(cell).not.toMatch(/&nbsp;/);
@ -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")