@mohndoe/pi-atlas 0.1.1

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.
Files changed (66) hide show
  1. package/.pi/extensions/guardrails.json +10 -0
  2. package/.pi/extensions/guardrails.v0.json +8 -0
  3. package/AGENTS.md +13 -0
  4. package/CONTEXT.md +119 -0
  5. package/LICENSE +21 -0
  6. package/README.md +40 -0
  7. package/bun.lock +325 -0
  8. package/docs/ARCHITECTURE.md +66 -0
  9. package/docs/adr/0001-global-session-project-map.md +9 -0
  10. package/docs/adr/0002-precomputed-summaries.md +9 -0
  11. package/docs/agents/domain.md +42 -0
  12. package/docs/agents/issue-tracker.md +22 -0
  13. package/docs/agents/triage-labels.md +14 -0
  14. package/package.json +49 -0
  15. package/src/__tests__/cache.test.ts +388 -0
  16. package/src/__tests__/components.fixtures.ts +54 -0
  17. package/src/__tests__/compute.fixtures.ts +49 -0
  18. package/src/__tests__/compute.test.ts +336 -0
  19. package/src/__tests__/e2e.test.ts +182 -0
  20. package/src/__tests__/format.test.ts +232 -0
  21. package/src/__tests__/parser.test.ts +1396 -0
  22. package/src/cache.ts +178 -0
  23. package/src/colorPalette.ts +119 -0
  24. package/src/components/BarChart.ts +288 -0
  25. package/src/components/Dashboard.ts +222 -0
  26. package/src/components/Header.ts +40 -0
  27. package/src/components/KpiCards.ts +104 -0
  28. package/src/components/LoadingView.ts +38 -0
  29. package/src/components/MarqueeText.ts +79 -0
  30. package/src/components/RangeSelector.ts +63 -0
  31. package/src/components/RankedBarList.ts +71 -0
  32. package/src/components/SortedTable.ts +221 -0
  33. package/src/components/StatCard.ts +64 -0
  34. package/src/components/TabBar.ts +59 -0
  35. package/src/components/UsageRow.ts +55 -0
  36. package/src/components/__tests__/Bar.test.ts +66 -0
  37. package/src/components/__tests__/BarChart.test.ts +224 -0
  38. package/src/components/__tests__/Dashboard.test.ts +452 -0
  39. package/src/components/__tests__/KpiCards.test.ts +83 -0
  40. package/src/components/__tests__/LoadingView.test.ts +26 -0
  41. package/src/components/__tests__/MarqueeText.test.ts +75 -0
  42. package/src/components/__tests__/RangeSelector.test.ts +34 -0
  43. package/src/components/__tests__/RankedBarList.test.ts +110 -0
  44. package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
  45. package/src/components/__tests__/SortedTable.test.ts +723 -0
  46. package/src/components/__tests__/TabBar.test.ts +62 -0
  47. package/src/components/__tests__/cells.test.ts +193 -0
  48. package/src/components/cells.ts +108 -0
  49. package/src/components/shared/Bar.ts +22 -0
  50. package/src/components/shared/GridRow.ts +22 -0
  51. package/src/compute.ts +210 -0
  52. package/src/format.ts +219 -0
  53. package/src/index.ts +88 -0
  54. package/src/parser.ts +363 -0
  55. package/src/tabs/Languages.ts +102 -0
  56. package/src/tabs/Models.ts +108 -0
  57. package/src/tabs/Overview.ts +152 -0
  58. package/src/tabs/Projects.ts +92 -0
  59. package/src/tabs/Usage.ts +181 -0
  60. package/src/tabs/__tests__/Languages.test.ts +158 -0
  61. package/src/tabs/__tests__/Models.test.ts +143 -0
  62. package/src/tabs/__tests__/Overview.test.ts +92 -0
  63. package/src/tabs/__tests__/Projects.test.ts +142 -0
  64. package/src/tabs/__tests__/Usage.test.ts +174 -0
  65. package/src/types.ts +99 -0
  66. package/tsconfig.json +30 -0
@@ -0,0 +1,452 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { makeMockTUI, makeRangeSelector, makeTheme } from "../../__tests__/components.fixtures";
3
+ import { makeSummary } from "../../__tests__/compute.fixtures";
4
+ import type { StatsSummary, TimeRange } from "../../types";
5
+ import { Dashboard } from "../Dashboard";
6
+
7
+ const mockTui = makeMockTUI();
8
+ export const allRanges: TimeRange[] = ["1d", "7d", "30d", "All"];
9
+
10
+ export function mapAllSummaries(ranges: TimeRange[], summary: ReturnType<typeof makeSummary>) {
11
+ return new Map(ranges.map((r) => [r, { ...summary }]));
12
+ }
13
+
14
+ export const ALL_SUMMARIES = mapAllSummaries(allRanges, makeSummary());
15
+
16
+ describe("Dashboard", () => {
17
+ it("renders all sections", () => {
18
+ const summaries = ALL_SUMMARIES;
19
+ const dash = new Dashboard(
20
+ summaries,
21
+ makeTheme(),
22
+ mockTui,
23
+ null,
24
+ makeRangeSelector(makeTheme()),
25
+ );
26
+ const lines = dash.render(80);
27
+ const text = lines.join("\n");
28
+ expect(text).toContain("Overview");
29
+ expect(text).toContain("All time");
30
+ expect(text).toContain("Total");
31
+ expect(text).toContain("Esc/q close");
32
+ expect(text).toContain("█");
33
+ });
34
+
35
+ it("shows 'No sessions found' when no session data exists", () => {
36
+ const zeroSummary = {
37
+ ...makeSummary(),
38
+ totalCost: 0,
39
+ sessionCount: 0,
40
+ totalMessages: 0,
41
+ totalTokens: 0,
42
+ dailySpend: [],
43
+ };
44
+ const summaries = mapAllSummaries(allRanges, zeroSummary);
45
+ const dash = new Dashboard(
46
+ summaries,
47
+ makeTheme(),
48
+ mockTui,
49
+ null,
50
+ makeRangeSelector(makeTheme()),
51
+ );
52
+ const lines = dash.render(80);
53
+ const text = lines.join("\n");
54
+ expect(text).toContain("No sessions found");
55
+ });
56
+
57
+ it("shows 'No data for this time range' when current range is empty", () => {
58
+ const dataSummary = { ...makeSummary(), totalCost: 5.0, sessionCount: 3 };
59
+ const zeroSummary = {
60
+ ...makeSummary(),
61
+ totalCost: 0,
62
+ sessionCount: 0,
63
+ totalMessages: 0,
64
+ totalTokens: 0,
65
+ dailySpend: [],
66
+ };
67
+ // 1d range empty, others have data
68
+ const summaries: Map<TimeRange, StatsSummary> = new Map([
69
+ ["1d", zeroSummary],
70
+ ["7d", dataSummary],
71
+ ["30d", dataSummary],
72
+ ["All", dataSummary],
73
+ ]);
74
+ const dash = new Dashboard(
75
+ summaries,
76
+ makeTheme(),
77
+ mockTui,
78
+ null,
79
+ makeRangeSelector(makeTheme()),
80
+ );
81
+ // Default range is All. r key cycles: All→1d
82
+ dash.handleInput("r");
83
+ const lines = dash.render(80);
84
+ const text = lines.join("\n");
85
+ expect(text).toContain("No data for this time range");
86
+ });
87
+
88
+ it("handles escape to close", () => {
89
+ const summaries = ALL_SUMMARIES;
90
+ let closed = false;
91
+ const dash = new Dashboard(
92
+ summaries,
93
+ makeTheme(),
94
+ mockTui,
95
+ null,
96
+ makeRangeSelector(makeTheme()),
97
+ () => {
98
+ closed = true;
99
+ },
100
+ );
101
+ dash.handleInput("\x1b");
102
+ expect(closed).toBe(true);
103
+ });
104
+
105
+ it("handles q to close", () => {
106
+ const summaries = ALL_SUMMARIES;
107
+ let closed = false;
108
+ const dash = new Dashboard(
109
+ summaries,
110
+ makeTheme(),
111
+ mockTui,
112
+ null,
113
+ makeRangeSelector(makeTheme()),
114
+ () => {
115
+ closed = true;
116
+ },
117
+ );
118
+ dash.handleInput("q");
119
+ expect(closed).toBe(true);
120
+ });
121
+
122
+ it("renders Languages tab when active", () => {
123
+ const summary = {
124
+ ...makeSummary(),
125
+ languages: [
126
+ { language: "TypeScript", lines: 1500, edits: 45 },
127
+ { language: "Python", lines: 800, edits: 20 },
128
+ { language: "JSON", lines: 300, edits: 5 },
129
+ ],
130
+ };
131
+ const summaries = mapAllSummaries(allRanges, summary);
132
+ const dash = new Dashboard(
133
+ summaries,
134
+ makeTheme(),
135
+ mockTui,
136
+ null,
137
+ makeRangeSelector(makeTheme()),
138
+ );
139
+
140
+ // Switch to Languages tab (index 1)
141
+ dash.handleInput("\x1b[C"); // right arrow
142
+ const lines = dash.render(80);
143
+ const text = lines.join("\n");
144
+
145
+ expect(text).toContain("Languages");
146
+ expect(text).toContain("TypeScript");
147
+ expect(text).toContain("Python");
148
+ expect(text).toContain("JSON");
149
+ expect(text).toContain("1.5k");
150
+ expect(text).toContain("800");
151
+ });
152
+
153
+ it("Languages tab updates when time range changes", () => {
154
+ const summary1d = {
155
+ ...makeSummary(),
156
+ languages: [{ language: "TypeScript", lines: 100, edits: 3 }],
157
+ };
158
+ const summary7d = {
159
+ ...makeSummary(),
160
+ languages: [
161
+ { language: "TypeScript", lines: 1500, edits: 45 },
162
+ { language: "Go", lines: 200, edits: 8 },
163
+ ],
164
+ };
165
+ const summaries: Map<TimeRange, StatsSummary> = new Map([
166
+ ["1d", summary1d],
167
+ ["7d", summary7d],
168
+ ["30d", summary7d],
169
+ ["All", summary7d],
170
+ ]);
171
+ const dash = new Dashboard(
172
+ summaries,
173
+ makeTheme(),
174
+ mockTui,
175
+ null,
176
+ makeRangeSelector(makeTheme()),
177
+ );
178
+
179
+ // Default range is All (= summary7d). r key cycles: All→1d
180
+ dash.handleInput("r"); // All → 1d
181
+ // Switch to Languages tab
182
+ dash.handleInput("\x1b[C"); // right to Languages
183
+ let lines = dash.render(80);
184
+ let text = lines.join("\n");
185
+ // Range 1d, only 1 language
186
+ expect(text).toContain("TypeScript");
187
+ expect(text).not.toContain("Go");
188
+
189
+ // Switch back to Overview, r to 7d, then back to Languages
190
+ dash.handleInput("\x1b[D"); // left to Overview
191
+ dash.handleInput("r"); // 1d → 7d
192
+ dash.handleInput("\x1b[C"); // right to Languages
193
+ lines = dash.render(80);
194
+ text = lines.join("\n");
195
+ expect(text).toContain("Go");
196
+ });
197
+
198
+ it("Languages tab shows empty state when no language data", () => {
199
+ const summary = { ...makeSummary(), languages: [] };
200
+ const summaries = mapAllSummaries(allRanges, summary);
201
+ const dash = new Dashboard(
202
+ summaries,
203
+ makeTheme(),
204
+ mockTui,
205
+ null,
206
+ makeRangeSelector(makeTheme()),
207
+ );
208
+
209
+ dash.handleInput("\x1b[C"); // right to Languages
210
+ const lines = dash.render(80);
211
+ const text = lines.join("\n");
212
+ expect(text).toContain("No language data");
213
+ });
214
+
215
+ // ---- Models tab ----
216
+
217
+ it("renders Models tab", () => {
218
+ const summary = {
219
+ ...makeSummary(),
220
+ models: [
221
+ { model: "claude-sonnet-4-20250514", cost: 12.34, calls: 150 },
222
+ { model: "deepseek-v4-pro", cost: 5.67, calls: 80 },
223
+ { model: "gemini-2.0-flash", cost: 1.23, calls: 40 },
224
+ ],
225
+ };
226
+ const summaries = mapAllSummaries(allRanges, summary);
227
+ const dash = new Dashboard(
228
+ summaries,
229
+ makeTheme(),
230
+ mockTui,
231
+ null,
232
+ makeRangeSelector(makeTheme()),
233
+ );
234
+
235
+ // Switch to Models tab (index 2)
236
+ dash.handleInput("\x1b[C"); // right to Languages
237
+ dash.handleInput("\x1b[C"); // right to Models
238
+ const lines = dash.render(80);
239
+ const text = lines.join("\n");
240
+
241
+ expect(text).toContain("Model");
242
+ expect(text).toContain("Provider");
243
+ });
244
+
245
+ it("formats model names in Models tab", () => {
246
+ const summary = {
247
+ ...makeSummary(),
248
+ models: [{ model: "claude-sonnet-4-20250514", cost: 1.0, calls: 10 }],
249
+ };
250
+ const summaries = mapAllSummaries(allRanges, summary);
251
+ const dash = new Dashboard(
252
+ summaries,
253
+ makeTheme(),
254
+ mockTui,
255
+ null,
256
+ makeRangeSelector(makeTheme()),
257
+ );
258
+
259
+ // Navigate to Models tab
260
+ dash.handleInput("\x1b[C"); // → Languages
261
+ dash.handleInput("\x1b[C"); // → Models
262
+ const lines = dash.render(80);
263
+ const text = lines.join("\n");
264
+
265
+ expect(text).toContain("Claude");
266
+ expect(text).not.toContain("claude-sonnet-4-20250514");
267
+ });
268
+
269
+ it("Models tab shows empty state when no model data", () => {
270
+ const summary = { ...makeSummary(), models: [] };
271
+ const summaries = mapAllSummaries(allRanges, summary);
272
+ const dash = new Dashboard(
273
+ summaries,
274
+ makeTheme(),
275
+ mockTui,
276
+ null,
277
+ makeRangeSelector(makeTheme()),
278
+ );
279
+
280
+ dash.handleInput("\x1b[C"); // → Languages
281
+ dash.handleInput("\x1b[C"); // → Models
282
+ const lines = dash.render(80);
283
+ const text = lines.join("\n");
284
+ expect(text).toContain("No model data");
285
+ });
286
+
287
+ it("Models tab updates when time range changes", () => {
288
+ const summary1d = {
289
+ ...makeSummary(),
290
+ models: [{ model: "claude-sonnet-4-20250514", cost: 1.0, calls: 5 }],
291
+ };
292
+ const summary7d = {
293
+ ...makeSummary(),
294
+ models: [
295
+ { model: "claude-sonnet-4-20250514", cost: 12.0, calls: 150 },
296
+ { model: "deepseek-v4-pro", cost: 5.0, calls: 80 },
297
+ ],
298
+ };
299
+ const summaries: Map<TimeRange, StatsSummary> = new Map([
300
+ ["1d", summary1d],
301
+ ["7d", summary7d],
302
+ ["30d", summary7d],
303
+ ["All", summary7d],
304
+ ]);
305
+ const dash = new Dashboard(
306
+ summaries,
307
+ makeTheme(),
308
+ mockTui,
309
+ null,
310
+ makeRangeSelector(makeTheme()),
311
+ );
312
+
313
+ // Default range is All (= summary7d). r key cycles: All→1d
314
+ dash.handleInput("r"); // All → 1d
315
+ dash.handleInput("\x1b[C"); // → Languages
316
+ dash.handleInput("\x1b[C"); // → Models
317
+ let lines = dash.render(80);
318
+ let text = lines.join("\n");
319
+ // Range 1d, only 1 model
320
+ expect(text).toContain("Claude");
321
+ // deepseek-v4-pro → "Deeps…" visible truncated name in 7d range
322
+ expect(text).not.toContain("Deeps");
323
+
324
+ // Switch back to Overview, r to 7d, then back to Models
325
+ dash.handleInput("\x1b[D"); // left to Languages
326
+ dash.handleInput("\x1b[D"); // left to Overview
327
+ dash.handleInput("r"); // 1d → 7d
328
+ dash.handleInput("\x1b[C"); // → Languages
329
+ dash.handleInput("\x1b[C"); // → Models
330
+ lines = dash.render(80);
331
+ text = lines.join("\n");
332
+ expect(text).toContain("Deeps");
333
+ });
334
+
335
+ it("switches tabs with left/right arrows", () => {
336
+ const summaries = ALL_SUMMARIES;
337
+ const dash = new Dashboard(
338
+ summaries,
339
+ makeTheme(),
340
+ mockTui,
341
+ null,
342
+ makeRangeSelector(makeTheme()),
343
+ );
344
+ dash.handleInput("\x1b[C"); // right
345
+ const lines = dash.render(80);
346
+ expect(lines.join("\n")).toContain("Languages");
347
+ });
348
+
349
+ // ---- Projects tab ----
350
+
351
+ it("renders Project tab", () => {
352
+ const summary = {
353
+ ...makeSummary(),
354
+ projects: [
355
+ { project: "pi-atlas", cost: 15.5, sessions: 42 },
356
+ { project: "dotfiles", cost: 8.2, sessions: 20 },
357
+ ],
358
+ tools: [
359
+ { name: "bash", count: 150 },
360
+ { name: "read", count: 120 },
361
+ ],
362
+ };
363
+ const summaries = mapAllSummaries(allRanges, summary);
364
+ const dash = new Dashboard(
365
+ summaries,
366
+ makeTheme(),
367
+ mockTui,
368
+ null,
369
+ makeRangeSelector(makeTheme()),
370
+ );
371
+
372
+ // Navigate to Projects+Tools tab (index 3)
373
+ dash.handleInput("\x1b[C"); // → Languages
374
+ dash.handleInput("\x1b[C"); // → Models
375
+ dash.handleInput("\x1b[C"); // → Projects + Tools
376
+ const lines = dash.render(80);
377
+ const text = lines.join("\n");
378
+
379
+ expect(text).toContain("Projects");
380
+ });
381
+
382
+ it("Projects tab shows empty states when no data", () => {
383
+ const summary = { ...makeSummary(), projects: [], tools: [] };
384
+ const summaries = mapAllSummaries(allRanges, summary);
385
+ const dash = new Dashboard(
386
+ summaries,
387
+ makeTheme(),
388
+ mockTui,
389
+ null,
390
+ makeRangeSelector(makeTheme()),
391
+ );
392
+
393
+ dash.handleInput("\x1b[C"); // → Languages
394
+ dash.handleInput("\x1b[C"); // → Models
395
+ dash.handleInput("\x1b[C"); // → Projects + Tools
396
+ const lines = dash.render(80);
397
+ const text = lines.join("\n");
398
+
399
+ expect(text).toContain("No projects data");
400
+ });
401
+
402
+ it("Projects tab updates when time range changes", () => {
403
+ const summary1d = {
404
+ ...makeSummary(),
405
+ projects: [{ project: "pi-atlas", cost: 1.0, sessions: 5 }],
406
+ };
407
+ const summary7d = {
408
+ ...makeSummary(),
409
+ projects: [
410
+ { project: "pi-atlas", cost: 15.5, sessions: 42 },
411
+ { project: "dotfiles", cost: 8.2, sessions: 20 },
412
+ ],
413
+ };
414
+ const summaries: Map<TimeRange, StatsSummary> = new Map([
415
+ ["1d", summary1d],
416
+ ["7d", summary7d],
417
+ ["30d", summary7d],
418
+ ["All", summary7d],
419
+ ]);
420
+ const dash = new Dashboard(
421
+ summaries,
422
+ makeTheme(),
423
+ mockTui,
424
+ null,
425
+ makeRangeSelector(makeTheme()),
426
+ );
427
+
428
+ // Default range is All (= summary7d). r key cycles: All→1d
429
+ dash.handleInput("r"); // All → 1d
430
+ dash.handleInput("\x1b[C"); // → Languages
431
+ dash.handleInput("\x1b[C"); // → Models
432
+ dash.handleInput("\x1b[C"); // → Projects
433
+ let lines = dash.render(80);
434
+ let text = lines.join("\n");
435
+ // 1d range: only pi-atlas
436
+ expect(text).toContain("pi-atlas");
437
+ expect(text).not.toContain("dotfiles");
438
+
439
+ // Switch back to Overview, r to 7d, then back to Projects+Tools
440
+ dash.handleInput("\x1b[D"); // ← Models
441
+ dash.handleInput("\x1b[D"); // ← Languages
442
+ dash.handleInput("\x1b[D"); // ← Overview
443
+ dash.handleInput("r"); // 1d → 7d
444
+ dash.handleInput("\x1b[C"); // → Languages
445
+ dash.handleInput("\x1b[C"); // → Models
446
+ dash.handleInput("\x1b[C"); // → Projects + Tools
447
+ lines = dash.render(80);
448
+ text = lines.join("\n");
449
+ expect(text).toContain("dotfiles");
450
+ expect(text).toContain("pi-atlas");
451
+ });
452
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { makeTheme } from "../../__tests__/components.fixtures";
3
+ import { KpiCards } from "../KpiCards";
4
+
5
+ describe("KpiCards", () => {
6
+ const kpis = {
7
+ totalCost: 12.34,
8
+ sessionCount: 42,
9
+ totalMessages: 1500,
10
+ totalTokens: 250000,
11
+ daysActive: 7,
12
+ avgCostPerDay: 1.76,
13
+ };
14
+
15
+ it("renders 6 KPIs in a grid", () => {
16
+ const cards = new KpiCards(kpis, makeTheme());
17
+ const lines = cards.render(80);
18
+ // Should have multiple lines (2 rows of 3 cards each)
19
+ expect(lines.length).toBeGreaterThanOrEqual(2);
20
+ // Should mention key metrics
21
+ const text = lines.join("\n");
22
+ expect(text).toContain("12.34");
23
+ expect(text).toContain("42");
24
+ expect(text).toContain("1.5k");
25
+ expect(text).toContain("250.0k");
26
+ expect(text).toContain("7");
27
+ expect(text).toContain("1.76");
28
+ });
29
+
30
+ it("renders label for each card", () => {
31
+ const cards = new KpiCards(kpis, makeTheme());
32
+ const lines = cards.render(80);
33
+ const text = lines.join("\n");
34
+ expect(text).toContain("Total");
35
+ expect(text).toContain("Sessions");
36
+ expect(text).toContain("Messages");
37
+ expect(text).toContain("Tokens");
38
+ expect(text).toContain("Active");
39
+ expect(text).toContain("Avg/Day");
40
+ });
41
+
42
+ it("renders within width", () => {
43
+ const cards = new KpiCards(kpis, makeTheme());
44
+ const lines = cards.render(50);
45
+ for (const line of lines) {
46
+ expect(line.length).toBeLessThanOrEqual(50);
47
+ }
48
+ });
49
+
50
+ it("formats large token numbers", () => {
51
+ const cards = new KpiCards({ ...kpis, totalTokens: 1500000 }, makeTheme());
52
+ const lines = cards.render(80);
53
+ expect(lines.join("\n")).toContain("1.50M");
54
+ });
55
+
56
+ it("formats large costs with compact notation", () => {
57
+ const cards = new KpiCards({ ...kpis, totalCost: 5432.1 }, makeTheme());
58
+ const lines = cards.render(80);
59
+ expect(lines.join("\n")).toContain("$5.4k");
60
+ });
61
+
62
+ it("formats very large costs with M notation", () => {
63
+ const cards = new KpiCards({ ...kpis, totalCost: 2_500_000 }, makeTheme());
64
+ const lines = cards.render(80);
65
+ expect(lines.join("\n")).toContain("$2.5M");
66
+ });
67
+
68
+ it("keeps exact notation for small costs", () => {
69
+ const cards = new KpiCards(kpis, makeTheme());
70
+ const lines = cards.render(80);
71
+ expect(lines.join("\n")).toContain("$12.34");
72
+ });
73
+
74
+ it("invalidates cache", () => {
75
+ const cards = new KpiCards(kpis, makeTheme());
76
+ cards.render(80);
77
+ cards.invalidate();
78
+ const lines = cards.render(60);
79
+ for (const line of lines) {
80
+ expect(line.length).toBeLessThanOrEqual(60);
81
+ }
82
+ });
83
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { LoadingView } from "../LoadingView";
3
+
4
+ describe("LoadingView", () => {
5
+ it("renders with 0% progress", () => {
6
+ const lv = new LoadingView();
7
+ const lines = lv.render(80);
8
+ expect(lines.join("\n")).toContain("Parsing session logs...");
9
+ expect(lines.join("\n")).toContain("0%");
10
+ });
11
+
12
+ it("updates progress", () => {
13
+ const lv = new LoadingView();
14
+ lv.setProgress(50);
15
+ const lines = lv.render(80);
16
+ expect(lines.join("\n")).toContain("50%");
17
+ });
18
+
19
+ it("renders progress bar with block chars", () => {
20
+ const lv = new LoadingView();
21
+ lv.setProgress(75);
22
+ const lines = lv.render(80);
23
+ expect(lines.join("\n")).toContain("█");
24
+ expect(lines.join("\n")).toContain("75%");
25
+ });
26
+ });
@@ -0,0 +1,75 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { makeMockTUI } from "../../__tests__/components.fixtures";
3
+ import { MarqueeText } from "../MarqueeText";
4
+
5
+ describe("MarqueeText", () => {
6
+ let tui: ReturnType<typeof makeMockTUI>;
7
+
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ tui = makeMockTUI();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ it("renders full text when it fits within width", () => {
18
+ const mt = new MarqueeText("Hello", tui);
19
+ const lines = mt.render(10);
20
+ expect(lines).toEqual(["Hello"]);
21
+ });
22
+
23
+ it("renders truncated window when text overflows", () => {
24
+ const mt = new MarqueeText("Hello World!", tui);
25
+ // tick=0, offset=0 → first 5 chars
26
+ const lines = mt.render(5);
27
+ expect(lines[0]).toBe("Hello");
28
+ });
29
+
30
+ it("advances tick via timer and shows next window", () => {
31
+ const mt = new MarqueeText("Hello World!", tui);
32
+ mt.render(5); // timer starts
33
+
34
+ // tick=0 → "Hello"
35
+ expect(mt.render(5)[0]).toBe("Hello");
36
+
37
+ // Advance 50ms = timer hasn't fired yet → still offset=0
38
+ vi.advanceTimersByTime(50);
39
+ expect(mt.render(5)[0]).toBe("Hello");
40
+
41
+ // Advance 100ms more = 1 timer tick at 150ms → offset=1 → "ello "
42
+ vi.advanceTimersByTime(100);
43
+ expect(mt.render(5)[0]).toBe("ello ");
44
+ });
45
+
46
+ it("wraps around when reaching end of content", () => {
47
+ const mt = new MarqueeText("ABCDEF", tui);
48
+ mt.render(3);
49
+
50
+ // Advance 600ms = 4 timer ticks → offset=4%11=4 → "EF "
51
+ // (5-space gap after text shows first gap space at position 6)
52
+ vi.advanceTimersByTime(600);
53
+ expect(mt.render(3)[0]).toBe("EF ");
54
+ });
55
+
56
+ it("resets marquee to start", () => {
57
+ const mt = new MarqueeText("Hello World!", tui);
58
+ mt.render(5);
59
+ vi.advanceTimersByTime(150); // 1 tick, offset=1
60
+ expect(mt.render(5)[0]).toBe("ello ");
61
+
62
+ mt.reset();
63
+ expect(mt.render(5)[0]).toBe("Hello");
64
+ });
65
+
66
+ it("handles empty string", () => {
67
+ const mt = new MarqueeText("", tui);
68
+ expect(mt.render(5)).toEqual([""]);
69
+ });
70
+
71
+ it("handles exact fit (text.length === width)", () => {
72
+ const mt = new MarqueeText("Hello", tui);
73
+ expect(mt.render(5)).toEqual(["Hello"]);
74
+ });
75
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { makeTheme } from "../../__tests__/components.fixtures";
3
+ import { type RangeOption, RangeSelector } from "../RangeSelector";
4
+
5
+ describe("RangeSelector", () => {
6
+ const ranges: RangeOption[] = [
7
+ { label: "Today", value: "1d" },
8
+ { label: "Last 7 days", value: "7d" },
9
+ { label: "Last 30 days", value: "30d" },
10
+ { label: "All time", value: "All" },
11
+ ];
12
+
13
+ it("renders selected range label", () => {
14
+ for (const [index, { label }] of ranges.entries()) {
15
+ const rs = new RangeSelector(makeTheme(), ranges, index);
16
+ const lines = rs.render(80);
17
+ expect(lines).toHaveLength(1);
18
+ expect(lines[0]).toContain(label);
19
+ }
20
+ });
21
+
22
+ it("returns selectedValue from getter", () => {
23
+ for (const [index, { value }] of ranges.entries()) {
24
+ const rs = new RangeSelector(makeTheme(), ranges, index);
25
+ expect(rs.selectedValue).toBe(value);
26
+ }
27
+ });
28
+
29
+ it("renders within width", () => {
30
+ const rs = new RangeSelector(makeTheme(), ranges, 0);
31
+ const lines = rs.render(40);
32
+ expect(lines[0]!.length).toBeLessThanOrEqual(40);
33
+ });
34
+ });