@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.
- package/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { makeMockTUI, testPalette, makeTheme } from "../../__tests__/components.fixtures";
|
|
3
|
+
import { Languages } from "../Languages";
|
|
4
|
+
import { type LangStat } from "../../types";
|
|
5
|
+
|
|
6
|
+
describe("Languages", () => {
|
|
7
|
+
const mockTui = makeMockTUI();
|
|
8
|
+
|
|
9
|
+
const languages: LangStat[] = [
|
|
10
|
+
{ language: "TypeScript", lines: 1500, edits: 45 },
|
|
11
|
+
{ language: "Python", lines: 800, edits: 20 },
|
|
12
|
+
{ language: "JSON", lines: 300, edits: 5 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
it("renders data rows with formatted values", () => {
|
|
16
|
+
const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
|
|
17
|
+
const lines = tab.render(80);
|
|
18
|
+
const text = lines.join("\n");
|
|
19
|
+
|
|
20
|
+
expect(lines[0]).toContain("Languages");
|
|
21
|
+
expect(lines[0]).toContain(languages.length.toString());
|
|
22
|
+
|
|
23
|
+
// Headers
|
|
24
|
+
expect(text).toContain("Name");
|
|
25
|
+
expect(text).toContain("Edits");
|
|
26
|
+
expect(text).toContain("Lines");
|
|
27
|
+
expect(text).toContain("Share %");
|
|
28
|
+
|
|
29
|
+
// Language names
|
|
30
|
+
expect(text).toContain("TypeScript");
|
|
31
|
+
expect(text).toContain("Python");
|
|
32
|
+
expect(text).toContain("JSON");
|
|
33
|
+
|
|
34
|
+
// Edits
|
|
35
|
+
expect(text).toContain("45");
|
|
36
|
+
expect(text).toContain("20");
|
|
37
|
+
expect(text).toContain("5");
|
|
38
|
+
|
|
39
|
+
// Lines formatted (no suffix)
|
|
40
|
+
expect(text).toContain("1.5k");
|
|
41
|
+
expect(text).toContain("800");
|
|
42
|
+
expect(text).toContain("300");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows empty state when languages is empty", () => {
|
|
46
|
+
const tab = new Languages([], makeTheme(), testPalette(), mockTui, 10);
|
|
47
|
+
|
|
48
|
+
const lines = tab.render(80);
|
|
49
|
+
const text = lines.join("\n");
|
|
50
|
+
|
|
51
|
+
expect(lines[0]).toContain("Languages");
|
|
52
|
+
// don't display 0 counter
|
|
53
|
+
expect(lines[0]).not.toContain("0");
|
|
54
|
+
|
|
55
|
+
expect(text).toContain("No language data for this time range");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders within width", () => {
|
|
59
|
+
const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
|
|
60
|
+
const lines = tab.render(50);
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
63
|
+
expect(visLen).toBeLessThanOrEqual(50);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("fill column adapts to width", () => {
|
|
68
|
+
const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
|
|
69
|
+
|
|
70
|
+
// At width 30, columns shrink — no line exceeds render width
|
|
71
|
+
const narrowLines = tab.render(30);
|
|
72
|
+
for (const line of narrowLines) {
|
|
73
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
74
|
+
expect(visLen).toBeLessThanOrEqual(30);
|
|
75
|
+
}
|
|
76
|
+
// Language column shrinks to ~9-10 chars — "TypeScript" (10 chars) truncates
|
|
77
|
+
const narrowText = narrowLines.join("\n");
|
|
78
|
+
expect(narrowText).not.toContain("TypeScript");
|
|
79
|
+
|
|
80
|
+
// At width 80, fill column is spacious — full names visible
|
|
81
|
+
const wideLines = tab.render(80);
|
|
82
|
+
const wideText = wideLines.join("\n");
|
|
83
|
+
expect(wideText).toContain("TypeScript");
|
|
84
|
+
expect(wideText).toContain("1.5k");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows sort indicator on Lines column", () => {
|
|
88
|
+
const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
|
|
89
|
+
const lines = tab.render(80);
|
|
90
|
+
const text = lines.join("\n");
|
|
91
|
+
// Lines is column 2, sort direction "desc" → ▼
|
|
92
|
+
expect(text).toContain("Lines ▼");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("invalidates render cache", () => {
|
|
96
|
+
const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
|
|
97
|
+
tab.render(80);
|
|
98
|
+
tab.invalidate();
|
|
99
|
+
const lines = tab.render(60);
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
102
|
+
expect(visLen).toBeLessThanOrEqual(60);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("supports re-render after invalidation (lifecycle path)", () => {
|
|
107
|
+
const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
|
|
108
|
+
|
|
109
|
+
const lines1 = tab.render(80);
|
|
110
|
+
expect(lines1.join("\n")).toContain("TypeScript");
|
|
111
|
+
|
|
112
|
+
tab.invalidate();
|
|
113
|
+
|
|
114
|
+
const lines2 = tab.render(80);
|
|
115
|
+
const text = lines2.join("\n");
|
|
116
|
+
expect(text).toContain("TypeScript");
|
|
117
|
+
expect(text).toContain("Lines ▼");
|
|
118
|
+
for (const line of lines2) {
|
|
119
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
120
|
+
expect(visLen).toBeLessThanOrEqual(80);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("marquee lifecycle", () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.useFakeTimers();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
vi.useRealTimers();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("clears marquee timers on invalidate", () => {
|
|
134
|
+
const longNames: LangStat[] = [
|
|
135
|
+
{
|
|
136
|
+
language: "TypeScript with a very long name that should overflow",
|
|
137
|
+
lines: 1500,
|
|
138
|
+
edits: 45,
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const tab = new Languages(longNames, makeTheme(), testPalette(), mockTui, 10);
|
|
142
|
+
|
|
143
|
+
// Render at narrow width where language name overflows fill column
|
|
144
|
+
// → MarqueeCell starts timer on focused row
|
|
145
|
+
tab.render(30);
|
|
146
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
147
|
+
|
|
148
|
+
// Invalidate propagates: Languages → SortedTable → cells → MarqueeCell → clearInterval
|
|
149
|
+
tab.invalidate();
|
|
150
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
151
|
+
|
|
152
|
+
// Re-render at wider width so fill column has room for the name
|
|
153
|
+
const lines = tab.render(80);
|
|
154
|
+
const text = lines.join("\n");
|
|
155
|
+
expect(text).toContain("TypeScript");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { makeMockTUI, makeTheme, testPalette } from "../../__tests__/components.fixtures";
|
|
3
|
+
import { type ModelStat } from "../../types";
|
|
4
|
+
import { Models } from "../Models";
|
|
5
|
+
|
|
6
|
+
describe("Models", () => {
|
|
7
|
+
const mockTui = makeMockTUI();
|
|
8
|
+
|
|
9
|
+
const models: ModelStat[] = [
|
|
10
|
+
{ model: "claude-sonnet-4-20250514", provider: "anthropic", cost: 150.5, calls: 42 },
|
|
11
|
+
{ model: "gemini-2.5-pro", provider: "Google", cost: 85.25, calls: 28 },
|
|
12
|
+
{ model: "gpt-4o", provider: "OpenAI", cost: 0.75, calls: 5 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
it("renders data rows with formatted model names and costs", () => {
|
|
16
|
+
const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
|
|
17
|
+
const lines = tab.render(80);
|
|
18
|
+
|
|
19
|
+
const text = lines.join("\n");
|
|
20
|
+
|
|
21
|
+
expect(lines[0]).toContain("Models");
|
|
22
|
+
expect(lines[0]).toContain(models.length.toString());
|
|
23
|
+
|
|
24
|
+
// formatModelName strips date suffix and capitalizes
|
|
25
|
+
// Model column is 6-char fill at width 80 — truncated name visible
|
|
26
|
+
expect(text).toContain("Claude");
|
|
27
|
+
expect(text).toContain("anthropic");
|
|
28
|
+
// Row 1 (Gemini) not focused — truncateToWidth shows "Gemin…"
|
|
29
|
+
expect(text).toContain("Gemin");
|
|
30
|
+
expect(text).toContain("Google");
|
|
31
|
+
expect(text).toContain("Gpt 4o");
|
|
32
|
+
expect(text).toContain("OpenAI");
|
|
33
|
+
// formatCost
|
|
34
|
+
expect(text).toContain("$150.5");
|
|
35
|
+
expect(text).toContain("$0.75");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("shows empty state when models is empty", () => {
|
|
39
|
+
const tab = new Models([], makeTheme(), testPalette(), mockTui, 10);
|
|
40
|
+
const lines = tab.render(80);
|
|
41
|
+
const text = lines.join("\n");
|
|
42
|
+
|
|
43
|
+
expect(lines[0]).toContain("Models");
|
|
44
|
+
// don't display 0 counter
|
|
45
|
+
expect(lines[0]).not.toContain("0");
|
|
46
|
+
expect(text).toContain("No model data for this time range");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders within width", () => {
|
|
50
|
+
const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
|
|
51
|
+
const lines = tab.render(50);
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
54
|
+
expect(visLen).toBeLessThanOrEqual(50);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("fill column adapts to width", () => {
|
|
59
|
+
const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
|
|
60
|
+
|
|
61
|
+
// At width 18, fill column is small — full model name truncated
|
|
62
|
+
const narrowLines = tab.render(18);
|
|
63
|
+
const narrowText = narrowLines.join("\n");
|
|
64
|
+
expect(narrowText).not.toContain("Claude Sonnet");
|
|
65
|
+
|
|
66
|
+
// At width 80, fill column is spacious — full name visible
|
|
67
|
+
const wideLines = tab.render(80);
|
|
68
|
+
const wideText = wideLines.join("\n");
|
|
69
|
+
expect(wideText).toContain("Claude Sonnet");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows sort indicator on Cost column", () => {
|
|
73
|
+
const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
|
|
74
|
+
const lines = tab.render(80);
|
|
75
|
+
const text = lines.join("\n");
|
|
76
|
+
// Cost column has sort: { column: 3, direction: "desc" } → ▼
|
|
77
|
+
expect(text).toContain("Cost ▼");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("invalidates render cache", () => {
|
|
81
|
+
const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
|
|
82
|
+
tab.render(80); // cache at width 80
|
|
83
|
+
tab.invalidate();
|
|
84
|
+
const lines = tab.render(60); // should re-render at new width
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
87
|
+
expect(visLen).toBeLessThanOrEqual(60);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("supports re-render after invalidation (lifecycle path)", () => {
|
|
92
|
+
const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
|
|
93
|
+
|
|
94
|
+
// First render cycle — creates table
|
|
95
|
+
const lines1 = tab.render(80);
|
|
96
|
+
expect(lines1.join("\n")).toContain("Claude");
|
|
97
|
+
|
|
98
|
+
// Invalidate — simulates Dashboard.buildTabs() lifecycle cleanup
|
|
99
|
+
tab.invalidate();
|
|
100
|
+
|
|
101
|
+
// Second render cycle — creates new table from clean state
|
|
102
|
+
const lines2 = tab.render(80);
|
|
103
|
+
const text = lines2.join("\n");
|
|
104
|
+
expect(text).toContain("Claude");
|
|
105
|
+
expect(text).toContain("Cost ▼");
|
|
106
|
+
for (const line of lines2) {
|
|
107
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
108
|
+
expect(visLen).toBeLessThanOrEqual(80);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("marquee lifecycle", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.useRealTimers();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("clears marquee timers on invalidate (Models→SortedTable→cells chain)", () => {
|
|
122
|
+
const longModels: ModelStat[] = [
|
|
123
|
+
{ model: "claude-sonnet-4-20250514", provider: "anthropic", cost: 150.5, calls: 42 },
|
|
124
|
+
];
|
|
125
|
+
const tab = new Models(longModels, makeTheme(), testPalette(), mockTui, 10);
|
|
126
|
+
|
|
127
|
+
// Render at narrow width where "Claude Sonnet 4 20250514" overflows fill column
|
|
128
|
+
// → MarqueeCell starts timer on focused row
|
|
129
|
+
tab.render(30);
|
|
130
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
131
|
+
|
|
132
|
+
// Invalidate propagates: Models → SortedTable → cells → MarqueeCell → clearInterval
|
|
133
|
+
tab.invalidate();
|
|
134
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
135
|
+
|
|
136
|
+
// Re-render still produces clean output (fill col ~2 chars, provider ~7 chars at width 30)
|
|
137
|
+
const lines = tab.render(30);
|
|
138
|
+
const text = lines.join("\n");
|
|
139
|
+
expect(text).toContain("C"); // first char of "Claude Sonnet 4"
|
|
140
|
+
expect(text).toContain("anthr"); // provider truncated to ~5 chars at width 30
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { makeTheme } from "../../__tests__/components.fixtures";
|
|
3
|
+
import { Overview } from "../Overview";
|
|
4
|
+
import { type StatsSummary } from "../../types";
|
|
5
|
+
import { makeSummary } from "../../__tests__/compute.fixtures";
|
|
6
|
+
|
|
7
|
+
describe("Overview", () => {
|
|
8
|
+
const mockSummary: StatsSummary = {
|
|
9
|
+
...makeSummary(),
|
|
10
|
+
totalCost: 12.34,
|
|
11
|
+
sessionCount: 42,
|
|
12
|
+
totalMessages: 1500,
|
|
13
|
+
totalTokens: 250000,
|
|
14
|
+
daysActive: 7,
|
|
15
|
+
avgCostPerDay: 1.76,
|
|
16
|
+
dailySpend: [
|
|
17
|
+
{ date: "2026-06-01", cost: 1.0 },
|
|
18
|
+
{ date: "2026-06-02", cost: 0.0 },
|
|
19
|
+
{ date: "2026-06-03", cost: 2.5 },
|
|
20
|
+
{ date: "2026-06-04", cost: 0.5 },
|
|
21
|
+
{ date: "2026-06-05", cost: 0.0 },
|
|
22
|
+
{ date: "2026-06-06", cost: 1.2 },
|
|
23
|
+
{ date: "2026-06-07", cost: 3.0 },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
it("renders KpiCards followed by spacer followed by BarChart", () => {
|
|
28
|
+
const overview = new Overview(mockSummary, "7d", makeTheme(), 15);
|
|
29
|
+
const lines = overview.render(80);
|
|
30
|
+
|
|
31
|
+
// Should have KPI + spacer + chart
|
|
32
|
+
expect(lines.length).toBeGreaterThanOrEqual(3);
|
|
33
|
+
|
|
34
|
+
const text = lines.join("\n");
|
|
35
|
+
// KPI metrics present
|
|
36
|
+
expect(text).toContain("12.34");
|
|
37
|
+
expect(text).toContain("42");
|
|
38
|
+
// Chart bars present
|
|
39
|
+
expect(text).toContain("█");
|
|
40
|
+
|
|
41
|
+
// Verify order: KPI lines before spacer before chart bars
|
|
42
|
+
// Find first occurrence of spacer (empty line) after KPI content
|
|
43
|
+
const kpiCostIdx = lines.findIndex(
|
|
44
|
+
(l) => l.includes("12.34") || l.includes("$12.34") || l.includes("Total"),
|
|
45
|
+
);
|
|
46
|
+
const spacerIdx = lines.findIndex((l) => l.trim() === "");
|
|
47
|
+
expect(spacerIdx).toBeGreaterThan(kpiCostIdx);
|
|
48
|
+
|
|
49
|
+
// Chart content (█ or label) should appear after the spacer
|
|
50
|
+
const chartIdx = lines.findIndex(
|
|
51
|
+
(l, i) => i > spacerIdx && (l.includes("█") || l.includes("No data") || l.includes("Mon")),
|
|
52
|
+
);
|
|
53
|
+
expect(chartIdx).toBeGreaterThan(spacerIdx);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("adapts bar chart height to available space after KpiCards", () => {
|
|
57
|
+
// Use a small maxHeight to verify chart still renders
|
|
58
|
+
const overview = new Overview(mockSummary, "7d", makeTheme(), 10);
|
|
59
|
+
const lines = overview.render(80);
|
|
60
|
+
|
|
61
|
+
// Chart should still render (not zero lines)
|
|
62
|
+
const text = lines.join("\n");
|
|
63
|
+
expect(text).toContain("█");
|
|
64
|
+
// Should have spacer before chart
|
|
65
|
+
expect(lines.some((l) => l.trim() === "")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shows 'No data' when daily spend is empty", () => {
|
|
69
|
+
const summary: StatsSummary = {
|
|
70
|
+
...mockSummary,
|
|
71
|
+
dailySpend: [],
|
|
72
|
+
};
|
|
73
|
+
const overview = new Overview(summary, "7d", makeTheme(), 15);
|
|
74
|
+
const lines = overview.render(80);
|
|
75
|
+
|
|
76
|
+
const text = lines.join("\n");
|
|
77
|
+
expect(text).toContain("No data");
|
|
78
|
+
// KPI cards still render
|
|
79
|
+
expect(text).toContain("Total");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("invalidate clears cache and re-renders at new width", () => {
|
|
83
|
+
const overview = new Overview(mockSummary, "7d", makeTheme(), 15);
|
|
84
|
+
overview.render(80);
|
|
85
|
+
overview.invalidate();
|
|
86
|
+
|
|
87
|
+
const lines = overview.render(60);
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
expect(line.length).toBeLessThanOrEqual(60);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { makeMockTUI, makeTheme } from "../../__tests__/components.fixtures";
|
|
3
|
+
import { type ProjectStat } from "../../types";
|
|
4
|
+
import { Projects } from "../Projects";
|
|
5
|
+
|
|
6
|
+
describe("Projects", () => {
|
|
7
|
+
const mockTui = makeMockTUI();
|
|
8
|
+
|
|
9
|
+
const projects: ProjectStat[] = [
|
|
10
|
+
{ project: "pi-atlas", cost: 15.5, sessions: 42 },
|
|
11
|
+
{ project: "dotfiles", cost: 8.2, sessions: 20 },
|
|
12
|
+
{ project: "sandbox", cost: 1.25, sessions: 5 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
it("renders data rows with formatted costs", () => {
|
|
16
|
+
const tab = new Projects(projects, makeTheme(), mockTui, 10);
|
|
17
|
+
const lines = tab.render(80);
|
|
18
|
+
const text = lines.join("\n");
|
|
19
|
+
|
|
20
|
+
expect(lines[0]).toContain("Projects");
|
|
21
|
+
expect(lines[0]).toContain(projects.length.toString());
|
|
22
|
+
|
|
23
|
+
// Headers
|
|
24
|
+
expect(text).toContain("Name");
|
|
25
|
+
expect(text).toContain("Sessions");
|
|
26
|
+
expect(text).toContain("Cost");
|
|
27
|
+
expect(text).toContain("Share %");
|
|
28
|
+
|
|
29
|
+
// Project names
|
|
30
|
+
expect(text).toContain("pi-atlas");
|
|
31
|
+
expect(text).toContain("dotfiles");
|
|
32
|
+
expect(text).toContain("sandbox");
|
|
33
|
+
|
|
34
|
+
// Sessions
|
|
35
|
+
expect(text).toContain("42");
|
|
36
|
+
expect(text).toContain("20");
|
|
37
|
+
expect(text).toContain("5");
|
|
38
|
+
|
|
39
|
+
// Costs formatted
|
|
40
|
+
expect(text).toContain("$15.50");
|
|
41
|
+
expect(text).toContain("$8.20");
|
|
42
|
+
expect(text).toContain("$1.25");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows empty state when projects is empty", () => {
|
|
46
|
+
const tab = new Projects([], makeTheme(), mockTui, 10);
|
|
47
|
+
const lines = tab.render(80);
|
|
48
|
+
const text = lines.join("\n");
|
|
49
|
+
|
|
50
|
+
expect(lines[0]).toContain("Projects");
|
|
51
|
+
// don't display 0 counter
|
|
52
|
+
expect(lines[0]).not.toContain("0");
|
|
53
|
+
expect(text).toContain("No projects data for this time range");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders within width", () => {
|
|
57
|
+
const tab = new Projects(projects, makeTheme(), mockTui, 10);
|
|
58
|
+
const lines = tab.render(50);
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
61
|
+
expect(visLen).toBeLessThanOrEqual(50);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fill column adapts to width", () => {
|
|
66
|
+
const tab = new Projects(projects, makeTheme(), mockTui, 10);
|
|
67
|
+
|
|
68
|
+
// At width 30, columns shrink — no line exceeds render width
|
|
69
|
+
const narrowLines = tab.render(30);
|
|
70
|
+
for (const line of narrowLines) {
|
|
71
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
72
|
+
expect(visLen).toBeLessThanOrEqual(30);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const wideLines = tab.render(80);
|
|
76
|
+
const wideText = wideLines.join("\n");
|
|
77
|
+
expect(wideText).toContain("pi-atlas");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows sort indicator on Cost column", () => {
|
|
81
|
+
const tab = new Projects(projects, makeTheme(), mockTui, 10);
|
|
82
|
+
const lines = tab.render(80);
|
|
83
|
+
const text = lines.join("\n");
|
|
84
|
+
expect(text).toContain("Cost ▼");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("invalidates render cache", () => {
|
|
88
|
+
const tab = new Projects(projects, makeTheme(), mockTui, 10);
|
|
89
|
+
tab.render(80);
|
|
90
|
+
tab.invalidate();
|
|
91
|
+
const lines = tab.render(60);
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
94
|
+
expect(visLen).toBeLessThanOrEqual(60);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("supports re-render after invalidation (lifecycle path)", () => {
|
|
99
|
+
const tab = new Projects(projects, makeTheme(), mockTui, 10);
|
|
100
|
+
|
|
101
|
+
const lines1 = tab.render(80);
|
|
102
|
+
expect(lines1.join("\n")).toContain("pi-atlas");
|
|
103
|
+
|
|
104
|
+
tab.invalidate();
|
|
105
|
+
|
|
106
|
+
const lines2 = tab.render(80);
|
|
107
|
+
const text = lines2.join("\n");
|
|
108
|
+
expect(text).toContain("pi-atlas");
|
|
109
|
+
expect(text).toContain("Cost ▼");
|
|
110
|
+
for (const line of lines2) {
|
|
111
|
+
const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
112
|
+
expect(visLen).toBeLessThanOrEqual(80);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("marquee lifecycle", () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
vi.useFakeTimers();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
vi.useRealTimers();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("clears marquee timers on invalidate", () => {
|
|
126
|
+
const longProjects: ProjectStat[] = [
|
|
127
|
+
{ project: "a-very-long-project", cost: 15.5, sessions: 42 },
|
|
128
|
+
];
|
|
129
|
+
const tab = new Projects(longProjects, makeTheme(), mockTui, 10);
|
|
130
|
+
|
|
131
|
+
tab.render(30);
|
|
132
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
133
|
+
|
|
134
|
+
tab.invalidate();
|
|
135
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
136
|
+
|
|
137
|
+
const lines = tab.render(80);
|
|
138
|
+
const text = lines.join("\n");
|
|
139
|
+
expect(text).toContain("a-very-long-project");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|