straeto 0.1.1 → 0.1.3
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/README.md +7 -0
- package/package.json +2 -2
- package/src/api.test.ts +46 -147
- package/src/cli.test.ts +14 -114
- package/src/cli.ts +2 -1
- package/src/commands.test.ts +319 -0
package/README.md
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
A CLI for the Icelandic bus system <a href="https://www.straeto.is">Strætó</a>, built with <a href="https://bun.sh">Bun</a>.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/straeto"><img src="https://img.shields.io/npm/v/straeto" alt="npm version" /></a>
|
|
13
|
+
<a href="https://github.com/magtastic/straeto-cli/actions/workflows/ci.yml"><img src="https://github.com/magtastic/straeto-cli/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/straeto"><img src="https://img.shields.io/npm/dm/straeto" alt="npm downloads" /></a>
|
|
15
|
+
<a href="https://github.com/magtastic/straeto-cli/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/straeto" alt="license" /></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
11
18
|
---
|
|
12
19
|
|
|
13
20
|
## Install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "straeto",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Strætó — Icelandic bus system CLI",
|
|
5
5
|
"author": "magtastic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"format": "biome format --write src",
|
|
34
34
|
"typecheck": "tsc --noEmit",
|
|
35
35
|
"test": "bun test",
|
|
36
|
-
"release": "npm version patch && git push --tags"
|
|
36
|
+
"release": "bun test && npm version patch && git push --tags"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@biomejs/biome": "2.4.6",
|
package/src/api.test.ts
CHANGED
|
@@ -1,183 +1,82 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import * as api from "./api";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
async function withRetry<T>(fn: () => Promise<T>, retries = 5): Promise<T> {
|
|
5
|
+
for (let i = 0; i < retries; i++) {
|
|
6
|
+
try {
|
|
7
|
+
return await fn();
|
|
8
|
+
} catch {
|
|
9
|
+
if (i === retries - 1) throw new Error(`Failed after ${retries} retries`);
|
|
10
|
+
await Bun.sleep(100 * 2 ** i); // 100ms, 200ms, 400ms, 800ms, 1600ms
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
throw new Error("unreachable");
|
|
14
|
+
}
|
|
7
15
|
|
|
16
|
+
describe("API schema validation", () => {
|
|
17
|
+
test("getBusLocations parses against schema", async () => {
|
|
18
|
+
const data = await withRetry(() => api.getBusLocations(["1"]));
|
|
8
19
|
expect(data.lastUpdate).toBeString();
|
|
9
20
|
expect(Array.isArray(data.results)).toBe(true);
|
|
10
|
-
|
|
11
|
-
for (const bus of data.results) {
|
|
12
|
-
expect(bus.busId).toBeString();
|
|
13
|
-
expect(bus.routeNr).toBe("1");
|
|
14
|
-
expect(bus.headsign).toBeString();
|
|
15
|
-
expect(bus.lat).toBeNumber();
|
|
16
|
-
expect(bus.lng).toBeNumber();
|
|
17
|
-
expect(bus.direction).toBeNumber();
|
|
18
|
-
expect(Array.isArray(bus.nextStops)).toBe(true);
|
|
19
|
-
|
|
20
|
-
for (const nextStop of bus.nextStops) {
|
|
21
|
-
expect(nextStop.arrival).toBeString();
|
|
22
|
-
expect(nextStop.waitingTime).toBeNumber();
|
|
23
|
-
if (nextStop.stop !== null) {
|
|
24
|
-
expect(nextStop.stop.name).toBeString();
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
21
|
});
|
|
29
22
|
|
|
30
|
-
test("returns empty
|
|
31
|
-
const data = await api.getBusLocations(["99999"]);
|
|
32
|
-
|
|
23
|
+
test("getBusLocations returns empty for nonexistent route", async () => {
|
|
24
|
+
const data = await withRetry(() => api.getBusLocations(["99999"]));
|
|
33
25
|
expect(data.lastUpdate).toBeString();
|
|
34
26
|
expect(data.results).toEqual([]);
|
|
35
27
|
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("getStops", () => {
|
|
39
|
-
test("returns a list of stops with required fields", async () => {
|
|
40
|
-
const stops = await api.getStops();
|
|
41
28
|
|
|
29
|
+
test("getStops parses against schema", async () => {
|
|
30
|
+
const stops = await withRetry(() => api.getStops());
|
|
42
31
|
expect(stops.length).toBeGreaterThan(100);
|
|
43
|
-
|
|
44
|
-
const stop = stops[0];
|
|
45
|
-
expect(stop).toBeDefined();
|
|
46
|
-
if (!stop) return;
|
|
47
|
-
|
|
48
|
-
expect(stop.id).toBeNumber();
|
|
49
|
-
expect(stop.name).toBeString();
|
|
50
|
-
expect(stop.lat).toBeNumber();
|
|
51
|
-
expect(stop.lon).toBeNumber();
|
|
52
|
-
expect(stop.type).toBeNumber();
|
|
53
|
-
expect(stop.code === null || typeof stop.code === "string").toBe(true);
|
|
54
|
-
expect(typeof stop.isTerminal).toBe("boolean");
|
|
55
|
-
expect(Array.isArray(stop.routes)).toBe(true);
|
|
56
32
|
});
|
|
57
33
|
|
|
58
|
-
test("
|
|
59
|
-
const stops = await api.getStops();
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
expect(names).toContain("Hamraborg");
|
|
63
|
-
expect(names).toContain("Mjódd A");
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("getStop", () => {
|
|
68
|
-
test("returns details for a known stop", async () => {
|
|
69
|
-
const stops = await api.getStops();
|
|
70
|
-
const hamraborg = stops.find((stop) => stop.name === "Hamraborg");
|
|
34
|
+
test("getStop parses against schema", async () => {
|
|
35
|
+
const stops = await withRetry(() => api.getStops());
|
|
36
|
+
const hamraborg = stops.find((s) => s.name === "Hamraborg");
|
|
71
37
|
expect(hamraborg).toBeDefined();
|
|
72
|
-
if (!hamraborg) return;
|
|
73
38
|
|
|
74
|
-
const stop = await api.getStop(String(hamraborg
|
|
39
|
+
const stop = await withRetry(() => api.getStop(String(hamraborg?.id)));
|
|
75
40
|
expect(stop).not.toBeNull();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
expect(stop.name).toBe("Hamraborg");
|
|
79
|
-
expect(stop.lat).toBeNumber();
|
|
80
|
-
expect(stop.lon).toBeNumber();
|
|
81
|
-
expect(Array.isArray(stop.routes)).toBe(true);
|
|
82
|
-
expect(Array.isArray(stop.routes)).toBe(true);
|
|
41
|
+
expect(stop?.name).toBe("Hamraborg");
|
|
83
42
|
});
|
|
84
43
|
|
|
85
|
-
test("returns null for nonexistent stop", async () => {
|
|
86
|
-
const stop = await api.getStop("0");
|
|
44
|
+
test("getStop returns null for nonexistent stop", async () => {
|
|
45
|
+
const stop = await withRetry(() => api.getStop("0"));
|
|
87
46
|
expect(stop).toBeNull();
|
|
88
47
|
});
|
|
89
|
-
});
|
|
90
48
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
49
|
+
test("getAlerts parses against schema", async () => {
|
|
50
|
+
const alertsIS = await withRetry(() => api.getAlerts("IS"));
|
|
51
|
+
expect(Array.isArray(alertsIS)).toBe(true);
|
|
94
52
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
for (const alert of alerts) {
|
|
98
|
-
expect(alert.id).toBeString();
|
|
99
|
-
expect(alert.cause).toBeString();
|
|
100
|
-
expect(alert.effect).toBeString();
|
|
101
|
-
expect(alert.title).toBeString();
|
|
102
|
-
expect(alert.text).toBeString();
|
|
103
|
-
expect(alert.dateStart).toBeString();
|
|
104
|
-
expect(Array.isArray(alert.routes)).toBe(true);
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("returns alerts in English", async () => {
|
|
109
|
-
const alerts = await api.getAlerts("EN");
|
|
110
|
-
expect(Array.isArray(alerts)).toBe(true);
|
|
53
|
+
const alertsEN = await withRetry(() => api.getAlerts("EN"));
|
|
54
|
+
expect(Array.isArray(alertsEN)).toBe(true);
|
|
111
55
|
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("geocode", () => {
|
|
115
|
-
test("finds results for a known location", async () => {
|
|
116
|
-
const results = await api.geocode("Hlemmur");
|
|
117
56
|
|
|
57
|
+
test("geocode parses against schema", async () => {
|
|
58
|
+
const results = await withRetry(() => api.geocode("Hlemmur"));
|
|
118
59
|
expect(results.length).toBeGreaterThan(0);
|
|
119
|
-
|
|
120
|
-
const first = results[0];
|
|
121
|
-
expect(first).toBeDefined();
|
|
122
|
-
if (!first) return;
|
|
123
|
-
|
|
124
|
-
expect(first.id).toBeString();
|
|
125
|
-
expect(first.name).toBeString();
|
|
126
|
-
expect(first.lat).toBeNumber();
|
|
127
|
-
expect(first.lon).toBeNumber();
|
|
128
|
-
expect(first.address).toBeString();
|
|
129
|
-
expect(first.type).toBeString();
|
|
130
|
-
expect(first.subType).toBeString();
|
|
131
60
|
});
|
|
132
61
|
|
|
133
|
-
test("returns empty for gibberish
|
|
134
|
-
const results = await api.geocode("xyzxyzxyz123456");
|
|
62
|
+
test("geocode returns empty for gibberish", async () => {
|
|
63
|
+
const results = await withRetry(() => api.geocode("xyzxyzxyz123456"));
|
|
135
64
|
expect(results).toEqual([]);
|
|
136
65
|
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe("planTrip", () => {
|
|
140
|
-
test("plans a trip between two known locations", async () => {
|
|
141
|
-
// Hlemmur → Mjódd (common route)
|
|
142
|
-
const trips = await api.planTrip({
|
|
143
|
-
from: "64.1426,-21.9009",
|
|
144
|
-
to: "64.1117,-21.8437",
|
|
145
|
-
date: new Date().toISOString().slice(0, 10),
|
|
146
|
-
time: "08:00",
|
|
147
|
-
arrivalBy: false,
|
|
148
|
-
});
|
|
149
66
|
|
|
67
|
+
test("planTrip parses against schema", async () => {
|
|
68
|
+
const tomorrow = new Date(Date.now() + 86400000);
|
|
69
|
+
const date = tomorrow.toISOString().slice(0, 10);
|
|
70
|
+
const trips = await withRetry(() =>
|
|
71
|
+
api.planTrip({
|
|
72
|
+
from: "64.1426,-21.9009",
|
|
73
|
+
to: "64.1117,-21.8437",
|
|
74
|
+
date,
|
|
75
|
+
time: "08:00",
|
|
76
|
+
arrivalBy: false,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
150
79
|
expect(trips.length).toBeGreaterThan(0);
|
|
151
|
-
|
|
152
|
-
const trip = trips[0];
|
|
153
|
-
expect(trip).toBeDefined();
|
|
154
|
-
if (!trip) return;
|
|
155
|
-
|
|
156
|
-
expect(trip.id).toBeString();
|
|
157
|
-
expect(trip.duration.total).toBeNumber();
|
|
158
|
-
expect(trip.duration.walk).toBeNumber();
|
|
159
|
-
expect(trip.duration.bus).toBeNumber();
|
|
160
|
-
expect(trip.time.from).toBeString();
|
|
161
|
-
expect(trip.time.to).toBeString();
|
|
162
|
-
expect(trip.legs.length).toBeGreaterThan(0);
|
|
163
|
-
|
|
164
|
-
for (const leg of trip.legs) {
|
|
165
|
-
expect(["WALK", "BUS"]).toContain(leg.type);
|
|
166
|
-
expect(leg.duration).toBeNumber();
|
|
167
|
-
expect(leg.distance).toBeNumber();
|
|
168
|
-
expect(leg.time.from).toBeString();
|
|
169
|
-
expect(leg.time.to).toBeString();
|
|
170
|
-
expect(leg.from.lat).toBeNumber();
|
|
171
|
-
expect(leg.from.lon).toBeNumber();
|
|
172
|
-
expect(leg.to.lat).toBeNumber();
|
|
173
|
-
expect(leg.to.lon).toBeNumber();
|
|
174
|
-
|
|
175
|
-
if (leg.type === "BUS") {
|
|
176
|
-
expect(leg.trip).toBeDefined();
|
|
177
|
-
expect(leg.trip?.routeNr).toBeString();
|
|
178
|
-
expect(leg.trip?.headsign).toBeString();
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
80
|
});
|
|
182
81
|
});
|
|
183
82
|
|
package/src/cli.test.ts
CHANGED
|
@@ -21,7 +21,6 @@ async function run(
|
|
|
21
21
|
describe("cli --help", () => {
|
|
22
22
|
test("shows usage and all commands", async () => {
|
|
23
23
|
const { stdout, exitCode } = await run("--help");
|
|
24
|
-
|
|
25
24
|
expect(exitCode).toBe(0);
|
|
26
25
|
expect(stdout).toContain("Usage: straeto");
|
|
27
26
|
expect(stdout).toContain("route");
|
|
@@ -33,32 +32,31 @@ describe("cli --help", () => {
|
|
|
33
32
|
});
|
|
34
33
|
|
|
35
34
|
describe("cli --version", () => {
|
|
36
|
-
test("prints version
|
|
35
|
+
test("prints version matching package.json", async () => {
|
|
36
|
+
const pkg = await Bun.file("package.json").json();
|
|
37
37
|
const { stdout, exitCode } = await run("--version");
|
|
38
|
-
|
|
39
38
|
expect(exitCode).toBe(0);
|
|
40
|
-
expect(stdout.trim()).
|
|
39
|
+
expect(stdout.trim()).toBe(pkg.version);
|
|
41
40
|
});
|
|
42
41
|
|
|
43
42
|
test("works with -v shorthand", async () => {
|
|
44
43
|
const { stdout, exitCode } = await run("-v");
|
|
45
|
-
|
|
46
44
|
expect(exitCode).toBe(0);
|
|
47
45
|
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
48
46
|
});
|
|
49
47
|
});
|
|
50
48
|
|
|
51
|
-
describe("cli
|
|
52
|
-
test("shows
|
|
53
|
-
const {
|
|
54
|
-
|
|
55
|
-
expect(
|
|
56
|
-
expect(stdout).toContain("Route 3");
|
|
49
|
+
describe("cli unknown command", () => {
|
|
50
|
+
test("shows error for unknown command", async () => {
|
|
51
|
+
const { stderr, exitCode } = await run("foobar");
|
|
52
|
+
expect(exitCode).toBe(1);
|
|
53
|
+
expect(stderr).toContain("unknown command");
|
|
57
54
|
});
|
|
55
|
+
});
|
|
58
56
|
|
|
57
|
+
describe("cli route (help/args)", () => {
|
|
59
58
|
test("shows help for route command", async () => {
|
|
60
59
|
const { stdout, exitCode } = await run("route", "--help");
|
|
61
|
-
|
|
62
60
|
expect(exitCode).toBe(0);
|
|
63
61
|
expect(stdout).toContain("Route number");
|
|
64
62
|
expect(stdout).toContain("--watch");
|
|
@@ -67,109 +65,14 @@ describe("cli route", () => {
|
|
|
67
65
|
|
|
68
66
|
test("fails without route argument", async () => {
|
|
69
67
|
const { stderr, exitCode } = await run("route");
|
|
70
|
-
|
|
71
68
|
expect(exitCode).toBe(1);
|
|
72
69
|
expect(stderr).toContain("missing required argument");
|
|
73
70
|
});
|
|
74
71
|
});
|
|
75
72
|
|
|
76
|
-
describe("cli
|
|
77
|
-
test("lists stops with default limit", async () => {
|
|
78
|
-
const { stdout, exitCode } = await run("stops");
|
|
79
|
-
|
|
80
|
-
expect(exitCode).toBe(0);
|
|
81
|
-
expect(stdout).toContain("Bus stops");
|
|
82
|
-
expect(stdout).toContain("more");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("filters stops by search", async () => {
|
|
86
|
-
const { stdout, exitCode } = await run("stops", "-s", "Hamraborg");
|
|
87
|
-
|
|
88
|
-
expect(exitCode).toBe(0);
|
|
89
|
-
expect(stdout).toContain("Hamraborg");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("respects -n limit flag", async () => {
|
|
93
|
-
const { stdout, exitCode } = await run("stops", "-n", "3");
|
|
94
|
-
|
|
95
|
-
expect(exitCode).toBe(0);
|
|
96
|
-
expect(stdout).toContain("Bus stops");
|
|
97
|
-
// Should show "and N more" since 3 is less than total
|
|
98
|
-
expect(stdout).toContain("more");
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe("cli stop", () => {
|
|
103
|
-
test("looks up a stop by name", async () => {
|
|
104
|
-
const { stdout, exitCode } = await run("stop", "Hamraborg");
|
|
105
|
-
|
|
106
|
-
expect(exitCode).toBe(0);
|
|
107
|
-
expect(stdout).toContain("Hamraborg");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("shows suggestions for partial match", async () => {
|
|
111
|
-
const { stdout, exitCode } = await run("stop", "Hamra");
|
|
112
|
-
|
|
113
|
-
expect(exitCode).toBe(0);
|
|
114
|
-
expect(stdout).toContain("Hamraborg");
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("fails for nonexistent stop", async () => {
|
|
118
|
-
const { stderr, exitCode } = await run("stop", "xyznonexistent123");
|
|
119
|
-
|
|
120
|
-
expect(exitCode).toBe(1);
|
|
121
|
-
expect(stderr).toContain("No stops matching");
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe("cli alerts", () => {
|
|
126
|
-
test("shows alerts in Icelandic by default", async () => {
|
|
127
|
-
const { stdout, exitCode } = await run("alerts");
|
|
128
|
-
|
|
129
|
-
expect(exitCode).toBe(0);
|
|
130
|
-
// May have 0 alerts, but should still show the header or "No active alerts"
|
|
131
|
-
expect(stdout.length).toBeGreaterThan(0);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("shows alerts in English with -l EN", async () => {
|
|
135
|
-
const { stdout, exitCode } = await run("alerts", "-l", "EN");
|
|
136
|
-
|
|
137
|
-
expect(exitCode).toBe(0);
|
|
138
|
-
expect(stdout.length).toBeGreaterThan(0);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("cli plan", () => {
|
|
143
|
-
test("plans a trip with coordinates", async () => {
|
|
144
|
-
// Use tomorrow to avoid time-of-day edge cases
|
|
145
|
-
const tomorrow = new Date(Date.now() + 86400000);
|
|
146
|
-
const date = tomorrow.toISOString().slice(0, 10);
|
|
147
|
-
const { stdout, exitCode } = await run(
|
|
148
|
-
"plan",
|
|
149
|
-
"-f",
|
|
150
|
-
"64.1426,-21.9009",
|
|
151
|
-
"-t",
|
|
152
|
-
"64.1117,-21.8437",
|
|
153
|
-
"--at",
|
|
154
|
-
"10:00",
|
|
155
|
-
"-d",
|
|
156
|
-
date,
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
expect(exitCode).toBe(0);
|
|
160
|
-
expect(stdout).toContain("→");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("fails without from/to in non-interactive mode", async () => {
|
|
164
|
-
const { stderr, exitCode } = await run("plan", "-f", "64.14,-21.90");
|
|
165
|
-
|
|
166
|
-
expect(exitCode).toBe(1);
|
|
167
|
-
expect(stderr).toContain("Missing --from and --to");
|
|
168
|
-
});
|
|
169
|
-
|
|
73
|
+
describe("cli plan (help/args)", () => {
|
|
170
74
|
test("shows help for plan command", async () => {
|
|
171
75
|
const { stdout, exitCode } = await run("plan", "--help");
|
|
172
|
-
|
|
173
76
|
expect(exitCode).toBe(0);
|
|
174
77
|
expect(stdout).toContain("--from");
|
|
175
78
|
expect(stdout).toContain("--to");
|
|
@@ -177,13 +80,10 @@ describe("cli plan", () => {
|
|
|
177
80
|
expect(stdout).toContain("--by");
|
|
178
81
|
expect(stdout).toContain("--interactive");
|
|
179
82
|
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe("cli unknown command", () => {
|
|
183
|
-
test("shows error for unknown command", async () => {
|
|
184
|
-
const { stderr, exitCode } = await run("foobar");
|
|
185
83
|
|
|
84
|
+
test("fails without from/to in non-interactive mode", async () => {
|
|
85
|
+
const { stderr, exitCode } = await run("plan", "-f", "64.14,-21.90");
|
|
186
86
|
expect(exitCode).toBe(1);
|
|
187
|
-
expect(stderr).toContain("
|
|
87
|
+
expect(stderr).toContain("Missing --from and --to");
|
|
188
88
|
});
|
|
189
89
|
});
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { program } from "commander";
|
|
4
|
+
import pkg from "../package.json";
|
|
4
5
|
import { alertsCommand } from "./commands/alerts";
|
|
5
6
|
import { planCommand } from "./commands/plan";
|
|
6
7
|
import { routeCommand } from "./commands/route";
|
|
@@ -18,7 +19,7 @@ function handleError(error: unknown) {
|
|
|
18
19
|
program
|
|
19
20
|
.name("straeto")
|
|
20
21
|
.description("Strætó — Icelandic bus system CLI")
|
|
21
|
-
.version(
|
|
22
|
+
.version(pkg.version, "-v, --version");
|
|
22
23
|
|
|
23
24
|
program
|
|
24
25
|
.command("route")
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Alert, BusLocation, Stop, StopDetail, TripItinerary } from "./api";
|
|
3
|
+
import { findBus, findLastStop } from "./api";
|
|
4
|
+
import * as fmt from "./format";
|
|
5
|
+
|
|
6
|
+
// --- Mock data ---
|
|
7
|
+
|
|
8
|
+
const mockBuses: BusLocation[] = [
|
|
9
|
+
{
|
|
10
|
+
busId: "1-A",
|
|
11
|
+
tripId: "t1",
|
|
12
|
+
routeNr: "3",
|
|
13
|
+
tag: null,
|
|
14
|
+
headsign: "Hlemmur",
|
|
15
|
+
lat: 64.14,
|
|
16
|
+
lng: -21.92,
|
|
17
|
+
direction: 90,
|
|
18
|
+
nextStops: [{ arrival: "2026-01-01T10:05:00", waitingTime: 3, stop: { name: "Hamraborg" } }],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
busId: "2-B",
|
|
22
|
+
tripId: "t2",
|
|
23
|
+
routeNr: "3",
|
|
24
|
+
tag: null,
|
|
25
|
+
headsign: "Seltjarnarnes",
|
|
26
|
+
lat: 64.15,
|
|
27
|
+
lng: -21.95,
|
|
28
|
+
direction: 270,
|
|
29
|
+
nextStops: [{ arrival: "2026-01-01T10:10:00", waitingTime: 5, stop: { name: "Grandi" } }],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const mockStops: Stop[] = [
|
|
34
|
+
{
|
|
35
|
+
id: 10001,
|
|
36
|
+
name: "Hamraborg",
|
|
37
|
+
lat: 64.11,
|
|
38
|
+
lon: -21.88,
|
|
39
|
+
type: 1,
|
|
40
|
+
code: "HB",
|
|
41
|
+
isTerminal: true,
|
|
42
|
+
routes: ["1", "2", "3"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 10002,
|
|
46
|
+
name: "Mjódd A",
|
|
47
|
+
lat: 64.1,
|
|
48
|
+
lon: -21.84,
|
|
49
|
+
type: 1,
|
|
50
|
+
code: null,
|
|
51
|
+
isTerminal: true,
|
|
52
|
+
routes: ["1", "3", "6"],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 10003,
|
|
56
|
+
name: "Hlemmur",
|
|
57
|
+
lat: 64.14,
|
|
58
|
+
lon: -21.9,
|
|
59
|
+
type: 1,
|
|
60
|
+
code: null,
|
|
61
|
+
isTerminal: false,
|
|
62
|
+
routes: ["1", "3", "6"],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 10004,
|
|
66
|
+
name: "Grandi",
|
|
67
|
+
lat: 64.15,
|
|
68
|
+
lon: -21.95,
|
|
69
|
+
type: 1,
|
|
70
|
+
code: null,
|
|
71
|
+
isTerminal: false,
|
|
72
|
+
routes: ["3"],
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const mockStopDetail: StopDetail = {
|
|
77
|
+
id: 10001,
|
|
78
|
+
name: "Hamraborg",
|
|
79
|
+
lat: 64.11,
|
|
80
|
+
lon: -21.88,
|
|
81
|
+
type: 1,
|
|
82
|
+
code: "HB",
|
|
83
|
+
isTerminal: true,
|
|
84
|
+
routes: ["1", "2", "3"],
|
|
85
|
+
streetView: null,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const mockAlerts: Alert[] = [
|
|
89
|
+
{
|
|
90
|
+
id: "a1",
|
|
91
|
+
cause: "CONSTRUCTION",
|
|
92
|
+
effect: "DETOUR",
|
|
93
|
+
routes: ["3"],
|
|
94
|
+
title: "Route 3 detour",
|
|
95
|
+
text: "Due to construction near Hamraborg",
|
|
96
|
+
dateStart: "2026-01-01",
|
|
97
|
+
dateEnd: null,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const mockTrips: TripItinerary[] = [
|
|
102
|
+
{
|
|
103
|
+
id: "trip1",
|
|
104
|
+
duration: { walk: 300, bus: 600, total: 900 },
|
|
105
|
+
time: { from: "2026-01-01T10:00:00", to: "2026-01-01T10:15:00" },
|
|
106
|
+
legs: [
|
|
107
|
+
{
|
|
108
|
+
type: "WALK",
|
|
109
|
+
duration: 120,
|
|
110
|
+
distance: 150,
|
|
111
|
+
time: { from: "2026-01-01T10:00:00", to: "2026-01-01T10:02:00" },
|
|
112
|
+
from: { lat: 64.14, lon: -21.9, depature: null, stop: null },
|
|
113
|
+
to: {
|
|
114
|
+
lat: 64.14,
|
|
115
|
+
lon: -21.9,
|
|
116
|
+
arrival: null,
|
|
117
|
+
stop: { id: "10003", name: "Hlemmur", lat: 64.14, lon: -21.9 },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: "BUS",
|
|
122
|
+
duration: 600,
|
|
123
|
+
distance: 3000,
|
|
124
|
+
time: { from: "2026-01-01T10:02:00", to: "2026-01-01T10:12:00" },
|
|
125
|
+
from: {
|
|
126
|
+
lat: 64.14,
|
|
127
|
+
lon: -21.9,
|
|
128
|
+
depature: "2026-01-01T10:02:00",
|
|
129
|
+
stop: { id: "10003", name: "Hlemmur", lat: 64.14, lon: -21.9 },
|
|
130
|
+
},
|
|
131
|
+
to: {
|
|
132
|
+
lat: 64.1,
|
|
133
|
+
lon: -21.84,
|
|
134
|
+
arrival: "2026-01-01T10:12:00",
|
|
135
|
+
stop: { id: "10002", name: "Mjódd A", lat: 64.1, lon: -21.84 },
|
|
136
|
+
},
|
|
137
|
+
trip: { routeNr: "3", headsign: "Mjódd" },
|
|
138
|
+
stops: [
|
|
139
|
+
{ id: "10003", name: "Hlemmur" },
|
|
140
|
+
{ id: "10001", name: "Hamraborg" },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: "WALK",
|
|
145
|
+
duration: 180,
|
|
146
|
+
distance: 200,
|
|
147
|
+
time: { from: "2026-01-01T10:12:00", to: "2026-01-01T10:15:00" },
|
|
148
|
+
from: {
|
|
149
|
+
lat: 64.1,
|
|
150
|
+
lon: -21.84,
|
|
151
|
+
depature: null,
|
|
152
|
+
stop: { id: "10002", name: "Mjódd A", lat: 64.1, lon: -21.84 },
|
|
153
|
+
},
|
|
154
|
+
to: { lat: 64.1, lon: -21.84, arrival: null, stop: null },
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// --- Format function tests ---
|
|
161
|
+
|
|
162
|
+
describe("formatBusOverview", () => {
|
|
163
|
+
test("shows route and buses", () => {
|
|
164
|
+
const output = fmt.formatBusOverview("3", mockBuses, "2026-01-01T10:00:00");
|
|
165
|
+
expect(output).toContain("Route 3");
|
|
166
|
+
expect(output).toContain("Hlemmur");
|
|
167
|
+
expect(output).toContain("Seltjarnarnes");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("shows no buses message for empty list", () => {
|
|
171
|
+
const output = fmt.formatBusOverview("999", [], "2026-01-01T10:00:00");
|
|
172
|
+
expect(output).toContain("No buses");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("formatBusDetail", () => {
|
|
177
|
+
test("shows bus detail with next stops", () => {
|
|
178
|
+
const bus = mockBuses[0];
|
|
179
|
+
expect(bus).toBeDefined();
|
|
180
|
+
if (!bus) return;
|
|
181
|
+
const output = fmt.formatBusDetail(bus, "2026-01-01T10:00:00");
|
|
182
|
+
expect(output).toContain("Bus 1-A");
|
|
183
|
+
expect(output).toContain("Hamraborg");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("shows last stop when provided", () => {
|
|
187
|
+
const bus = mockBuses[0];
|
|
188
|
+
expect(bus).toBeDefined();
|
|
189
|
+
if (!bus) return;
|
|
190
|
+
const output = fmt.formatBusDetail(bus, "2026-01-01T10:00:00", "Grandi");
|
|
191
|
+
expect(output).toContain("Grandi");
|
|
192
|
+
expect(output).toContain("passed");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("formatStops", () => {
|
|
197
|
+
test("lists all stops", () => {
|
|
198
|
+
const output = fmt.formatStops(mockStops);
|
|
199
|
+
expect(output).toContain("Bus stops");
|
|
200
|
+
expect(output).toContain("Hamraborg");
|
|
201
|
+
expect(output).toContain("Mjódd A");
|
|
202
|
+
expect(output).toContain("Hlemmur");
|
|
203
|
+
expect(output).toContain("Grandi");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("shows terminal indicator", () => {
|
|
207
|
+
const output = fmt.formatStops(mockStops);
|
|
208
|
+
// Hamraborg is a terminal
|
|
209
|
+
expect(output).toContain("◉");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("formatStop", () => {
|
|
214
|
+
test("shows stop detail", () => {
|
|
215
|
+
const output = fmt.formatStop(mockStopDetail);
|
|
216
|
+
expect(output).toContain("Hamraborg");
|
|
217
|
+
expect(output).toContain("10001");
|
|
218
|
+
expect(output).toContain("Terminal");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("formatAlerts", () => {
|
|
223
|
+
test("shows alerts", () => {
|
|
224
|
+
const output = fmt.formatAlerts(mockAlerts);
|
|
225
|
+
expect(output).toContain("Route 3 detour");
|
|
226
|
+
expect(output).toContain("construction");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("shows no alerts message", () => {
|
|
230
|
+
const output = fmt.formatAlerts([]);
|
|
231
|
+
expect(output).toContain("No active alerts");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("formatTrips", () => {
|
|
236
|
+
test("shows trip plan", () => {
|
|
237
|
+
const output = fmt.formatTrips(mockTrips);
|
|
238
|
+
expect(output).toContain("→");
|
|
239
|
+
expect(output).toContain("Hlemmur");
|
|
240
|
+
expect(output).toContain("Mjódd");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("shows no trips message", () => {
|
|
244
|
+
const output = fmt.formatTrips([]);
|
|
245
|
+
expect(output).toContain("No trips found");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// --- Helper function tests ---
|
|
250
|
+
|
|
251
|
+
describe("findBus", () => {
|
|
252
|
+
test("finds a bus by route and letter", () => {
|
|
253
|
+
const found = findBus(mockBuses, "3", "b");
|
|
254
|
+
expect(found).toBeDefined();
|
|
255
|
+
expect(found?.busId).toBe("2-B");
|
|
256
|
+
|
|
257
|
+
expect(findBus(mockBuses, "3", "c")).toBeUndefined();
|
|
258
|
+
expect(findBus(mockBuses, "5", "a")).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("findLastStop", () => {
|
|
263
|
+
test("returns closest stop not in nextStops", () => {
|
|
264
|
+
const bus = mockBuses[0];
|
|
265
|
+
expect(bus).toBeDefined();
|
|
266
|
+
if (!bus) return;
|
|
267
|
+
// Hamraborg is in nextStops, so it should find the closest non-next stop
|
|
268
|
+
const result = findLastStop(bus, mockStops);
|
|
269
|
+
expect(result).toBeDefined();
|
|
270
|
+
expect(result).not.toBe("Hamraborg");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("returns undefined when no route stops exist", () => {
|
|
274
|
+
const bus: BusLocation = {
|
|
275
|
+
busId: "1-A",
|
|
276
|
+
tripId: "t1",
|
|
277
|
+
routeNr: "99",
|
|
278
|
+
tag: null,
|
|
279
|
+
headsign: "Nowhere",
|
|
280
|
+
lat: 64.0,
|
|
281
|
+
lng: -21.0,
|
|
282
|
+
direction: 0,
|
|
283
|
+
nextStops: [],
|
|
284
|
+
};
|
|
285
|
+
expect(findLastStop(bus, [])).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// --- Stop filtering logic (mirrors stops/stop commands) ---
|
|
290
|
+
|
|
291
|
+
describe("stop filtering", () => {
|
|
292
|
+
test("filters by route", () => {
|
|
293
|
+
const route6 = mockStops.filter((s) => s.routes.includes("6"));
|
|
294
|
+
expect(route6.map((s) => s.name)).toContain("Mjódd A");
|
|
295
|
+
expect(route6.map((s) => s.name)).toContain("Hlemmur");
|
|
296
|
+
expect(route6.map((s) => s.name)).not.toContain("Grandi");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("filters by search", () => {
|
|
300
|
+
const query = "hamra";
|
|
301
|
+
const results = mockStops.filter((s) => s.name.toLowerCase().includes(query));
|
|
302
|
+
expect(results).toHaveLength(1);
|
|
303
|
+
expect(results[0]?.name).toBe("Hamraborg");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("exact match lookup", () => {
|
|
307
|
+
const query = "hamraborg";
|
|
308
|
+
const matches = mockStops.filter((s) => s.name.toLowerCase() === query);
|
|
309
|
+
expect(matches).toHaveLength(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("fuzzy suggestions for partial match", () => {
|
|
313
|
+
const query = "hamra";
|
|
314
|
+
const exact = mockStops.filter((s) => s.name.toLowerCase() === query);
|
|
315
|
+
expect(exact).toHaveLength(0);
|
|
316
|
+
const fuzzy = mockStops.filter((s) => s.name.toLowerCase().includes(query));
|
|
317
|
+
expect(fuzzy.length).toBeGreaterThan(0);
|
|
318
|
+
});
|
|
319
|
+
});
|