@osmix/gtfs 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # @osmix/gtfs
2
+
3
+ ## 0.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 2a634cb: Fix publishing
8
+ - Updated dependencies [2a634cb]
9
+ - @osmix/shared@0.0.12
10
+ - @osmix/core@0.1.7
11
+
12
+ ## 0.0.6
13
+
14
+ ### Patch Changes
15
+
16
+ - 3c8ee95: Fix and simplify package exports
17
+ - Updated dependencies [3c8ee95]
18
+ - @osmix/core@0.1.6
19
+ - @osmix/shared@0.0.11
20
+
21
+ ## 0.0.5
22
+
23
+ ### Patch Changes
24
+
25
+ - 12728ed: Replace `csv-parse` usage in `@osmix/gtfs` with a browser-friendly shared streaming CSV parser in `@osmix/shared`, adapted from `mafintosh/csv-parser` parsing behavior.
26
+ - Updated dependencies [12728ed]
27
+ - @osmix/shared@0.0.10
28
+ - @osmix/core@0.1.5
29
+
30
+ ## 0.0.4
31
+
32
+ ### Patch Changes
33
+
34
+ - f32e4ee: General cleanup
35
+ - Updated dependencies [f32e4ee]
36
+ - @osmix/core@0.1.4
37
+ - @osmix/shared@0.0.9
38
+
39
+ ## 0.0.3
40
+
41
+ ### Patch Changes
42
+
43
+ - f468db5: Fix publishing (2)
44
+ - 536a3cd: Remove JSR dependency for CSV parsing
45
+ - Updated dependencies [f468db5]
46
+ - @osmix/core@0.1.3
47
+ - @osmix/shared@0.0.8
48
+
49
+ ## 0.0.2
50
+
51
+ ### Patch Changes
52
+
53
+ - 68d6bd8: Fix publishing for packages.
54
+ - Updated dependencies [68d6bd8]
55
+ - @osmix/core@0.1.2
56
+ - @osmix/shared@0.0.7
57
+
58
+ ## 0.0.1
59
+
60
+ ### Added
61
+
62
+ - Initial release with GTFS to OSM conversion.
63
+ - Stops parsed as nodes with tags.
64
+ - Routes parsed as ways with shape geometry.
package/package.json CHANGED
@@ -1,53 +1,30 @@
1
1
  {
2
- "$schema": "https://json.schemastore.org/package",
2
+ "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@osmix/gtfs",
4
4
  "description": "Convert GTFS transit feeds to OSM format.",
5
- "version": "0.0.6",
5
+ "version": "0.0.7",
6
6
  "type": "module",
7
- "types": "./dist/index.d.ts",
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
7
  "license": "MIT",
12
8
  "repository": {
13
9
  "type": "git",
14
10
  "url": "git+https://github.com/conveyal/osmix.git"
15
11
  },
16
- "homepage": "https://github.com/conveyal/osmix#readme",
17
- "bugs": {
18
- "url": "https://github.com/conveyal/osmix/issues"
19
- },
20
- "sideEffects": false,
12
+ "main": "./dist/index.js",
21
13
  "scripts": {
22
14
  "build": "tsc -p tsconfig.build.json",
23
15
  "bench": "bun test --bench",
24
- "prepare": "bun run build",
25
- "release": "bun publish",
26
16
  "test": "bun test",
27
17
  "typecheck": "tsgo --noEmit"
28
18
  },
29
19
  "devDependencies": {
30
- "@types/bun": "catalog:",
20
+ "@types/bun": "^1.3.9",
31
21
  "fflate": "^0.8.2",
32
- "typescript": "catalog:"
22
+ "typescript": "^5.9.0"
33
23
  },
34
24
  "dependencies": {
35
- "@osmix/core": "workspace:*",
36
- "@osmix/shared": "workspace:*",
25
+ "@osmix/core": "0.1.7",
26
+ "@osmix/shared": "0.0.12",
37
27
  "but-unzip": "^0.1.7"
38
28
  },
39
- "exports": {
40
- ".": {
41
- "types": "./src/index.ts",
42
- "bun": "./src/index.ts",
43
- "development|production": "./src/index.ts",
44
- "default": "./dist/index.js"
45
- }
46
- },
47
- "files": [
48
- "dist",
49
- "src",
50
- "README.md",
51
- "LICENSE"
52
- ]
53
- }
29
+ "types": "./dist/index.d.ts"
30
+ }
@@ -0,0 +1,514 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ fromGtfs,
4
+ GtfsArchive,
5
+ GtfsOsmBuilder,
6
+ isGtfsZip,
7
+ routeTypeToOsmRoute,
8
+ wheelchairBoardingToOsm,
9
+ } from "../src"
10
+ import { routeToTags, stopToTags } from "../src/utils"
11
+ import { createSharedShapeGtfsZip, createTestGtfsZip } from "./helpers"
12
+
13
+ // Path to the Monaco GTFS fixture
14
+ const MONACO_GTFS_PATH = new URL(
15
+ "../../../fixtures/monaco-gtfs.zip",
16
+ import.meta.url,
17
+ )
18
+
19
+ describe("routeTypeToOsmRoute", () => {
20
+ test("maps GTFS route types to OSM route values", () => {
21
+ expect(routeTypeToOsmRoute("0")).toBe("tram")
22
+ expect(routeTypeToOsmRoute("1")).toBe("subway")
23
+ expect(routeTypeToOsmRoute("2")).toBe("train")
24
+ expect(routeTypeToOsmRoute("3")).toBe("bus")
25
+ expect(routeTypeToOsmRoute("4")).toBe("ferry")
26
+ expect(routeTypeToOsmRoute("5")).toBe("tram")
27
+ expect(routeTypeToOsmRoute("6")).toBe("aerialway")
28
+ expect(routeTypeToOsmRoute("7")).toBe("funicular")
29
+ expect(routeTypeToOsmRoute("11")).toBe("trolleybus")
30
+ expect(routeTypeToOsmRoute("12")).toBe("train")
31
+ })
32
+
33
+ test("defaults to bus for unknown types", () => {
34
+ expect(routeTypeToOsmRoute("99")).toBe("bus")
35
+ expect(routeTypeToOsmRoute("")).toBe("bus")
36
+ })
37
+ })
38
+
39
+ describe("wheelchairBoardingToOsm", () => {
40
+ test("maps wheelchair boarding values", () => {
41
+ expect(wheelchairBoardingToOsm("1")).toBe("yes")
42
+ expect(wheelchairBoardingToOsm("2")).toBe("no")
43
+ expect(wheelchairBoardingToOsm("0")).toBeUndefined()
44
+ expect(wheelchairBoardingToOsm(undefined)).toBeUndefined()
45
+ })
46
+ })
47
+
48
+ describe("stopToTags", () => {
49
+ test("tags regular stops as platforms", () => {
50
+ const tags = stopToTags({
51
+ stop_id: "stop1",
52
+ stop_name: "Main St",
53
+ stop_lat: "40.7128",
54
+ stop_lon: "-74.0060",
55
+ location_type: "0",
56
+ })
57
+
58
+ expect(tags["public_transport"]).toBe("platform")
59
+ expect(tags["name"]).toBe("Main St")
60
+ })
61
+
62
+ test("tags stations correctly", () => {
63
+ const tags = stopToTags({
64
+ stop_id: "station1",
65
+ stop_name: "Central Station",
66
+ stop_lat: "40.7128",
67
+ stop_lon: "-74.0060",
68
+ location_type: "1",
69
+ })
70
+
71
+ expect(tags["public_transport"]).toBe("station")
72
+ })
73
+
74
+ test("tags entrances without public_transport tag", () => {
75
+ const tags = stopToTags({
76
+ stop_id: "entrance1",
77
+ stop_name: "Station Entrance A",
78
+ stop_lat: "40.7128",
79
+ stop_lon: "-74.0060",
80
+ location_type: "2",
81
+ })
82
+
83
+ // Entrances should have railway=subway_entrance but NOT public_transport
84
+ expect(tags["railway"]).toBe("subway_entrance")
85
+ expect(tags["public_transport"]).toBeUndefined()
86
+ expect(tags["name"]).toBe("Station Entrance A")
87
+ })
88
+
89
+ test("tags boarding areas as platforms", () => {
90
+ const tags = stopToTags({
91
+ stop_id: "boarding1",
92
+ stop_name: "Platform A",
93
+ stop_lat: "40.7128",
94
+ stop_lon: "-74.0060",
95
+ location_type: "4",
96
+ })
97
+
98
+ expect(tags["public_transport"]).toBe("platform")
99
+ })
100
+ })
101
+
102
+ describe("routeToTags", () => {
103
+ test("normalizes valid hex colors", () => {
104
+ const tags = routeToTags({
105
+ route_id: "route1",
106
+ route_short_name: "1",
107
+ route_type: "3",
108
+ route_color: "ff0000",
109
+ route_text_color: "FFFFFF",
110
+ })
111
+
112
+ expect(tags["color"]).toBe("#FF0000")
113
+ expect(tags["text_color"]).toBe("#FFFFFF")
114
+ })
115
+
116
+ test("accepts colors with # prefix", () => {
117
+ const tags = routeToTags({
118
+ route_id: "route1",
119
+ route_short_name: "1",
120
+ route_type: "3",
121
+ route_color: "#00FF00",
122
+ })
123
+
124
+ expect(tags["color"]).toBe("#00FF00")
125
+ })
126
+
127
+ test("accepts 3-character shorthand colors", () => {
128
+ const tags = routeToTags({
129
+ route_id: "route1",
130
+ route_short_name: "1",
131
+ route_type: "3",
132
+ route_color: "F00",
133
+ })
134
+
135
+ expect(tags["color"]).toBe("#FF0000")
136
+ })
137
+
138
+ test("rejects invalid hex colors", () => {
139
+ const tags = routeToTags({
140
+ route_id: "route1",
141
+ route_short_name: "1",
142
+ route_type: "3",
143
+ route_color: "ZZZZZZ",
144
+ route_text_color: "not-a-color",
145
+ })
146
+
147
+ // Invalid colors should not be added to tags
148
+ expect(tags["color"]).toBeUndefined()
149
+ expect(tags["text_color"]).toBeUndefined()
150
+ })
151
+
152
+ test("handles missing colors", () => {
153
+ const tags = routeToTags({
154
+ route_id: "route1",
155
+ route_short_name: "1",
156
+ route_type: "3",
157
+ })
158
+
159
+ expect(tags["color"]).toBeUndefined()
160
+ expect(tags["text_color"]).toBeUndefined()
161
+ })
162
+
163
+ test("sets route type and name correctly", () => {
164
+ const tags = routeToTags({
165
+ route_id: "route1",
166
+ route_short_name: "R1",
167
+ route_long_name: "Red Line",
168
+ route_type: "1",
169
+ })
170
+
171
+ expect(tags["route"]).toBe("subway")
172
+ expect(tags["name"]).toBe("Red Line")
173
+ expect(tags["ref"]).toBe("R1")
174
+ })
175
+ })
176
+
177
+ describe("GtfsArchive", () => {
178
+ test("parses a GTFS zip file lazily", async () => {
179
+ const zipData = await createTestGtfsZip()
180
+ const archive = GtfsArchive.fromZip(zipData)
181
+
182
+ // Check files are listed
183
+ const files = archive.listFiles()
184
+ expect(files).toContain("agency.txt")
185
+ expect(files).toContain("stops.txt")
186
+ expect(files).toContain("routes.txt")
187
+
188
+ // Parse agencies on demand
189
+ const agencies = await Array.fromAsync(archive.iter("agency.txt"))
190
+ expect(agencies.length).toBe(1)
191
+ expect(agencies[0]?.agency_name).toBe("Test Transit")
192
+
193
+ // Parse stops on demand
194
+ const stops = await Array.fromAsync(archive.iter("stops.txt"))
195
+ expect(stops.length).toBe(3)
196
+ expect(stops[0]?.stop_name).toBe("Main St Station")
197
+
198
+ // Parse routes on demand
199
+ const routes = await Array.fromAsync(archive.iter("routes.txt"))
200
+ expect(routes.length).toBe(1)
201
+ expect(routes[0]?.route_short_name).toBe("1")
202
+ })
203
+
204
+ test("parses quoted CSV fields", async () => {
205
+ const { zipSync } = await import("fflate")
206
+ const encoder = new TextEncoder()
207
+ const zipData = zipSync({
208
+ "stops.txt": encoder.encode(`stop_id,stop_name,stop_lat,stop_lon
209
+ stop1,"Main ""Central"", Downtown",40.7128,-74.0060`),
210
+ })
211
+
212
+ const archive = GtfsArchive.fromZip(zipData)
213
+ const stops = await Array.fromAsync(archive.iter("stops.txt"))
214
+
215
+ expect(stops.length).toBe(1)
216
+ expect(stops[0]?.stop_name).toBe('Main "Central", Downtown')
217
+ })
218
+
219
+ test("iterates stops without loading all at once", async () => {
220
+ const zipData = await createTestGtfsZip()
221
+ const archive = GtfsArchive.fromZip(zipData)
222
+
223
+ const stops = []
224
+ for await (const stop of archive.iter("stops.txt")) {
225
+ stops.push(stop)
226
+ }
227
+
228
+ expect(stops.length).toBe(3)
229
+ expect(stops[0]?.stop_name).toBe("Main St Station")
230
+ })
231
+
232
+ test("iter() returns correctly typed records", async () => {
233
+ const zipData = await createTestGtfsZip()
234
+ const archive = GtfsArchive.fromZip(zipData)
235
+
236
+ // TypeScript should infer the correct type for each file
237
+ for await (const stop of archive.iter("stops.txt")) {
238
+ // stop is GtfsStop
239
+ expect(stop.stop_id).toBeDefined()
240
+ expect(stop.stop_lat).toBeDefined()
241
+ break
242
+ }
243
+
244
+ for await (const route of archive.iter("routes.txt")) {
245
+ // route is GtfsRoute
246
+ expect(route.route_id).toBeDefined()
247
+ expect(route.route_type).toBeDefined()
248
+ break
249
+ }
250
+
251
+ for await (const shape of archive.iter("shapes.txt")) {
252
+ // shape is GtfsShapePoint
253
+ expect(shape.shape_id).toBeDefined()
254
+ expect(shape.shape_pt_lat).toBeDefined()
255
+ break
256
+ }
257
+ })
258
+ })
259
+
260
+ describe("isGtfsZip", () => {
261
+ test("detects GTFS zip created in tests", async () => {
262
+ const zipData = await createTestGtfsZip()
263
+ expect(isGtfsZip(zipData)).toBe(true)
264
+ })
265
+
266
+ test("detects Monaco GTFS fixture as GTFS", async () => {
267
+ const file = Bun.file(MONACO_GTFS_PATH)
268
+ const zipData = new Uint8Array(await file.arrayBuffer())
269
+ expect(isGtfsZip(zipData)).toBe(true)
270
+ })
271
+
272
+ test("returns false for non-GTFS zip", async () => {
273
+ const encoder = new TextEncoder()
274
+ const { zipSync } = await import("fflate")
275
+
276
+ const bytes = zipSync({
277
+ "readme.txt": encoder.encode("not a gtfs feed"),
278
+ "data.csv": encoder.encode("col1,col2\n1,2\n"),
279
+ })
280
+
281
+ expect(isGtfsZip(bytes)).toBe(false)
282
+ })
283
+ })
284
+
285
+ describe("fromGtfs", () => {
286
+ test("converts GTFS to OSM with shapes", async () => {
287
+ const zipData = await createTestGtfsZip()
288
+ const osm = await fromGtfs(zipData, { id: "test-transit" })
289
+
290
+ // Should have stops as nodes
291
+ expect(osm.nodes.size).toBeGreaterThanOrEqual(3)
292
+
293
+ // Should have route as way
294
+ expect(osm.ways.size).toBe(1)
295
+
296
+ // Check node tags
297
+ const stopNodes = []
298
+ for (let i = 0; i < osm.nodes.size; i++) {
299
+ const tags = osm.nodes.tags.getTags(i)
300
+ if (tags?.["public_transport"]) {
301
+ stopNodes.push({ index: i, tags })
302
+ }
303
+ }
304
+ expect(stopNodes.length).toBe(3)
305
+
306
+ // First stop should have correct tags
307
+ const firstStop = stopNodes.find(
308
+ (n) => n.tags?.["name"] === "Main St Station",
309
+ )
310
+ expect(firstStop).toBeDefined()
311
+ expect(firstStop?.tags?.["ref"]).toBe("stop1")
312
+ expect(firstStop?.tags?.["wheelchair"]).toBe("yes")
313
+
314
+ // Check way tags
315
+ const wayTags = osm.ways.tags.getTags(0)
316
+ expect(wayTags?.["route"]).toBe("bus")
317
+ expect(wayTags?.["ref"]).toBe("1")
318
+ expect(wayTags?.["name"]).toBe("Downtown Express")
319
+ })
320
+
321
+ test("can exclude stops entirely", async () => {
322
+ const zipData = await createTestGtfsZip()
323
+ const osm = await fromGtfs(
324
+ zipData,
325
+ { id: "routes-only" },
326
+ { includeStops: false },
327
+ )
328
+
329
+ // Should have only shape nodes, no stop nodes
330
+ let stopCount = 0
331
+ for (let i = 0; i < osm.nodes.size; i++) {
332
+ const tags = osm.nodes.tags.getTags(i)
333
+ if (tags?.["public_transport"]) {
334
+ stopCount++
335
+ }
336
+ }
337
+ expect(stopCount).toBe(0)
338
+
339
+ // Should still have the route
340
+ expect(osm.ways.size).toBe(1)
341
+ })
342
+
343
+ test("can exclude routes entirely", async () => {
344
+ const zipData = await createTestGtfsZip()
345
+ const osm = await fromGtfs(
346
+ zipData,
347
+ { id: "stops-only" },
348
+ { includeRoutes: false },
349
+ )
350
+
351
+ // Should have stops
352
+ let stopCount = 0
353
+ for (let i = 0; i < osm.nodes.size; i++) {
354
+ const tags = osm.nodes.tags.getTags(i)
355
+ if (tags?.["public_transport"]) {
356
+ stopCount++
357
+ }
358
+ }
359
+ expect(stopCount).toBe(3)
360
+
361
+ // Should have no routes
362
+ expect(osm.ways.size).toBe(0)
363
+ })
364
+
365
+ test("creates separate ways for routes sharing the same shape", async () => {
366
+ const zipData = await createSharedShapeGtfsZip()
367
+ const osm = await fromGtfs(
368
+ zipData,
369
+ { id: "shared-shape" },
370
+ { includeStops: false },
371
+ )
372
+
373
+ // Should have 2 ways - one for each route, even though they share a shape
374
+ expect(osm.ways.size).toBe(2)
375
+
376
+ // Collect way tags
377
+ const wayTagsList: Record<string, unknown>[] = []
378
+ for (let i = 0; i < osm.ways.size; i++) {
379
+ const tags = osm.ways.tags.getTags(i)
380
+ if (tags) wayTagsList.push(tags)
381
+ }
382
+
383
+ // Find the Red Line (route1) way
384
+ const redLineWay = wayTagsList.find((tags) => tags["ref"] === "R1")
385
+ expect(redLineWay).toBeDefined()
386
+ expect(redLineWay?.["name"]).toBe("Red Line")
387
+ expect(redLineWay?.["route"]).toBe("subway")
388
+ expect(redLineWay?.["color"]).toBe("#FF0000")
389
+ // Should have 2 trips (trip1 and trip2)
390
+ expect(Number(redLineWay?.["gtfs:trip_count"])).toBe(2)
391
+ expect(redLineWay?.["gtfs:trip_ids"]).toBe("trip1;trip2")
392
+
393
+ // Find the Blue Express (route2) way
394
+ const blueExpressWay = wayTagsList.find((tags) => tags["ref"] === "B2")
395
+ expect(blueExpressWay).toBeDefined()
396
+ expect(blueExpressWay?.["name"]).toBe("Blue Express")
397
+ expect(blueExpressWay?.["route"]).toBe("bus")
398
+ expect(blueExpressWay?.["color"]).toBe("#0000FF")
399
+ // Should have 1 trip (trip3)
400
+ expect(Number(blueExpressWay?.["gtfs:trip_count"])).toBe(1)
401
+ expect(blueExpressWay?.["gtfs:trip_ids"]).toBe("trip3")
402
+
403
+ // Both should reference the same shape
404
+ expect(redLineWay?.["gtfs:shape_id"]).toBe("shared_shape")
405
+ expect(blueExpressWay?.["gtfs:shape_id"]).toBe("shared_shape")
406
+ })
407
+ })
408
+
409
+ describe("GtfsOsmBuilder", () => {
410
+ test("can be used for step-by-step conversion", async () => {
411
+ const zipData = await createTestGtfsZip()
412
+ const archive = GtfsArchive.fromZip(zipData)
413
+
414
+ const stops = await Array.fromAsync(archive.iter("stops.txt"))
415
+ const routes = await Array.fromAsync(archive.iter("routes.txt"))
416
+
417
+ expect(stops.length).toBe(3)
418
+ expect(routes.length).toBe(1)
419
+
420
+ const builder = new GtfsOsmBuilder({ id: "manual-test" })
421
+ await builder.processStops(archive)
422
+ await builder.processRoutes(archive)
423
+ const osm = builder.buildOsm()
424
+
425
+ expect(osm.nodes.size).toBeGreaterThanOrEqual(3)
426
+ expect(osm.ways.size).toBe(1)
427
+ })
428
+ })
429
+
430
+ describe("Monaco GTFS fixture", () => {
431
+ test("parses Monaco GTFS archive", async () => {
432
+ const file = Bun.file(MONACO_GTFS_PATH)
433
+ const zipData = await file.arrayBuffer()
434
+ const archive = GtfsArchive.fromZip(zipData)
435
+
436
+ // Check expected files exist
437
+ expect(archive.hasFile("agency.txt")).toBe(true)
438
+ expect(archive.hasFile("stops.txt")).toBe(true)
439
+ expect(archive.hasFile("routes.txt")).toBe(true)
440
+ expect(archive.hasFile("shapes.txt")).toBe(true)
441
+ expect(archive.hasFile("trips.txt")).toBe(true)
442
+ expect(archive.hasFile("stop_times.txt")).toBe(true)
443
+
444
+ // Parse agency
445
+ const agencies = await Array.fromAsync(archive.iter("agency.txt"))
446
+ expect(agencies.length).toBe(1)
447
+
448
+ // Parse stops
449
+ const stops = await Array.fromAsync(archive.iter("stops.txt"))
450
+ expect(stops.length).toBe(98)
451
+
452
+ // Parse routes
453
+ const routes = await Array.fromAsync(archive.iter("routes.txt"))
454
+ expect(routes.length).toBe(15)
455
+ })
456
+
457
+ test("converts Monaco GTFS to OSM with routes only", async () => {
458
+ const zipData = await Bun.file(MONACO_GTFS_PATH).arrayBuffer()
459
+
460
+ // Only include routes (no stops) to test shapes parsing
461
+ const osm = await fromGtfs(
462
+ zipData,
463
+ { id: "monaco-routes" },
464
+ { includeStops: false },
465
+ )
466
+
467
+ // Should have routes as ways (one per shape+route pair, not per trip)
468
+ // Previously 271 when grouping by shape only, now 315 with shape+route pairs
469
+ expect(osm.ways.size).toBe(315)
470
+
471
+ // Check a route has proper tags
472
+ const wayTags = osm.ways.tags.getTags(0)
473
+ expect(wayTags?.["route"]).toBeDefined()
474
+ })
475
+
476
+ test("converts Monaco GTFS to OSM with stops only", async () => {
477
+ const zipData = await Bun.file(MONACO_GTFS_PATH).arrayBuffer()
478
+
479
+ // Only include stops (no routes)
480
+ const osm = await fromGtfs(
481
+ zipData,
482
+ { id: "monaco-stops" },
483
+ { includeRoutes: false },
484
+ )
485
+
486
+ // Should have stops as nodes
487
+ expect(osm.nodes.size).toBeGreaterThan(0)
488
+
489
+ // No routes
490
+ expect(osm.ways.size).toBe(0)
491
+
492
+ // Check a stop has proper tags
493
+ let foundStop = false
494
+ for (let i = 0; i < osm.nodes.size; i++) {
495
+ const tags = osm.nodes.tags.getTags(i)
496
+ if (tags?.["public_transport"]) {
497
+ foundStop = true
498
+ expect(tags["name"]).toBeDefined()
499
+ break
500
+ }
501
+ }
502
+ expect(foundStop).toBe(true)
503
+ })
504
+
505
+ test("converts full Monaco GTFS to OSM", async () => {
506
+ const zipData = await Bun.file(MONACO_GTFS_PATH).arrayBuffer()
507
+
508
+ const osm = await fromGtfs(zipData, { id: "monaco-full" })
509
+
510
+ // Should have both stops and routes
511
+ expect(osm.nodes.size).toBeGreaterThan(0)
512
+ expect(osm.ways.size).toBeGreaterThan(0)
513
+ })
514
+ })
@@ -0,0 +1,118 @@
1
+ import { zipSync } from "fflate"
2
+
3
+ /**
4
+ * Create a test GTFS zip file with sample data.
5
+ */
6
+ export async function createTestGtfsZip(): Promise<Uint8Array> {
7
+ const encoder = new TextEncoder()
8
+
9
+ const files: Record<string, Uint8Array> = {
10
+ "agency.txt":
11
+ encoder.encode(`agency_id,agency_name,agency_url,agency_timezone
12
+ agency1,Test Transit,https://example.com,America/New_York`),
13
+
14
+ "stops.txt":
15
+ encoder.encode(`stop_id,stop_name,stop_lat,stop_lon,location_type,wheelchair_boarding
16
+ stop1,Main St Station,40.7128,-74.0060,0,1
17
+ stop2,Broadway Station,40.7580,-73.9855,1,2
18
+ stop3,Park Ave Stop,40.7614,-73.9776,0,0`),
19
+
20
+ "routes.txt":
21
+ encoder.encode(`route_id,route_short_name,route_long_name,route_type,route_color,route_text_color
22
+ route1,1,Downtown Express,3,FF0000,FFFFFF`),
23
+
24
+ "trips.txt": encoder.encode(`trip_id,route_id,service_id,shape_id
25
+ trip1,route1,weekday,shape1`),
26
+
27
+ "stop_times.txt":
28
+ encoder.encode(`trip_id,stop_id,stop_sequence,arrival_time,departure_time
29
+ trip1,stop1,1,08:00:00,08:00:00
30
+ trip1,stop2,2,08:10:00,08:10:00
31
+ trip1,stop3,3,08:20:00,08:20:00`),
32
+
33
+ "shapes.txt":
34
+ encoder.encode(`shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence
35
+ shape1,40.7128,-74.0060,1
36
+ shape1,40.7400,-73.9900,2
37
+ shape1,40.7614,-73.9776,3`),
38
+
39
+ "calendar.txt":
40
+ encoder.encode(`service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date
41
+ weekday,1,1,1,1,1,0,0,20240101,20241231`),
42
+ }
43
+
44
+ return zipSync(files)
45
+ }
46
+
47
+ /**
48
+ * Create a GTFS zip where two routes share the same shape.
49
+ * Used to test that each route gets its own way with correct metadata.
50
+ */
51
+ export async function createSharedShapeGtfsZip(): Promise<Uint8Array> {
52
+ const encoder = new TextEncoder()
53
+
54
+ const files: Record<string, Uint8Array> = {
55
+ "agency.txt":
56
+ encoder.encode(`agency_id,agency_name,agency_url,agency_timezone
57
+ agency1,Test Transit,https://example.com,America/New_York`),
58
+
59
+ "stops.txt": encoder.encode(`stop_id,stop_name,stop_lat,stop_lon
60
+ stop1,Stop A,40.7128,-74.0060
61
+ stop2,Stop B,40.7614,-73.9776`),
62
+
63
+ "routes.txt":
64
+ encoder.encode(`route_id,route_short_name,route_long_name,route_type,route_color
65
+ route1,R1,Red Line,1,FF0000
66
+ route2,B2,Blue Express,3,0000FF`),
67
+
68
+ // Both trips use the same shape but different routes
69
+ "trips.txt": encoder.encode(`trip_id,route_id,service_id,shape_id
70
+ trip1,route1,daily,shared_shape
71
+ trip2,route1,daily,shared_shape
72
+ trip3,route2,daily,shared_shape`),
73
+
74
+ "stop_times.txt": encoder.encode(`trip_id,stop_id,stop_sequence
75
+ trip1,stop1,1
76
+ trip1,stop2,2
77
+ trip2,stop1,1
78
+ trip2,stop2,2
79
+ trip3,stop1,1
80
+ trip3,stop2,2`),
81
+
82
+ "shapes.txt":
83
+ encoder.encode(`shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence
84
+ shared_shape,40.7128,-74.0060,1
85
+ shared_shape,40.7400,-73.9900,2
86
+ shared_shape,40.7614,-73.9776,3`),
87
+ }
88
+
89
+ return zipSync(files)
90
+ }
91
+
92
+ /**
93
+ * Create a minimal GTFS zip with just stops (no routes or shapes).
94
+ */
95
+ export async function createMinimalGtfsZip(): Promise<Uint8Array> {
96
+ const encoder = new TextEncoder()
97
+
98
+ const files: Record<string, Uint8Array> = {
99
+ "agency.txt":
100
+ encoder.encode(`agency_id,agency_name,agency_url,agency_timezone
101
+ agency1,Minimal Transit,https://example.com,America/New_York`),
102
+
103
+ "stops.txt": encoder.encode(`stop_id,stop_name,stop_lat,stop_lon
104
+ stop1,Test Stop,40.7128,-74.0060`),
105
+
106
+ "routes.txt":
107
+ encoder.encode(`route_id,route_short_name,route_long_name,route_type
108
+ route1,M,Metro Line,1`),
109
+
110
+ "trips.txt": encoder.encode(`trip_id,route_id,service_id
111
+ trip1,route1,daily`),
112
+
113
+ "stop_times.txt": encoder.encode(`trip_id,stop_id,stop_sequence
114
+ trip1,stop1,1`),
115
+ }
116
+
117
+ return zipSync(files)
118
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "./tsconfig.json",
4
+ "include": ["src"]
5
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "@osmix/shared/tsconfig/base.json",
4
+ "exclude": ["node_modules", "dist"],
5
+ "include": ["src", "test"],
6
+ "compilerOptions": {
7
+ "outDir": "./dist"
8
+ }
9
+ }