Reject impossible LCK calendar dates

Date-driven LCK lookups should fail fast for impossible user input instead of silently returning an empty match list. The parser now validates month/day bounds with explicit leap-year handling, and the tests lock both direct normalization and schedule normalization behavior.

Constraint: PR #55 review requested a regression for 2026-02-31 before implementation
Rejected: Rely on Date parsing round-trip | explicit bounds avoid timezone and overflow normalization surprises
Confidence: high
Scope-risk: narrow
Directive: Keep date validation before schedule filtering so invalid user dates cannot become misleading no-match responses
Tested: node --test packages/lck-analytics/test/index.test.js scripts/skill-docs.test.js
Tested: npm run lint --workspace lck-analytics
Tested: npm test --workspace lck-analytics
Tested: npm run ci
Tested: getMatchResults('2026-02-31') rejection smoke
Tested: getLckSummary('2026-04-01', { team: '한화', includeStandings: true }) live smoke
Tested: lck-analytics script smokes for sync-oracle, build-match-report, and analyze-live-game
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-28 23:14:42 +09:00
commit 2120ca7cc0
2 changed files with 52 additions and 2 deletions

View file

@ -38,8 +38,7 @@ function normalizeDateInput(value) {
}
const [year, month, day] = [match[1], match[2], match[3]];
const candidate = new Date(`${year}-${month}-${day}T00:00:00+09:00`);
if (Number.isNaN(candidate.getTime())) {
if (!isValidCalendarDate(year, month, day)) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
@ -51,6 +50,40 @@ function normalizeDateInput(value) {
};
}
function isValidCalendarDate(year, month, day) {
const numericYear = Number(year);
const numericMonth = Number(month);
const numericDay = Number(day);
if (!Number.isInteger(numericYear) || !Number.isInteger(numericMonth) || !Number.isInteger(numericDay)) {
return false;
}
if (numericMonth < 1 || numericMonth > 12 || numericDay < 1) {
return false;
}
const daysInMonth = [
31,
isLeapYear(numericYear) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
][numericMonth - 1];
return numericDay <= daysInMonth;
}
function isLeapYear(year) {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function eventToKoreaDateTime(startTime) {
const date = new Date(startTime);
const parts = new Intl.DateTimeFormat("en-CA", {

View file

@ -13,6 +13,7 @@ const {
parseOracleCsv,
} = require("../src/index");
const {
normalizeDateInput,
normalizeScheduleResponse,
normalizeStandingsResponse,
} = require("../src/parse");
@ -45,6 +46,22 @@ test("normalizeScheduleResponse filters requested LCK date and Korean team alias
assert.deepEqual(result.matches[0].score, { team1: 1, team2: 0 });
});
test("date normalization rejects impossible calendar dates", () => {
assert.equal(normalizeDateInput("2024-02-29").isoDate, "2024-02-29");
assert.throws(
() => normalizeDateInput("2026-02-31"),
/date must be a valid Date or YYYY-MM-DD string\./,
);
assert.throws(
() => normalizeDateInput("2026-02-29"),
/date must be a valid Date or YYYY-MM-DD string\./,
);
assert.throws(
() => normalizeScheduleResponse(schedulePayload, { date: "2026-02-31" }),
/date must be a valid Date or YYYY-MM-DD string\./,
);
});
test("normalizeStandingsResponse keeps the LCK standings shape and alias resolution", () => {
const table = normalizeStandingsResponse(standingsPayload, {
tournament: {