straeto 0.1.1 → 0.1.2

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 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.1",
3
+ "version": "0.1.2",
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
- describe("getBusLocations", () => {
5
- test("returns valid bus locations for a known route", async () => {
6
- const data = await api.getBusLocations(["1"]);
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 results for nonexistent route", async () => {
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("contains known stops", async () => {
59
- const stops = await api.getStops();
60
- const names = stops.map((stop) => stop.name);
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.id));
39
+ const stop = await withRetry(() => api.getStop(String(hamraborg?.id)));
75
40
  expect(stop).not.toBeNull();
76
- if (!stop) return;
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
- describe("getAlerts", () => {
92
- test("returns alerts in Icelandic", async () => {
93
- const alerts = await api.getAlerts("IS");
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
- expect(Array.isArray(alerts)).toBe(true);
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 query", async () => {
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 number", async () => {
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()).toMatch(/^\d+\.\d+\.\d+$/);
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 route", () => {
52
- test("shows bus overview for a route", async () => {
53
- const { stdout, exitCode } = await run("route", "3");
54
-
55
- expect(exitCode).toBe(0);
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 stops", () => {
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("unknown command");
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("0.1.0", "-v, --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
+ });