Build TFI Live G2 deployable app
This commit is contained in:
131
scripts/build-stop-index.mjs
Normal file
131
scripts/build-stop-index.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createReadStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
export async function buildStopIndex(gtfsDir, outputPath = 'public/stops.json') {
|
||||
const stops = readCsv(join(gtfsDir, 'stops.txt'))
|
||||
const routes = readCsv(join(gtfsDir, 'routes.txt'))
|
||||
const trips = readCsv(join(gtfsDir, 'trips.txt'))
|
||||
const routeById = new Map(routes.map((route) => [route.route_id, route]))
|
||||
const tripRouteById = new Map(trips.map((trip) => [trip.trip_id, trip.route_id]))
|
||||
const routesByStop = new Map()
|
||||
const modesByStop = new Map()
|
||||
|
||||
await eachCsvRow(join(gtfsDir, 'stop_times.txt'), (row) => {
|
||||
const stopId = row.stop_id
|
||||
const routeId = tripRouteById.get(row.trip_id)
|
||||
if (!stopId || !routeId) {
|
||||
return
|
||||
}
|
||||
|
||||
const route = routeById.get(routeId)
|
||||
const routeName = route?.route_short_name || route?.route_long_name || routeId
|
||||
pushUnique(routesByStop, stopId, routeName)
|
||||
pushUnique(modesByStop, stopId, modeFromRouteType(route?.route_type))
|
||||
})
|
||||
|
||||
const index = stops
|
||||
.map((stop) => ({
|
||||
id: stop.stop_id,
|
||||
code: stop.stop_code || stop.stop_id,
|
||||
name: stop.stop_name,
|
||||
lat: Number(stop.stop_lat),
|
||||
lon: Number(stop.stop_lon),
|
||||
modes: modesByStop.get(stop.stop_id) || ['unknown'],
|
||||
routes: routesByStop.get(stop.stop_id) || [],
|
||||
}))
|
||||
.filter((stop) => stop.id && stop.name && Number.isFinite(stop.lat) && Number.isFinite(stop.lon))
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, `${JSON.stringify({ stops: index })}\n`)
|
||||
return index.length
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const [gtfsDir, outputPath = 'public/stops.json'] = process.argv.slice(2)
|
||||
|
||||
if (!gtfsDir) {
|
||||
console.error('Usage: node scripts/build-stop-index.mjs <extracted-gtfs-dir> [output.json]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const count = await buildStopIndex(gtfsDir, outputPath)
|
||||
console.log(`Wrote ${count} stops to ${outputPath}`)
|
||||
}
|
||||
|
||||
function readCsv(path) {
|
||||
const [headerLine, ...lines] = readFileSync(path, 'utf8').trim().split(/\r?\n/)
|
||||
const headers = parseCsvLine(headerLine)
|
||||
return lines.map((line) => rowFromValues(headers, parseCsvLine(line)))
|
||||
}
|
||||
|
||||
async function eachCsvRow(path, callback) {
|
||||
const reader = createInterface({
|
||||
input: createReadStream(path),
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
let headers = []
|
||||
for await (const line of reader) {
|
||||
if (!headers.length) {
|
||||
headers = parseCsvLine(line)
|
||||
continue
|
||||
}
|
||||
callback(rowFromValues(headers, parseCsvLine(line)))
|
||||
}
|
||||
}
|
||||
|
||||
function rowFromValues(headers, values) {
|
||||
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']))
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = []
|
||||
let value = ''
|
||||
let quoted = false
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index]
|
||||
const next = line[index + 1]
|
||||
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
value += '"'
|
||||
index += 1
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted
|
||||
} else if (char === ',' && !quoted) {
|
||||
values.push(value)
|
||||
value = ''
|
||||
} else {
|
||||
value += char
|
||||
}
|
||||
}
|
||||
|
||||
values.push(value)
|
||||
return values
|
||||
}
|
||||
|
||||
function pushUnique(map, key, value) {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
const values = map.get(key) || []
|
||||
if (!values.includes(value)) {
|
||||
values.push(value)
|
||||
map.set(key, values)
|
||||
}
|
||||
}
|
||||
|
||||
function modeFromRouteType(routeType) {
|
||||
switch (String(routeType)) {
|
||||
case '0':
|
||||
return 'tram'
|
||||
case '2':
|
||||
return 'rail'
|
||||
case '3':
|
||||
return 'bus'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
135
scripts/smoke-test.mjs
Normal file
135
scripts/smoke-test.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { buildStopIndex } from './build-stop-index.mjs'
|
||||
import worker from '../worker/nta-proxy.js'
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'tfi-live-g2-'))
|
||||
|
||||
try {
|
||||
await testStopIndexBuilder()
|
||||
await testWorkerDepartures()
|
||||
testToolkitMigration()
|
||||
testStopsJson()
|
||||
testNoLocalSecretBundled()
|
||||
console.log('Smoke tests passed')
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function testStopIndexBuilder() {
|
||||
const gtfsDir = join(tempDir, 'gtfs')
|
||||
mkdirSync(gtfsDir, { recursive: true })
|
||||
writeFileSync(join(gtfsDir, 'stops.txt'), 'stop_id,stop_code,stop_name,stop_lat,stop_lon\nS1,100,Main Street,53.1,-6.1\n')
|
||||
writeFileSync(join(gtfsDir, 'routes.txt'), 'route_id,route_short_name,route_long_name,route_type\nR1,46A,Route 46A,3\n')
|
||||
writeFileSync(join(gtfsDir, 'trips.txt'), 'route_id,service_id,trip_id\nR1,W,T1\n')
|
||||
writeFileSync(join(gtfsDir, 'stop_times.txt'), 'trip_id,arrival_time,departure_time,stop_id,stop_sequence\nT1,10:00:00,10:00:00,S1,1\n')
|
||||
|
||||
const outputPath = join(tempDir, 'stops.json')
|
||||
await buildStopIndex(gtfsDir, outputPath)
|
||||
const payload = JSON.parse(readFileSync(outputPath, 'utf8'))
|
||||
assert(payload.stops?.[0]?.routes?.[0] === '46A', 'stop index should include route short name')
|
||||
assert(payload.stops?.[0]?.modes?.[0] === 'bus', 'stop index should infer bus mode')
|
||||
}
|
||||
|
||||
async function testWorkerDepartures() {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const feed = {
|
||||
entity: [
|
||||
{
|
||||
id: 'fresh',
|
||||
tripUpdate: {
|
||||
trip: { routeId: '46A', tripHeadsign: 'Dun Laoghaire' },
|
||||
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds + 300 } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stale',
|
||||
tripUpdate: {
|
||||
trip: { routeId: '46A', tripHeadsign: 'Past' },
|
||||
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds - 600 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const cacheStore = new Map()
|
||||
globalThis.caches = {
|
||||
default: {
|
||||
match: async (request) => cacheStore.get(request.url),
|
||||
put: async (request, response) => {
|
||||
cacheStore.set(request.url, response)
|
||||
},
|
||||
},
|
||||
}
|
||||
globalThis.fetch = async () => Response.json(feed)
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://proxy.example/departures?stopId=S1'),
|
||||
{ NTA_API_KEY: 'test' },
|
||||
{ waitUntil: () => undefined },
|
||||
)
|
||||
assert(response.ok, 'worker departures response should be ok')
|
||||
const payload = await response.json()
|
||||
assert(payload.departures.length === 1, 'worker should filter stale departures')
|
||||
assert(payload.departures[0].route === '46A', 'worker should preserve route id')
|
||||
}
|
||||
|
||||
function testNoLocalSecretBundled() {
|
||||
const key = process.env.NTA_API_KEY
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = findFilesContaining(key, ['dist', 'src', 'worker', 'scripts', 'README.md', 'app.json'])
|
||||
assert(matches.length === 0, `local NTA_API_KEY must not be bundled or documented verbatim: ${matches.join(', ')}`)
|
||||
}
|
||||
|
||||
function testStopsJson() {
|
||||
const payload = JSON.parse(readFileSync('public/stops.json', 'utf8'))
|
||||
assert(payload.stops?.length > 10_000, 'public/stops.json should contain the generated TFI stop index')
|
||||
assert(payload.stops.some((stop) => stop.name && stop.lat && stop.lon), 'stops should include names and coordinates')
|
||||
}
|
||||
|
||||
function testToolkitMigration() {
|
||||
const source = readFileSync('src/main.ts', 'utf8')
|
||||
assert(source.includes("from 'even-toolkit/bridge'"), 'app should use Even toolkit bridge')
|
||||
assert(source.includes("from 'even-toolkit/glass-display-builders'"), 'glasses lists should use toolkit display builders')
|
||||
assert(source.includes("from 'even-toolkit/web/button'"), 'phone UI should use Even toolkit Button')
|
||||
assert(source.includes("from 'even-toolkit/web/list-item'"), 'phone lists should use Even toolkit ListItem')
|
||||
assert(source.includes("from 'even-toolkit/web/stat-grid'"), 'phone status grid should use Even toolkit StatGrid')
|
||||
assert(source.includes("from 'react-dom/client'"), 'phone UI should be rendered through React toolkit components')
|
||||
assert(!source.includes('createStartUpPageContainer'), 'app should not render glasses UI through raw startup containers')
|
||||
assert(!source.includes('new TextContainerUpgrade'), 'app should not update glasses UI through raw text upgrades')
|
||||
assert(!source.includes('document.createElement'), 'phone UI should not use hand-built DOM rendering')
|
||||
assert(!source.includes('innerHTML'), 'phone UI should not render raw HTML strings')
|
||||
}
|
||||
|
||||
function findFilesContaining(needle, paths) {
|
||||
const matches = []
|
||||
for (const path of paths) {
|
||||
collectMatches(path, needle, matches)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function collectMatches(path, needle, matches) {
|
||||
const stats = statSync(path)
|
||||
if (stats.isDirectory()) {
|
||||
for (const entry of readdirSync(path)) {
|
||||
collectMatches(join(path, entry), needle, matches)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const content = readFileSync(path, 'utf8')
|
||||
if (content.includes(needle)) {
|
||||
matches.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user