From a566db99e4878933326edac84b13290c13aec62f Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 09:01:55 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[Fix]=20=EC=9E=90=EC=A0=95=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EC=8B=9C=EB=B0=9C=20=EC=97=B4=EC=B0=A8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=9D=84=20=EC=9A=B4=ED=96=89=EC=9D=BC=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84(03:00)=20=EA=B8=B0=EC=A4=80=2024=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EC=B4=88=EB=A1=9C=20=EC=A0=81=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인천 1·2호선 공식 FILE과 KRIC 역별 API(GTX-A·의정부)가 00시대로 적은 자정 이후 막차 시각을 전날 운행일의 심야 시각(86400초 이상)으로 적재한다. 스냅샷은 바꾸지 않는다. - 전국 후보 조립 뒤 운행일 경계 앞 정차 시각이 남으면 후보 생성을 실패시킨다. Closes #918 --- tools/ci/data-test-ownership.json | 5 +++ .../lib/kric-station-lines-timetable.mjs | 8 +++- .../lib/kric-station-lines-timetable.test.mjs | 22 ++++++++-- tools/datapack/lib/service-day-seconds.mjs | 20 +++++++++ .../datapack/lib/service-day-seconds.test.mjs | 42 +++++++++++++++++++ .../materialize-incheon-timetable.mjs | 6 ++- .../materialize-incheon-timetable.test.mjs | 10 +++++ .../prepare-nationwide-candidate-run.mjs | 8 ++++ .../prepare-nationwide-candidate-run.test.mjs | 3 ++ 9 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 tools/datapack/lib/service-day-seconds.mjs create mode 100644 tools/datapack/lib/service-day-seconds.test.mjs diff --git a/tools/ci/data-test-ownership.json b/tools/ci/data-test-ownership.json index d30ac58b2..e0bf376a6 100644 --- a/tools/ci/data-test-ownership.json +++ b/tools/ci/data-test-ownership.json @@ -299,6 +299,10 @@ "data913": { "issueUrl": "https://github.com/AquilaXk/easysubway-data/issues/913", "title": "[Fix] 데이터팩·서버 번들 만료 시각에 시간표 원천 신선도 반영" + }, + "data918": { + "issueUrl": "https://github.com/AquilaXk/easysubway-data/issues/918", + "title": "[Fix] 인천 1·2호선 자정 이후 열차가 86400초 이상과 0시대 값으로 섞여 표현되는지 원천·컴파일러 확인" } }, "workflows": { @@ -536,6 +540,7 @@ {"path":"tools/datapack/lib/kric-station-timetable-api-trips.test.mjs","semanticOwner":"data903","classes":["required-pr"],"durationMs":40}, {"path":"tools/datapack/lib/kric-korail-timetable.test.mjs","semanticOwner":"data903","classes":["required-pr"],"durationMs":60}, {"path":"tools/datapack/lib/kric-station-lines-timetable.test.mjs","semanticOwner":"data903","classes":["required-pr"],"durationMs":60}, + {"path":"tools/datapack/lib/service-day-seconds.test.mjs","semanticOwner":"data918","classes":["required-pr"],"durationMs":10}, {"path":"tools/datapack/lib/kric-retained-gwangju-projection.test.mjs","semanticOwner":"data913","classes":["required-pr"],"durationMs":60}, {"path":"tools/datapack/lib/map-catalog-oci-publisher.test.mjs","semanticOwner":"data535","classes":["required-pr"],"durationMs":7}, {"path":"tools/datapack/lib/molit-svg-provider-identity.test.mjs","semanticOwner":"data29","classes":["required-pr"],"durationMs":2}, diff --git a/tools/datapack/lib/kric-station-lines-timetable.mjs b/tools/datapack/lib/kric-station-lines-timetable.mjs index 056ed9f7d..51f356af0 100644 --- a/tools/datapack/lib/kric-station-lines-timetable.mjs +++ b/tools/datapack/lib/kric-station-lines-timetable.mjs @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { codepointCompare } from "../../lib/codepoint-compare.mjs"; import { KRIC_API_STATION_TIMETABLE_BINDINGS } from "./kric-station-timetable-api-trips.mjs"; import { canonicalJson } from "./manifest-validation.mjs"; +import { serviceDaySeconds } from "./service-day-seconds.mjs"; // #903: kric-subway-timetable-station-lines 등록기가 커밋한 파생 스냅샷(역별 KRIC API 재구성 trip)을 // 공용 적재기(materializeOfficialLineTimetables) 입력으로 바꾼다. 스냅샷 내용 해시를 다시 확인하고 trip은 바꾸지 않는다. @@ -37,7 +38,12 @@ export function kricStationLinesOfficialTimetable(snapshot, evidence, { bindings lineId: trip.lineId, routeKey: lnCdOf.get(trip.lineId) ?? fail("LINE", trip.lineId), providerTripKey: trip.providerTripKey, trainNumber: trip.provenance.trainNumber, serviceDayKind: SERVICE_DAY_KIND[trip.serviceDayKind] ?? fail("SERVICE_DAY", trip.providerTripKey), sourceDayKey: trip.provenance.dayCd, servicePattern: trip.servicePattern, headsign: trip.headsign, - sourceRowSha256: trip.sourceRowSha256, stops: trip.stops, + sourceRowSha256: trip.sourceRowSha256, + // #918: KRIC 응답(HHMMSS)이 00시대로 적은 자정 이후 시발 열차 시각은 전날 운행일의 심야 시각이다(운행일 경계 03:00). + // 스냅샷 trip은 바꾸지 않고 적재 입력에서만 운행일 초로 표현한다. + stops: trip.stops.map(({ stationName, arrivalSeconds, departureSeconds }) => ({ + stationName, arrivalSeconds: serviceDaySeconds(arrivalSeconds), departureSeconds: serviceDaySeconds(departureSeconds), + })), })); const quarantine = snapshot.quarantine.map((row) => { const [, trainNumber, dayCd] = row.providerTripKey.split("|"); diff --git a/tools/datapack/lib/kric-station-lines-timetable.test.mjs b/tools/datapack/lib/kric-station-lines-timetable.test.mjs index 4a41061a8..827d90ac5 100644 --- a/tools/datapack/lib/kric-station-lines-timetable.test.mjs +++ b/tools/datapack/lib/kric-station-lines-timetable.test.mjs @@ -13,15 +13,14 @@ const trip = (trainNumber, dayCd, kind) => ({ provenance: { lnCd: "B1", trainNumber, dayCd, direction: "ASC" }, }); -function snapshotAndEvidence(quarantine = []) { - const trips = [trip("1001", "8", "WEEKDAY"), trip("3001", "9", "SATURDAY_SUNDAY_HOLIDAY")]; +function snapshotAndEvidence(quarantine = [], trips = [trip("1001", "8", "WEEKDAY"), trip("3001", "9", "SATURDAY_SUNDAY_HOLIDAY")]) { const content = { schemaVersion: 1, artifactKind: "kric-station-timetable-snapshot", sourceId: "kric-subway-timetable-station-lines", catalogProviderId: "provider:kric-subway-timetable", capturedAt: "2026-10-03T00:30:00.000Z", collectedAt: "2026-10-03T00:31:00.000Z", raw: { rawSha256: "a".repeat(64), byteSize: 1, rawObjectUri: "oci://x", publicationReceiptSha256: "b".repeat(64) }, serviceDayPolicy: "policy", lines: [], trips, quarantine }; const contentSha256 = sha(canonicalJson(content)); const snapshot = { ...content, snapshotId: "kric-subway-timetable-station-lines-20261003", contentSha256 }; - const evidence = { snapshotId: snapshot.snapshotId, contentSha256, rawSha256: "a".repeat(64), tripsSha256: sha(JSON.stringify(trips)), tripCount: 2 }; + const evidence = { snapshotId: snapshot.snapshotId, contentSha256, rawSha256: "a".repeat(64), tripsSha256: sha(JSON.stringify(trips)), tripCount: trips.length }; return { snapshot, evidence }; } @@ -56,3 +55,20 @@ test("격리 행의 운행일은 dayCd 8=평일, 9=주말·휴일만 받고 그 assert.deepEqual(kinds("9"), ["WEEKEND_HOLIDAY"]); for (const dayCd of ["7", "1", ""]) assert.throws(() => kinds(dayCd), /KRIC_STATION_LINES_TIMETABLE_SERVICE_DAY/u); }); + +test("#918 자정 이후 시발 열차의 00~02시 시각은 전날 운행일의 24시 이후 초로 적재한다(스냅샷은 바꾸지 않는다)", () => { + const timed = (trainNumber, departure, arrival) => ({ ...trip(trainNumber, "8", "WEEKDAY"), + stops: [{ stationName: "가역(부역)", arrivalSeconds: null, departureSeconds: departure }, { stationName: "나역", arrivalSeconds: arrival, departureSeconds: null }] }); + // 원천 KRIC 응답(HHMMSS)은 00시대로 적는다. 자정을 넘는 열차는 스냅샷에 이미 86400초 이상으로 이어져 있다. + const { snapshot, evidence } = snapshotAndEvidence([], [timed("1901", 360, 840), timed("1899", 86_040, 86_460), timed("1001", 21_600, 21_780)]); + const before = structuredClone(snapshot); + const { provider } = kricStationLinesOfficialTimetable(snapshot, evidence, { bindings: BINDINGS }); + const stops = Object.fromEntries(provider.trips.map(({ trainNumber, stops: tripStops }) => [trainNumber, + tripStops.map(({ arrivalSeconds, departureSeconds }) => [arrivalSeconds, departureSeconds])])); + assert.deepEqual(stops, { + 1001: [[null, 21_600], [21_780, null]], + 1899: [[null, 86_040], [86_460, null]], + 1901: [[null, 86_760], [87_240, null]], + }); + assert.deepEqual(snapshot, before); +}); diff --git a/tools/datapack/lib/service-day-seconds.mjs b/tools/datapack/lib/service-day-seconds.mjs new file mode 100644 index 000000000..ddf3e4f4f --- /dev/null +++ b/tools/datapack/lib/service-day-seconds.mjs @@ -0,0 +1,20 @@ +// #918: 운행일 경계. 시간표 원천은 자정 이후 시각을 24시 미만(00:00~02:59)으로 적는다. +// 그 시각은 전날 운행일의 심야 시각이므로 86400초 이상으로 표현한다. +// 근거(2026-10-04 실측): 인천 1·2호선 공식 FILE 8개는 00시대 시발 행을 모두 파일 끝(23시대 시발 뒤)에 두고 +// 02~04시 시각이 하나도 없다. KRIC 역별 API(GTX-A·의정부·에버라인·김포골드·부산김해)도 01~04시 시발 열차가 없다. +// normalize-kric-timetable.mjs(03:00)와 같은 경계를 쓴다. +export const SERVICE_DAY_BOUNDARY_SECONDS = 3 * 3_600; +const SECONDS_PER_DAY = 86_400; + +/** 원천 시각(초)을 운행일 시각(초)으로 바꾼다. null은 시각 없음이다. */ +export function serviceDaySeconds(seconds) { + if (seconds === null) return null; + if (!Number.isSafeInteger(seconds) || seconds < 0) throw new Error(`SERVICE_DAY_SECONDS_INVALID: ${seconds}`); + return seconds < SERVICE_DAY_BOUNDARY_SECONDS ? seconds + SECONDS_PER_DAY : seconds; +} + +/** 운행일 경계 앞 시각이 남은 stop_time 행을 돌려준다. 비어 있어야 팩을 만들 수 있다. */ +export function serviceDayBoundaryViolations(stopTimes) { + const early = (seconds) => typeof seconds === "number" && seconds < SERVICE_DAY_BOUNDARY_SECONDS; + return stopTimes.filter(({ arrivalSeconds, departureSeconds }) => early(arrivalSeconds) || early(departureSeconds)); +} diff --git a/tools/datapack/lib/service-day-seconds.test.mjs b/tools/datapack/lib/service-day-seconds.test.mjs new file mode 100644 index 000000000..6c92edc6a --- /dev/null +++ b/tools/datapack/lib/service-day-seconds.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + SERVICE_DAY_BOUNDARY_SECONDS, + serviceDayBoundaryViolations, + serviceDaySeconds, +} from "./service-day-seconds.mjs"; + +// #918: 원천이 24시 미만으로 적은 00:00~02:59 시각은 전날 운행일의 심야 시각이다. +// 운행일 경계는 03:00이고, 그 앞의 시각은 86400초 이상으로 표현한다. + +test("운행일 경계(03:00) 앞 시각은 같은 운행일의 24시 이후 초로 옮기고, 경계 이후 시각은 그대로 둔다", () => { + assert.equal(SERVICE_DAY_BOUNDARY_SECONDS, 10_800); + assert.equal(serviceDaySeconds(0), 86_400); + assert.equal(serviceDaySeconds(570), 86_970); + assert.equal(serviceDaySeconds(10_799), 97_199); + assert.equal(serviceDaySeconds(10_800), 10_800); + assert.equal(serviceDaySeconds(20_100), 20_100); + assert.equal(serviceDaySeconds(86_430), 86_430); + assert.equal(serviceDaySeconds(null), null); +}); + +test("시각이 정수 초가 아니면 운행일 시각으로 바꾸지 않고 실패한다", () => { + for (const value of [-1, 1.5, "570", undefined, Number.NaN]) { + assert.throws(() => serviceDaySeconds(value), /SERVICE_DAY_SECONDS_INVALID/u); + } +}); + +test("운행일 경계 앞 정차 시각이 남은 stop_time을 모두 찾는다", () => { + const violations = serviceDayBoundaryViolations([ + { tripId: "trip-a", stopSequence: 1, arrivalSeconds: 86_430, departureSeconds: 86_430 }, + { tripId: "trip-b", stopSequence: 1, arrivalSeconds: 570, departureSeconds: 570 }, + { tripId: "trip-b", stopSequence: 2, arrivalSeconds: 10_790, departureSeconds: 10_800 }, + { tripId: "trip-c", stopSequence: 1, arrivalSeconds: 19_800, departureSeconds: 19_800 }, + ]); + assert.deepEqual(violations, [ + { tripId: "trip-b", stopSequence: 1, arrivalSeconds: 570, departureSeconds: 570 }, + { tripId: "trip-b", stopSequence: 2, arrivalSeconds: 10_790, departureSeconds: 10_800 }, + ]); + assert.deepEqual(serviceDayBoundaryViolations([]), []); +}); diff --git a/tools/datapack/materialize-incheon-timetable.mjs b/tools/datapack/materialize-incheon-timetable.mjs index e97b900c3..93e1826ca 100644 --- a/tools/datapack/materialize-incheon-timetable.mjs +++ b/tools/datapack/materialize-incheon-timetable.mjs @@ -13,6 +13,7 @@ import { isDeepStrictEqual } from "node:util"; import { INCHEON_TIMETABLE_LINES } from "./collect-incheon-timetable.mjs"; import { validateIncheonStationInfoSnapshot } from "./collect-incheon-station-info.mjs"; +import { serviceDaySeconds } from "./lib/service-day-seconds.mjs"; const ISSUE = 2488; const MATERIALIZER = "tools/datapack/materialize-incheon-timetable.mjs"; @@ -412,8 +413,9 @@ function addRoutesAndTrips(pack, line, stationIdByLineCode) { stopSequence: index + 1, stationId, lineId: config.lineId, - arrivalSeconds: stop.a, - departureSeconds: stop.d, + // #918: 공식 FILE이 00시대로 적은 자정 이후 시각은 전날 운행일의 심야 시각이다(운행일 경계 03:00). + arrivalSeconds: serviceDaySeconds(stop.a), + departureSeconds: serviceDaySeconds(stop.d), pickupType: index === trip.stops.length - 1 ? 1 : 0, dropOffType: index === 0 ? 1 : 0, }, tripProvenance)); diff --git a/tools/datapack/materialize-incheon-timetable.test.mjs b/tools/datapack/materialize-incheon-timetable.test.mjs index 5e07b6974..d655b88c1 100644 --- a/tools/datapack/materialize-incheon-timetable.test.mjs +++ b/tools/datapack/materialize-incheon-timetable.test.mjs @@ -248,6 +248,16 @@ test("인천 1·2호선 공식 timetable을 1414 trip·40898 stop_time·WEEK/HOL const maxArrival = Math.max(...stopTimes.map(({ arrivalSeconds }) => arrivalSeconds)); assert.ok(maxArrival > 86_400, `expected a post-midnight trip, got max ${maxArrival}`); + + // #918: 공식 FILE은 자정 이후 막차를 00시대로 적고 파일 끝에 둔다(02~04시 시각 없음). + // 운행일 경계(03:00) 앞 시각은 전날 운행일의 심야 시각이므로 86400초 이상으로 싣는다. + assert.deepEqual(stopTimes.filter(({ arrivalSeconds, departureSeconds }) => + arrivalSeconds < 10_800 || departureSeconds < 10_800), []); + const bakchon = (tripId) => stopTimes.find((row) => row.tripId === tripId && row.stationId === "station-f497b2d7043f"); + assert.deepEqual( + ["1301", "1303", "1305", "1307", "1309"].map((trainNo) => bakchon(`trip-incheon-1-dn-week-${trainNo}`).departureSeconds), + [86_430, 87_120, 87_810, 88_530, 89_070], + ); }); test("인천 timetable materializer는 snapshot·inventory·freshness·topology lineage 변조를 fail-closed한다", async () => { diff --git a/tools/datapack/prepare-nationwide-candidate-run.mjs b/tools/datapack/prepare-nationwide-candidate-run.mjs index e126054f2..da642afbc 100644 --- a/tools/datapack/prepare-nationwide-candidate-run.mjs +++ b/tools/datapack/prepare-nationwide-candidate-run.mjs @@ -19,6 +19,7 @@ import { outOfStationTransferNetworkEdges } from "./build-datapack.mjs"; import { HOLIDAYS_2026, materializeIncheonTimetable } from "./materialize-incheon-timetable.mjs"; import { buildNationwidePlatformInfoMap } from "./lib/nationwide-platform-resolver.mjs"; import { integrateRegionalTimetables } from "./lib/regional-timetable-integrator.mjs"; +import { serviceDayBoundaryViolations } from "./lib/service-day-seconds.mjs"; import { deriveFreshnessExpiresAt } from "./freshness-policy.mjs"; import { deriveApprovedItxTopologyEvidencePath } from "./activate-current-source-set.mjs"; import { officialOdFareAdmissionsBySource, officialOdFareQuoteSetHash } from "./lib/official-od-fare-evidence.mjs"; @@ -1577,6 +1578,13 @@ export async function prepareNationwideCandidate({ ...gwangjuTopologySource.coverageScope[field]])].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); } + // #918: 모든 시간표 원천을 합친 뒤 운행일 경계(03:00) 앞 정차 시각이 남으면 후보를 만들지 않는다. + const serviceDayBoundaryRows = serviceDayBoundaryViolations(finalPack.transitStopTimes); + if (serviceDayBoundaryRows.length > 0) { + const tripIds = [...new Set(serviceDayBoundaryRows.map(({ tripId }) => tripId))]; + throw new Error(`nationwide candidate stop times precede the 03:00 service-day boundary: ${tripIds.length} trips (${tripIds.slice(0, 5).join(", ")})`); + } + // #855: 대전·광주 원천은 역별 시각 하나만 준다. 원천 정차 2개 이상으로 열차를 만들 수 없는 // 원천 시각은 팩에 싣지 않고 사유·식별자·개수를 격리 증거로 남긴다. const timetableQuarantine = [ diff --git a/tools/datapack/prepare-nationwide-candidate-run.test.mjs b/tools/datapack/prepare-nationwide-candidate-run.test.mjs index 9e6218025..716c59699 100644 --- a/tools/datapack/prepare-nationwide-candidate-run.test.mjs +++ b/tools/datapack/prepare-nationwide-candidate-run.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { expandExternalStopTimes } from "./lib/external-stop-times.mjs"; import { canonicalJson } from "./lib/manifest-validation.mjs"; +import { serviceDayBoundaryViolations } from "./lib/service-day-seconds.mjs"; import { topologySnapshotFreshUntil } from "./lib/topology-freshness-cutover.mjs"; import { admitOutOfStationTransferLinks, officialTransferEndpointRecords, packOutOfStationTransferLinks, applyMeasuredTransferTimePrecedence, assertCandidateClockAfterRawStorage, prepareNationwideCandidate, resolveSeoulMeasuredTransferMetrics, formatPlatformInfo, gwangjuFacilityState, regionalFacilityTypeCounts, busanFacilityState, officialTransferMetricsByDirection, resolveBusanTransferMetrics, resolveMolitTransferSnapshot, resolveNationwideCandidateInputSnapshots } from "./prepare-nationwide-candidate-run.mjs"; @@ -482,6 +483,8 @@ test("prepareNationwideCandidate binds platform metadata onto stationLines", asy }); const pack = result.finalPack; + // #918: 조립한 전국 팩에는 운행일 경계(03:00) 앞 정차 시각이 남지 않는다(인천 1·2호선, KRIC GTX-A·의정부 포함). + assert.deepEqual(serviceDayBoundaryViolations(pack.transitStopTimes), []); const targetLine = pack.stationLines.find( (sl) => sl.stationId === "station-00089f8f97de" && sl.lineId === "line-558d0bd8312d" ); From 87d3731064c195a01b90e55c60847a5bbb156dfb Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 09:37:13 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[Fix]=20=EC=9A=B4=ED=96=89=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B3=80=ED=99=98=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=ED=8C=A9=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=ED=8C=A9=20SQLite=20=EC=BB=B4?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정본 팩 JSON의 인천 시간표는 build-datapack이 materializer로 다시 만들어 비교한다(수도권·전국 정본 팩 모두). materializer 출력을 바꾸면 해시가 여러 증거에 결속된 수도권 정본 팩까지 다시 만들어야 하므로, 정본 팩은 원천 시각을 그대로 두고 transit_stop_times를 SQLite에 쓸 때 운행일 초로 바꾼다. - build-datapack: transit_stop_times를 serviceDayStopTimes로 바꿔 쓴다(인천·KRIC 역별 노선 등 원천과 무관). - 바꾼 뒤 trip 안 시각이 줄어들면 validate-datapack의 기존 순서 검사가 거부한다. - materializer·KRIC 역별 adapter·후보 조립 검사 변경은 되돌린다. - 문서 파편(build-datapack.mjs blob)을 갱신한다. Refs #918 --- .../documentation/documentation-fragment.json | 70 +++++++++---------- tools/datapack/build-datapack.mjs | 5 +- .../lib/kric-station-lines-timetable.mjs | 8 +-- .../lib/kric-station-lines-timetable.test.mjs | 22 +----- tools/datapack/lib/service-day-seconds.mjs | 23 +++--- .../datapack/lib/service-day-seconds.test.mjs | 34 +++++---- .../materialize-incheon-timetable.mjs | 6 +- .../materialize-incheon-timetable.test.mjs | 24 ++++--- .../prepare-nationwide-candidate-run.mjs | 8 --- .../prepare-nationwide-candidate-run.test.mjs | 3 - 10 files changed, 96 insertions(+), 107 deletions(-) diff --git a/contracts/documentation/documentation-fragment.json b/contracts/documentation/documentation-fragment.json index eacd092b2..113978624 100644 --- a/contracts/documentation/documentation-fragment.json +++ b/contracts/documentation/documentation-fragment.json @@ -2,9 +2,9 @@ "$schema": "https://raw.githubusercontent.com/AquilaXk/easysubway/32ce139789b97ce1f0c9bb059966cfc19f497480/contracts/documentation/documentation-fragment.schema.json", "schemaVersion": 1, "repository": "AquilaXk/easysubway-data", - "sourceSha": "cc6dd6e670ea53290a80171006120b2a8a6669a3", + "sourceSha": "a566db99e4878933326edac84b13290c13aec62f", "status": "ACTIVE", - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/237", "https://github.com/AquilaXk/easysubway-data/issues/281", @@ -38,7 +38,7 @@ "documentationFamily": "QUALITY_TEST", "kindCandidate": "DATA_REQUIRED_CI_WORKFLOW", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:.github/workflows/ci.yml:1a692be89eb795b44c7c0662208b9d3c33923134", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:.github/workflows/ci.yml:1a692be89eb795b44c7c0662208b9d3c33923134", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/25", @@ -62,8 +62,8 @@ "reviewTrigger": [ "event:workflow-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:.github/workflows/ci.yml:1a692be89eb795b44c7c0662208b9d3c33923134", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:.github/workflows/ci.yml:1a692be89eb795b44c7c0662208b9d3c33923134", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/25" @@ -88,7 +88,7 @@ "documentationFamily": "RELEASE_CHANGE", "kindCandidate": "DATAPACK_IMMUTABLE_RELEASE_WORKFLOW", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:.github/workflows/datapack-release.yml:fe67cb90c0778888ed2196785dcd8ca5611b89e5", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:.github/workflows/datapack-release.yml:fe67cb90c0778888ed2196785dcd8ca5611b89e5", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/54", @@ -113,8 +113,8 @@ "reviewTrigger": [ "event:release-workflow-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:.github/workflows/datapack-release.yml:fe67cb90c0778888ed2196785dcd8ca5611b89e5", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:.github/workflows/datapack-release.yml:fe67cb90c0778888ed2196785dcd8ca5611b89e5", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/54" @@ -139,7 +139,7 @@ "documentationFamily": "PRODUCT", "kindCandidate": "DATA_PRODUCT_README", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:README.md:7e9e4208607c224bb8a04852be26daf2ac93b438", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:README.md:7e9e4208607c224bb8a04852be26daf2ac93b438", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/38", @@ -166,8 +166,8 @@ "reviewTrigger": [ "event:source-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:README.md:7e9e4208607c224bb8a04852be26daf2ac93b438", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:README.md:7e9e4208607c224bb8a04852be26daf2ac93b438", "verificationMethod": "owner-review", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/38" @@ -192,7 +192,7 @@ "documentationFamily": "ARCHITECTURE", "kindCandidate": "THREE_ARTIFACT_COMPONENT_BOUNDARY", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:contracts/datapack/bundle-component-contract.json:854a53ccf773a00b3c69bbd0b336854061cb06f2", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:contracts/datapack/bundle-component-contract.json:854a53ccf773a00b3c69bbd0b336854061cb06f2", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/41", @@ -217,8 +217,8 @@ "reviewTrigger": [ "event:artifact-boundary-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:contracts/datapack/bundle-component-contract.json:854a53ccf773a00b3c69bbd0b336854061cb06f2", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:contracts/datapack/bundle-component-contract.json:854a53ccf773a00b3c69bbd0b336854061cb06f2", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/41" @@ -243,7 +243,7 @@ "documentationFamily": "GOVERNANCE_EVIDENCE", "kindCandidate": "DATAPACK_MOBILE_COMPATIBILITY_MATRIX", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:contracts/datapack/compatibility-matrix.json:6fbf25901c16c019c4f9cf6ea3da2975caf5c480", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:contracts/datapack/compatibility-matrix.json:6fbf25901c16c019c4f9cf6ea3da2975caf5c480", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/54", @@ -267,8 +267,8 @@ "reviewTrigger": [ "event:compatibility-contract-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:contracts/datapack/compatibility-matrix.json:6fbf25901c16c019c4f9cf6ea3da2975caf5c480", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:contracts/datapack/compatibility-matrix.json:6fbf25901c16c019c4f9cf6ea3da2975caf5c480", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/54" @@ -293,7 +293,7 @@ "documentationFamily": "API_CONTRACT", "kindCandidate": "SIGNED_DATAPACK_MANIFEST_SCHEMA", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:contracts/datapack/datapack-manifest.schema.json:6fd7d44ddd72c70fce266663977deecbe6401d91", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:contracts/datapack/datapack-manifest.schema.json:6fd7d44ddd72c70fce266663977deecbe6401d91", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/38", @@ -318,8 +318,8 @@ "reviewTrigger": [ "event:manifest-schema-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:contracts/datapack/datapack-manifest.schema.json:6fd7d44ddd72c70fce266663977deecbe6401d91", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:contracts/datapack/datapack-manifest.schema.json:6fd7d44ddd72c70fce266663977deecbe6401d91", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/38" @@ -344,7 +344,7 @@ "documentationFamily": "ENGINEERING", "kindCandidate": "DETERMINISTIC_DATAPACK_BUILDER", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/build-datapack.mjs:5446348b02a088d57093cab8f91e5db3bb1be8d8", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/build-datapack.mjs:5e565c5621d7f6889ac8ccc34ce4539d871d4bf7", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/51", @@ -368,8 +368,8 @@ "reviewTrigger": [ "event:builder-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/build-datapack.mjs:5446348b02a088d57093cab8f91e5db3bb1be8d8", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/build-datapack.mjs:5e565c5621d7f6889ac8ccc34ce4539d871d4bf7", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/51" @@ -394,7 +394,7 @@ "documentationFamily": "OPERATIONS_RELIABILITY", "kindCandidate": "SOURCE_ADMISSION_OPERATION_CONTRACT", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/source-admission-runbook.json:86e4b668413bf91b90b4fb9976b485cdd7c56815", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/source-admission-runbook.json:86e4b668413bf91b90b4fb9976b485cdd7c56815", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/28", @@ -418,8 +418,8 @@ "reviewTrigger": [ "event:source-admission-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/source-admission-runbook.json:86e4b668413bf91b90b4fb9976b485cdd7c56815", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/source-admission-runbook.json:86e4b668413bf91b90b4fb9976b485cdd7c56815", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/28" @@ -444,7 +444,7 @@ "documentationFamily": "SECURITY_PRIVACY", "kindCandidate": "SOURCE_GOVERNANCE_LICENSE_RETENTION_POLICY", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/source-governance-policy.json:cdacd5175884ae86677c65fa8d249a7da1d23990", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/source-governance-policy.json:cdacd5175884ae86677c65fa8d249a7da1d23990", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/29", @@ -468,8 +468,8 @@ "reviewTrigger": [ "event:source-governance-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/source-governance-policy.json:cdacd5175884ae86677c65fa8d249a7da1d23990", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/source-governance-policy.json:cdacd5175884ae86677c65fa8d249a7da1d23990", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/29" @@ -494,7 +494,7 @@ "documentationFamily": "DATA_KNOWLEDGE", "kindCandidate": "PRODUCTION_SOURCE_INVENTORY", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/source-inventory.json:57e3345d47bacc74f23ba82b0f93638d94ef9186", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/source-inventory.json:57e3345d47bacc74f23ba82b0f93638d94ef9186", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/28", @@ -519,8 +519,8 @@ "reviewTrigger": [ "event:source-freshness-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/datapack/source-inventory.json:57e3345d47bacc74f23ba82b0f93638d94ef9186", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/datapack/source-inventory.json:57e3345d47bacc74f23ba82b0f93638d94ef9186", "verificationMethod": "release-audit", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/28" @@ -545,7 +545,7 @@ "documentationFamily": "USER_SUPPORT_LEGAL_PUBLIC", "kindCandidate": "ROUTE_MAP_LICENSE_DECISION", "sourceSurface": "TRACKED", - "canonicalIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/route-map/route-map-license-decision.json:272a4bdb05f2bdefb51256ceeed67561d95d8759", + "canonicalIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/route-map/route-map-license-decision.json:272a4bdb05f2bdefb51256ceeed67561d95d8759", "status": "ACTIVE", "ownerRepository": "AquilaXk/easysubway-data", "ownerIssue": "https://github.com/AquilaXk/easysubway-data/issues/21", @@ -569,8 +569,8 @@ "reviewTrigger": [ "event:license-decision-change" ], - "lastVerifiedAt": "2026-10-03T07:46:30.384Z", - "lastVerifiedIdentity": "git:cc6dd6e670ea53290a80171006120b2a8a6669a3:tools/route-map/route-map-license-decision.json:272a4bdb05f2bdefb51256ceeed67561d95d8759", + "lastVerifiedAt": "2026-10-04T00:37:04.361Z", + "lastVerifiedIdentity": "git:a566db99e4878933326edac84b13290c13aec62f:tools/route-map/route-map-license-decision.json:272a4bdb05f2bdefb51256ceeed67561d95d8759", "verificationMethod": "contract-test", "verificationEvidence": [ "https://github.com/AquilaXk/easysubway-data/issues/21" diff --git a/tools/datapack/build-datapack.mjs b/tools/datapack/build-datapack.mjs index 5446348b0..5e565c562 100644 --- a/tools/datapack/build-datapack.mjs +++ b/tools/datapack/build-datapack.mjs @@ -71,6 +71,7 @@ import { import { bindStationContacts, loadStationContactInputs } from "./build-station-contacts.mjs"; import { isCapitalRouteTopologySnapshotId } from "./lib/capital-route-topology-snapshot-id.mjs"; import { expandExternalStopTimes } from "./lib/external-stop-times.mjs"; +import { serviceDayStopTimes } from "./lib/service-day-seconds.mjs"; import { deriveFreshnessExpiresAt } from "./freshness-policy.mjs"; import { validateSourceSnapshotFreshness } from "./validate-source-snapshot-freshness.mjs"; @@ -4323,7 +4324,9 @@ export function buildSqlitePack(sqlitePath, schema, pack, officialOdFareAdmissio "pickup_type", "drop_off_type", ], - pack.transitStopTimes ?? [], + // #918: 원천이 00:00~02:59로 적은 자정 이후 시각은 전날 운행일의 24시 이후 초로 싣는다(운행일 경계 03:00). + // 바꾼 뒤 trip 안 시각이 줄어들면(경계를 가로지르는 열차) validate-datapack의 순서 검사가 팩을 거부한다. + serviceDayStopTimes(pack.transitStopTimes ?? []), (row) => [ requiredString(row.tripId, "transitStopTimes.tripId"), requiredPositiveInteger(row.stopSequence, "transitStopTimes.stopSequence"), diff --git a/tools/datapack/lib/kric-station-lines-timetable.mjs b/tools/datapack/lib/kric-station-lines-timetable.mjs index 51f356af0..056ed9f7d 100644 --- a/tools/datapack/lib/kric-station-lines-timetable.mjs +++ b/tools/datapack/lib/kric-station-lines-timetable.mjs @@ -3,7 +3,6 @@ import { createHash } from "node:crypto"; import { codepointCompare } from "../../lib/codepoint-compare.mjs"; import { KRIC_API_STATION_TIMETABLE_BINDINGS } from "./kric-station-timetable-api-trips.mjs"; import { canonicalJson } from "./manifest-validation.mjs"; -import { serviceDaySeconds } from "./service-day-seconds.mjs"; // #903: kric-subway-timetable-station-lines 등록기가 커밋한 파생 스냅샷(역별 KRIC API 재구성 trip)을 // 공용 적재기(materializeOfficialLineTimetables) 입력으로 바꾼다. 스냅샷 내용 해시를 다시 확인하고 trip은 바꾸지 않는다. @@ -38,12 +37,7 @@ export function kricStationLinesOfficialTimetable(snapshot, evidence, { bindings lineId: trip.lineId, routeKey: lnCdOf.get(trip.lineId) ?? fail("LINE", trip.lineId), providerTripKey: trip.providerTripKey, trainNumber: trip.provenance.trainNumber, serviceDayKind: SERVICE_DAY_KIND[trip.serviceDayKind] ?? fail("SERVICE_DAY", trip.providerTripKey), sourceDayKey: trip.provenance.dayCd, servicePattern: trip.servicePattern, headsign: trip.headsign, - sourceRowSha256: trip.sourceRowSha256, - // #918: KRIC 응답(HHMMSS)이 00시대로 적은 자정 이후 시발 열차 시각은 전날 운행일의 심야 시각이다(운행일 경계 03:00). - // 스냅샷 trip은 바꾸지 않고 적재 입력에서만 운행일 초로 표현한다. - stops: trip.stops.map(({ stationName, arrivalSeconds, departureSeconds }) => ({ - stationName, arrivalSeconds: serviceDaySeconds(arrivalSeconds), departureSeconds: serviceDaySeconds(departureSeconds), - })), + sourceRowSha256: trip.sourceRowSha256, stops: trip.stops, })); const quarantine = snapshot.quarantine.map((row) => { const [, trainNumber, dayCd] = row.providerTripKey.split("|"); diff --git a/tools/datapack/lib/kric-station-lines-timetable.test.mjs b/tools/datapack/lib/kric-station-lines-timetable.test.mjs index 827d90ac5..4a41061a8 100644 --- a/tools/datapack/lib/kric-station-lines-timetable.test.mjs +++ b/tools/datapack/lib/kric-station-lines-timetable.test.mjs @@ -13,14 +13,15 @@ const trip = (trainNumber, dayCd, kind) => ({ provenance: { lnCd: "B1", trainNumber, dayCd, direction: "ASC" }, }); -function snapshotAndEvidence(quarantine = [], trips = [trip("1001", "8", "WEEKDAY"), trip("3001", "9", "SATURDAY_SUNDAY_HOLIDAY")]) { +function snapshotAndEvidence(quarantine = []) { + const trips = [trip("1001", "8", "WEEKDAY"), trip("3001", "9", "SATURDAY_SUNDAY_HOLIDAY")]; const content = { schemaVersion: 1, artifactKind: "kric-station-timetable-snapshot", sourceId: "kric-subway-timetable-station-lines", catalogProviderId: "provider:kric-subway-timetable", capturedAt: "2026-10-03T00:30:00.000Z", collectedAt: "2026-10-03T00:31:00.000Z", raw: { rawSha256: "a".repeat(64), byteSize: 1, rawObjectUri: "oci://x", publicationReceiptSha256: "b".repeat(64) }, serviceDayPolicy: "policy", lines: [], trips, quarantine }; const contentSha256 = sha(canonicalJson(content)); const snapshot = { ...content, snapshotId: "kric-subway-timetable-station-lines-20261003", contentSha256 }; - const evidence = { snapshotId: snapshot.snapshotId, contentSha256, rawSha256: "a".repeat(64), tripsSha256: sha(JSON.stringify(trips)), tripCount: trips.length }; + const evidence = { snapshotId: snapshot.snapshotId, contentSha256, rawSha256: "a".repeat(64), tripsSha256: sha(JSON.stringify(trips)), tripCount: 2 }; return { snapshot, evidence }; } @@ -55,20 +56,3 @@ test("격리 행의 운행일은 dayCd 8=평일, 9=주말·휴일만 받고 그 assert.deepEqual(kinds("9"), ["WEEKEND_HOLIDAY"]); for (const dayCd of ["7", "1", ""]) assert.throws(() => kinds(dayCd), /KRIC_STATION_LINES_TIMETABLE_SERVICE_DAY/u); }); - -test("#918 자정 이후 시발 열차의 00~02시 시각은 전날 운행일의 24시 이후 초로 적재한다(스냅샷은 바꾸지 않는다)", () => { - const timed = (trainNumber, departure, arrival) => ({ ...trip(trainNumber, "8", "WEEKDAY"), - stops: [{ stationName: "가역(부역)", arrivalSeconds: null, departureSeconds: departure }, { stationName: "나역", arrivalSeconds: arrival, departureSeconds: null }] }); - // 원천 KRIC 응답(HHMMSS)은 00시대로 적는다. 자정을 넘는 열차는 스냅샷에 이미 86400초 이상으로 이어져 있다. - const { snapshot, evidence } = snapshotAndEvidence([], [timed("1901", 360, 840), timed("1899", 86_040, 86_460), timed("1001", 21_600, 21_780)]); - const before = structuredClone(snapshot); - const { provider } = kricStationLinesOfficialTimetable(snapshot, evidence, { bindings: BINDINGS }); - const stops = Object.fromEntries(provider.trips.map(({ trainNumber, stops: tripStops }) => [trainNumber, - tripStops.map(({ arrivalSeconds, departureSeconds }) => [arrivalSeconds, departureSeconds])])); - assert.deepEqual(stops, { - 1001: [[null, 21_600], [21_780, null]], - 1899: [[null, 86_040], [86_460, null]], - 1901: [[null, 86_760], [87_240, null]], - }); - assert.deepEqual(snapshot, before); -}); diff --git a/tools/datapack/lib/service-day-seconds.mjs b/tools/datapack/lib/service-day-seconds.mjs index ddf3e4f4f..a40152770 100644 --- a/tools/datapack/lib/service-day-seconds.mjs +++ b/tools/datapack/lib/service-day-seconds.mjs @@ -1,8 +1,9 @@ // #918: 운행일 경계. 시간표 원천은 자정 이후 시각을 24시 미만(00:00~02:59)으로 적는다. -// 그 시각은 전날 운행일의 심야 시각이므로 86400초 이상으로 표현한다. -// 근거(2026-10-04 실측): 인천 1·2호선 공식 FILE 8개는 00시대 시발 행을 모두 파일 끝(23시대 시발 뒤)에 두고 -// 02~04시 시각이 하나도 없다. KRIC 역별 API(GTX-A·의정부·에버라인·김포골드·부산김해)도 01~04시 시발 열차가 없다. -// normalize-kric-timetable.mjs(03:00)와 같은 경계를 쓴다. +// 그 시각은 전날 운행일의 심야 시각이므로 데이터팩 SQLite(transit_stop_times)에는 86400초 이상으로 싣는다. +// 근거(2026-10-04 실측) +// - 인천 1·2호선 공식 FILE 8개는 00시대 시발 행을 모두 파일 끝(23시대 시발 뒤)에 두고, 02~04시 시각이 하나도 없다. +// - KRIC 역별 API(GTX-A·의정부·에버라인·김포골드·부산김해)도 01~04시에 출발하는 열차가 없다. +// normalize-kric-timetable.mjs와 같은 03:00 경계를 쓴다. export const SERVICE_DAY_BOUNDARY_SECONDS = 3 * 3_600; const SECONDS_PER_DAY = 86_400; @@ -13,8 +14,14 @@ export function serviceDaySeconds(seconds) { return seconds < SERVICE_DAY_BOUNDARY_SECONDS ? seconds + SECONDS_PER_DAY : seconds; } -/** 운행일 경계 앞 시각이 남은 stop_time 행을 돌려준다. 비어 있어야 팩을 만들 수 있다. */ -export function serviceDayBoundaryViolations(stopTimes) { - const early = (seconds) => typeof seconds === "number" && seconds < SERVICE_DAY_BOUNDARY_SECONDS; - return stopTimes.filter(({ arrivalSeconds, departureSeconds }) => early(arrivalSeconds) || early(departureSeconds)); +/** + * stop_time 행의 도착·출발을 운행일 초로 바꾼 새 행을 돌려준다. + * 바꾼 뒤 trip 안 시각 순서(운행일 경계를 가로지르는 열차 등)는 validate-datapack의 순서 검사가 막는다. + */ +export function serviceDayStopTimes(stopTimes) { + return stopTimes.map((row) => ({ + ...row, + arrivalSeconds: serviceDaySeconds(row.arrivalSeconds), + departureSeconds: serviceDaySeconds(row.departureSeconds), + })); } diff --git a/tools/datapack/lib/service-day-seconds.test.mjs b/tools/datapack/lib/service-day-seconds.test.mjs index 6c92edc6a..b0a0c18b2 100644 --- a/tools/datapack/lib/service-day-seconds.test.mjs +++ b/tools/datapack/lib/service-day-seconds.test.mjs @@ -3,8 +3,8 @@ import { test } from "node:test"; import { SERVICE_DAY_BOUNDARY_SECONDS, - serviceDayBoundaryViolations, serviceDaySeconds, + serviceDayStopTimes, } from "./service-day-seconds.mjs"; // #918: 원천이 24시 미만으로 적은 00:00~02:59 시각은 전날 운행일의 심야 시각이다. @@ -27,16 +27,26 @@ test("시각이 정수 초가 아니면 운행일 시각으로 바꾸지 않고 } }); -test("운행일 경계 앞 정차 시각이 남은 stop_time을 모두 찾는다", () => { - const violations = serviceDayBoundaryViolations([ - { tripId: "trip-a", stopSequence: 1, arrivalSeconds: 86_430, departureSeconds: 86_430 }, - { tripId: "trip-b", stopSequence: 1, arrivalSeconds: 570, departureSeconds: 570 }, - { tripId: "trip-b", stopSequence: 2, arrivalSeconds: 10_790, departureSeconds: 10_800 }, - { tripId: "trip-c", stopSequence: 1, arrivalSeconds: 19_800, departureSeconds: 19_800 }, +test("자정 이후 시발 열차(인천 1호선 1305형)는 정차 시각 전체가 86400초 이상이 되고, 자정을 넘는 열차는 그대로다", () => { + const rows = [ + // 1301: 수집기가 이미 자정 이후 정차를 86400초 이상으로 이어 둔 열차 + { tripId: "trip-1301", stopSequence: 1, arrivalSeconds: 85_590, departureSeconds: 85_590 }, + { tripId: "trip-1301", stopSequence: 2, arrivalSeconds: 86_430, departureSeconds: 86_430 }, + // 1305: 원천 FILE이 00:09:30 시발로 적은 막차 + { tripId: "trip-1305", stopSequence: 2, arrivalSeconds: 1_410, departureSeconds: 1_410 }, + { tripId: "trip-1305", stopSequence: 1, arrivalSeconds: 570, departureSeconds: 570 }, + { tripId: "trip-first", stopSequence: 1, arrivalSeconds: 19_800, departureSeconds: 19_800 }, + { tripId: "trip-first", stopSequence: 2, arrivalSeconds: 19_920, departureSeconds: 19_950 }, + ]; + const before = structuredClone(rows); + assert.deepEqual(serviceDayStopTimes(rows).map(({ tripId, stopSequence, arrivalSeconds, departureSeconds }) => + [tripId, stopSequence, arrivalSeconds, departureSeconds]), [ + ["trip-1301", 1, 85_590, 85_590], + ["trip-1301", 2, 86_430, 86_430], + ["trip-1305", 2, 87_810, 87_810], + ["trip-1305", 1, 86_970, 86_970], + ["trip-first", 1, 19_800, 19_800], + ["trip-first", 2, 19_920, 19_950], ]); - assert.deepEqual(violations, [ - { tripId: "trip-b", stopSequence: 1, arrivalSeconds: 570, departureSeconds: 570 }, - { tripId: "trip-b", stopSequence: 2, arrivalSeconds: 10_790, departureSeconds: 10_800 }, - ]); - assert.deepEqual(serviceDayBoundaryViolations([]), []); + assert.deepEqual(rows, before); }); diff --git a/tools/datapack/materialize-incheon-timetable.mjs b/tools/datapack/materialize-incheon-timetable.mjs index 93e1826ca..e97b900c3 100644 --- a/tools/datapack/materialize-incheon-timetable.mjs +++ b/tools/datapack/materialize-incheon-timetable.mjs @@ -13,7 +13,6 @@ import { isDeepStrictEqual } from "node:util"; import { INCHEON_TIMETABLE_LINES } from "./collect-incheon-timetable.mjs"; import { validateIncheonStationInfoSnapshot } from "./collect-incheon-station-info.mjs"; -import { serviceDaySeconds } from "./lib/service-day-seconds.mjs"; const ISSUE = 2488; const MATERIALIZER = "tools/datapack/materialize-incheon-timetable.mjs"; @@ -413,9 +412,8 @@ function addRoutesAndTrips(pack, line, stationIdByLineCode) { stopSequence: index + 1, stationId, lineId: config.lineId, - // #918: 공식 FILE이 00시대로 적은 자정 이후 시각은 전날 운행일의 심야 시각이다(운행일 경계 03:00). - arrivalSeconds: serviceDaySeconds(stop.a), - departureSeconds: serviceDaySeconds(stop.d), + arrivalSeconds: stop.a, + departureSeconds: stop.d, pickupType: index === trip.stops.length - 1 ? 1 : 0, dropOffType: index === 0 ? 1 : 0, }, tripProvenance)); diff --git a/tools/datapack/materialize-incheon-timetable.test.mjs b/tools/datapack/materialize-incheon-timetable.test.mjs index d655b88c1..844f286a1 100644 --- a/tools/datapack/materialize-incheon-timetable.test.mjs +++ b/tools/datapack/materialize-incheon-timetable.test.mjs @@ -248,16 +248,6 @@ test("인천 1·2호선 공식 timetable을 1414 trip·40898 stop_time·WEEK/HOL const maxArrival = Math.max(...stopTimes.map(({ arrivalSeconds }) => arrivalSeconds)); assert.ok(maxArrival > 86_400, `expected a post-midnight trip, got max ${maxArrival}`); - - // #918: 공식 FILE은 자정 이후 막차를 00시대로 적고 파일 끝에 둔다(02~04시 시각 없음). - // 운행일 경계(03:00) 앞 시각은 전날 운행일의 심야 시각이므로 86400초 이상으로 싣는다. - assert.deepEqual(stopTimes.filter(({ arrivalSeconds, departureSeconds }) => - arrivalSeconds < 10_800 || departureSeconds < 10_800), []); - const bakchon = (tripId) => stopTimes.find((row) => row.tripId === tripId && row.stationId === "station-f497b2d7043f"); - assert.deepEqual( - ["1301", "1303", "1305", "1307", "1309"].map((trainNo) => bakchon(`trip-incheon-1-dn-week-${trainNo}`).departureSeconds), - [86_430, 87_120, 87_810, 88_530, 89_070], - ); }); test("인천 timetable materializer는 snapshot·inventory·freshness·topology lineage 변조를 fail-closed한다", async () => { @@ -445,6 +435,20 @@ test("materialized SQLite와 provenance가 인천 schedule_timetable 2건을 SUP "incheon-line2-holiday-2026": 372, "incheon-line2-weekday-2026": 468, }); + // #918: 공식 FILE은 자정 이후 막차를 00시대로 적고 파일 끝에 둔다(02~04시 시각 없음). + // 데이터팩 SQLite에는 운행일 경계(03:00) 앞 시각을 전날 운행일의 24시 이후 초로 싣는다. + assert.equal(database.prepare(` + SELECT COUNT(*) AS count FROM transit_stop_times + WHERE trip_id LIKE 'trip-incheon-%' AND (arrival_seconds < 10800 OR departure_seconds < 10800) + `).get().count, 0); + assert.deepEqual(database.prepare(` + SELECT trip_id AS tripId, departure_seconds AS departureSeconds FROM transit_stop_times + WHERE station_id = 'station-f497b2d7043f' AND trip_id IN ( + 'trip-incheon-1-dn-week-1301', 'trip-incheon-1-dn-week-1303', 'trip-incheon-1-dn-week-1305', + 'trip-incheon-1-dn-week-1307', 'trip-incheon-1-dn-week-1309') + ORDER BY trip_id + `).all().map(({ tripId, departureSeconds }) => [tripId.slice(-4), departureSeconds]), + [["1301", 86_430], ["1303", 87_120], ["1305", 87_810], ["1307", 88_530], ["1309", 89_070]]); database.close(); await execFileAsync(process.execPath, [ diff --git a/tools/datapack/prepare-nationwide-candidate-run.mjs b/tools/datapack/prepare-nationwide-candidate-run.mjs index da642afbc..e126054f2 100644 --- a/tools/datapack/prepare-nationwide-candidate-run.mjs +++ b/tools/datapack/prepare-nationwide-candidate-run.mjs @@ -19,7 +19,6 @@ import { outOfStationTransferNetworkEdges } from "./build-datapack.mjs"; import { HOLIDAYS_2026, materializeIncheonTimetable } from "./materialize-incheon-timetable.mjs"; import { buildNationwidePlatformInfoMap } from "./lib/nationwide-platform-resolver.mjs"; import { integrateRegionalTimetables } from "./lib/regional-timetable-integrator.mjs"; -import { serviceDayBoundaryViolations } from "./lib/service-day-seconds.mjs"; import { deriveFreshnessExpiresAt } from "./freshness-policy.mjs"; import { deriveApprovedItxTopologyEvidencePath } from "./activate-current-source-set.mjs"; import { officialOdFareAdmissionsBySource, officialOdFareQuoteSetHash } from "./lib/official-od-fare-evidence.mjs"; @@ -1578,13 +1577,6 @@ export async function prepareNationwideCandidate({ ...gwangjuTopologySource.coverageScope[field]])].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); } - // #918: 모든 시간표 원천을 합친 뒤 운행일 경계(03:00) 앞 정차 시각이 남으면 후보를 만들지 않는다. - const serviceDayBoundaryRows = serviceDayBoundaryViolations(finalPack.transitStopTimes); - if (serviceDayBoundaryRows.length > 0) { - const tripIds = [...new Set(serviceDayBoundaryRows.map(({ tripId }) => tripId))]; - throw new Error(`nationwide candidate stop times precede the 03:00 service-day boundary: ${tripIds.length} trips (${tripIds.slice(0, 5).join(", ")})`); - } - // #855: 대전·광주 원천은 역별 시각 하나만 준다. 원천 정차 2개 이상으로 열차를 만들 수 없는 // 원천 시각은 팩에 싣지 않고 사유·식별자·개수를 격리 증거로 남긴다. const timetableQuarantine = [ diff --git a/tools/datapack/prepare-nationwide-candidate-run.test.mjs b/tools/datapack/prepare-nationwide-candidate-run.test.mjs index 716c59699..9e6218025 100644 --- a/tools/datapack/prepare-nationwide-candidate-run.test.mjs +++ b/tools/datapack/prepare-nationwide-candidate-run.test.mjs @@ -7,7 +7,6 @@ import { fileURLToPath } from "node:url"; import { expandExternalStopTimes } from "./lib/external-stop-times.mjs"; import { canonicalJson } from "./lib/manifest-validation.mjs"; -import { serviceDayBoundaryViolations } from "./lib/service-day-seconds.mjs"; import { topologySnapshotFreshUntil } from "./lib/topology-freshness-cutover.mjs"; import { admitOutOfStationTransferLinks, officialTransferEndpointRecords, packOutOfStationTransferLinks, applyMeasuredTransferTimePrecedence, assertCandidateClockAfterRawStorage, prepareNationwideCandidate, resolveSeoulMeasuredTransferMetrics, formatPlatformInfo, gwangjuFacilityState, regionalFacilityTypeCounts, busanFacilityState, officialTransferMetricsByDirection, resolveBusanTransferMetrics, resolveMolitTransferSnapshot, resolveNationwideCandidateInputSnapshots } from "./prepare-nationwide-candidate-run.mjs"; @@ -483,8 +482,6 @@ test("prepareNationwideCandidate binds platform metadata onto stationLines", asy }); const pack = result.finalPack; - // #918: 조립한 전국 팩에는 운행일 경계(03:00) 앞 정차 시각이 남지 않는다(인천 1·2호선, KRIC GTX-A·의정부 포함). - assert.deepEqual(serviceDayBoundaryViolations(pack.transitStopTimes), []); const targetLine = pack.stationLines.find( (sl) => sl.stationId === "station-00089f8f97de" && sl.lineId === "line-558d0bd8312d" ); From 15798a8b3bb3d984aac54b4d332ef77e83029f92 Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 13:12:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[Fix]=20=EC=BB=B4=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=90=9C=20SQLite=EC=97=90=2003:00=20=EC=A0=84=20=EC=A0=95?= =?UTF-8?q?=EC=B0=A8=20=EC=8B=9C=EA=B0=81=EC=9D=B4=20=EB=82=A8=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=ED=8C=A9=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EA=B1=B0=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 F1(#921). 운행일 경계 변환을 SQLite 기록 한 곳에만 두면 다른 기록 경로나 이후 리팩터로 0시대 값이 돌아와도 검증을 통과했다. validate-datapack이 모든 기관의 transit_stop_times에서 03:00 전 도착·출발을 거부한다. Refs #918 --- tools/datapack/datapack-tools.test.mjs | 28 ++++++++++++++++++++++++++ tools/datapack/validate-datapack.mjs | 5 +++++ 2 files changed, 33 insertions(+) diff --git a/tools/datapack/datapack-tools.test.mjs b/tools/datapack/datapack-tools.test.mjs index 8835f62bf..03d50933b 100644 --- a/tools/datapack/datapack-tools.test.mjs +++ b/tools/datapack/datapack-tools.test.mjs @@ -4499,6 +4499,34 @@ test("데이터팩 검증기는 현장·운영기관 확인 시설 AVAILABLE 근 ); }); +// #918 불변식: 컴파일된 SQLite에 03:00(운행일 경계) 전 정차 시각이 남으면 어느 기관이든 팩을 거부한다. +test("#918 데이터팩 검증기는 운행일 경계(03:00) 전 정차 시각이 남은 비인천 trip을 거부한다", async (context) => { + const artifact = await buildCurrentProductionArtifact(context); + await mutateCurrentProductionSqlite(artifact, ({ database }) => { + const trip = database.prepare(` + SELECT trip_id FROM transit_stop_times + WHERE trip_id NOT LIKE 'trip-incheon-%' + GROUP BY trip_id HAVING MIN(arrival_seconds) >= 86400 + ORDER BY trip_id LIMIT 1 + `).get(); + assert.ok(trip, "current production artifact requires one non-Incheon after-midnight trip"); + const update = database.prepare(` + UPDATE transit_stop_times + SET arrival_seconds = arrival_seconds - 86400, departure_seconds = departure_seconds - 86400 + WHERE trip_id = ? + `).run(trip.trip_id); + assert.ok(update.changes >= 2); + }); + await assert.rejects( + execFileAsync( + process.execPath, + ["tools/datapack/validate-datapack.mjs", ...currentProductionValidationArgs(artifact)], + { cwd: root, env: productionEnv }, + ), + /transit_stop_times must not precede the 03:00 service-day boundary/, + ); +}); + test("데이터팩 검증기는 근거 없는 시설 operationalStatus AVAILABLE을 거부한다", async (context) => { const artifact = await buildCurrentProductionArtifact(context); await mutateCurrentProductionSqlite(artifact, ({ database }) => { diff --git a/tools/datapack/validate-datapack.mjs b/tools/datapack/validate-datapack.mjs index c0a64acc8..ab19b0520 100644 --- a/tools/datapack/validate-datapack.mjs +++ b/tools/datapack/validate-datapack.mjs @@ -24,6 +24,7 @@ import { canonicalCurrentReleaseCandidateAccessibilityAuthorityJson, isNationwideCandidateId, } from "./build-current-release-candidate-accessibility-input.mjs"; +import { SERVICE_DAY_BOUNDARY_SECONDS } from "./lib/service-day-seconds.mjs"; const facilityEvidenceProvenanceColumns = [ "source_id", "source_snapshot_id", @@ -494,6 +495,10 @@ function validateTransitSchedule(database, pack) { if (stopTime.arrival_seconds < previousDeparture) { throw new Error(`${pack.id}@${pack.version} transit_stop_times must be monotonic: ${trip.id}`); } + // #918: 03:00(운행일 경계) 전 시각은 전날 운행일의 24시 이후 초로 실어야 한다. 0시대 값이 남으면 기관과 상관없이 거부한다. + if (stopTime.arrival_seconds < SERVICE_DAY_BOUNDARY_SECONDS || stopTime.departure_seconds < SERVICE_DAY_BOUNDARY_SECONDS) { + throw new Error(`${pack.id}@${pack.version} transit_stop_times must not precede the 03:00 service-day boundary: ${trip.id}`); + } if (stopTime.line_id !== route.line_id) { throw new Error(`${pack.id}@${pack.version} transit_stop_times line_id must match route line_id: ${trip.id}`); } From f75d7b8f0e6e15eb69e35db905f8e1b32e6e427e Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 13:12:54 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[Test]=20=EB=B9=84=EC=9D=B8=EC=B2=9C=2000?= =?UTF-8?q?=EC=8B=9C=EB=8C=80=20trip=20=EB=B3=80=ED=99=98=EA=B3=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=ED=9A=A1=EB=8B=A8=20trip=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20=EB=B9=8C=EB=93=9C=20=EC=88=98=EC=A4=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 F4(#921). catalog fixture의 4호선 trip을 00시대로 옮겨 빌드하면 SQLite에 86400초 이상으로 실리고 검증을 통과한다. 02:55→03:05로 경계를 가로지르는 trip은 변환 뒤 역행해 검증기가 거부한다. Refs #918 --- tools/datapack/datapack-tools.test.mjs | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tools/datapack/datapack-tools.test.mjs b/tools/datapack/datapack-tools.test.mjs index 03d50933b..327678831 100644 --- a/tools/datapack/datapack-tools.test.mjs +++ b/tools/datapack/datapack-tools.test.mjs @@ -1501,6 +1501,64 @@ test("데이터팩 검증기는 trip별 stop_time 시간이 역행하면 거부 ); }); +// #918: 원천이 00:00~02:59로 적은 시각은 전날 운행일의 24시 이후 초로 SQLite에 싣는다(인천만이 아니라 모든 기관). +async function buildFixtureWithStopTimes(name, times) { + const fixture = JSON.parse(await readFile("tools/datapack/fixtures/catalog-fixture.json", "utf8")); + const outputDir = path.join(tmpdir(), `easysubway-datapack-service-day-${name}-${Date.now()}`); + const fixturePath = path.join(outputDir, "fixture.json"); + await rm(outputDir, { recursive: true, force: true }); + await mkdir(outputDir, { recursive: true }); + const rows = fixture.packs[0].transitStopTimes + .filter(({ tripId }) => tripId === "trip-seoul-4-local-0805") + .sort((left, right) => left.stopSequence - right.stopSequence); + assert.equal(rows.length, times.length); + rows.forEach((row, index) => Object.assign(row, times[index])); + await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`); + await execFileAsync( + process.execPath, + ["tools/datapack/build-datapack.mjs", "--fixture", fixturePath, "--output", outputDir], + { cwd: root, env: productionEnv }, + ); + return outputDir; +} + +test("#918 수도권 4호선 trip의 00시대 정차도 데이터팩 SQLite에는 86400초 이상으로 싣고 검증을 통과한다", async (context) => { + const outputDir = await buildFixtureWithStopTimes("after-midnight", [ + { arrivalSeconds: 600, departureSeconds: 600 }, + { arrivalSeconds: 1_320, departureSeconds: 1_440 }, + ]); + context.after(() => rm(outputDir, { recursive: true, force: true })); + const database = new DatabaseSync(path.join(outputDir, "catalog", "capital-v1.sqlite"), { readOnly: true }); + try { + assert.deepEqual(database.prepare(` + SELECT stop_sequence, arrival_seconds, departure_seconds FROM transit_stop_times + WHERE trip_id = 'trip-seoul-4-local-0805' ORDER BY stop_sequence + `).all().map((row) => ({ ...row })), [ + { stop_sequence: 1, arrival_seconds: 87_000, departure_seconds: 87_000 }, + { stop_sequence: 2, arrival_seconds: 87_720, departure_seconds: 87_840 }, + ]); + } finally { + database.close(); + } + await execFileAsync(process.execPath, [ + "tools/datapack/validate-datapack.mjs", "--manifest", path.join(outputDir, "current.json"), "--root", outputDir, + ], { cwd: root, env: productionEnv }); +}); + +test("#918 운행일 경계(03:00)를 가로지르는 trip은 변환 뒤 시각이 역행해 검증기가 거부한다", async (context) => { + const outputDir = await buildFixtureWithStopTimes("straddle", [ + { arrivalSeconds: 10_500, departureSeconds: 10_500 }, + { arrivalSeconds: 11_100, departureSeconds: 11_220 }, + ]); + context.after(() => rm(outputDir, { recursive: true, force: true })); + await assert.rejects( + execFileAsync(process.execPath, [ + "tools/datapack/validate-datapack.mjs", "--manifest", path.join(outputDir, "current.json"), "--root", outputDir, + ], { cwd: root, env: productionEnv }), + /transit_stop_times must be monotonic: trip-seoul-4-local-0805/, + ); +}); + test("원격 데이터팩 검증 wrapper는 manifest와 pack을 내려받아 기존 validator를 실행한다", async () => { const packOutputDir = path.join(tmpdir(), `easysubway-remote-datapack-source-${Date.now()}`); const downloadDir = path.join(tmpdir(), `easysubway-remote-datapack-download-${Date.now()}`); From 8ff62d962c29cea30bab637c906a8d737f53db23 Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 13:13:38 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[Refactor]=20=EC=9A=B4=ED=96=89=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=2003:00=EC=9D=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=AA=BD=20=ED=95=9C=20=EC=83=81=EC=88=98=EB=A1=9C?= =?UTF-8?q?=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20Journey=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EA=B0=92=EA=B3=BC=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 F3(#921). normalize-kric-timetable의 별도 3시 상수를 lib/service-day-seconds의 상수로 바꾼다. SERVICE_DAY_CUTOFF_LOCAL_TIME("03:00")이 hub journey-v3 serviceDayCutoff·backend ServiceDayResolver와 같은 값임을 테스트로 고정한다. Refs #918 --- tools/datapack/lib/service-day-seconds.mjs | 4 +++- tools/datapack/lib/service-day-seconds.test.mjs | 15 +++++++++++++++ tools/datapack/normalize-kric-timetable.mjs | 5 +++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tools/datapack/lib/service-day-seconds.mjs b/tools/datapack/lib/service-day-seconds.mjs index a40152770..e987b1eb4 100644 --- a/tools/datapack/lib/service-day-seconds.mjs +++ b/tools/datapack/lib/service-day-seconds.mjs @@ -3,7 +3,9 @@ // 근거(2026-10-04 실측) // - 인천 1·2호선 공식 FILE 8개는 00시대 시발 행을 모두 파일 끝(23시대 시발 뒤)에 두고, 02~04시 시각이 하나도 없다. // - KRIC 역별 API(GTX-A·의정부·에버라인·김포골드·부산김해)도 01~04시에 출발하는 열차가 없다. -// normalize-kric-timetable.mjs와 같은 03:00 경계를 쓴다. +// 데이터 쪽 운행일 경계는 이 상수 하나다(normalize-kric-timetable.mjs도 가져다 쓴다). +// hub Journey 계약(journey-v3 serviceDayCutoff "03:00")과 backend ServiceDayResolver.CUTOFF_LOCAL_TIME과 같아야 한다. +export const SERVICE_DAY_CUTOFF_LOCAL_TIME = "03:00"; export const SERVICE_DAY_BOUNDARY_SECONDS = 3 * 3_600; const SECONDS_PER_DAY = 86_400; diff --git a/tools/datapack/lib/service-day-seconds.test.mjs b/tools/datapack/lib/service-day-seconds.test.mjs index b0a0c18b2..533a35372 100644 --- a/tools/datapack/lib/service-day-seconds.test.mjs +++ b/tools/datapack/lib/service-day-seconds.test.mjs @@ -50,3 +50,18 @@ test("자정 이후 시발 열차(인천 1호선 1305형)는 정차 시각 전 ]); assert.deepEqual(rows, before); }); + +// 리뷰 F3(#921): 운행일 경계는 데이터 쪽에서 이 모듈 하나로만 정한다. +// 다른 레포의 같은 값(읽기 전용 대조, 2026-10-04): +// - hub contracts/api/journey-v3.openapi.yaml `serviceDayCutoff: enum ["03:00"]` +// - backend ServiceDayResolver.CUTOFF_LOCAL_TIME(응답 serviceDayCutoff), mobile은 응답의 serviceDayCutoff를 읽는다. +test("운행일 경계는 Journey 계약의 serviceDayCutoff \"03:00\"과 같고 KRIC 정규화도 같은 상수를 쓴다", async () => { + const { readFile } = await import("node:fs/promises"); + const { SERVICE_DAY_CUTOFF_LOCAL_TIME } = await import("./service-day-seconds.mjs"); + assert.equal(SERVICE_DAY_CUTOFF_LOCAL_TIME, "03:00"); + const [hours, minutes] = SERVICE_DAY_CUTOFF_LOCAL_TIME.split(":").map(Number); + assert.equal(SERVICE_DAY_BOUNDARY_SECONDS, hours * 3_600 + minutes * 60); + const normalizer = await readFile(new URL("../normalize-kric-timetable.mjs", import.meta.url), "utf8"); + assert.match(normalizer, /import \{ SERVICE_DAY_BOUNDARY_SECONDS \} from "\.\/lib\/service-day-seconds\.mjs";/u); + assert.doesNotMatch(normalizer, /3 \* 3600|10_?800/u); +}); diff --git a/tools/datapack/normalize-kric-timetable.mjs b/tools/datapack/normalize-kric-timetable.mjs index bdde271fb..710d86bbb 100644 --- a/tools/datapack/normalize-kric-timetable.mjs +++ b/tools/datapack/normalize-kric-timetable.mjs @@ -6,7 +6,8 @@ // - stinCd/lnCd/railOprIsttCd는 canonical stationId/lineId로 매핑(context 제공). // - dayCd(8/7/9)는 그대로 전달 — serviceId 매핑은 적재 시 context.serviceIdByDayCd가 담당. -const SERVICE_DAY_CUTOFF_SECONDS = 3 * 3600; +import { SERVICE_DAY_BOUNDARY_SECONDS } from "./lib/service-day-seconds.mjs"; + const DAY_SECONDS = 24 * 3600; export function normalizeKricSubwayTimetable(kricRows, context) { @@ -81,7 +82,7 @@ function parseKricTime(value) { // KRIC은 심야(자정 넘김) 열차 시각을 24:00~이 아니라 실제 시계(00:xx)로 반환한다. 지하철 서비스데이는 // ~03시에 끊기고(planner cutoff 동일) 03~05시 운행이 없으므로, 3시 미만 시각은 전 서비스데이 심야분(+24h)이다. // 이 정규화가 없으면 23:59 도착→00:00 출발 같은 자정 넘김에서 도착>출발이 된다. - return raw < SERVICE_DAY_CUTOFF_SECONDS ? raw + DAY_SECONDS : raw; + return raw < SERVICE_DAY_BOUNDARY_SECONDS ? raw + DAY_SECONDS : raw; } function requireMapping(lookup, key, label) { From 7f78d63a412a97ed85223817e3ca28707534d40b Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 13:13:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[Docs]=20=EC=9A=B4=ED=96=89=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B3=80=ED=99=98=EC=9D=B4=20SQLite=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=EC=9D=B8=20=EC=9D=B4=EC=9C=A0=EC=99=80=20JSO?= =?UTF-8?q?N=20=EA=B2=BD=EB=A1=9C=20=EC=86=8C=EB=B9=84=EC=B2=98=20?= =?UTF-8?q?=EB=B6=80=EC=9E=AC=EB=A5=BC=20=EC=BD=94=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 F2(#921). build-backend-timetable-seed·build-server-timetable-snapshot은 정본 팩 JSON을 읽지만 backend #479에서 seed 로더가 제거되어 운영 소비처가 없다. 다시 쓰려면 같은 변환이 필요함을 남긴다. Refs #918 --- tools/datapack/lib/service-day-seconds.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/datapack/lib/service-day-seconds.mjs b/tools/datapack/lib/service-day-seconds.mjs index e987b1eb4..b470f142b 100644 --- a/tools/datapack/lib/service-day-seconds.mjs +++ b/tools/datapack/lib/service-day-seconds.mjs @@ -3,6 +3,11 @@ // 근거(2026-10-04 실측) // - 인천 1·2호선 공식 FILE 8개는 00시대 시발 행을 모두 파일 끝(23시대 시발 뒤)에 두고, 02~04시 시각이 하나도 없다. // - KRIC 역별 API(GTX-A·의정부·에버라인·김포골드·부산김해)도 01~04시에 출발하는 열차가 없다. +// 적용 범위는 데이터팩 SQLite(transit_stop_times)다. 서버 경로 번들과 모바일 카탈로그 팩이 이 SQLite를 쓴다. +// 정본 팩 JSON을 직접 읽는 build-backend-timetable-seed·build-server-timetable-snapshot은 운영 소비처가 없다. +// 확인 근거(2026-10-04): backend #479가 TimetableSeedLoader와 seed 리소스를 지웠고, +// backend main 리소스에는 server-timetable-snapshot 산출물이 없다. +// 그 경로를 다시 쓰려면 같은 변환을 먼저 적용해야 한다. // 데이터 쪽 운행일 경계는 이 상수 하나다(normalize-kric-timetable.mjs도 가져다 쓴다). // hub Journey 계약(journey-v3 serviceDayCutoff "03:00")과 backend ServiceDayResolver.CUTOFF_LOCAL_TIME과 같아야 한다. export const SERVICE_DAY_CUTOFF_LOCAL_TIME = "03:00"; From e2eb772d12c88cf0c4179cadd8fa574fdeabfa5e Mon Sep 17 00:00:00 2001 From: AquilaXk Date: Sun, 4 Oct 2026 13:43:38 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[Test]=20=EC=9A=B4=ED=96=89=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B6=88=EB=B3=80=EC=8B=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=A0=84=EA=B5=AD=20RC=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=8C=80=EC=8B=A0=20fixture=20SQLite=20?= =?UTF-8?q?=EB=B3=80=EC=A1=B0=EB=A1=9C=20=EA=B0=80=EB=B3=8D=EA=B2=8C=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mobile-v19 CI 러너가 자원 한도로 중단(exit 143)되는 빈도를 줄이려고, 전국 RC 산출물을 새로 빌드하던 F1 테스트를 catalog fixture 빌드(4호선, 인천 아님)와 SQLite 직접 변조·fixture 서명 갱신으로 바꾼다. 검증기의 03:00 검사를 끄면 이 테스트가 실패한다. Refs #918 --- tools/datapack/datapack-tools.test.mjs | 75 ++++++++++++++++---------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/tools/datapack/datapack-tools.test.mjs b/tools/datapack/datapack-tools.test.mjs index 327678831..842fac48c 100644 --- a/tools/datapack/datapack-tools.test.mjs +++ b/tools/datapack/datapack-tools.test.mjs @@ -1545,6 +1545,53 @@ test("#918 수도권 4호선 trip의 00시대 정차도 데이터팩 SQLite에 ], { cwd: root, env: productionEnv }); }); +// #918 불변식: 컴파일된 SQLite에 03:00(운행일 경계) 전 정차 시각이 남으면 어느 기관이든 팩을 거부한다. +// 빌드는 이미 변환하므로, 다른 기록 경로를 흉내 내 SQLite를 직접 0시대로 되돌린다(4호선, 인천 아님). +test("#918 데이터팩 검증기는 운행일 경계(03:00) 전 정차 시각이 남은 비인천 trip을 거부한다", async (context) => { + const output = await buildFixtureWithStopTimes("pre-boundary", [ + { arrivalSeconds: 600, departureSeconds: 600 }, + { arrivalSeconds: 1_320, departureSeconds: 1_440 }, + ]); + context.after(() => rm(output, { recursive: true, force: true })); + const manifestPath = path.join(output, "current.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const sqlitePath = path.join(output, "catalog", "capital-v1.sqlite"); + const database = new DatabaseSync(sqlitePath); + try { + const update = database.prepare(` + UPDATE transit_stop_times + SET arrival_seconds = arrival_seconds - 86400, departure_seconds = departure_seconds - 86400 + WHERE trip_id = 'trip-seoul-4-local-0805' + `).run(); + assert.equal(update.changes, 2); + } finally { + database.close(); + } + const sqliteBytes = await readFile(sqlitePath); + const gzipBytes = gzipSync(sqliteBytes); + await writeFile(`${sqlitePath}.gz`, gzipBytes); + const pack = manifest.packs[0]; + Object.assign(pack, { sizeBytes: gzipBytes.length, sha256: sha256(gzipBytes), sqliteSha256: sha256(sqliteBytes) }); + // fixture 팩 서명은 payload의 sha256이다(production 팩만 RSA 서명). + const fixturePayload = `${pack.id}:${pack.version}:${pack.sha256}:${pack.sqliteSha256}:${pack.sizeBytes}`; + pack.signature.value = sha256(Buffer.from(fixturePayload)); + pack.representativeRouteRegressionSignature.value = sha256(Buffer.from( + `${fixturePayload}:${representativeRouteRegressionPayload(pack.representativeRouteRegressions)}`)); + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + await writeFile(manifestPath, manifestBytes); + const provenancePath = path.join(output, "current.provenance.json"); + const provenance = JSON.parse(await readFile(provenancePath, "utf8")); + provenance.manifestSha256 = sha256(manifestBytes); + provenance.packs.find(({ id }) => id === pack.id).sqliteSha256 = pack.sqliteSha256; + await writeFile(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`); + await assert.rejects( + execFileAsync(process.execPath, [ + "tools/datapack/validate-datapack.mjs", "--manifest", path.join(output, "current.json"), "--root", output, + ], { cwd: root, env: productionEnv }), + /transit_stop_times must not precede the 03:00 service-day boundary: trip-seoul-4-local-0805/, + ); +}); + test("#918 운행일 경계(03:00)를 가로지르는 trip은 변환 뒤 시각이 역행해 검증기가 거부한다", async (context) => { const outputDir = await buildFixtureWithStopTimes("straddle", [ { arrivalSeconds: 10_500, departureSeconds: 10_500 }, @@ -4557,34 +4604,6 @@ test("데이터팩 검증기는 현장·운영기관 확인 시설 AVAILABLE 근 ); }); -// #918 불변식: 컴파일된 SQLite에 03:00(운행일 경계) 전 정차 시각이 남으면 어느 기관이든 팩을 거부한다. -test("#918 데이터팩 검증기는 운행일 경계(03:00) 전 정차 시각이 남은 비인천 trip을 거부한다", async (context) => { - const artifact = await buildCurrentProductionArtifact(context); - await mutateCurrentProductionSqlite(artifact, ({ database }) => { - const trip = database.prepare(` - SELECT trip_id FROM transit_stop_times - WHERE trip_id NOT LIKE 'trip-incheon-%' - GROUP BY trip_id HAVING MIN(arrival_seconds) >= 86400 - ORDER BY trip_id LIMIT 1 - `).get(); - assert.ok(trip, "current production artifact requires one non-Incheon after-midnight trip"); - const update = database.prepare(` - UPDATE transit_stop_times - SET arrival_seconds = arrival_seconds - 86400, departure_seconds = departure_seconds - 86400 - WHERE trip_id = ? - `).run(trip.trip_id); - assert.ok(update.changes >= 2); - }); - await assert.rejects( - execFileAsync( - process.execPath, - ["tools/datapack/validate-datapack.mjs", ...currentProductionValidationArgs(artifact)], - { cwd: root, env: productionEnv }, - ), - /transit_stop_times must not precede the 03:00 service-day boundary/, - ); -}); - test("데이터팩 검증기는 근거 없는 시설 operationalStatus AVAILABLE을 거부한다", async (context) => { const artifact = await buildCurrentProductionArtifact(context); await mutateCurrentProductionSqlite(artifact, ({ database }) => {