diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 5a5d9b0..b335c85 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -122,9 +122,20 @@ function parseHHMM(t: string): number | null { } /** - * 일자별 TTL 산출. 본 모듈 상단의 표 참조. + * 일자별 TTL 산출. + * + * 과거 : 그 날 경기가 모두 종료/취소면 7d, 아니면 30s + * 오늘 : 시작 전이면 첫 경기 시작까지(최대 1h), 그 외 30s, 모두 끝났으면 7d + * 내일 : 6h + * 모레 이후: 7d — 단 해당 날짜 자정을 넘기지 않는다 + * + * 마지막 조건이 중요하다. TTL은 '쓰는 시점의 미래 거리'로만 정해지고 시간이 + * 흘러도 재평가되지 않으므로, 자르지 않으면 2~7일 앞서 캐시된 날짜가 경기 + * 당일과 그 이후까지 "경기 전" 스냅샷을 계속 내놓는다. + * (실제 사고: 07-27에 담긴 08-01이 7d TTL로 08-03까지 살아남아, 폭염취소된 + * 경기가 달력에서 계속 `scheduled`로 보였다.) */ -function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { +export function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { const y = parseInt(yyyymmdd.slice(0, 4), 10); const m = parseInt(yyyymmdd.slice(4, 6), 10); const d = parseInt(yyyymmdd.slice(6, 8), 10); @@ -148,7 +159,12 @@ function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS; } if (diffDays === 1) return SIX_HOURS_MS; - if (diffDays >= 2) return SEVEN_DAYS_MS; + if (diffDays >= 2) { + // 해당 날짜 00:00 에 만료시켜, 경기 당일에는 반드시 다시 받아오게 한다. + // 그 뒤로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL을 다시 매긴다. + const untilDayStartMs = dayStart - now.getTime(); + return Math.min(SEVEN_DAYS_MS, Math.max(untilDayStartMs, THIRTY_SEC_MS)); + } // 오늘 if (games.length === 0) return SIX_HOURS_MS; diff --git a/tests/unit/scheduleDayTtl.test.ts b/tests/unit/scheduleDayTtl.test.ts new file mode 100644 index 0000000..0d927a3 --- /dev/null +++ b/tests/unit/scheduleDayTtl.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { dayTtlMs } from "../../src/repositories/kboRepository"; +import type { ScheduleGame, GameStatus } from "../../src/kbo/schedule"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * ONE_DAY_MS; +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; +const THIRTY_SEC_MS = 30_000; + +function game(status: GameStatus, time = "18:00"): ScheduleGame { + return { status, time } as unknown as ScheduleGame; +} + +/** yyyymmdd 문자열의 로컬 자정 타임스탬프. dayTtlMs 와 같은 기준. */ +function localDayStart(yyyymmdd: string): number { + const y = Number(yyyymmdd.slice(0, 4)); + const m = Number(yyyymmdd.slice(4, 6)); + const d = Number(yyyymmdd.slice(6, 8)); + return new Date(y, m - 1, d).getTime(); +} + +describe("dayTtlMs", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("모레 이후", () => { + it("TTL이 해당 날짜 자정을 넘기지 않는다", () => { + // 회귀 테스트: 2026-07-27 에 담긴 08-01 스냅샷이 7d TTL로 08-03까지 + // 살아남아, 폭염취소된 경기가 달력에서 계속 `scheduled` 로 보였다. + vi.setSystemTime(new Date(2026, 6, 27, 21, 6)); + + const ttl = dayTtlMs("20260801", [game("scheduled")]); + + expect(Date.now() + ttl).toBe(localDayStart("20260801")); + expect(ttl).toBeLessThan(SEVEN_DAYS_MS); + }); + + it("7일보다 먼 날짜는 7d 로 상한을 둔다", () => { + vi.setSystemTime(new Date(2026, 6, 27, 21, 6)); + + expect(dayTtlMs("20260901", [game("scheduled")])).toBe(SEVEN_DAYS_MS); + }); + + it("자정 직전이어도 최소 30s 는 보장한다", () => { + // 2026-08-01 23:59:59 → 08-03 자정까지 남은 시간은 0 에 가깝지 않지만, + // 경계에서 0/음수 TTL 이 나오지 않는지 하한을 확인한다. + vi.setSystemTime(new Date(2026, 7, 1, 23, 59, 59, 900)); + + expect(dayTtlMs("20260803", [game("scheduled")])) + .toBeGreaterThanOrEqual(THIRTY_SEC_MS); + }); + }); + + it("내일은 6h", () => { + vi.setSystemTime(new Date(2026, 7, 1, 10, 0)); + + expect(dayTtlMs("20260802", [game("scheduled")])).toBe(SIX_HOURS_MS); + }); + + describe("지난 날짜", () => { + it("모두 종료/취소면 7d", () => { + vi.setSystemTime(new Date(2026, 7, 3, 10, 0)); + + const games = [game("completed"), game("cancelled")]; + expect(dayTtlMs("20260801", games)).toBe(SEVEN_DAYS_MS); + }); + + it("미종료 경기가 남아 있으면 30s", () => { + vi.setSystemTime(new Date(2026, 7, 3, 10, 0)); + + const games = [game("completed"), game("scheduled")]; + expect(dayTtlMs("20260801", games)).toBe(THIRTY_SEC_MS); + }); + }); + + describe("오늘", () => { + it("첫 경기 시작 전이면 시작까지만 캐시한다", () => { + vi.setSystemTime(new Date(2026, 7, 3, 17, 30)); + + // 18:00 시작 → 30분 + expect(dayTtlMs("20260803", [game("scheduled", "18:00")])) + .toBe(30 * 60_000); + }); + + it("모두 끝났으면 7d", () => { + vi.setSystemTime(new Date(2026, 7, 3, 23, 0)); + + expect(dayTtlMs("20260803", [game("completed")])).toBe(SEVEN_DAYS_MS); + }); + }); +});