njtransit 1.0.0
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.
- checksums.yaml +7 -0
- data/.claude/commands/njtransit.md +196 -0
- data/.mcp.json.example +12 -0
- data/.mcp.json.sample +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +87 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +159 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/Rakefile +12 -0
- data/docs/plans/2025-01-24-njtransit-gem-design.md +112 -0
- data/docs/plans/2026-01-24-bus-api-design.md +119 -0
- data/docs/plans/2026-01-24-gtfs-implementation.md +2216 -0
- data/docs/plans/2026-01-24-gtfs-loader-design.md +351 -0
- data/docs/superpowers/plans/2026-03-26-dev-infra-and-agent.md +480 -0
- data/lefthook.yml +17 -0
- data/lib/njtransit/client.rb +291 -0
- data/lib/njtransit/configuration.rb +49 -0
- data/lib/njtransit/error.rb +50 -0
- data/lib/njtransit/gtfs/database.rb +145 -0
- data/lib/njtransit/gtfs/importer.rb +124 -0
- data/lib/njtransit/gtfs/models/route.rb +59 -0
- data/lib/njtransit/gtfs/models/stop.rb +63 -0
- data/lib/njtransit/gtfs/queries/routes_between.rb +62 -0
- data/lib/njtransit/gtfs/queries/schedule.rb +75 -0
- data/lib/njtransit/gtfs.rb +119 -0
- data/lib/njtransit/railtie.rb +9 -0
- data/lib/njtransit/resources/base.rb +35 -0
- data/lib/njtransit/resources/bus/enrichment.rb +105 -0
- data/lib/njtransit/resources/bus.rb +95 -0
- data/lib/njtransit/resources/bus_gtfs.rb +34 -0
- data/lib/njtransit/resources/rail.rb +47 -0
- data/lib/njtransit/resources/rail_gtfs.rb +27 -0
- data/lib/njtransit/tasks.rb +74 -0
- data/lib/njtransit/version.rb +5 -0
- data/lib/njtransit.rb +40 -0
- data/sig/njtransit.rbs +4 -0
- metadata +177 -0
|
@@ -0,0 +1,2216 @@
|
|
|
1
|
+
# GTFS Static Data Loader Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add GTFS static data loading with SQLite storage and automatic Bus API enrichment.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Sequel gem for database access, SQLite for persistent storage at XDG-compliant path. GTFS import is a one-time deployment step via Rake task. Bus API methods automatically enrich responses with GTFS data (lat/lon, route names).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby 3.2+, Sequel ~> 5.0, SQLite3 ~> 2.0, Rake
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Add Dependencies
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `njtransit.gemspec:36-39`
|
|
17
|
+
- Modify: `Gemfile`
|
|
18
|
+
|
|
19
|
+
**Step 1: Add sequel and sqlite3 to gemspec**
|
|
20
|
+
|
|
21
|
+
In `njtransit.gemspec`, add after line 39:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
spec.add_dependency "sequel", "~> 5.0"
|
|
25
|
+
spec.add_dependency "sqlite3", "~> 2.0"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Step 2: Run bundle install**
|
|
29
|
+
|
|
30
|
+
Run: `bundle install`
|
|
31
|
+
Expected: Dependencies installed successfully
|
|
32
|
+
|
|
33
|
+
**Step 3: Commit**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git add njtransit.gemspec Gemfile.lock
|
|
37
|
+
git commit -m "feat: add sequel and sqlite3 dependencies for GTFS"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Task 2: Add GTFSNotImportedError
|
|
43
|
+
|
|
44
|
+
**Files:**
|
|
45
|
+
- Modify: `lib/njtransit/error.rb`
|
|
46
|
+
- Create: `spec/njtransit/gtfs_not_imported_error_spec.rb`
|
|
47
|
+
|
|
48
|
+
**Step 1: Write the failing test**
|
|
49
|
+
|
|
50
|
+
Create `spec/njtransit/gtfs_not_imported_error_spec.rb`:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# frozen_string_literal: true
|
|
54
|
+
|
|
55
|
+
RSpec.describe NJTransit::GTFSNotImportedError do
|
|
56
|
+
describe "#initialize" do
|
|
57
|
+
it "includes the base message" do
|
|
58
|
+
error = described_class.new
|
|
59
|
+
expect(error.message).to include("GTFS data not found")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "includes hint when gtfs_path is provided" do
|
|
63
|
+
error = described_class.new(detected_path: "./bus_data")
|
|
64
|
+
expect(error.message).to include("Detected GTFS files at: ./bus_data")
|
|
65
|
+
expect(error.message).to include("rake njtransit:gtfs:import[./bus_data]")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "excludes hint when no path detected" do
|
|
69
|
+
error = described_class.new
|
|
70
|
+
expect(error.message).not_to include("Detected GTFS files")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "inherits from NJTransit::Error" do
|
|
75
|
+
expect(described_class.superclass).to eq(NJTransit::Error)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Step 2: Run test to verify it fails**
|
|
81
|
+
|
|
82
|
+
Run: `bundle exec rspec spec/njtransit/gtfs_not_imported_error_spec.rb -v`
|
|
83
|
+
Expected: FAIL with "uninitialized constant NJTransit::GTFSNotImportedError"
|
|
84
|
+
|
|
85
|
+
**Step 3: Write minimal implementation**
|
|
86
|
+
|
|
87
|
+
Add to `lib/njtransit/error.rb` after line 37 (before final `end`):
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# GTFS not imported
|
|
91
|
+
class GTFSNotImportedError < Error
|
|
92
|
+
def initialize(detected_path: nil)
|
|
93
|
+
message = "GTFS data not found. Run: rake njtransit:gtfs:import[/path/to/bus_data]"
|
|
94
|
+
if detected_path
|
|
95
|
+
message += "\n\nDetected GTFS files at: #{detected_path}"
|
|
96
|
+
message += "\nHint: rake njtransit:gtfs:import[#{detected_path}]"
|
|
97
|
+
end
|
|
98
|
+
super(message)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Step 4: Run test to verify it passes**
|
|
104
|
+
|
|
105
|
+
Run: `bundle exec rspec spec/njtransit/gtfs_not_imported_error_spec.rb -v`
|
|
106
|
+
Expected: PASS (3 examples, 0 failures)
|
|
107
|
+
|
|
108
|
+
**Step 5: Commit**
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
git add lib/njtransit/error.rb spec/njtransit/gtfs_not_imported_error_spec.rb
|
|
112
|
+
git commit -m "feat: add GTFSNotImportedError with auto-detection hint"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Task 3: Add Configuration for GTFS Database Path
|
|
118
|
+
|
|
119
|
+
**Files:**
|
|
120
|
+
- Modify: `lib/njtransit/configuration.rb`
|
|
121
|
+
- Modify: `spec/njtransit/configuration_spec.rb`
|
|
122
|
+
|
|
123
|
+
**Step 1: Write the failing test**
|
|
124
|
+
|
|
125
|
+
Add to `spec/njtransit/configuration_spec.rb`:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
describe "#gtfs_database_path" do
|
|
129
|
+
it "defaults to XDG_DATA_HOME if set" do
|
|
130
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
131
|
+
allow(ENV).to receive(:fetch).with("NJTRANSIT_GTFS_DATABASE_PATH", anything).and_return(nil)
|
|
132
|
+
allow(ENV).to receive(:[]).with("XDG_DATA_HOME").and_return("/custom/xdg")
|
|
133
|
+
|
|
134
|
+
config = described_class.new
|
|
135
|
+
expect(config.gtfs_database_path).to eq("/custom/xdg/njtransit/gtfs.sqlite3")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "defaults to ~/.local/share/njtransit when XDG_DATA_HOME not set" do
|
|
139
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
140
|
+
allow(ENV).to receive(:fetch).with("NJTRANSIT_GTFS_DATABASE_PATH", anything).and_return(nil)
|
|
141
|
+
allow(ENV).to receive(:[]).with("XDG_DATA_HOME").and_return(nil)
|
|
142
|
+
|
|
143
|
+
config = described_class.new
|
|
144
|
+
expect(config.gtfs_database_path).to eq(File.expand_path("~/.local/share/njtransit/gtfs.sqlite3"))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "can be overridden via environment variable" do
|
|
148
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
149
|
+
allow(ENV).to receive(:fetch).with("NJTRANSIT_GTFS_DATABASE_PATH", anything).and_return("/custom/path/gtfs.db")
|
|
150
|
+
|
|
151
|
+
config = described_class.new
|
|
152
|
+
expect(config.gtfs_database_path).to eq("/custom/path/gtfs.db")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "can be set directly" do
|
|
156
|
+
config = described_class.new
|
|
157
|
+
config.gtfs_database_path = "/my/path/gtfs.sqlite3"
|
|
158
|
+
expect(config.gtfs_database_path).to eq("/my/path/gtfs.sqlite3")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Step 2: Run test to verify it fails**
|
|
164
|
+
|
|
165
|
+
Run: `bundle exec rspec spec/njtransit/configuration_spec.rb -v`
|
|
166
|
+
Expected: FAIL with "undefined method `gtfs_database_path'"
|
|
167
|
+
|
|
168
|
+
**Step 3: Write minimal implementation**
|
|
169
|
+
|
|
170
|
+
In `lib/njtransit/configuration.rb`:
|
|
171
|
+
|
|
172
|
+
Add to attr_accessor on line 9:
|
|
173
|
+
```ruby
|
|
174
|
+
attr_accessor :username, :password, :base_url, :timeout, :gtfs_database_path
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Add to initialize method after line 17:
|
|
178
|
+
```ruby
|
|
179
|
+
@gtfs_database_path = ENV.fetch("NJTRANSIT_GTFS_DATABASE_PATH", nil) || default_gtfs_database_path
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Add private method before the final `end`:
|
|
183
|
+
```ruby
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def default_gtfs_database_path
|
|
187
|
+
base = ENV["XDG_DATA_HOME"] || File.expand_path("~/.local/share")
|
|
188
|
+
File.join(base, "njtransit", "gtfs.sqlite3")
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Step 4: Run test to verify it passes**
|
|
193
|
+
|
|
194
|
+
Run: `bundle exec rspec spec/njtransit/configuration_spec.rb -v`
|
|
195
|
+
Expected: PASS
|
|
196
|
+
|
|
197
|
+
**Step 5: Commit**
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git add lib/njtransit/configuration.rb spec/njtransit/configuration_spec.rb
|
|
201
|
+
git commit -m "feat: add gtfs_database_path configuration with XDG support"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Task 4: Create Test Fixtures
|
|
207
|
+
|
|
208
|
+
**Files:**
|
|
209
|
+
- Create: `spec/fixtures/gtfs/agency.txt`
|
|
210
|
+
- Create: `spec/fixtures/gtfs/routes.txt`
|
|
211
|
+
- Create: `spec/fixtures/gtfs/stops.txt`
|
|
212
|
+
- Create: `spec/fixtures/gtfs/trips.txt`
|
|
213
|
+
- Create: `spec/fixtures/gtfs/stop_times.txt`
|
|
214
|
+
- Create: `spec/fixtures/gtfs/calendar_dates.txt`
|
|
215
|
+
- Create: `spec/fixtures/gtfs/shapes.txt`
|
|
216
|
+
|
|
217
|
+
**Step 1: Create agency.txt**
|
|
218
|
+
|
|
219
|
+
```csv
|
|
220
|
+
agency_id,agency_name,agency_url,agency_timezone
|
|
221
|
+
NJB,NJ TRANSIT BUS,http://www.njtransit.com,America/New_York
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Step 2: Create routes.txt**
|
|
225
|
+
|
|
226
|
+
```csv
|
|
227
|
+
route_id,agency_id,route_short_name,route_long_name,route_type,route_color
|
|
228
|
+
1,NJB,1,Newark - Jersey City,3,000000
|
|
229
|
+
2,NJB,10,Bloomfield - Newark,3,000000
|
|
230
|
+
3,NJB,197,Willowbrook - Port Authority,3,FF0000
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Step 3: Create stops.txt**
|
|
234
|
+
|
|
235
|
+
```csv
|
|
236
|
+
stop_id,stop_code,stop_name,stop_desc,stop_lat,stop_lon,zone_id
|
|
237
|
+
1,WBRK,WILLOWBROOK MALL,,40.852300,-74.256700,NB
|
|
238
|
+
2,PABT,PORT AUTHORITY BUS TERMINAL,,40.756600,-73.990900,NY
|
|
239
|
+
3,21681,MAIN ST AT CENTER,,40.123400,-74.567800,NB
|
|
240
|
+
4,21682,BROAD ST AT MARKET,,40.234500,-74.678900,NK
|
|
241
|
+
5,21683,CENTRAL AVE AT 1ST,,40.345600,-74.789000,JC
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Step 4: Create trips.txt**
|
|
245
|
+
|
|
246
|
+
```csv
|
|
247
|
+
route_id,service_id,trip_id,trip_headsign,direction_id,block_id,shape_id
|
|
248
|
+
3,1,100,197 PORT AUTHORITY,0,BLK001,1
|
|
249
|
+
3,1,101,197 PORT AUTHORITY,0,BLK002,1
|
|
250
|
+
3,1,102,197 WILLOWBROOK,1,BLK003,2
|
|
251
|
+
3,2,103,197 PORT AUTHORITY,0,BLK004,1
|
|
252
|
+
1,1,200,1 JERSEY CITY,0,BLK010,3
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Step 5: Create stop_times.txt**
|
|
256
|
+
|
|
257
|
+
```csv
|
|
258
|
+
trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type
|
|
259
|
+
100,06:00:00,06:00:00,1,1,0,0
|
|
260
|
+
100,06:45:00,06:45:00,2,2,0,0
|
|
261
|
+
101,07:00:00,07:00:00,1,1,0,0
|
|
262
|
+
101,07:45:00,07:45:00,2,2,0,0
|
|
263
|
+
102,08:00:00,08:00:00,2,1,0,0
|
|
264
|
+
102,08:45:00,08:45:00,1,2,0,0
|
|
265
|
+
103,09:00:00,09:00:00,1,1,0,0
|
|
266
|
+
103,09:45:00,09:45:00,2,2,0,0
|
|
267
|
+
200,05:30:00,05:30:00,4,1,0,0
|
|
268
|
+
200,06:00:00,06:00:00,5,2,0,0
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Step 6: Create calendar_dates.txt**
|
|
272
|
+
|
|
273
|
+
```csv
|
|
274
|
+
service_id,date,exception_type
|
|
275
|
+
1,20260124,1
|
|
276
|
+
1,20260125,1
|
|
277
|
+
2,20260126,1
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Step 7: Create shapes.txt**
|
|
281
|
+
|
|
282
|
+
```csv
|
|
283
|
+
shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence
|
|
284
|
+
1,40.852300,-74.256700,1
|
|
285
|
+
1,40.800000,-74.100000,2
|
|
286
|
+
1,40.756600,-73.990900,3
|
|
287
|
+
2,40.756600,-73.990900,1
|
|
288
|
+
2,40.800000,-74.100000,2
|
|
289
|
+
2,40.852300,-74.256700,3
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Step 8: Commit**
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
git add spec/fixtures/gtfs/
|
|
296
|
+
git commit -m "test: add GTFS fixture files for testing"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Task 5: Create GTFS Database Module
|
|
302
|
+
|
|
303
|
+
**Files:**
|
|
304
|
+
- Create: `lib/njtransit/gtfs/database.rb`
|
|
305
|
+
- Create: `spec/njtransit/gtfs/database_spec.rb`
|
|
306
|
+
|
|
307
|
+
**Step 1: Write the failing test**
|
|
308
|
+
|
|
309
|
+
Create `spec/njtransit/gtfs/database_spec.rb`:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
# frozen_string_literal: true
|
|
313
|
+
|
|
314
|
+
require "fileutils"
|
|
315
|
+
|
|
316
|
+
RSpec.describe NJTransit::GTFS::Database do
|
|
317
|
+
let(:test_db_path) { "tmp/test_gtfs.sqlite3" }
|
|
318
|
+
|
|
319
|
+
before do
|
|
320
|
+
FileUtils.mkdir_p("tmp")
|
|
321
|
+
FileUtils.rm_f(test_db_path)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
after do
|
|
325
|
+
described_class.disconnect
|
|
326
|
+
FileUtils.rm_f(test_db_path)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
describe ".connection" do
|
|
330
|
+
it "creates a Sequel SQLite connection" do
|
|
331
|
+
conn = described_class.connection(test_db_path)
|
|
332
|
+
expect(conn).to be_a(Sequel::SQLite::Database)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "creates the database file" do
|
|
336
|
+
described_class.connection(test_db_path)
|
|
337
|
+
expect(File.exist?(test_db_path)).to be true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it "returns the same connection on subsequent calls" do
|
|
341
|
+
conn1 = described_class.connection(test_db_path)
|
|
342
|
+
conn2 = described_class.connection(test_db_path)
|
|
343
|
+
expect(conn1).to be(conn2)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
describe ".setup_schema!" do
|
|
348
|
+
before { described_class.connection(test_db_path) }
|
|
349
|
+
|
|
350
|
+
it "creates the agencies table" do
|
|
351
|
+
described_class.setup_schema!
|
|
352
|
+
expect(described_class.connection(test_db_path).table_exists?(:agencies)).to be true
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "creates the routes table" do
|
|
356
|
+
described_class.setup_schema!
|
|
357
|
+
expect(described_class.connection(test_db_path).table_exists?(:routes)).to be true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "creates the stops table" do
|
|
361
|
+
described_class.setup_schema!
|
|
362
|
+
expect(described_class.connection(test_db_path).table_exists?(:stops)).to be true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it "creates the trips table" do
|
|
366
|
+
described_class.setup_schema!
|
|
367
|
+
expect(described_class.connection(test_db_path).table_exists?(:trips)).to be true
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it "creates the stop_times table" do
|
|
371
|
+
described_class.setup_schema!
|
|
372
|
+
expect(described_class.connection(test_db_path).table_exists?(:stop_times)).to be true
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "creates the calendar_dates table" do
|
|
376
|
+
described_class.setup_schema!
|
|
377
|
+
expect(described_class.connection(test_db_path).table_exists?(:calendar_dates)).to be true
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it "creates the shapes table" do
|
|
381
|
+
described_class.setup_schema!
|
|
382
|
+
expect(described_class.connection(test_db_path).table_exists?(:shapes)).to be true
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
describe ".exists?" do
|
|
387
|
+
it "returns false when database does not exist" do
|
|
388
|
+
expect(described_class.exists?(test_db_path)).to be false
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "returns true when database exists" do
|
|
392
|
+
described_class.connection(test_db_path)
|
|
393
|
+
described_class.setup_schema!
|
|
394
|
+
described_class.disconnect
|
|
395
|
+
expect(described_class.exists?(test_db_path)).to be true
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Step 2: Run test to verify it fails**
|
|
402
|
+
|
|
403
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/database_spec.rb -v`
|
|
404
|
+
Expected: FAIL with "uninitialized constant NJTransit::GTFS"
|
|
405
|
+
|
|
406
|
+
**Step 3: Create the directory structure**
|
|
407
|
+
|
|
408
|
+
Run: `mkdir -p lib/njtransit/gtfs`
|
|
409
|
+
|
|
410
|
+
**Step 4: Write implementation**
|
|
411
|
+
|
|
412
|
+
Create `lib/njtransit/gtfs/database.rb`:
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# frozen_string_literal: true
|
|
416
|
+
|
|
417
|
+
require "sequel"
|
|
418
|
+
require "fileutils"
|
|
419
|
+
|
|
420
|
+
module NJTransit
|
|
421
|
+
module GTFS
|
|
422
|
+
module Database
|
|
423
|
+
class << self
|
|
424
|
+
def connection(path = nil)
|
|
425
|
+
@path = path if path
|
|
426
|
+
@connection ||= begin
|
|
427
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
428
|
+
Sequel.sqlite(@path)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def disconnect
|
|
433
|
+
@connection&.disconnect
|
|
434
|
+
@connection = nil
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def exists?(path)
|
|
438
|
+
return false unless File.exist?(path)
|
|
439
|
+
|
|
440
|
+
db = Sequel.sqlite(path)
|
|
441
|
+
db.table_exists?(:agencies) && db.table_exists?(:stops)
|
|
442
|
+
rescue StandardError
|
|
443
|
+
false
|
|
444
|
+
ensure
|
|
445
|
+
db&.disconnect
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def setup_schema!
|
|
449
|
+
db = connection
|
|
450
|
+
|
|
451
|
+
db.create_table?(:agencies) do
|
|
452
|
+
String :agency_id, primary_key: true
|
|
453
|
+
String :agency_name
|
|
454
|
+
String :agency_url
|
|
455
|
+
String :agency_timezone
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
db.create_table?(:routes) do
|
|
459
|
+
String :route_id, primary_key: true
|
|
460
|
+
String :agency_id
|
|
461
|
+
String :route_short_name
|
|
462
|
+
String :route_long_name
|
|
463
|
+
Integer :route_type
|
|
464
|
+
String :route_color
|
|
465
|
+
index :route_short_name
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
db.create_table?(:stops) do
|
|
469
|
+
String :stop_id, primary_key: true
|
|
470
|
+
String :stop_code
|
|
471
|
+
String :stop_name
|
|
472
|
+
Float :stop_lat
|
|
473
|
+
Float :stop_lon
|
|
474
|
+
String :zone_id
|
|
475
|
+
index :stop_code
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
db.create_table?(:trips) do
|
|
479
|
+
String :trip_id, primary_key: true
|
|
480
|
+
String :route_id
|
|
481
|
+
String :service_id
|
|
482
|
+
String :trip_headsign
|
|
483
|
+
Integer :direction_id
|
|
484
|
+
String :shape_id
|
|
485
|
+
index :route_id
|
|
486
|
+
index :service_id
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
db.create_table?(:stop_times) do
|
|
490
|
+
primary_key :id
|
|
491
|
+
String :trip_id
|
|
492
|
+
String :stop_id
|
|
493
|
+
String :arrival_time
|
|
494
|
+
String :departure_time
|
|
495
|
+
Integer :stop_sequence
|
|
496
|
+
index :trip_id
|
|
497
|
+
index :stop_id
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
db.create_table?(:calendar_dates) do
|
|
501
|
+
primary_key :id
|
|
502
|
+
String :service_id
|
|
503
|
+
String :date
|
|
504
|
+
Integer :exception_type
|
|
505
|
+
index [:service_id, :date]
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
db.create_table?(:shapes) do
|
|
509
|
+
primary_key :id
|
|
510
|
+
String :shape_id
|
|
511
|
+
Float :shape_pt_lat
|
|
512
|
+
Float :shape_pt_lon
|
|
513
|
+
Integer :shape_pt_sequence
|
|
514
|
+
index :shape_id
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
db.create_table?(:import_metadata) do
|
|
518
|
+
primary_key :id
|
|
519
|
+
DateTime :imported_at
|
|
520
|
+
String :source_path
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def clear!
|
|
525
|
+
db = connection
|
|
526
|
+
%i[agencies routes stops trips stop_times calendar_dates shapes import_metadata].each do |table|
|
|
527
|
+
db.drop_table?(table)
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Step 5: Run test to verify it passes**
|
|
537
|
+
|
|
538
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/database_spec.rb -v`
|
|
539
|
+
Expected: PASS
|
|
540
|
+
|
|
541
|
+
**Step 6: Commit**
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
git add lib/njtransit/gtfs/database.rb spec/njtransit/gtfs/database_spec.rb
|
|
545
|
+
git commit -m "feat: add GTFS database module with schema setup"
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## Task 6: Create GTFS Importer
|
|
551
|
+
|
|
552
|
+
**Files:**
|
|
553
|
+
- Create: `lib/njtransit/gtfs/importer.rb`
|
|
554
|
+
- Create: `spec/njtransit/gtfs/importer_spec.rb`
|
|
555
|
+
|
|
556
|
+
**Step 1: Write the failing test**
|
|
557
|
+
|
|
558
|
+
Create `spec/njtransit/gtfs/importer_spec.rb`:
|
|
559
|
+
|
|
560
|
+
```ruby
|
|
561
|
+
# frozen_string_literal: true
|
|
562
|
+
|
|
563
|
+
require "fileutils"
|
|
564
|
+
|
|
565
|
+
RSpec.describe NJTransit::GTFS::Importer do
|
|
566
|
+
let(:fixtures_path) { "spec/fixtures/gtfs" }
|
|
567
|
+
let(:test_db_path) { "tmp/test_import.sqlite3" }
|
|
568
|
+
|
|
569
|
+
before do
|
|
570
|
+
FileUtils.mkdir_p("tmp")
|
|
571
|
+
FileUtils.rm_f(test_db_path)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
after do
|
|
575
|
+
NJTransit::GTFS::Database.disconnect
|
|
576
|
+
FileUtils.rm_f(test_db_path)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
describe "#import" do
|
|
580
|
+
subject(:importer) { described_class.new(fixtures_path, test_db_path) }
|
|
581
|
+
|
|
582
|
+
it "imports agencies" do
|
|
583
|
+
importer.import
|
|
584
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
585
|
+
expect(db[:agencies].count).to eq(1)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
it "imports routes" do
|
|
589
|
+
importer.import
|
|
590
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
591
|
+
expect(db[:routes].count).to eq(3)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
it "imports stops" do
|
|
595
|
+
importer.import
|
|
596
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
597
|
+
expect(db[:stops].count).to eq(5)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
it "imports trips" do
|
|
601
|
+
importer.import
|
|
602
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
603
|
+
expect(db[:trips].count).to eq(5)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
it "imports stop_times" do
|
|
607
|
+
importer.import
|
|
608
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
609
|
+
expect(db[:stop_times].count).to eq(10)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
it "imports calendar_dates" do
|
|
613
|
+
importer.import
|
|
614
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
615
|
+
expect(db[:calendar_dates].count).to eq(3)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
it "imports shapes" do
|
|
619
|
+
importer.import
|
|
620
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
621
|
+
expect(db[:shapes].count).to eq(6)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
it "records import metadata" do
|
|
625
|
+
importer.import
|
|
626
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
627
|
+
metadata = db[:import_metadata].first
|
|
628
|
+
expect(metadata[:source_path]).to eq(fixtures_path)
|
|
629
|
+
expect(metadata[:imported_at]).not_to be_nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
it "clears existing data when force: true" do
|
|
633
|
+
importer.import
|
|
634
|
+
# Import again with force
|
|
635
|
+
described_class.new(fixtures_path, test_db_path).import(force: true)
|
|
636
|
+
db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
637
|
+
expect(db[:import_metadata].count).to eq(1)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
it "raises error if database exists without force" do
|
|
641
|
+
importer.import
|
|
642
|
+
NJTransit::GTFS::Database.disconnect
|
|
643
|
+
expect {
|
|
644
|
+
described_class.new(fixtures_path, test_db_path).import
|
|
645
|
+
}.to raise_error(NJTransit::Error, /already exists/)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
describe "#valid_gtfs_directory?" do
|
|
650
|
+
it "returns true for valid GTFS directory" do
|
|
651
|
+
importer = described_class.new(fixtures_path, test_db_path)
|
|
652
|
+
expect(importer.valid_gtfs_directory?).to be true
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
it "returns false for invalid directory" do
|
|
656
|
+
importer = described_class.new("/nonexistent", test_db_path)
|
|
657
|
+
expect(importer.valid_gtfs_directory?).to be false
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
**Step 2: Run test to verify it fails**
|
|
664
|
+
|
|
665
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/importer_spec.rb -v`
|
|
666
|
+
Expected: FAIL with "uninitialized constant NJTransit::GTFS::Importer"
|
|
667
|
+
|
|
668
|
+
**Step 3: Write implementation**
|
|
669
|
+
|
|
670
|
+
Create `lib/njtransit/gtfs/importer.rb`:
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
# frozen_string_literal: true
|
|
674
|
+
|
|
675
|
+
require "csv"
|
|
676
|
+
|
|
677
|
+
module NJTransit
|
|
678
|
+
module GTFS
|
|
679
|
+
class Importer
|
|
680
|
+
REQUIRED_FILES = %w[agency.txt routes.txt stops.txt].freeze
|
|
681
|
+
OPTIONAL_FILES = %w[trips.txt stop_times.txt calendar_dates.txt shapes.txt].freeze
|
|
682
|
+
|
|
683
|
+
attr_reader :source_path, :db_path
|
|
684
|
+
|
|
685
|
+
def initialize(source_path, db_path)
|
|
686
|
+
@source_path = source_path
|
|
687
|
+
@db_path = db_path
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def import(force: false)
|
|
691
|
+
if Database.exists?(db_path) && !force
|
|
692
|
+
raise NJTransit::Error, "GTFS database already exists at #{db_path}. Use force: true to reimport."
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
Database.disconnect
|
|
696
|
+
FileUtils.rm_f(db_path) if force
|
|
697
|
+
|
|
698
|
+
Database.connection(db_path)
|
|
699
|
+
Database.setup_schema!
|
|
700
|
+
|
|
701
|
+
import_agencies
|
|
702
|
+
import_routes
|
|
703
|
+
import_stops
|
|
704
|
+
import_trips
|
|
705
|
+
import_stop_times
|
|
706
|
+
import_calendar_dates
|
|
707
|
+
import_shapes
|
|
708
|
+
record_metadata
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def valid_gtfs_directory?
|
|
712
|
+
return false unless File.directory?(source_path)
|
|
713
|
+
|
|
714
|
+
REQUIRED_FILES.all? { |f| File.exist?(File.join(source_path, f)) }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
private
|
|
718
|
+
|
|
719
|
+
def import_agencies
|
|
720
|
+
import_csv("agency.txt", :agencies) do |row|
|
|
721
|
+
{
|
|
722
|
+
agency_id: row["agency_id"],
|
|
723
|
+
agency_name: row["agency_name"],
|
|
724
|
+
agency_url: row["agency_url"],
|
|
725
|
+
agency_timezone: row["agency_timezone"]
|
|
726
|
+
}
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def import_routes
|
|
731
|
+
import_csv("routes.txt", :routes) do |row|
|
|
732
|
+
{
|
|
733
|
+
route_id: row["route_id"],
|
|
734
|
+
agency_id: row["agency_id"],
|
|
735
|
+
route_short_name: row["route_short_name"],
|
|
736
|
+
route_long_name: row["route_long_name"],
|
|
737
|
+
route_type: row["route_type"]&.to_i,
|
|
738
|
+
route_color: row["route_color"]
|
|
739
|
+
}
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def import_stops
|
|
744
|
+
import_csv("stops.txt", :stops) do |row|
|
|
745
|
+
{
|
|
746
|
+
stop_id: row["stop_id"],
|
|
747
|
+
stop_code: row["stop_code"],
|
|
748
|
+
stop_name: row["stop_name"],
|
|
749
|
+
stop_lat: row["stop_lat"]&.to_f,
|
|
750
|
+
stop_lon: row["stop_lon"]&.to_f,
|
|
751
|
+
zone_id: row["zone_id"]
|
|
752
|
+
}
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def import_trips
|
|
757
|
+
import_csv("trips.txt", :trips) do |row|
|
|
758
|
+
{
|
|
759
|
+
trip_id: row["trip_id"],
|
|
760
|
+
route_id: row["route_id"],
|
|
761
|
+
service_id: row["service_id"],
|
|
762
|
+
trip_headsign: row["trip_headsign"],
|
|
763
|
+
direction_id: row["direction_id"]&.to_i,
|
|
764
|
+
shape_id: row["shape_id"]
|
|
765
|
+
}
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def import_stop_times
|
|
770
|
+
import_csv("stop_times.txt", :stop_times, batch_size: 10_000) do |row|
|
|
771
|
+
{
|
|
772
|
+
trip_id: row["trip_id"],
|
|
773
|
+
stop_id: row["stop_id"],
|
|
774
|
+
arrival_time: row["arrival_time"],
|
|
775
|
+
departure_time: row["departure_time"],
|
|
776
|
+
stop_sequence: row["stop_sequence"]&.to_i
|
|
777
|
+
}
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def import_calendar_dates
|
|
782
|
+
import_csv("calendar_dates.txt", :calendar_dates) do |row|
|
|
783
|
+
{
|
|
784
|
+
service_id: row["service_id"],
|
|
785
|
+
date: row["date"],
|
|
786
|
+
exception_type: row["exception_type"]&.to_i
|
|
787
|
+
}
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def import_shapes
|
|
792
|
+
import_csv("shapes.txt", :shapes, batch_size: 50_000) do |row|
|
|
793
|
+
{
|
|
794
|
+
shape_id: row["shape_id"],
|
|
795
|
+
shape_pt_lat: row["shape_pt_lat"]&.to_f,
|
|
796
|
+
shape_pt_lon: row["shape_pt_lon"]&.to_f,
|
|
797
|
+
shape_pt_sequence: row["shape_pt_sequence"]&.to_i
|
|
798
|
+
}
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def import_csv(filename, table, batch_size: 1000)
|
|
803
|
+
path = File.join(source_path, filename)
|
|
804
|
+
return unless File.exist?(path)
|
|
805
|
+
|
|
806
|
+
db = Database.connection
|
|
807
|
+
batch = []
|
|
808
|
+
|
|
809
|
+
CSV.foreach(path, headers: true) do |row|
|
|
810
|
+
batch << yield(row)
|
|
811
|
+
|
|
812
|
+
if batch.size >= batch_size
|
|
813
|
+
db[table].multi_insert(batch)
|
|
814
|
+
batch.clear
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
db[table].multi_insert(batch) unless batch.empty?
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def record_metadata
|
|
822
|
+
Database.connection[:import_metadata].insert(
|
|
823
|
+
imported_at: Time.now,
|
|
824
|
+
source_path: source_path
|
|
825
|
+
)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
**Step 4: Run test to verify it passes**
|
|
833
|
+
|
|
834
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/importer_spec.rb -v`
|
|
835
|
+
Expected: PASS
|
|
836
|
+
|
|
837
|
+
**Step 5: Commit**
|
|
838
|
+
|
|
839
|
+
```bash
|
|
840
|
+
git add lib/njtransit/gtfs/importer.rb spec/njtransit/gtfs/importer_spec.rb
|
|
841
|
+
git commit -m "feat: add GTFS importer with batch CSV parsing"
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
---
|
|
845
|
+
|
|
846
|
+
## Task 7: Create GTFS Models (Stop, Route)
|
|
847
|
+
|
|
848
|
+
**Files:**
|
|
849
|
+
- Create: `lib/njtransit/gtfs/models/stop.rb`
|
|
850
|
+
- Create: `lib/njtransit/gtfs/models/route.rb`
|
|
851
|
+
- Create: `spec/njtransit/gtfs/models/stop_spec.rb`
|
|
852
|
+
- Create: `spec/njtransit/gtfs/models/route_spec.rb`
|
|
853
|
+
|
|
854
|
+
**Step 1: Write the failing test for Stop**
|
|
855
|
+
|
|
856
|
+
Create `spec/njtransit/gtfs/models/stop_spec.rb`:
|
|
857
|
+
|
|
858
|
+
```ruby
|
|
859
|
+
# frozen_string_literal: true
|
|
860
|
+
|
|
861
|
+
require "fileutils"
|
|
862
|
+
|
|
863
|
+
RSpec.describe NJTransit::GTFS::Models::Stop do
|
|
864
|
+
let(:fixtures_path) { "spec/fixtures/gtfs" }
|
|
865
|
+
let(:test_db_path) { "tmp/test_models.sqlite3" }
|
|
866
|
+
|
|
867
|
+
before(:all) do
|
|
868
|
+
FileUtils.mkdir_p("tmp")
|
|
869
|
+
FileUtils.rm_f("tmp/test_models.sqlite3")
|
|
870
|
+
NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_models.sqlite3").import
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
after(:all) do
|
|
874
|
+
NJTransit::GTFS::Database.disconnect
|
|
875
|
+
FileUtils.rm_f("tmp/test_models.sqlite3")
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
before do
|
|
879
|
+
described_class.db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
describe ".all" do
|
|
883
|
+
it "returns all stops" do
|
|
884
|
+
stops = described_class.all
|
|
885
|
+
expect(stops.count).to eq(5)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
describe ".find" do
|
|
890
|
+
it "finds stop by stop_id" do
|
|
891
|
+
stop = described_class.find("1")
|
|
892
|
+
expect(stop.stop_code).to eq("WBRK")
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
it "returns nil when not found" do
|
|
896
|
+
stop = described_class.find("nonexistent")
|
|
897
|
+
expect(stop).to be_nil
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
describe ".find_by_code" do
|
|
902
|
+
it "finds stop by stop_code" do
|
|
903
|
+
stop = described_class.find_by_code("WBRK")
|
|
904
|
+
expect(stop.stop_name).to eq("WILLOWBROOK MALL")
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
it "returns nil when not found" do
|
|
908
|
+
stop = described_class.find_by_code("XXXXX")
|
|
909
|
+
expect(stop).to be_nil
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
describe ".where" do
|
|
914
|
+
it "filters by attributes" do
|
|
915
|
+
stops = described_class.where(zone_id: "NB")
|
|
916
|
+
expect(stops.count).to eq(2)
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
describe "instance methods" do
|
|
921
|
+
let(:stop) { described_class.find_by_code("WBRK") }
|
|
922
|
+
|
|
923
|
+
it "has lat accessor" do
|
|
924
|
+
expect(stop.lat).to eq(40.8523)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
it "has lon accessor" do
|
|
928
|
+
expect(stop.lon).to eq(-74.2567)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
it "converts to hash" do
|
|
932
|
+
hash = stop.to_h
|
|
933
|
+
expect(hash[:stop_id]).to eq("1")
|
|
934
|
+
expect(hash[:stop_code]).to eq("WBRK")
|
|
935
|
+
expect(hash[:lat]).to eq(40.8523)
|
|
936
|
+
expect(hash[:lon]).to eq(-74.2567)
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
**Step 2: Run test to verify it fails**
|
|
943
|
+
|
|
944
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/models/stop_spec.rb -v`
|
|
945
|
+
Expected: FAIL with "uninitialized constant"
|
|
946
|
+
|
|
947
|
+
**Step 3: Create directories**
|
|
948
|
+
|
|
949
|
+
Run: `mkdir -p lib/njtransit/gtfs/models spec/njtransit/gtfs/models`
|
|
950
|
+
|
|
951
|
+
**Step 4: Write Stop implementation**
|
|
952
|
+
|
|
953
|
+
Create `lib/njtransit/gtfs/models/stop.rb`:
|
|
954
|
+
|
|
955
|
+
```ruby
|
|
956
|
+
# frozen_string_literal: true
|
|
957
|
+
|
|
958
|
+
module NJTransit
|
|
959
|
+
module GTFS
|
|
960
|
+
module Models
|
|
961
|
+
class Stop
|
|
962
|
+
class << self
|
|
963
|
+
attr_accessor :db
|
|
964
|
+
|
|
965
|
+
def all
|
|
966
|
+
db[:stops].all.map { |row| new(row) }
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
def find(stop_id)
|
|
970
|
+
row = db[:stops].where(stop_id: stop_id).first
|
|
971
|
+
row ? new(row) : nil
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def find_by_code(stop_code)
|
|
975
|
+
row = db[:stops].where(stop_code: stop_code).first
|
|
976
|
+
row ? new(row) : nil
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def where(conditions)
|
|
980
|
+
db[:stops].where(conditions).all.map { |row| new(row) }
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
attr_reader :stop_id, :stop_code, :stop_name, :stop_lat, :stop_lon, :zone_id
|
|
985
|
+
|
|
986
|
+
def initialize(attributes)
|
|
987
|
+
@stop_id = attributes[:stop_id]
|
|
988
|
+
@stop_code = attributes[:stop_code]
|
|
989
|
+
@stop_name = attributes[:stop_name]
|
|
990
|
+
@stop_lat = attributes[:stop_lat]
|
|
991
|
+
@stop_lon = attributes[:stop_lon]
|
|
992
|
+
@zone_id = attributes[:zone_id]
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def lat
|
|
996
|
+
stop_lat
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def lon
|
|
1000
|
+
stop_lon
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def to_h
|
|
1004
|
+
{
|
|
1005
|
+
stop_id: stop_id,
|
|
1006
|
+
stop_code: stop_code,
|
|
1007
|
+
stop_name: stop_name,
|
|
1008
|
+
stop_lat: stop_lat,
|
|
1009
|
+
stop_lon: stop_lon,
|
|
1010
|
+
lat: lat,
|
|
1011
|
+
lon: lon,
|
|
1012
|
+
zone_id: zone_id
|
|
1013
|
+
}
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
**Step 5: Write the failing test for Route**
|
|
1022
|
+
|
|
1023
|
+
Create `spec/njtransit/gtfs/models/route_spec.rb`:
|
|
1024
|
+
|
|
1025
|
+
```ruby
|
|
1026
|
+
# frozen_string_literal: true
|
|
1027
|
+
|
|
1028
|
+
require "fileutils"
|
|
1029
|
+
|
|
1030
|
+
RSpec.describe NJTransit::GTFS::Models::Route do
|
|
1031
|
+
let(:test_db_path) { "tmp/test_models.sqlite3" }
|
|
1032
|
+
|
|
1033
|
+
before(:all) do
|
|
1034
|
+
FileUtils.mkdir_p("tmp")
|
|
1035
|
+
FileUtils.rm_f("tmp/test_models.sqlite3")
|
|
1036
|
+
NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_models.sqlite3").import
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
after(:all) do
|
|
1040
|
+
NJTransit::GTFS::Database.disconnect
|
|
1041
|
+
FileUtils.rm_f("tmp/test_models.sqlite3")
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
before do
|
|
1045
|
+
described_class.db = NJTransit::GTFS::Database.connection(test_db_path)
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
describe ".all" do
|
|
1049
|
+
it "returns all routes" do
|
|
1050
|
+
routes = described_class.all
|
|
1051
|
+
expect(routes.count).to eq(3)
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
describe ".find" do
|
|
1056
|
+
it "finds route by route_id" do
|
|
1057
|
+
route = described_class.find("3")
|
|
1058
|
+
expect(route.route_short_name).to eq("197")
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
it "finds route by short_name" do
|
|
1062
|
+
route = described_class.find("197")
|
|
1063
|
+
expect(route.route_id).to eq("3")
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
it "returns nil when not found" do
|
|
1067
|
+
route = described_class.find("nonexistent")
|
|
1068
|
+
expect(route).to be_nil
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
describe "instance methods" do
|
|
1073
|
+
let(:route) { described_class.find("197") }
|
|
1074
|
+
|
|
1075
|
+
it "has short_name accessor" do
|
|
1076
|
+
expect(route.short_name).to eq("197")
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
it "has long_name accessor" do
|
|
1080
|
+
expect(route.long_name).to eq("Willowbrook - Port Authority")
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
it "converts to hash" do
|
|
1084
|
+
hash = route.to_h
|
|
1085
|
+
expect(hash[:route_id]).to eq("3")
|
|
1086
|
+
expect(hash[:short_name]).to eq("197")
|
|
1087
|
+
end
|
|
1088
|
+
end
|
|
1089
|
+
end
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
**Step 6: Write Route implementation**
|
|
1093
|
+
|
|
1094
|
+
Create `lib/njtransit/gtfs/models/route.rb`:
|
|
1095
|
+
|
|
1096
|
+
```ruby
|
|
1097
|
+
# frozen_string_literal: true
|
|
1098
|
+
|
|
1099
|
+
module NJTransit
|
|
1100
|
+
module GTFS
|
|
1101
|
+
module Models
|
|
1102
|
+
class Route
|
|
1103
|
+
class << self
|
|
1104
|
+
attr_accessor :db
|
|
1105
|
+
|
|
1106
|
+
def all
|
|
1107
|
+
db[:routes].all.map { |row| new(row) }
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
def find(identifier)
|
|
1111
|
+
row = db[:routes].where(route_id: identifier).first
|
|
1112
|
+
row ||= db[:routes].where(route_short_name: identifier).first
|
|
1113
|
+
row ? new(row) : nil
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def where(conditions)
|
|
1117
|
+
db[:routes].where(conditions).all.map { |row| new(row) }
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
attr_reader :route_id, :agency_id, :route_short_name, :route_long_name, :route_type, :route_color
|
|
1122
|
+
|
|
1123
|
+
def initialize(attributes)
|
|
1124
|
+
@route_id = attributes[:route_id]
|
|
1125
|
+
@agency_id = attributes[:agency_id]
|
|
1126
|
+
@route_short_name = attributes[:route_short_name]
|
|
1127
|
+
@route_long_name = attributes[:route_long_name]
|
|
1128
|
+
@route_type = attributes[:route_type]
|
|
1129
|
+
@route_color = attributes[:route_color]
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
def short_name
|
|
1133
|
+
route_short_name
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def long_name
|
|
1137
|
+
route_long_name
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def to_h
|
|
1141
|
+
{
|
|
1142
|
+
route_id: route_id,
|
|
1143
|
+
agency_id: agency_id,
|
|
1144
|
+
route_short_name: route_short_name,
|
|
1145
|
+
route_long_name: route_long_name,
|
|
1146
|
+
short_name: short_name,
|
|
1147
|
+
long_name: long_name,
|
|
1148
|
+
route_type: route_type,
|
|
1149
|
+
route_color: route_color
|
|
1150
|
+
}
|
|
1151
|
+
end
|
|
1152
|
+
end
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
**Step 7: Run tests to verify they pass**
|
|
1159
|
+
|
|
1160
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/models/ -v`
|
|
1161
|
+
Expected: PASS
|
|
1162
|
+
|
|
1163
|
+
**Step 8: Commit**
|
|
1164
|
+
|
|
1165
|
+
```bash
|
|
1166
|
+
git add lib/njtransit/gtfs/models/ spec/njtransit/gtfs/models/
|
|
1167
|
+
git commit -m "feat: add Stop and Route GTFS models"
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
---
|
|
1171
|
+
|
|
1172
|
+
## Task 8: Create Routes Between Query
|
|
1173
|
+
|
|
1174
|
+
**Files:**
|
|
1175
|
+
- Create: `lib/njtransit/gtfs/queries/routes_between.rb`
|
|
1176
|
+
- Create: `spec/njtransit/gtfs/queries/routes_between_spec.rb`
|
|
1177
|
+
|
|
1178
|
+
**Step 1: Write the failing test**
|
|
1179
|
+
|
|
1180
|
+
Create `spec/njtransit/gtfs/queries/routes_between_spec.rb`:
|
|
1181
|
+
|
|
1182
|
+
```ruby
|
|
1183
|
+
# frozen_string_literal: true
|
|
1184
|
+
|
|
1185
|
+
require "fileutils"
|
|
1186
|
+
|
|
1187
|
+
RSpec.describe NJTransit::GTFS::Queries::RoutesBetween do
|
|
1188
|
+
let(:test_db_path) { "tmp/test_queries.sqlite3" }
|
|
1189
|
+
|
|
1190
|
+
before(:all) do
|
|
1191
|
+
FileUtils.mkdir_p("tmp")
|
|
1192
|
+
FileUtils.rm_f("tmp/test_queries.sqlite3")
|
|
1193
|
+
NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_queries.sqlite3").import
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
after(:all) do
|
|
1197
|
+
NJTransit::GTFS::Database.disconnect
|
|
1198
|
+
FileUtils.rm_f("tmp/test_queries.sqlite3")
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
let(:db) { NJTransit::GTFS::Database.connection(test_db_path) }
|
|
1202
|
+
|
|
1203
|
+
describe "#call" do
|
|
1204
|
+
it "finds routes serving both stops" do
|
|
1205
|
+
query = described_class.new(db, from: "WBRK", to: "PABT")
|
|
1206
|
+
routes = query.call
|
|
1207
|
+
expect(routes).to include("197")
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
it "returns empty array when no routes connect stops" do
|
|
1211
|
+
query = described_class.new(db, from: "21681", to: "21682")
|
|
1212
|
+
routes = query.call
|
|
1213
|
+
expect(routes).to be_empty
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
it "accepts stop_id or stop_code" do
|
|
1217
|
+
query = described_class.new(db, from: "1", to: "2")
|
|
1218
|
+
routes = query.call
|
|
1219
|
+
expect(routes).to include("197")
|
|
1220
|
+
end
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
**Step 2: Run test to verify it fails**
|
|
1226
|
+
|
|
1227
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/queries/routes_between_spec.rb -v`
|
|
1228
|
+
Expected: FAIL with "uninitialized constant"
|
|
1229
|
+
|
|
1230
|
+
**Step 3: Create directories**
|
|
1231
|
+
|
|
1232
|
+
Run: `mkdir -p lib/njtransit/gtfs/queries spec/njtransit/gtfs/queries`
|
|
1233
|
+
|
|
1234
|
+
**Step 4: Write implementation**
|
|
1235
|
+
|
|
1236
|
+
Create `lib/njtransit/gtfs/queries/routes_between.rb`:
|
|
1237
|
+
|
|
1238
|
+
```ruby
|
|
1239
|
+
# frozen_string_literal: true
|
|
1240
|
+
|
|
1241
|
+
module NJTransit
|
|
1242
|
+
module GTFS
|
|
1243
|
+
module Queries
|
|
1244
|
+
class RoutesBetween
|
|
1245
|
+
attr_reader :db, :from, :to
|
|
1246
|
+
|
|
1247
|
+
def initialize(db, from:, to:)
|
|
1248
|
+
@db = db
|
|
1249
|
+
@from = from
|
|
1250
|
+
@to = to
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def call
|
|
1254
|
+
from_stop_id = resolve_stop_id(from)
|
|
1255
|
+
to_stop_id = resolve_stop_id(to)
|
|
1256
|
+
|
|
1257
|
+
return [] if from_stop_id.nil? || to_stop_id.nil?
|
|
1258
|
+
|
|
1259
|
+
# Find trips that stop at both locations
|
|
1260
|
+
from_trips = db[:stop_times].where(stop_id: from_stop_id).select_map(:trip_id)
|
|
1261
|
+
to_trips = db[:stop_times].where(stop_id: to_stop_id).select_map(:trip_id)
|
|
1262
|
+
|
|
1263
|
+
common_trips = from_trips & to_trips
|
|
1264
|
+
return [] if common_trips.empty?
|
|
1265
|
+
|
|
1266
|
+
# Get route_ids for those trips
|
|
1267
|
+
route_ids = db[:trips].where(trip_id: common_trips).select_map(:route_id).uniq
|
|
1268
|
+
|
|
1269
|
+
# Get route short names
|
|
1270
|
+
db[:routes].where(route_id: route_ids).select_map(:route_short_name).uniq
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
private
|
|
1274
|
+
|
|
1275
|
+
def resolve_stop_id(identifier)
|
|
1276
|
+
# Try as stop_id first
|
|
1277
|
+
stop = db[:stops].where(stop_id: identifier).first
|
|
1278
|
+
return identifier if stop
|
|
1279
|
+
|
|
1280
|
+
# Try as stop_code
|
|
1281
|
+
stop = db[:stops].where(stop_code: identifier).first
|
|
1282
|
+
stop&.dig(:stop_id)
|
|
1283
|
+
end
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
**Step 5: Run test to verify it passes**
|
|
1291
|
+
|
|
1292
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/queries/routes_between_spec.rb -v`
|
|
1293
|
+
Expected: PASS
|
|
1294
|
+
|
|
1295
|
+
**Step 6: Commit**
|
|
1296
|
+
|
|
1297
|
+
```bash
|
|
1298
|
+
git add lib/njtransit/gtfs/queries/ spec/njtransit/gtfs/queries/
|
|
1299
|
+
git commit -m "feat: add routes_between query"
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
---
|
|
1303
|
+
|
|
1304
|
+
## Task 9: Create Schedule Query
|
|
1305
|
+
|
|
1306
|
+
**Files:**
|
|
1307
|
+
- Create: `lib/njtransit/gtfs/queries/schedule.rb`
|
|
1308
|
+
- Create: `spec/njtransit/gtfs/queries/schedule_spec.rb`
|
|
1309
|
+
|
|
1310
|
+
**Step 1: Write the failing test**
|
|
1311
|
+
|
|
1312
|
+
Create `spec/njtransit/gtfs/queries/schedule_spec.rb`:
|
|
1313
|
+
|
|
1314
|
+
```ruby
|
|
1315
|
+
# frozen_string_literal: true
|
|
1316
|
+
|
|
1317
|
+
require "fileutils"
|
|
1318
|
+
|
|
1319
|
+
RSpec.describe NJTransit::GTFS::Queries::Schedule do
|
|
1320
|
+
let(:test_db_path) { "tmp/test_queries.sqlite3" }
|
|
1321
|
+
|
|
1322
|
+
before(:all) do
|
|
1323
|
+
FileUtils.mkdir_p("tmp")
|
|
1324
|
+
FileUtils.rm_f("tmp/test_queries.sqlite3")
|
|
1325
|
+
NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_queries.sqlite3").import
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
after(:all) do
|
|
1329
|
+
NJTransit::GTFS::Database.disconnect
|
|
1330
|
+
FileUtils.rm_f("tmp/test_queries.sqlite3")
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
let(:db) { NJTransit::GTFS::Database.connection(test_db_path) }
|
|
1334
|
+
|
|
1335
|
+
describe "#call" do
|
|
1336
|
+
it "returns schedule for route at stop on date" do
|
|
1337
|
+
# Date 20260124 has service_id 1
|
|
1338
|
+
query = described_class.new(db, route: "197", stop: "WBRK", date: Date.new(2026, 1, 24))
|
|
1339
|
+
schedule = query.call
|
|
1340
|
+
expect(schedule).not_to be_empty
|
|
1341
|
+
expect(schedule.first).to have_key(:arrival_time)
|
|
1342
|
+
expect(schedule.first).to have_key(:departure_time)
|
|
1343
|
+
expect(schedule.first).to have_key(:trip_id)
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
it "returns empty array when no service on date" do
|
|
1347
|
+
query = described_class.new(db, route: "197", stop: "WBRK", date: Date.new(2026, 1, 20))
|
|
1348
|
+
schedule = query.call
|
|
1349
|
+
expect(schedule).to be_empty
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
it "orders by arrival time" do
|
|
1353
|
+
query = described_class.new(db, route: "197", stop: "WBRK", date: Date.new(2026, 1, 24))
|
|
1354
|
+
schedule = query.call
|
|
1355
|
+
times = schedule.map { |s| s[:arrival_time] }
|
|
1356
|
+
expect(times).to eq(times.sort)
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
**Step 2: Run test to verify it fails**
|
|
1363
|
+
|
|
1364
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/queries/schedule_spec.rb -v`
|
|
1365
|
+
Expected: FAIL with "uninitialized constant"
|
|
1366
|
+
|
|
1367
|
+
**Step 3: Write implementation**
|
|
1368
|
+
|
|
1369
|
+
Create `lib/njtransit/gtfs/queries/schedule.rb`:
|
|
1370
|
+
|
|
1371
|
+
```ruby
|
|
1372
|
+
# frozen_string_literal: true
|
|
1373
|
+
|
|
1374
|
+
module NJTransit
|
|
1375
|
+
module GTFS
|
|
1376
|
+
module Queries
|
|
1377
|
+
class Schedule
|
|
1378
|
+
attr_reader :db, :route, :stop, :date
|
|
1379
|
+
|
|
1380
|
+
def initialize(db, route:, stop:, date:)
|
|
1381
|
+
@db = db
|
|
1382
|
+
@route = route
|
|
1383
|
+
@stop = stop
|
|
1384
|
+
@date = date
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
def call
|
|
1388
|
+
route_id = resolve_route_id
|
|
1389
|
+
stop_id = resolve_stop_id
|
|
1390
|
+
service_ids = active_service_ids
|
|
1391
|
+
|
|
1392
|
+
return [] if route_id.nil? || stop_id.nil? || service_ids.empty?
|
|
1393
|
+
|
|
1394
|
+
# Find trips for this route on active services
|
|
1395
|
+
trip_ids = db[:trips]
|
|
1396
|
+
.where(route_id: route_id, service_id: service_ids)
|
|
1397
|
+
.select_map(:trip_id)
|
|
1398
|
+
|
|
1399
|
+
return [] if trip_ids.empty?
|
|
1400
|
+
|
|
1401
|
+
# Get stop times for these trips at this stop
|
|
1402
|
+
db[:stop_times]
|
|
1403
|
+
.where(trip_id: trip_ids, stop_id: stop_id)
|
|
1404
|
+
.order(:arrival_time)
|
|
1405
|
+
.all
|
|
1406
|
+
.map do |row|
|
|
1407
|
+
{
|
|
1408
|
+
trip_id: row[:trip_id],
|
|
1409
|
+
arrival_time: row[:arrival_time],
|
|
1410
|
+
departure_time: row[:departure_time],
|
|
1411
|
+
stop_sequence: row[:stop_sequence]
|
|
1412
|
+
}
|
|
1413
|
+
end
|
|
1414
|
+
end
|
|
1415
|
+
|
|
1416
|
+
private
|
|
1417
|
+
|
|
1418
|
+
def resolve_route_id
|
|
1419
|
+
route_row = db[:routes].where(route_id: route).first
|
|
1420
|
+
route_row ||= db[:routes].where(route_short_name: route).first
|
|
1421
|
+
route_row&.dig(:route_id)
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1424
|
+
def resolve_stop_id
|
|
1425
|
+
stop_row = db[:stops].where(stop_id: stop).first
|
|
1426
|
+
stop_row ||= db[:stops].where(stop_code: stop).first
|
|
1427
|
+
stop_row&.dig(:stop_id)
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
def active_service_ids
|
|
1431
|
+
date_str = date.strftime("%Y%m%d")
|
|
1432
|
+
db[:calendar_dates]
|
|
1433
|
+
.where(date: date_str, exception_type: 1)
|
|
1434
|
+
.select_map(:service_id)
|
|
1435
|
+
end
|
|
1436
|
+
end
|
|
1437
|
+
end
|
|
1438
|
+
end
|
|
1439
|
+
end
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
**Step 4: Run test to verify it passes**
|
|
1443
|
+
|
|
1444
|
+
Run: `bundle exec rspec spec/njtransit/gtfs/queries/schedule_spec.rb -v`
|
|
1445
|
+
Expected: PASS
|
|
1446
|
+
|
|
1447
|
+
**Step 5: Commit**
|
|
1448
|
+
|
|
1449
|
+
```bash
|
|
1450
|
+
git add lib/njtransit/gtfs/queries/schedule.rb spec/njtransit/gtfs/queries/schedule_spec.rb
|
|
1451
|
+
git commit -m "feat: add schedule query"
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
---
|
|
1455
|
+
|
|
1456
|
+
## Task 10: Create Main GTFS Module
|
|
1457
|
+
|
|
1458
|
+
**Files:**
|
|
1459
|
+
- Create: `lib/njtransit/gtfs.rb`
|
|
1460
|
+
- Update: `lib/njtransit.rb`
|
|
1461
|
+
- Create: `spec/njtransit/gtfs_spec.rb`
|
|
1462
|
+
|
|
1463
|
+
**Step 1: Write the failing test**
|
|
1464
|
+
|
|
1465
|
+
Create `spec/njtransit/gtfs_spec.rb`:
|
|
1466
|
+
|
|
1467
|
+
```ruby
|
|
1468
|
+
# frozen_string_literal: true
|
|
1469
|
+
|
|
1470
|
+
require "fileutils"
|
|
1471
|
+
|
|
1472
|
+
RSpec.describe NJTransit::GTFS do
|
|
1473
|
+
let(:fixtures_path) { "spec/fixtures/gtfs" }
|
|
1474
|
+
let(:test_db_path) { "tmp/test_gtfs_main.sqlite3" }
|
|
1475
|
+
|
|
1476
|
+
before do
|
|
1477
|
+
FileUtils.mkdir_p("tmp")
|
|
1478
|
+
FileUtils.rm_f(test_db_path)
|
|
1479
|
+
allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return(test_db_path)
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
after do
|
|
1483
|
+
NJTransit::GTFS::Database.disconnect
|
|
1484
|
+
FileUtils.rm_f(test_db_path)
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
describe ".import" do
|
|
1488
|
+
it "imports GTFS data" do
|
|
1489
|
+
described_class.import(fixtures_path)
|
|
1490
|
+
expect(NJTransit::GTFS::Database.exists?(test_db_path)).to be true
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
it "raises error for invalid directory" do
|
|
1494
|
+
expect {
|
|
1495
|
+
described_class.import("/nonexistent")
|
|
1496
|
+
}.to raise_error(NJTransit::Error, /Invalid GTFS directory/)
|
|
1497
|
+
end
|
|
1498
|
+
end
|
|
1499
|
+
|
|
1500
|
+
describe ".status" do
|
|
1501
|
+
context "when not imported" do
|
|
1502
|
+
it "returns imported: false" do
|
|
1503
|
+
status = described_class.status
|
|
1504
|
+
expect(status[:imported]).to be false
|
|
1505
|
+
end
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
context "when imported" do
|
|
1509
|
+
before { described_class.import(fixtures_path) }
|
|
1510
|
+
|
|
1511
|
+
it "returns imported: true" do
|
|
1512
|
+
status = described_class.status
|
|
1513
|
+
expect(status[:imported]).to be true
|
|
1514
|
+
end
|
|
1515
|
+
|
|
1516
|
+
it "returns record counts" do
|
|
1517
|
+
status = described_class.status
|
|
1518
|
+
expect(status[:routes]).to eq(3)
|
|
1519
|
+
expect(status[:stops]).to eq(5)
|
|
1520
|
+
end
|
|
1521
|
+
end
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
describe ".new" do
|
|
1525
|
+
context "when GTFS imported" do
|
|
1526
|
+
before { described_class.import(fixtures_path) }
|
|
1527
|
+
|
|
1528
|
+
it "returns a query interface" do
|
|
1529
|
+
gtfs = described_class.new
|
|
1530
|
+
expect(gtfs).to be_a(NJTransit::GTFS::QueryInterface)
|
|
1531
|
+
end
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
context "when GTFS not imported" do
|
|
1535
|
+
it "raises GTFSNotImportedError" do
|
|
1536
|
+
expect {
|
|
1537
|
+
described_class.new
|
|
1538
|
+
}.to raise_error(NJTransit::GTFSNotImportedError)
|
|
1539
|
+
end
|
|
1540
|
+
end
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1543
|
+
describe ".detect_gtfs_path" do
|
|
1544
|
+
it "returns nil when no GTFS files found" do
|
|
1545
|
+
expect(described_class.detect_gtfs_path).to be_nil
|
|
1546
|
+
end
|
|
1547
|
+
|
|
1548
|
+
it "returns path when GTFS files exist" do
|
|
1549
|
+
allow(File).to receive(:exist?).and_call_original
|
|
1550
|
+
allow(File).to receive(:exist?).with("./docs/api/njtransit/bus_data/agency.txt").and_return(true)
|
|
1551
|
+
allow(File).to receive(:directory?).with("./docs/api/njtransit/bus_data").and_return(true)
|
|
1552
|
+
|
|
1553
|
+
expect(described_class.detect_gtfs_path).to eq("./docs/api/njtransit/bus_data")
|
|
1554
|
+
end
|
|
1555
|
+
end
|
|
1556
|
+
end
|
|
1557
|
+
|
|
1558
|
+
RSpec.describe NJTransit::GTFS::QueryInterface do
|
|
1559
|
+
let(:fixtures_path) { "spec/fixtures/gtfs" }
|
|
1560
|
+
let(:test_db_path) { "tmp/test_gtfs_query.sqlite3" }
|
|
1561
|
+
|
|
1562
|
+
before(:all) do
|
|
1563
|
+
FileUtils.mkdir_p("tmp")
|
|
1564
|
+
FileUtils.rm_f("tmp/test_gtfs_query.sqlite3")
|
|
1565
|
+
allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return("tmp/test_gtfs_query.sqlite3")
|
|
1566
|
+
NJTransit::GTFS.import("spec/fixtures/gtfs")
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
after(:all) do
|
|
1570
|
+
NJTransit::GTFS::Database.disconnect
|
|
1571
|
+
FileUtils.rm_f("tmp/test_gtfs_query.sqlite3")
|
|
1572
|
+
end
|
|
1573
|
+
|
|
1574
|
+
let(:gtfs) { NJTransit::GTFS.new }
|
|
1575
|
+
|
|
1576
|
+
before do
|
|
1577
|
+
allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return(test_db_path)
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
describe "#stops" do
|
|
1581
|
+
it "provides stop query methods" do
|
|
1582
|
+
expect(gtfs.stops.all.count).to eq(5)
|
|
1583
|
+
end
|
|
1584
|
+
|
|
1585
|
+
it "finds by code" do
|
|
1586
|
+
stop = gtfs.stops.find_by_code("WBRK")
|
|
1587
|
+
expect(stop.stop_name).to eq("WILLOWBROOK MALL")
|
|
1588
|
+
end
|
|
1589
|
+
end
|
|
1590
|
+
|
|
1591
|
+
describe "#routes" do
|
|
1592
|
+
it "provides route query methods" do
|
|
1593
|
+
expect(gtfs.routes.all.count).to eq(3)
|
|
1594
|
+
end
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
describe "#routes_between" do
|
|
1598
|
+
it "finds routes between two stops" do
|
|
1599
|
+
routes = gtfs.routes_between(from: "WBRK", to: "PABT")
|
|
1600
|
+
expect(routes).to include("197")
|
|
1601
|
+
end
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
describe "#schedule" do
|
|
1605
|
+
it "returns schedule for route/stop/date" do
|
|
1606
|
+
schedule = gtfs.schedule(route: "197", stop: "WBRK", date: Date.new(2026, 1, 24))
|
|
1607
|
+
expect(schedule).not_to be_empty
|
|
1608
|
+
end
|
|
1609
|
+
end
|
|
1610
|
+
end
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
**Step 2: Run test to verify it fails**
|
|
1614
|
+
|
|
1615
|
+
Run: `bundle exec rspec spec/njtransit/gtfs_spec.rb -v`
|
|
1616
|
+
Expected: FAIL
|
|
1617
|
+
|
|
1618
|
+
**Step 3: Write implementation**
|
|
1619
|
+
|
|
1620
|
+
Create `lib/njtransit/gtfs.rb`:
|
|
1621
|
+
|
|
1622
|
+
```ruby
|
|
1623
|
+
# frozen_string_literal: true
|
|
1624
|
+
|
|
1625
|
+
require_relative "gtfs/database"
|
|
1626
|
+
require_relative "gtfs/importer"
|
|
1627
|
+
require_relative "gtfs/models/stop"
|
|
1628
|
+
require_relative "gtfs/models/route"
|
|
1629
|
+
require_relative "gtfs/queries/routes_between"
|
|
1630
|
+
require_relative "gtfs/queries/schedule"
|
|
1631
|
+
|
|
1632
|
+
module NJTransit
|
|
1633
|
+
module GTFS
|
|
1634
|
+
SEARCH_PATHS = [
|
|
1635
|
+
"./bus_data",
|
|
1636
|
+
"./vendor/bus_data",
|
|
1637
|
+
"./docs/api/njtransit/bus_data"
|
|
1638
|
+
].freeze
|
|
1639
|
+
|
|
1640
|
+
class << self
|
|
1641
|
+
def import(source_path, force: false)
|
|
1642
|
+
importer = Importer.new(source_path, database_path)
|
|
1643
|
+
|
|
1644
|
+
unless importer.valid_gtfs_directory?
|
|
1645
|
+
raise NJTransit::Error, "Invalid GTFS directory: #{source_path}. Must contain agency.txt, routes.txt, stops.txt"
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
importer.import(force: force)
|
|
1649
|
+
end
|
|
1650
|
+
|
|
1651
|
+
def status
|
|
1652
|
+
path = database_path
|
|
1653
|
+
return { imported: false, path: path } unless Database.exists?(path)
|
|
1654
|
+
|
|
1655
|
+
Database.connection(path)
|
|
1656
|
+
db = Database.connection
|
|
1657
|
+
|
|
1658
|
+
metadata = db[:import_metadata].order(Sequel.desc(:id)).first
|
|
1659
|
+
|
|
1660
|
+
{
|
|
1661
|
+
imported: true,
|
|
1662
|
+
path: path,
|
|
1663
|
+
routes: db[:routes].count,
|
|
1664
|
+
stops: db[:stops].count,
|
|
1665
|
+
trips: db[:trips].count,
|
|
1666
|
+
stop_times: db[:stop_times].count,
|
|
1667
|
+
imported_at: metadata&.dig(:imported_at),
|
|
1668
|
+
source_path: metadata&.dig(:source_path)
|
|
1669
|
+
}
|
|
1670
|
+
end
|
|
1671
|
+
|
|
1672
|
+
def new
|
|
1673
|
+
path = database_path
|
|
1674
|
+
|
|
1675
|
+
unless Database.exists?(path)
|
|
1676
|
+
detected = detect_gtfs_path
|
|
1677
|
+
raise GTFSNotImportedError.new(detected_path: detected)
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
QueryInterface.new(path)
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
def detect_gtfs_path
|
|
1684
|
+
SEARCH_PATHS.find do |path|
|
|
1685
|
+
File.directory?(path) && File.exist?(File.join(path, "agency.txt"))
|
|
1686
|
+
end
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
def clear!
|
|
1690
|
+
Database.connection(database_path)
|
|
1691
|
+
Database.clear!
|
|
1692
|
+
Database.disconnect
|
|
1693
|
+
FileUtils.rm_f(database_path)
|
|
1694
|
+
end
|
|
1695
|
+
|
|
1696
|
+
private
|
|
1697
|
+
|
|
1698
|
+
def database_path
|
|
1699
|
+
NJTransit.configuration.gtfs_database_path
|
|
1700
|
+
end
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
class QueryInterface
|
|
1704
|
+
attr_reader :db
|
|
1705
|
+
|
|
1706
|
+
def initialize(db_path)
|
|
1707
|
+
Database.connection(db_path)
|
|
1708
|
+
@db = Database.connection
|
|
1709
|
+
setup_models
|
|
1710
|
+
end
|
|
1711
|
+
|
|
1712
|
+
def stops
|
|
1713
|
+
Models::Stop
|
|
1714
|
+
end
|
|
1715
|
+
|
|
1716
|
+
def routes
|
|
1717
|
+
Models::Route
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
def routes_between(from:, to:)
|
|
1721
|
+
Queries::RoutesBetween.new(db, from: from, to: to).call
|
|
1722
|
+
end
|
|
1723
|
+
|
|
1724
|
+
def schedule(route:, stop:, date:)
|
|
1725
|
+
Queries::Schedule.new(db, route: route, stop: stop, date: date).call
|
|
1726
|
+
end
|
|
1727
|
+
|
|
1728
|
+
private
|
|
1729
|
+
|
|
1730
|
+
def setup_models
|
|
1731
|
+
Models::Stop.db = db
|
|
1732
|
+
Models::Route.db = db
|
|
1733
|
+
end
|
|
1734
|
+
end
|
|
1735
|
+
end
|
|
1736
|
+
end
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
**Step 4: Update lib/njtransit.rb**
|
|
1740
|
+
|
|
1741
|
+
Add after line 5 (`require_relative "njtransit/client"`):
|
|
1742
|
+
|
|
1743
|
+
```ruby
|
|
1744
|
+
require_relative "njtransit/gtfs"
|
|
1745
|
+
```
|
|
1746
|
+
|
|
1747
|
+
**Step 5: Run test to verify it passes**
|
|
1748
|
+
|
|
1749
|
+
Run: `bundle exec rspec spec/njtransit/gtfs_spec.rb -v`
|
|
1750
|
+
Expected: PASS
|
|
1751
|
+
|
|
1752
|
+
**Step 6: Commit**
|
|
1753
|
+
|
|
1754
|
+
```bash
|
|
1755
|
+
git add lib/njtransit/gtfs.rb lib/njtransit.rb spec/njtransit/gtfs_spec.rb
|
|
1756
|
+
git commit -m "feat: add main GTFS module with query interface"
|
|
1757
|
+
```
|
|
1758
|
+
|
|
1759
|
+
---
|
|
1760
|
+
|
|
1761
|
+
## Task 11: Add Rake Tasks
|
|
1762
|
+
|
|
1763
|
+
**Files:**
|
|
1764
|
+
- Create: `lib/njtransit/tasks.rb`
|
|
1765
|
+
- Create: `lib/njtransit/railtie.rb`
|
|
1766
|
+
|
|
1767
|
+
**Step 1: Create Rake tasks**
|
|
1768
|
+
|
|
1769
|
+
Create `lib/njtransit/tasks.rb`:
|
|
1770
|
+
|
|
1771
|
+
```ruby
|
|
1772
|
+
# frozen_string_literal: true
|
|
1773
|
+
|
|
1774
|
+
require "rake"
|
|
1775
|
+
|
|
1776
|
+
namespace :njtransit do
|
|
1777
|
+
namespace :gtfs do
|
|
1778
|
+
desc "Import GTFS data from specified path"
|
|
1779
|
+
task :import, [:path] do |_t, args|
|
|
1780
|
+
require "njtransit"
|
|
1781
|
+
|
|
1782
|
+
path = args[:path]
|
|
1783
|
+
if path.nil? || path.empty?
|
|
1784
|
+
detected = NJTransit::GTFS.detect_gtfs_path
|
|
1785
|
+
if detected
|
|
1786
|
+
puts "No path specified. Detected GTFS data at: #{detected}"
|
|
1787
|
+
print "Use this path? [Y/n] "
|
|
1788
|
+
response = $stdin.gets&.strip&.downcase
|
|
1789
|
+
path = detected if response.nil? || response.empty? || response == "y"
|
|
1790
|
+
end
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1793
|
+
if path.nil? || path.empty?
|
|
1794
|
+
puts "Usage: rake njtransit:gtfs:import[/path/to/gtfs/data]"
|
|
1795
|
+
exit 1
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
puts "Importing GTFS data from #{path}..."
|
|
1799
|
+
NJTransit::GTFS.import(path, force: ENV["FORCE"] == "true")
|
|
1800
|
+
status = NJTransit::GTFS.status
|
|
1801
|
+
puts "Import complete!"
|
|
1802
|
+
puts " Routes: #{status[:routes]}"
|
|
1803
|
+
puts " Stops: #{status[:stops]}"
|
|
1804
|
+
puts " Trips: #{status[:trips]}"
|
|
1805
|
+
puts " Stop times: #{status[:stop_times]}"
|
|
1806
|
+
puts " Database: #{status[:path]}"
|
|
1807
|
+
end
|
|
1808
|
+
|
|
1809
|
+
desc "Show GTFS import status"
|
|
1810
|
+
task :status do
|
|
1811
|
+
require "njtransit"
|
|
1812
|
+
|
|
1813
|
+
status = NJTransit::GTFS.status
|
|
1814
|
+
if status[:imported]
|
|
1815
|
+
puts "GTFS Status: Imported"
|
|
1816
|
+
puts " Database: #{status[:path]}"
|
|
1817
|
+
puts " Routes: #{status[:routes]}"
|
|
1818
|
+
puts " Stops: #{status[:stops]}"
|
|
1819
|
+
puts " Trips: #{status[:trips]}"
|
|
1820
|
+
puts " Stop times: #{status[:stop_times]}"
|
|
1821
|
+
puts " Imported at: #{status[:imported_at]}"
|
|
1822
|
+
puts " Source: #{status[:source_path]}"
|
|
1823
|
+
else
|
|
1824
|
+
puts "GTFS Status: Not imported"
|
|
1825
|
+
puts " Database path: #{status[:path]}"
|
|
1826
|
+
detected = NJTransit::GTFS.detect_gtfs_path
|
|
1827
|
+
puts " Detected GTFS data: #{detected}" if detected
|
|
1828
|
+
end
|
|
1829
|
+
end
|
|
1830
|
+
|
|
1831
|
+
desc "Clear GTFS database"
|
|
1832
|
+
task :clear do
|
|
1833
|
+
require "njtransit"
|
|
1834
|
+
|
|
1835
|
+
print "Are you sure you want to clear the GTFS database? [y/N] "
|
|
1836
|
+
response = $stdin.gets&.strip&.downcase
|
|
1837
|
+
if response == "y"
|
|
1838
|
+
NJTransit::GTFS.clear!
|
|
1839
|
+
puts "GTFS database cleared."
|
|
1840
|
+
else
|
|
1841
|
+
puts "Cancelled."
|
|
1842
|
+
end
|
|
1843
|
+
end
|
|
1844
|
+
end
|
|
1845
|
+
end
|
|
1846
|
+
```
|
|
1847
|
+
|
|
1848
|
+
**Step 2: Create Railtie for Rails auto-loading**
|
|
1849
|
+
|
|
1850
|
+
Create `lib/njtransit/railtie.rb`:
|
|
1851
|
+
|
|
1852
|
+
```ruby
|
|
1853
|
+
# frozen_string_literal: true
|
|
1854
|
+
|
|
1855
|
+
module NJTransit
|
|
1856
|
+
class Railtie < Rails::Railtie
|
|
1857
|
+
rake_tasks do
|
|
1858
|
+
load "njtransit/tasks.rb"
|
|
1859
|
+
end
|
|
1860
|
+
end
|
|
1861
|
+
end
|
|
1862
|
+
```
|
|
1863
|
+
|
|
1864
|
+
**Step 3: Update lib/njtransit.rb to load railtie**
|
|
1865
|
+
|
|
1866
|
+
Add at the end of the file (before final `end`):
|
|
1867
|
+
|
|
1868
|
+
```ruby
|
|
1869
|
+
require_relative "njtransit/railtie" if defined?(Rails::Railtie)
|
|
1870
|
+
```
|
|
1871
|
+
|
|
1872
|
+
**Step 4: Commit**
|
|
1873
|
+
|
|
1874
|
+
```bash
|
|
1875
|
+
git add lib/njtransit/tasks.rb lib/njtransit/railtie.rb lib/njtransit.rb
|
|
1876
|
+
git commit -m "feat: add Rake tasks for GTFS import/status/clear"
|
|
1877
|
+
```
|
|
1878
|
+
|
|
1879
|
+
---
|
|
1880
|
+
|
|
1881
|
+
## Task 12: Add Bus API Enrichment
|
|
1882
|
+
|
|
1883
|
+
**Files:**
|
|
1884
|
+
- Modify: `lib/njtransit/resources/bus.rb`
|
|
1885
|
+
- Create: `spec/njtransit/resources/bus_enrichment_spec.rb`
|
|
1886
|
+
|
|
1887
|
+
**Step 1: Write the failing test**
|
|
1888
|
+
|
|
1889
|
+
Create `spec/njtransit/resources/bus_enrichment_spec.rb`:
|
|
1890
|
+
|
|
1891
|
+
```ruby
|
|
1892
|
+
# frozen_string_literal: true
|
|
1893
|
+
|
|
1894
|
+
require "fileutils"
|
|
1895
|
+
|
|
1896
|
+
RSpec.describe "Bus API Enrichment" do
|
|
1897
|
+
let(:test_db_path) { "tmp/test_enrichment.sqlite3" }
|
|
1898
|
+
let(:client) { instance_double(NJTransit::Client) }
|
|
1899
|
+
let(:bus) { NJTransit::Resources::Bus.new(client) }
|
|
1900
|
+
|
|
1901
|
+
before do
|
|
1902
|
+
FileUtils.mkdir_p("tmp")
|
|
1903
|
+
FileUtils.rm_f(test_db_path)
|
|
1904
|
+
allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return(test_db_path)
|
|
1905
|
+
NJTransit::GTFS.import("spec/fixtures/gtfs")
|
|
1906
|
+
allow(client).to receive(:token).and_return("test_token")
|
|
1907
|
+
end
|
|
1908
|
+
|
|
1909
|
+
after do
|
|
1910
|
+
NJTransit::GTFS::Database.disconnect
|
|
1911
|
+
FileUtils.rm_f(test_db_path)
|
|
1912
|
+
end
|
|
1913
|
+
|
|
1914
|
+
describe "#stops with enrichment" do
|
|
1915
|
+
let(:api_response) do
|
|
1916
|
+
[
|
|
1917
|
+
{ "stop_id" => "WBRK", "stop_name" => "WILLOWBROOK" },
|
|
1918
|
+
{ "stop_id" => "PABT", "stop_name" => "PORT AUTHORITY" }
|
|
1919
|
+
]
|
|
1920
|
+
end
|
|
1921
|
+
|
|
1922
|
+
before do
|
|
1923
|
+
allow(client).to receive(:post_form).and_return(api_response)
|
|
1924
|
+
end
|
|
1925
|
+
|
|
1926
|
+
it "adds lat/lon from GTFS by default" do
|
|
1927
|
+
result = bus.stops(route: "197", direction: "New York")
|
|
1928
|
+
expect(result.first["stop_lat"]).to eq(40.8523)
|
|
1929
|
+
expect(result.first["stop_lon"]).to eq(-74.2567)
|
|
1930
|
+
end
|
|
1931
|
+
|
|
1932
|
+
it "skips enrichment when enrich: false" do
|
|
1933
|
+
result = bus.stops(route: "197", direction: "New York", enrich: false)
|
|
1934
|
+
expect(result.first).not_to have_key("stop_lat")
|
|
1935
|
+
end
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1938
|
+
describe "#departures with enrichment" do
|
|
1939
|
+
let(:api_response) do
|
|
1940
|
+
[
|
|
1941
|
+
{ "stop_id" => "WBRK", "route" => "197" }
|
|
1942
|
+
]
|
|
1943
|
+
end
|
|
1944
|
+
|
|
1945
|
+
before do
|
|
1946
|
+
allow(client).to receive(:post_form).and_return(api_response)
|
|
1947
|
+
end
|
|
1948
|
+
|
|
1949
|
+
it "adds stop coordinates and route name" do
|
|
1950
|
+
result = bus.departures(stop: "WBRK")
|
|
1951
|
+
expect(result.first["stop_lat"]).to eq(40.8523)
|
|
1952
|
+
expect(result.first["route_long_name"]).to eq("Willowbrook - Port Authority")
|
|
1953
|
+
end
|
|
1954
|
+
end
|
|
1955
|
+
|
|
1956
|
+
describe "when GTFS not imported" do
|
|
1957
|
+
before do
|
|
1958
|
+
NJTransit::GTFS.clear!
|
|
1959
|
+
NJTransit::GTFS::Database.disconnect
|
|
1960
|
+
FileUtils.rm_f(test_db_path)
|
|
1961
|
+
end
|
|
1962
|
+
|
|
1963
|
+
it "raises GTFSNotImportedError for enriched calls" do
|
|
1964
|
+
expect {
|
|
1965
|
+
bus.stops(route: "197", direction: "New York")
|
|
1966
|
+
}.to raise_error(NJTransit::GTFSNotImportedError)
|
|
1967
|
+
end
|
|
1968
|
+
|
|
1969
|
+
it "succeeds with enrich: false" do
|
|
1970
|
+
allow(client).to receive(:post_form).and_return([])
|
|
1971
|
+
expect {
|
|
1972
|
+
bus.stops(route: "197", direction: "New York", enrich: false)
|
|
1973
|
+
}.not_to raise_error
|
|
1974
|
+
end
|
|
1975
|
+
end
|
|
1976
|
+
end
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
**Step 2: Run test to verify it fails**
|
|
1980
|
+
|
|
1981
|
+
Run: `bundle exec rspec spec/njtransit/resources/bus_enrichment_spec.rb -v`
|
|
1982
|
+
Expected: FAIL (enrichment not implemented)
|
|
1983
|
+
|
|
1984
|
+
**Step 3: Write implementation**
|
|
1985
|
+
|
|
1986
|
+
Replace `lib/njtransit/resources/bus.rb`:
|
|
1987
|
+
|
|
1988
|
+
```ruby
|
|
1989
|
+
# frozen_string_literal: true
|
|
1990
|
+
|
|
1991
|
+
module NJTransit
|
|
1992
|
+
module Resources
|
|
1993
|
+
class Bus < Base
|
|
1994
|
+
MODE = "BUS"
|
|
1995
|
+
|
|
1996
|
+
def locations
|
|
1997
|
+
post_form("/api/BUSDV2/getLocations", mode: MODE)
|
|
1998
|
+
end
|
|
1999
|
+
|
|
2000
|
+
def routes
|
|
2001
|
+
post_form("/api/BUSDV2/getBusRoutes", mode: MODE)
|
|
2002
|
+
end
|
|
2003
|
+
|
|
2004
|
+
def directions(route:)
|
|
2005
|
+
post_form("/api/BUSDV2/getBusDirectionsData", route: route)
|
|
2006
|
+
end
|
|
2007
|
+
|
|
2008
|
+
def stops(route:, direction:, name_contains: nil, enrich: true)
|
|
2009
|
+
params = { route: route, direction: direction }
|
|
2010
|
+
params[:namecontains] = name_contains if name_contains
|
|
2011
|
+
result = post_form("/api/BUSDV2/getStops", params)
|
|
2012
|
+
enrich ? enrich_stops(result) : result
|
|
2013
|
+
end
|
|
2014
|
+
|
|
2015
|
+
def stop_name(stop_number:, enrich: true)
|
|
2016
|
+
result = post_form("/api/BUSDV2/getStopName", stopnum: stop_number)
|
|
2017
|
+
enrich ? enrich_stop_name(result, stop_number) : result
|
|
2018
|
+
end
|
|
2019
|
+
|
|
2020
|
+
def route_trips(location:, route:)
|
|
2021
|
+
post_form("/api/BUSDV2/getRouteTrips", location: location, route: route)
|
|
2022
|
+
end
|
|
2023
|
+
|
|
2024
|
+
def departures(stop:, route: nil, direction: nil, enrich: true)
|
|
2025
|
+
params = { stop: stop }
|
|
2026
|
+
params[:route] = route if route
|
|
2027
|
+
params[:direction] = direction if direction
|
|
2028
|
+
result = post_form("/api/BUSDV2/getBusDV", params)
|
|
2029
|
+
enrich ? enrich_departures(result) : result
|
|
2030
|
+
end
|
|
2031
|
+
|
|
2032
|
+
def trip_stops(internal_trip_number:, sched_dep_time:, timing_point_id: nil)
|
|
2033
|
+
params = {
|
|
2034
|
+
internal_trip_number: internal_trip_number,
|
|
2035
|
+
sched_dep_time: sched_dep_time
|
|
2036
|
+
}
|
|
2037
|
+
params[:timing_point_id] = timing_point_id if timing_point_id
|
|
2038
|
+
post_form("/api/BUSDV2/getTripStops", params)
|
|
2039
|
+
end
|
|
2040
|
+
|
|
2041
|
+
def stops_nearby(lat:, lon:, radius:, route: nil, direction: nil, enrich: true)
|
|
2042
|
+
params = { lat: lat, lon: lon, radius: radius, mode: MODE }
|
|
2043
|
+
params[:route] = route if route
|
|
2044
|
+
params[:direction] = direction if direction
|
|
2045
|
+
result = post_form("/api/BUSDV2/getBusLocationsData", params)
|
|
2046
|
+
enrich ? enrich_stops_nearby(result) : result
|
|
2047
|
+
end
|
|
2048
|
+
|
|
2049
|
+
def vehicles_nearby(lat:, lon:, radius:, enrich: true)
|
|
2050
|
+
result = post_form("/api/BUSDV2/getVehicleLocations", lat: lat, lon: lon, radius: radius, mode: MODE)
|
|
2051
|
+
enrich ? enrich_vehicles(result) : result
|
|
2052
|
+
end
|
|
2053
|
+
|
|
2054
|
+
private
|
|
2055
|
+
|
|
2056
|
+
def post_form(path, params = {})
|
|
2057
|
+
params[:token] = client.token
|
|
2058
|
+
client.post_form(path, params)
|
|
2059
|
+
end
|
|
2060
|
+
|
|
2061
|
+
def gtfs
|
|
2062
|
+
@gtfs ||= GTFS.new
|
|
2063
|
+
end
|
|
2064
|
+
|
|
2065
|
+
def enrich_stops(stops)
|
|
2066
|
+
return stops unless stops.is_a?(Array)
|
|
2067
|
+
|
|
2068
|
+
stops.each do |stop|
|
|
2069
|
+
stop_code = stop["stop_id"] || stop[:stop_id]
|
|
2070
|
+
next unless stop_code
|
|
2071
|
+
|
|
2072
|
+
gtfs_stop = gtfs.stops.find_by_code(stop_code.to_s)
|
|
2073
|
+
next unless gtfs_stop
|
|
2074
|
+
|
|
2075
|
+
stop["stop_lat"] = gtfs_stop.lat
|
|
2076
|
+
stop["stop_lon"] = gtfs_stop.lon
|
|
2077
|
+
stop["zone_id"] = gtfs_stop.zone_id
|
|
2078
|
+
end
|
|
2079
|
+
stops
|
|
2080
|
+
end
|
|
2081
|
+
|
|
2082
|
+
def enrich_stop_name(result, stop_number)
|
|
2083
|
+
gtfs_stop = gtfs.stops.find_by_code(stop_number.to_s)
|
|
2084
|
+
return result unless gtfs_stop
|
|
2085
|
+
|
|
2086
|
+
if result.is_a?(Hash)
|
|
2087
|
+
result["stop_lat"] = gtfs_stop.lat
|
|
2088
|
+
result["stop_lon"] = gtfs_stop.lon
|
|
2089
|
+
end
|
|
2090
|
+
result
|
|
2091
|
+
end
|
|
2092
|
+
|
|
2093
|
+
def enrich_departures(departures)
|
|
2094
|
+
return departures unless departures.is_a?(Array)
|
|
2095
|
+
|
|
2096
|
+
departures.each do |dep|
|
|
2097
|
+
# Enrich stop info
|
|
2098
|
+
stop_code = dep["stop_id"] || dep[:stop_id]
|
|
2099
|
+
if stop_code
|
|
2100
|
+
gtfs_stop = gtfs.stops.find_by_code(stop_code.to_s)
|
|
2101
|
+
if gtfs_stop
|
|
2102
|
+
dep["stop_lat"] = gtfs_stop.lat
|
|
2103
|
+
dep["stop_lon"] = gtfs_stop.lon
|
|
2104
|
+
end
|
|
2105
|
+
end
|
|
2106
|
+
|
|
2107
|
+
# Enrich route info
|
|
2108
|
+
route_name = dep["route"] || dep[:route]
|
|
2109
|
+
if route_name
|
|
2110
|
+
gtfs_route = gtfs.routes.find(route_name.to_s)
|
|
2111
|
+
dep["route_long_name"] = gtfs_route.long_name if gtfs_route
|
|
2112
|
+
end
|
|
2113
|
+
end
|
|
2114
|
+
departures
|
|
2115
|
+
end
|
|
2116
|
+
|
|
2117
|
+
def enrich_stops_nearby(stops)
|
|
2118
|
+
return stops unless stops.is_a?(Array)
|
|
2119
|
+
|
|
2120
|
+
stops.each do |stop|
|
|
2121
|
+
stop_code = stop["stop_id"] || stop[:stop_id]
|
|
2122
|
+
next unless stop_code
|
|
2123
|
+
|
|
2124
|
+
gtfs_stop = gtfs.stops.find_by_code(stop_code.to_s)
|
|
2125
|
+
stop["zone_id"] = gtfs_stop.zone_id if gtfs_stop
|
|
2126
|
+
end
|
|
2127
|
+
stops
|
|
2128
|
+
end
|
|
2129
|
+
|
|
2130
|
+
def enrich_vehicles(vehicles)
|
|
2131
|
+
return vehicles unless vehicles.is_a?(Array)
|
|
2132
|
+
|
|
2133
|
+
vehicles.each do |vehicle|
|
|
2134
|
+
route_name = vehicle["route"] || vehicle[:route]
|
|
2135
|
+
next unless route_name
|
|
2136
|
+
|
|
2137
|
+
gtfs_route = gtfs.routes.find(route_name.to_s)
|
|
2138
|
+
vehicle["route_long_name"] = gtfs_route.long_name if gtfs_route
|
|
2139
|
+
end
|
|
2140
|
+
vehicles
|
|
2141
|
+
end
|
|
2142
|
+
end
|
|
2143
|
+
end
|
|
2144
|
+
end
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
**Step 4: Run test to verify it passes**
|
|
2148
|
+
|
|
2149
|
+
Run: `bundle exec rspec spec/njtransit/resources/bus_enrichment_spec.rb -v`
|
|
2150
|
+
Expected: PASS
|
|
2151
|
+
|
|
2152
|
+
**Step 5: Commit**
|
|
2153
|
+
|
|
2154
|
+
```bash
|
|
2155
|
+
git add lib/njtransit/resources/bus.rb spec/njtransit/resources/bus_enrichment_spec.rb
|
|
2156
|
+
git commit -m "feat: add automatic GTFS enrichment to Bus API responses"
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
---
|
|
2160
|
+
|
|
2161
|
+
## Task 13: Run Full Test Suite
|
|
2162
|
+
|
|
2163
|
+
**Step 1: Run all tests**
|
|
2164
|
+
|
|
2165
|
+
Run: `bundle exec rspec --format documentation`
|
|
2166
|
+
Expected: All tests pass
|
|
2167
|
+
|
|
2168
|
+
**Step 2: Run RuboCop**
|
|
2169
|
+
|
|
2170
|
+
Run: `bundle exec rubocop`
|
|
2171
|
+
Expected: No offenses (or fix any that appear)
|
|
2172
|
+
|
|
2173
|
+
**Step 3: Commit any fixes**
|
|
2174
|
+
|
|
2175
|
+
```bash
|
|
2176
|
+
git add -A
|
|
2177
|
+
git commit -m "chore: fix rubocop offenses"
|
|
2178
|
+
```
|
|
2179
|
+
|
|
2180
|
+
---
|
|
2181
|
+
|
|
2182
|
+
## Task 14: Update README
|
|
2183
|
+
|
|
2184
|
+
**Files:**
|
|
2185
|
+
- Modify: `README.md`
|
|
2186
|
+
|
|
2187
|
+
Add GTFS documentation section covering:
|
|
2188
|
+
- Installation (bundle install)
|
|
2189
|
+
- GTFS import (rake task)
|
|
2190
|
+
- Query API usage
|
|
2191
|
+
- Bus API enrichment
|
|
2192
|
+
- Configuration options
|
|
2193
|
+
|
|
2194
|
+
**Commit:**
|
|
2195
|
+
|
|
2196
|
+
```bash
|
|
2197
|
+
git add README.md
|
|
2198
|
+
git commit -m "docs: add GTFS loader documentation to README"
|
|
2199
|
+
```
|
|
2200
|
+
|
|
2201
|
+
---
|
|
2202
|
+
|
|
2203
|
+
## Summary
|
|
2204
|
+
|
|
2205
|
+
This plan implements the GTFS static data loader with:
|
|
2206
|
+
|
|
2207
|
+
1. **Dependencies:** Sequel + SQLite3
|
|
2208
|
+
2. **Database:** XDG-compliant SQLite storage with full schema
|
|
2209
|
+
3. **Importer:** Batch CSV parsing with progress
|
|
2210
|
+
4. **Models:** Stop, Route with query methods
|
|
2211
|
+
5. **Queries:** routes_between, schedule
|
|
2212
|
+
6. **Rake tasks:** import, status, clear
|
|
2213
|
+
7. **Enrichment:** Automatic GTFS data in Bus API responses
|
|
2214
|
+
8. **Testing:** Full coverage with fixtures
|
|
2215
|
+
|
|
2216
|
+
Total: ~14 tasks, each with TDD approach (test → implement → verify → commit)
|