diff --git a/.gitignore b/.gitignore index 2eecc8b..e217473 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ yarn-error.log* # Temporary files *.tmp .cache/ + +# Extracted GTFS files from ZIP +tests/fixtures/*.txt diff --git a/README.md b/README.md index cb0d530..083e268 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ A TypeScript library for loading GTFS (General Transit Feed Specification) data **Théophile Helleboid / SysDevRun** +- Email: contact@sys-dev-run.fr +- Website: https://www.sys-dev-run.fr/ + ## Features - ✅ Load GTFS data from ZIP files (URL or local path) diff --git a/package-lock.json b/package-lock.json index a7c45fb..1dc7aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "license": "MIT", "dependencies": { "jszip": "^3.10.1", + "papaparse": "^5.5.3", "sql.js": "^1.10.3" }, "devDependencies": { "@types/node": "^20.11.5", + "@types/papaparse": "^5.5.0", "@types/sql.js": "^1.4.9", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", @@ -1111,6 +1113,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/papaparse": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz", + "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -3057,6 +3069,12 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index eb432f7..14a4a61 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,12 @@ }, "dependencies": { "jszip": "^3.10.1", + "papaparse": "^5.5.3", "sql.js": "^1.10.3" }, "devDependencies": { "@types/node": "^20.11.5", + "@types/papaparse": "^5.5.0", "@types/sql.js": "^1.4.9", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", diff --git a/src/gtfs-sqljs.ts b/src/gtfs-sqljs.ts index eb24844..74e9474 100644 --- a/src/gtfs-sqljs.ts +++ b/src/gtfs-sqljs.ts @@ -8,7 +8,7 @@ import { loadGTFSZip } from './loaders/zip-loader'; import { loadGTFSData } from './loaders/data-loader'; // Query methods -import { getStopById, getStopByCode, searchStopsByName, getAllStops } from './queries/stops'; +import { getStopById, getStopByCode, searchStopsByName, getAllStops, getStopsByTrip } from './queries/stops'; import { getRouteById, getAllRoutes, getRoutesByAgency } from './queries/routes'; import { getActiveServiceIds, @@ -21,6 +21,7 @@ import { getTripsByRoute, getTripsByRouteAndService, getTripsByRouteServiceAndDirection, + getTripsByService, } from './queries/trips'; import { getStopTimesByTrip, @@ -286,6 +287,23 @@ export class GtfsSqlJs { return getTripsByRouteServiceAndDirection(this.db, routeId, serviceIds, directionId); } + /** + * Get all trips for a given date (YYYYMMDD format) + */ + getTripsByDate(date: string): Trip[] { + if (!this.db) throw new Error('Database not initialized'); + const serviceIds = getActiveServiceIds(this.db, date); + return getTripsByService(this.db, serviceIds); + } + + /** + * Get stops for a given trip (ordered by stop_sequence) + */ + getStopsByTrip(tripId: string): Stop[] { + if (!this.db) throw new Error('Database not initialized'); + return getStopsByTrip(this.db, tripId); + } + // ==================== Stop Time Methods ==================== /** diff --git a/src/loaders/csv-parser.ts b/src/loaders/csv-parser.ts index c3531d2..3c1bb63 100644 --- a/src/loaders/csv-parser.ts +++ b/src/loaders/csv-parser.ts @@ -1,79 +1,35 @@ /** - * CSV Parser for GTFS files + * CSV Parser for GTFS files using papaparse */ +import Papa from 'papaparse'; + export interface ParsedCSV { headers: string[]; rows: Record[]; } /** - * Parse CSV text into structured data + * Parse CSV text into structured data using papaparse */ export function parseCSV(text: string): ParsedCSV { - const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); - - if (lines.length === 0) { - return { headers: [], rows: [] }; + const result = Papa.parse(text, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.trim(), + transform: (value) => value.trim(), + }); + + if (result.errors.length > 0) { + console.warn('CSV parsing warnings:', result.errors); } - // Parse headers - const headers = parseCSVLine(lines[0]); - - // Parse rows - const rows: Record[] = []; - for (let i = 1; i < lines.length; i++) { - const values = parseCSVLine(lines[i]); - if (values.length === 0) continue; - - const row: Record = {}; - for (let j = 0; j < headers.length; j++) { - const header = headers[j]; - const value = values[j] || ''; - row[header] = value; - } - rows.push(row); - } + const headers = result.meta.fields || []; + const rows = result.data as Record[]; return { headers, rows }; } -/** - * Parse a single CSV line, handling quoted values - */ -function parseCSVLine(line: string): string[] { - const result: string[] = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - const nextChar = line[i + 1]; - - if (char === '"') { - if (inQuotes && nextChar === '"') { - // Escaped quote - current += '"'; - i++; // Skip next quote - } else { - // Toggle quote state - inQuotes = !inQuotes; - } - } else if (char === ',' && !inQuotes) { - // End of field - result.push(current.trim()); - current = ''; - } else { - current += char; - } - } - - // Add last field - result.push(current.trim()); - - return result; -} - /** * Convert parsed row to typed object with proper type conversions */ @@ -83,14 +39,16 @@ export function convertRowTypes( ): Record { const result: Record = {}; - for (const [key, value] of Object.entries(row)) { - // Empty string means null/undefined - if (value === '') { + // Iterate over all expected columns, not just the ones present in the row + for (const [key, type] of Object.entries(columnTypes)) { + const value = row[key]; + + // Handle missing or empty values + if (value === undefined || value === null || value === '') { result[key] = null; continue; } - const type = columnTypes[key]; if (type === 'INTEGER') { const parsed = parseInt(value, 10); result[key] = isNaN(parsed) ? null : parsed; diff --git a/src/loaders/data-loader.ts b/src/loaders/data-loader.ts index 89089eb..f678bda 100644 --- a/src/loaders/data-loader.ts +++ b/src/loaders/data-loader.ts @@ -58,7 +58,8 @@ async function loadTableData(db: Database, schema: TableSchema, csvContent: stri const typedRow = convertRowTypes(row, columnTypes); const values = columns.map((col) => { const value = typedRow[col]; - return value === null ? null : value; + // Handle undefined, null, or empty values + return value === null || value === undefined ? null : value; }); try { diff --git a/src/loaders/zip-loader.ts b/src/loaders/zip-loader.ts index f2c2969..d0709a7 100644 --- a/src/loaders/zip-loader.ts +++ b/src/loaders/zip-loader.ts @@ -50,16 +50,11 @@ export async function loadGTFSZip(source: string | ArrayBuffer | Uint8Array): Pr * Fetch ZIP file from URL or file path */ async function fetchZip(source: string): Promise { - // Try to detect environment and use appropriate method - if (typeof fetch !== 'undefined') { - // Browser or Node.js with fetch support - const response = await fetch(source); - if (!response.ok) { - throw new Error(`Failed to fetch GTFS ZIP: ${response.status} ${response.statusText}`); - } - return await response.arrayBuffer(); - } else if (typeof require !== 'undefined') { - // Node.js environment without fetch + // Check if source is a URL or file path + const isUrl = source.startsWith('http://') || source.startsWith('https://'); + + // If it's a file path, try to use fs first + if (!isUrl) { try { // Dynamic import for Node.js fs module const fs = await import('fs'); @@ -68,9 +63,19 @@ async function fetchZip(source: string): Promise { } catch (error) { throw new Error(`Failed to read GTFS ZIP file: ${error}`); } - } else { - throw new Error('No method available to fetch ZIP file'); } + + // For URLs, use fetch + if (typeof fetch !== 'undefined') { + // Browser or Node.js with fetch support + const response = await fetch(source); + if (!response.ok) { + throw new Error(`Failed to fetch GTFS ZIP: ${response.status} ${response.statusText}`); + } + return await response.arrayBuffer(); + } + + throw new Error('No method available to fetch ZIP file from URL'); } /** diff --git a/src/queries/stops.ts b/src/queries/stops.ts index 295d797..e76b3de 100644 --- a/src/queries/stops.ts +++ b/src/queries/stops.ts @@ -78,6 +78,28 @@ export function getAllStops(db: Database, limit?: number): Stop[] { return stops; } +/** + * Get stops for a given trip (ordered by stop_sequence) + */ +export function getStopsByTrip(db: Database, tripId: string): Stop[] { + const stmt = db.prepare(` + SELECT s.* FROM stops s + INNER JOIN stop_times st ON s.stop_id = st.stop_id + WHERE st.trip_id = ? + ORDER BY st.stop_sequence + `); + stmt.bind([tripId]); + + const stops: Stop[] = []; + while (stmt.step()) { + const row = stmt.getAsObject() as Record; + stops.push(rowToStop(row)); + } + + stmt.free(); + return stops; +} + /** * Convert database row to Stop object */ diff --git a/src/queries/trips.ts b/src/queries/trips.ts index a7cc43e..93485f0 100644 --- a/src/queries/trips.ts +++ b/src/queries/trips.ts @@ -96,6 +96,28 @@ export function getTripsByRouteServiceAndDirection( return trips; } +/** + * Get all trips for a service (active on a given date) + */ +export function getTripsByService(db: Database, serviceIds: string[]): Trip[] { + if (serviceIds.length === 0) { + return []; + } + + const placeholders = serviceIds.map(() => '?').join(', '); + const stmt = db.prepare(`SELECT * FROM trips WHERE service_id IN (${placeholders})`); + stmt.bind(serviceIds); + + const trips: Trip[] = []; + while (stmt.step()) { + const row = stmt.getAsObject() as Record; + trips.push(rowToTrip(row)); + } + + stmt.free(); + return trips; +} + /** * Convert database row to Trip object */ diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..dd729e5 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,38 @@ +# GTFS Sample Feed + +## Source + +This sample GTFS feed comes from: +https://developers.google.com/transit/gtfs/examples/gtfs-feed + +## License + +This sample feed is licensed under the **Apache 2.0 License**. + +Copyright Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +## Description + +This is a simple GTFS feed provided by Google for testing and demonstration purposes. +It contains sample transit data including: + +- Agency information +- Stops +- Routes +- Trips +- Stop times +- Calendar data + +The feed is used in our test suite to verify the gtfs-sqljs library works correctly with real GTFS data. diff --git a/tests/fixtures/sample-feed.zip b/tests/fixtures/sample-feed.zip new file mode 100644 index 0000000..79819e2 Binary files /dev/null and b/tests/fixtures/sample-feed.zip differ diff --git a/tests/sample-feed.test.ts b/tests/sample-feed.test.ts new file mode 100644 index 0000000..2fc5079 --- /dev/null +++ b/tests/sample-feed.test.ts @@ -0,0 +1,467 @@ +/** + * Tests using the official Google GTFS sample feed + * + * This test file uses the real sample feed from: + * https://developers.google.com/transit/gtfs/examples/gtfs-feed + * + * The tests validate specific known data from the feed. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { GtfsSqlJs } from '../src/gtfs-sqljs'; +import path from 'path'; + +describe('Sample GTFS Feed Tests - Actual Data', () => { + let gtfs: GtfsSqlJs; + + beforeAll(async () => { + // Load the sample GTFS feed + const feedPath = path.join(__dirname, 'fixtures', 'sample-feed.zip'); + gtfs = await GtfsSqlJs.fromZip(feedPath); + }); + + afterAll(() => { + gtfs?.close(); + }); + + describe('Agency', () => { + it('should have DTA (Demo Transit Authority)', () => { + const db = gtfs.getDatabase(); + const stmt = db.prepare('SELECT * FROM agency WHERE agency_id = ?'); + stmt.bind(['DTA']); + + expect(stmt.step()).toBe(true); + const agency = stmt.getAsObject(); + stmt.free(); + + expect(agency.agency_id).toBe('DTA'); + expect(agency.agency_name).toBe('Demo Transit Authority'); + expect(agency.agency_url).toBe('http://google.com'); + expect(agency.agency_timezone).toBe('America/Los_Angeles'); + }); + }); + + describe('Stops', () => { + it('should have 9 stops', () => { + const stops = gtfs.getAllStops(); + expect(stops.length).toBe(9); + }); + + it('should have BEATTY_AIRPORT with correct details', () => { + const stop = gtfs.getStopById('BEATTY_AIRPORT'); + + expect(stop).not.toBeNull(); + expect(stop!.stop_id).toBe('BEATTY_AIRPORT'); + expect(stop!.stop_name).toBe('Nye County Airport (Demo)'); + expect(stop!.stop_lat).toBeCloseTo(36.868446, 5); + expect(stop!.stop_lon).toBeCloseTo(-116.784582, 5); + }); + + it('should have BULLFROG with correct details', () => { + const stop = gtfs.getStopById('BULLFROG'); + + expect(stop).not.toBeNull(); + expect(stop!.stop_id).toBe('BULLFROG'); + expect(stop!.stop_name).toBe('Bullfrog (Demo)'); + expect(stop!.stop_lat).toBeCloseTo(36.88108, 5); + expect(stop!.stop_lon).toBeCloseTo(-116.81797, 5); + }); + + it('should find stops when searching for "Airport"', () => { + const stops = gtfs.searchStopsByName('Airport'); + expect(stops.length).toBeGreaterThanOrEqual(1); + + const airportStop = stops.find(s => s.stop_id === 'BEATTY_AIRPORT'); + expect(airportStop).toBeDefined(); + expect(airportStop!.stop_name).toBe('Nye County Airport (Demo)'); + }); + + it('should get stops for trip AB1 in correct order', () => { + const stops = gtfs.getStopsByTrip('AB1'); + + expect(stops.length).toBe(2); + expect(stops[0].stop_id).toBe('BEATTY_AIRPORT'); + expect(stops[0].stop_name).toBe('Nye County Airport (Demo)'); + expect(stops[1].stop_id).toBe('BULLFROG'); + expect(stops[1].stop_name).toBe('Bullfrog (Demo)'); + }); + + it('should get stops for trip CITY1 in correct order', () => { + const stops = gtfs.getStopsByTrip('CITY1'); + + expect(stops.length).toBe(5); + expect(stops[0].stop_id).toBe('STAGECOACH'); + expect(stops[1].stop_id).toBe('NANAA'); + expect(stops[2].stop_id).toBe('NADAV'); + expect(stops[3].stop_id).toBe('DADAN'); + expect(stops[4].stop_id).toBe('EMSI'); + }); + }); + + describe('Routes', () => { + it('should have 5 routes', () => { + const routes = gtfs.getAllRoutes(); + expect(routes.length).toBe(5); + }); + + it('should have route AB (Airport - Bullfrog)', () => { + const route = gtfs.getRouteById('AB'); + + expect(route).not.toBeNull(); + expect(route!.route_id).toBe('AB'); + expect(route!.agency_id).toBe('DTA'); + expect(route!.route_short_name).toBe('10'); + expect(route!.route_long_name).toBe('Airport - Bullfrog'); + expect(route!.route_type).toBe(3); // Bus + }); + + it('should have route BFC (Bullfrog - Furnace Creek Resort)', () => { + const route = gtfs.getRouteById('BFC'); + + expect(route).not.toBeNull(); + expect(route!.route_id).toBe('BFC'); + expect(route!.route_short_name).toBe('20'); + expect(route!.route_long_name).toBe('Bullfrog - Furnace Creek Resort'); + expect(route!.route_type).toBe(3); + }); + + it('should have route CITY (City)', () => { + const route = gtfs.getRouteById('CITY'); + + expect(route).not.toBeNull(); + expect(route!.route_id).toBe('CITY'); + expect(route!.route_short_name).toBe('40'); + expect(route!.route_long_name).toBe('City'); + }); + + it('should get all routes for agency DTA', () => { + const routes = gtfs.getRoutesByAgency('DTA'); + expect(routes.length).toBe(5); + }); + }); + + describe('Calendar', () => { + it('should have FULLW service (full week)', () => { + const calendar = gtfs.getCalendarByServiceId('FULLW'); + + expect(calendar).not.toBeNull(); + expect(calendar!.service_id).toBe('FULLW'); + expect(calendar!.monday).toBe(1); + expect(calendar!.tuesday).toBe(1); + expect(calendar!.wednesday).toBe(1); + expect(calendar!.thursday).toBe(1); + expect(calendar!.friday).toBe(1); + expect(calendar!.saturday).toBe(1); + expect(calendar!.sunday).toBe(1); + expect(calendar!.start_date).toBe('20070101'); + expect(calendar!.end_date).toBe('20101231'); + }); + + it('should have WE service (weekend only)', () => { + const calendar = gtfs.getCalendarByServiceId('WE'); + + expect(calendar).not.toBeNull(); + expect(calendar!.service_id).toBe('WE'); + expect(calendar!.monday).toBe(0); + expect(calendar!.tuesday).toBe(0); + expect(calendar!.wednesday).toBe(0); + expect(calendar!.thursday).toBe(0); + expect(calendar!.friday).toBe(0); + expect(calendar!.saturday).toBe(1); + expect(calendar!.sunday).toBe(1); + expect(calendar!.start_date).toBe('20070101'); + expect(calendar!.end_date).toBe('20101231'); + }); + + it('should return FULLW service for Monday 2007-01-01', () => { + // 2007-01-01 was a Monday + const serviceIds = gtfs.getActiveServiceIds('20070101'); + + expect(serviceIds).toContain('FULLW'); + expect(serviceIds).not.toContain('WE'); // Weekend service not active on Monday + }); + + it('should return both FULLW and WE services for Saturday 2007-01-06', () => { + // 2007-01-06 was a Saturday + const serviceIds = gtfs.getActiveServiceIds('20070106'); + + expect(serviceIds).toContain('FULLW'); + expect(serviceIds).toContain('WE'); + }); + + it('should return both services for Sunday 2007-01-07', () => { + // 2007-01-07 was a Sunday + const serviceIds = gtfs.getActiveServiceIds('20070107'); + + expect(serviceIds).toContain('FULLW'); + expect(serviceIds).toContain('WE'); + }); + + it('should return no services for date outside range', () => { + const serviceIds = gtfs.getActiveServiceIds('20110101'); + expect(serviceIds.length).toBe(0); + }); + }); + + describe('Trips', () => { + it('should have trip AB1 (to Bullfrog)', () => { + const trip = gtfs.getTripById('AB1'); + + expect(trip).not.toBeNull(); + expect(trip!.trip_id).toBe('AB1'); + expect(trip!.route_id).toBe('AB'); + expect(trip!.service_id).toBe('FULLW'); + expect(trip!.trip_headsign).toBe('to Bullfrog'); + expect(trip!.direction_id).toBe(0); + expect(trip!.block_id).toBe('1'); + }); + + it('should have trip AB2 (to Airport)', () => { + const trip = gtfs.getTripById('AB2'); + + expect(trip).not.toBeNull(); + expect(trip!.trip_id).toBe('AB2'); + expect(trip!.route_id).toBe('AB'); + expect(trip!.service_id).toBe('FULLW'); + expect(trip!.trip_headsign).toBe('to Airport'); + expect(trip!.direction_id).toBe(1); + expect(trip!.block_id).toBe('2'); + }); + + it('should get 2 trips for route AB', () => { + const trips = gtfs.getTripsByRoute('AB'); + expect(trips.length).toBe(2); + + const tripIds = trips.map(t => t.trip_id).sort(); + expect(tripIds).toEqual(['AB1', 'AB2']); + }); + + it('should get trips for route AB on Monday', () => { + const trips = gtfs.getTripsByRouteAndDate('AB', '20070101'); + expect(trips.length).toBe(2); + expect(trips.every(t => t.service_id === 'FULLW')).toBe(true); + }); + + it('should get 7 trips for Monday (all FULLW trips)', () => { + const trips = gtfs.getTripsByDate('20070101'); + expect(trips.length).toBe(7); // AB1, AB2, STBA, CITY1, CITY2, BFC1, BFC2 + expect(trips.every(t => t.service_id === 'FULLW')).toBe(true); + }); + + it('should get 11 trips for Saturday (FULLW + WE trips)', () => { + const trips = gtfs.getTripsByDate('20070106'); + expect(trips.length).toBe(11); // 7 FULLW + 4 AAMV (WE) + + const fullwTrips = trips.filter(t => t.service_id === 'FULLW'); + const weTrips = trips.filter(t => t.service_id === 'WE'); + + expect(fullwTrips.length).toBe(7); + expect(weTrips.length).toBe(4); + }); + + it('should get trips by direction', () => { + const tripsDir0 = gtfs.getTripsByRouteAndDateAndDirection('AB', '20070101', 0); + const tripsDir1 = gtfs.getTripsByRouteAndDateAndDirection('AB', '20070101', 1); + + expect(tripsDir0.length).toBe(1); + expect(tripsDir0[0].trip_id).toBe('AB1'); + expect(tripsDir0[0].trip_headsign).toBe('to Bullfrog'); + + expect(tripsDir1.length).toBe(1); + expect(tripsDir1[0].trip_id).toBe('AB2'); + expect(tripsDir1[0].trip_headsign).toBe('to Airport'); + }); + }); + + describe('Stop Times', () => { + it('should have correct stop times for trip AB1', () => { + const stopTimes = gtfs.getStopTimesByTrip('AB1'); + + expect(stopTimes.length).toBe(2); + + // First stop + expect(stopTimes[0].trip_id).toBe('AB1'); + expect(stopTimes[0].stop_id).toBe('BEATTY_AIRPORT'); + expect(stopTimes[0].arrival_time).toBe('8:00:00'); + expect(stopTimes[0].departure_time).toBe('8:00:00'); + expect(stopTimes[0].stop_sequence).toBe(1); + + // Second stop + expect(stopTimes[1].trip_id).toBe('AB1'); + expect(stopTimes[1].stop_id).toBe('BULLFROG'); + expect(stopTimes[1].arrival_time).toBe('8:10:00'); + expect(stopTimes[1].departure_time).toBe('8:15:00'); + expect(stopTimes[1].stop_sequence).toBe(2); + }); + + it('should have correct stop times for trip CITY1', () => { + const stopTimes = gtfs.getStopTimesByTrip('CITY1'); + + expect(stopTimes.length).toBe(5); + + // Verify all stops in sequence + expect(stopTimes[0].stop_id).toBe('STAGECOACH'); + expect(stopTimes[0].arrival_time).toBe('6:00:00'); + + expect(stopTimes[1].stop_id).toBe('NANAA'); + expect(stopTimes[1].arrival_time).toBe('6:05:00'); + expect(stopTimes[1].departure_time).toBe('6:07:00'); + + expect(stopTimes[2].stop_id).toBe('NADAV'); + expect(stopTimes[3].stop_id).toBe('DADAN'); + expect(stopTimes[4].stop_id).toBe('EMSI'); + }); + + it('should get stop times for BEATTY_AIRPORT', () => { + const stopTimes = gtfs.getStopTimesByStop('BEATTY_AIRPORT', 100); + + // BEATTY_AIRPORT appears in: STBA, AB1, AB2, AAMV1, AAMV2, AAMV3, AAMV4 = 7 trips + expect(stopTimes.length).toBe(7); + + // Should be ordered by arrival_time (as strings) + // Note: SQL ORDER BY treats times as strings, so "11:00:00" < "6:20:00" alphabetically + const times = stopTimes.map(st => st.arrival_time); + expect(times).toContain('6:20:00'); // STBA + expect(times).toContain('8:00:00'); // AB1 or AAMV1 + expect(times).toContain('12:15:00'); // AB2 + }); + + it('should get stop times for route AB at BEATTY_AIRPORT on Monday', () => { + const stopTimes = gtfs.getStopTimesForStopRouteAndDate('BEATTY_AIRPORT', 'AB', '20070101'); + + expect(stopTimes.length).toBe(2); // AB1 and AB2 + + // Check specific times + const ab1Time = stopTimes.find(st => st.trip_id === 'AB1'); + const ab2Time = stopTimes.find(st => st.trip_id === 'AB2'); + + expect(ab1Time).toBeDefined(); + expect(ab1Time!.arrival_time).toBe('8:00:00'); + + expect(ab2Time).toBeDefined(); + expect(ab2Time!.arrival_time).toBe('12:15:00'); + }); + + it('should filter by direction', () => { + const stopTimes = gtfs.getStopTimesForStopRouteAndDate('BEATTY_AIRPORT', 'AB', '20070101', 0); + + expect(stopTimes.length).toBe(1); + expect(stopTimes[0].trip_id).toBe('AB1'); + expect(stopTimes[0].arrival_time).toBe('8:00:00'); + }); + }); + + describe('Complete Journey Scenarios', () => { + it('should plan journey from Airport to Bullfrog at 8am', () => { + // User wants to go from BEATTY_AIRPORT to BULLFROG + const origin = gtfs.getStopById('BEATTY_AIRPORT'); + const destination = gtfs.getStopById('BULLFROG'); + + expect(origin).not.toBeNull(); + expect(destination).not.toBeNull(); + + // Find route AB + const route = gtfs.getRouteById('AB'); + expect(route).not.toBeNull(); + + // Get trips on a Monday + const trips = gtfs.getTripsByRouteAndDate('AB', '20070101'); + + // Trip AB1 goes to Bullfrog (direction 0) + const trip = trips.find(t => t.trip_headsign === 'to Bullfrog'); + expect(trip).toBeDefined(); + expect(trip!.trip_id).toBe('AB1'); + + // Get stop times + const stopTimes = gtfs.getStopTimesByTrip('AB1'); + expect(stopTimes[0].stop_id).toBe('BEATTY_AIRPORT'); + expect(stopTimes[0].departure_time).toBe('8:00:00'); + expect(stopTimes[1].stop_id).toBe('BULLFROG'); + expect(stopTimes[1].arrival_time).toBe('8:10:00'); + + console.log('\nJourney Plan:'); + console.log(`From: ${origin!.stop_name}`); + console.log(`To: ${destination!.stop_name}`); + console.log(`Route: ${route!.route_short_name} - ${route!.route_long_name}`); + console.log(`Depart: ${stopTimes[0].departure_time}`); + console.log(`Arrive: ${stopTimes[1].arrival_time}`); + console.log(`Duration: 10 minutes`); + }); + + it('should find all routes serving BULLFROG', () => { + const stopTimes = gtfs.getStopTimesByStop('BULLFROG', 100); + + // Get unique trip IDs + const tripIds = [...new Set(stopTimes.map(st => st.trip_id))]; + + // Get routes for these trips + const routes = new Set(); + for (const tripId of tripIds) { + const trip = gtfs.getTripById(tripId); + if (trip) { + routes.add(trip.route_id); + } + } + + // BULLFROG is served by routes AB and BFC + expect(routes.size).toBe(2); + expect(routes.has('AB')).toBe(true); + expect(routes.has('BFC')).toBe(true); + }); + + it('should find city circuit route with all stops', () => { + const route = gtfs.getRouteById('CITY'); + expect(route).not.toBeNull(); + + // Get both directions + const trip1 = gtfs.getTripById('CITY1'); + const trip2 = gtfs.getTripById('CITY2'); + + const stops1 = gtfs.getStopsByTrip('CITY1'); + const stops2 = gtfs.getStopsByTrip('CITY2'); + + // Both trips have 5 stops + expect(stops1.length).toBe(5); + expect(stops2.length).toBe(5); + + // They visit the same stops in reverse order + expect(stops1[0].stop_id).toBe('STAGECOACH'); + expect(stops2[4].stop_id).toBe('STAGECOACH'); + + expect(stops1[4].stop_id).toBe('EMSI'); + expect(stops2[0].stop_id).toBe('EMSI'); + }); + }); + + describe('Database Export/Import', () => { + it('should export and re-import database with all data intact', async () => { + // Export database + const buffer = gtfs.export(); + expect(buffer.byteLength).toBeGreaterThan(0); + + // Create new instance from exported buffer + const gtfs2 = await GtfsSqlJs.fromDatabase(buffer); + + // Verify specific data is intact + const db = gtfs2.getDatabase(); + const agency = db.prepare('SELECT * FROM agency WHERE agency_id = ?'); + agency.bind(['DTA']); + expect(agency.step()).toBe(true); + const agencyData = agency.getAsObject(); + agency.free(); + expect(agencyData.agency_name).toBe('Demo Transit Authority'); + + // Verify route + const route = gtfs2.getRouteById('AB'); + expect(route).not.toBeNull(); + expect(route!.route_long_name).toBe('Airport - Bullfrog'); + + // Verify trip + const trip = gtfs2.getTripById('AB1'); + expect(trip).not.toBeNull(); + expect(trip!.trip_headsign).toBe('to Bullfrog'); + + gtfs2.close(); + }); + }); +});