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/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/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/datapack-tools.test.mjs b/tools/datapack/datapack-tools.test.mjs index 8835f62bf..842fac48c 100644 --- a/tools/datapack/datapack-tools.test.mjs +++ b/tools/datapack/datapack-tools.test.mjs @@ -1501,6 +1501,111 @@ 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 }); +}); + +// #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 }, + { 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()}`); diff --git a/tools/datapack/lib/service-day-seconds.mjs b/tools/datapack/lib/service-day-seconds.mjs new file mode 100644 index 000000000..b470f142b --- /dev/null +++ b/tools/datapack/lib/service-day-seconds.mjs @@ -0,0 +1,34 @@ +// #918: 운행일 경계. 시간표 원천은 자정 이후 시각을 24시 미만(00:00~02:59)으로 적는다. +// 그 시각은 전날 운행일의 심야 시각이므로 데이터팩 SQLite(transit_stop_times)에는 86400초 이상으로 싣는다. +// 근거(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"; +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 행의 도착·출발을 운행일 초로 바꾼 새 행을 돌려준다. + * 바꾼 뒤 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 new file mode 100644 index 000000000..533a35372 --- /dev/null +++ b/tools/datapack/lib/service-day-seconds.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + SERVICE_DAY_BOUNDARY_SECONDS, + serviceDaySeconds, + serviceDayStopTimes, +} 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("자정 이후 시발 열차(인천 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(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/materialize-incheon-timetable.test.mjs b/tools/datapack/materialize-incheon-timetable.test.mjs index 5e07b6974..844f286a1 100644 --- a/tools/datapack/materialize-incheon-timetable.test.mjs +++ b/tools/datapack/materialize-incheon-timetable.test.mjs @@ -435,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/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) { 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}`); }