cc-context-stats 1.5.1 → 1.6.0
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/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +33 -0
- package/.github/workflows/ci.yml +39 -2
- package/CHANGELOG.md +16 -8
- package/CLAUDE.md +54 -0
- package/CODE_OF_CONDUCT.md +59 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/RELEASE_NOTES.md +16 -7
- package/SECURITY.md +44 -0
- package/TODOS.md +72 -0
- package/docs/ARCHITECTURE.md +101 -0
- package/docs/CSV_FORMAT.md +40 -0
- package/docs/DEPLOYMENT.md +60 -0
- package/docs/DEVELOPMENT.md +125 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/statusline-full.sh +1 -1
- package/scripts/statusline.js +61 -8
- package/scripts/statusline.py +9 -8
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +20 -1
- package/src/claude_statusline/core/config.py +5 -4
- package/src/claude_statusline/core/state.py +64 -7
- package/tests/bash/test_parity.bats +315 -0
- package/tests/fixtures/json/comma_in_path.json +31 -0
- package/tests/node/rotation.test.js +89 -0
- package/tests/python/test_data_pipeline.py +446 -0
- package/tests/python/test_state_rotation_validation.py +232 -0
- package/.claude/commands/context-stats.md +0 -17
- package/.claude/settings.local.json +0 -120
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Tests for core data pipeline: CSV state parsing, statistics, and zone thresholds."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from claude_statusline.core.colors import ColorManager
|
|
6
|
+
from claude_statusline.core.state import StateEntry
|
|
7
|
+
from claude_statusline.graphs.renderer import GraphDimensions, GraphRenderer
|
|
8
|
+
from claude_statusline.graphs.statistics import (
|
|
9
|
+
Stats,
|
|
10
|
+
calculate_deltas,
|
|
11
|
+
calculate_stats,
|
|
12
|
+
detect_spike,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_entry(**kwargs) -> StateEntry:
|
|
17
|
+
"""Factory for StateEntry with sensible defaults."""
|
|
18
|
+
defaults = dict(
|
|
19
|
+
timestamp=1710288000,
|
|
20
|
+
total_input_tokens=75000,
|
|
21
|
+
total_output_tokens=8500,
|
|
22
|
+
current_input_tokens=50000,
|
|
23
|
+
current_output_tokens=5000,
|
|
24
|
+
cache_creation=10000,
|
|
25
|
+
cache_read=20000,
|
|
26
|
+
cost_usd=0.05234,
|
|
27
|
+
lines_added=250,
|
|
28
|
+
lines_removed=45,
|
|
29
|
+
session_id="abc-123-def",
|
|
30
|
+
model_id="claude-opus-4-5",
|
|
31
|
+
workspace_project_dir="/home/user/my-project",
|
|
32
|
+
context_window_size=200000,
|
|
33
|
+
)
|
|
34
|
+
defaults.update(kwargs)
|
|
35
|
+
return StateEntry(**defaults)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _render_summary_output(entries, deltas=None):
|
|
39
|
+
"""Render summary and return buffered output as string."""
|
|
40
|
+
renderer = GraphRenderer(
|
|
41
|
+
colors=ColorManager(enabled=False),
|
|
42
|
+
dimensions=GraphDimensions(
|
|
43
|
+
term_width=120,
|
|
44
|
+
term_height=40,
|
|
45
|
+
graph_width=105,
|
|
46
|
+
graph_height=13,
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
renderer.begin_buffering()
|
|
50
|
+
renderer.render_summary(entries, deltas if deltas is not None else [])
|
|
51
|
+
return renderer.get_buffer()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Class 1: CSV Round-Trip
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestStateEntryRoundTrip:
|
|
60
|
+
"""Tests for StateEntry.from_csv_line and to_csv_line."""
|
|
61
|
+
|
|
62
|
+
def test_full_14_field_round_trip(self):
|
|
63
|
+
original = _make_entry()
|
|
64
|
+
csv_line = original.to_csv_line()
|
|
65
|
+
parsed = StateEntry.from_csv_line(csv_line)
|
|
66
|
+
assert parsed is not None
|
|
67
|
+
assert parsed.timestamp == original.timestamp
|
|
68
|
+
assert parsed.total_input_tokens == original.total_input_tokens
|
|
69
|
+
assert parsed.total_output_tokens == original.total_output_tokens
|
|
70
|
+
assert parsed.current_input_tokens == original.current_input_tokens
|
|
71
|
+
assert parsed.current_output_tokens == original.current_output_tokens
|
|
72
|
+
assert parsed.cache_creation == original.cache_creation
|
|
73
|
+
assert parsed.cache_read == original.cache_read
|
|
74
|
+
assert parsed.cost_usd == pytest.approx(original.cost_usd)
|
|
75
|
+
assert parsed.lines_added == original.lines_added
|
|
76
|
+
assert parsed.lines_removed == original.lines_removed
|
|
77
|
+
assert parsed.session_id == original.session_id
|
|
78
|
+
assert parsed.model_id == original.model_id
|
|
79
|
+
assert parsed.workspace_project_dir == original.workspace_project_dir
|
|
80
|
+
assert parsed.context_window_size == original.context_window_size
|
|
81
|
+
|
|
82
|
+
def test_old_format_two_fields(self):
|
|
83
|
+
entry = StateEntry.from_csv_line("1710288000,50000")
|
|
84
|
+
assert entry is not None
|
|
85
|
+
assert entry.timestamp == 1710288000
|
|
86
|
+
assert entry.total_input_tokens == 50000
|
|
87
|
+
assert entry.total_output_tokens == 0
|
|
88
|
+
assert entry.current_input_tokens == 0
|
|
89
|
+
assert entry.session_id == ""
|
|
90
|
+
assert entry.context_window_size == 0
|
|
91
|
+
|
|
92
|
+
def test_old_format_round_trip_expands(self):
|
|
93
|
+
"""Old 2-field format expands to 14 fields on serialize."""
|
|
94
|
+
entry = StateEntry.from_csv_line("1710288000,50000")
|
|
95
|
+
assert entry is not None
|
|
96
|
+
csv_line = entry.to_csv_line()
|
|
97
|
+
parts = csv_line.split(",")
|
|
98
|
+
assert len(parts) == 14
|
|
99
|
+
|
|
100
|
+
def test_empty_string_returns_none(self):
|
|
101
|
+
assert StateEntry.from_csv_line("") is None
|
|
102
|
+
|
|
103
|
+
def test_single_field_returns_none(self):
|
|
104
|
+
assert StateEntry.from_csv_line("1710288000") is None
|
|
105
|
+
|
|
106
|
+
def test_non_numeric_timestamp_returns_none(self):
|
|
107
|
+
assert StateEntry.from_csv_line("abc,50000") is None
|
|
108
|
+
|
|
109
|
+
def test_missing_fields_default_to_zero(self):
|
|
110
|
+
"""Line with only 5 fields: fields 5-13 default to 0/empty."""
|
|
111
|
+
entry = StateEntry.from_csv_line("1710288000,100,200,300,400")
|
|
112
|
+
assert entry is not None
|
|
113
|
+
assert entry.timestamp == 1710288000
|
|
114
|
+
assert entry.total_input_tokens == 100
|
|
115
|
+
assert entry.total_output_tokens == 200
|
|
116
|
+
assert entry.current_input_tokens == 300
|
|
117
|
+
assert entry.current_output_tokens == 400
|
|
118
|
+
assert entry.cache_creation == 0
|
|
119
|
+
assert entry.cache_read == 0
|
|
120
|
+
assert entry.cost_usd == pytest.approx(0.0)
|
|
121
|
+
assert entry.lines_added == 0
|
|
122
|
+
assert entry.lines_removed == 0
|
|
123
|
+
assert entry.session_id == ""
|
|
124
|
+
assert entry.model_id == ""
|
|
125
|
+
assert entry.workspace_project_dir == ""
|
|
126
|
+
assert entry.context_window_size == 0
|
|
127
|
+
|
|
128
|
+
def test_non_numeric_fields_default_safely(self):
|
|
129
|
+
"""safe_int returns 0 for non-numeric values."""
|
|
130
|
+
line = "1710288000,abc,200,xyz,400,0,0,0.0,0,0,sess,model,/tmp,0"
|
|
131
|
+
entry = StateEntry.from_csv_line(line)
|
|
132
|
+
assert entry is not None
|
|
133
|
+
assert entry.total_input_tokens == 0 # "abc" -> 0
|
|
134
|
+
assert entry.current_input_tokens == 0 # "xyz" -> 0
|
|
135
|
+
assert entry.total_output_tokens == 200
|
|
136
|
+
|
|
137
|
+
def test_comma_in_workspace_path_sanitized(self):
|
|
138
|
+
"""Commas in workspace_project_dir become underscores on serialize."""
|
|
139
|
+
entry = _make_entry(workspace_project_dir="/home/user/path,with,commas")
|
|
140
|
+
csv_line = entry.to_csv_line()
|
|
141
|
+
assert "/home/user/path_with_commas" in csv_line
|
|
142
|
+
assert "/home/user/path,with,commas" not in csv_line
|
|
143
|
+
|
|
144
|
+
def test_comma_in_path_round_trip_lossy(self):
|
|
145
|
+
"""Round-trip is intentionally lossy for paths with commas."""
|
|
146
|
+
entry = _make_entry(workspace_project_dir="/home/user/path,with,commas")
|
|
147
|
+
csv_line = entry.to_csv_line()
|
|
148
|
+
parsed = StateEntry.from_csv_line(csv_line)
|
|
149
|
+
assert parsed is not None
|
|
150
|
+
assert parsed.workspace_project_dir == "/home/user/path_with_commas"
|
|
151
|
+
|
|
152
|
+
def test_float_cost_preserved(self):
|
|
153
|
+
entry = _make_entry(cost_usd=0.05234)
|
|
154
|
+
csv_line = entry.to_csv_line()
|
|
155
|
+
parsed = StateEntry.from_csv_line(csv_line)
|
|
156
|
+
assert parsed is not None
|
|
157
|
+
assert parsed.cost_usd == pytest.approx(0.05234)
|
|
158
|
+
|
|
159
|
+
def test_zero_values_round_trip(self):
|
|
160
|
+
entry = _make_entry(
|
|
161
|
+
total_input_tokens=0,
|
|
162
|
+
total_output_tokens=0,
|
|
163
|
+
current_input_tokens=0,
|
|
164
|
+
current_output_tokens=0,
|
|
165
|
+
cache_creation=0,
|
|
166
|
+
cache_read=0,
|
|
167
|
+
cost_usd=0.0,
|
|
168
|
+
lines_added=0,
|
|
169
|
+
lines_removed=0,
|
|
170
|
+
session_id="",
|
|
171
|
+
model_id="",
|
|
172
|
+
workspace_project_dir="",
|
|
173
|
+
context_window_size=0,
|
|
174
|
+
)
|
|
175
|
+
csv_line = entry.to_csv_line()
|
|
176
|
+
parsed = StateEntry.from_csv_line(csv_line)
|
|
177
|
+
assert parsed is not None
|
|
178
|
+
assert parsed.total_input_tokens == 0
|
|
179
|
+
assert parsed.session_id == ""
|
|
180
|
+
assert parsed.workspace_project_dir == ""
|
|
181
|
+
|
|
182
|
+
def test_whitespace_line_stripped(self):
|
|
183
|
+
entry = StateEntry.from_csv_line(" 1710288000,50000 \n")
|
|
184
|
+
assert entry is not None
|
|
185
|
+
assert entry.timestamp == 1710288000
|
|
186
|
+
|
|
187
|
+
def test_to_csv_line_no_trailing_newline(self):
|
|
188
|
+
entry = _make_entry()
|
|
189
|
+
csv_line = entry.to_csv_line()
|
|
190
|
+
assert "\n" not in csv_line
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Class 2: StateEntry Properties
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestStateEntryProperties:
|
|
199
|
+
"""Tests for StateEntry computed properties."""
|
|
200
|
+
|
|
201
|
+
def test_total_tokens(self):
|
|
202
|
+
entry = _make_entry(total_input_tokens=75000, total_output_tokens=8500)
|
|
203
|
+
assert entry.total_tokens == 83500
|
|
204
|
+
|
|
205
|
+
def test_current_used_tokens(self):
|
|
206
|
+
entry = _make_entry(
|
|
207
|
+
current_input_tokens=50000, cache_creation=10000, cache_read=20000
|
|
208
|
+
)
|
|
209
|
+
assert entry.current_used_tokens == 80000
|
|
210
|
+
|
|
211
|
+
def test_current_used_tokens_all_zero(self):
|
|
212
|
+
entry = _make_entry(
|
|
213
|
+
current_input_tokens=0, cache_creation=0, cache_read=0
|
|
214
|
+
)
|
|
215
|
+
assert entry.current_used_tokens == 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Class 3: calculate_deltas
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestCalculateDeltas:
|
|
224
|
+
"""Tests for calculate_deltas."""
|
|
225
|
+
|
|
226
|
+
def test_empty_list(self):
|
|
227
|
+
assert calculate_deltas([]) == []
|
|
228
|
+
|
|
229
|
+
def test_single_value(self):
|
|
230
|
+
assert calculate_deltas([100]) == []
|
|
231
|
+
|
|
232
|
+
def test_two_values(self):
|
|
233
|
+
assert calculate_deltas([100, 250]) == [150]
|
|
234
|
+
|
|
235
|
+
def test_increasing_sequence(self):
|
|
236
|
+
assert calculate_deltas([100, 200, 350, 600]) == [100, 150, 250]
|
|
237
|
+
|
|
238
|
+
def test_negative_delta_clamped_to_zero(self):
|
|
239
|
+
"""Session reset: value decreases, delta clamped to 0."""
|
|
240
|
+
assert calculate_deltas([500, 300]) == [0]
|
|
241
|
+
|
|
242
|
+
def test_mixed_positive_and_negative(self):
|
|
243
|
+
assert calculate_deltas([100, 300, 200, 400]) == [200, 0, 200]
|
|
244
|
+
|
|
245
|
+
def test_constant_values(self):
|
|
246
|
+
assert calculate_deltas([100, 100, 100]) == [0, 0]
|
|
247
|
+
|
|
248
|
+
def test_large_negative_delta(self):
|
|
249
|
+
"""Full session reset from 1M to 0."""
|
|
250
|
+
assert calculate_deltas([1000000, 0]) == [0]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Class 4: calculate_stats
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestCalculateStats:
|
|
259
|
+
"""Tests for calculate_stats."""
|
|
260
|
+
|
|
261
|
+
def test_empty_data(self):
|
|
262
|
+
result = calculate_stats([])
|
|
263
|
+
assert result == Stats(min_val=0, max_val=0, avg_val=0, total=0, count=0)
|
|
264
|
+
|
|
265
|
+
def test_single_value(self):
|
|
266
|
+
result = calculate_stats([42])
|
|
267
|
+
assert result == Stats(min_val=42, max_val=42, avg_val=42, total=42, count=1)
|
|
268
|
+
|
|
269
|
+
def test_normal_data(self):
|
|
270
|
+
result = calculate_stats([10, 20, 30, 40, 50])
|
|
271
|
+
assert result.min_val == 10
|
|
272
|
+
assert result.max_val == 50
|
|
273
|
+
assert result.avg_val == 30
|
|
274
|
+
assert result.total == 150
|
|
275
|
+
assert result.count == 5
|
|
276
|
+
|
|
277
|
+
def test_avg_uses_integer_division(self):
|
|
278
|
+
result = calculate_stats([10, 20])
|
|
279
|
+
assert result.avg_val == 15 # 30 // 2
|
|
280
|
+
|
|
281
|
+
def test_all_same_values(self):
|
|
282
|
+
result = calculate_stats([100, 100, 100])
|
|
283
|
+
assert result.min_val == 100
|
|
284
|
+
assert result.max_val == 100
|
|
285
|
+
assert result.avg_val == 100
|
|
286
|
+
|
|
287
|
+
def test_includes_zeros(self):
|
|
288
|
+
result = calculate_stats([0, 0, 100])
|
|
289
|
+
assert result.min_val == 0
|
|
290
|
+
assert result.max_val == 100
|
|
291
|
+
assert result.avg_val == 33 # 100 // 3
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Class 5: detect_spike (boundary-focused, complements test_icons.py)
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TestDetectSpike:
|
|
300
|
+
"""Boundary and edge-case tests for detect_spike."""
|
|
301
|
+
|
|
302
|
+
def test_empty_deltas(self):
|
|
303
|
+
assert detect_spike([], 200000) is False
|
|
304
|
+
|
|
305
|
+
def test_at_exactly_15_percent_not_spike(self):
|
|
306
|
+
"""30000 = exactly 15% of 200000. Strict > means not a spike."""
|
|
307
|
+
assert detect_spike([30000], 200000) is False
|
|
308
|
+
|
|
309
|
+
def test_just_above_15_percent_is_spike(self):
|
|
310
|
+
assert detect_spike([30001], 200000) is True
|
|
311
|
+
|
|
312
|
+
def test_relative_at_exactly_3x_not_spike(self):
|
|
313
|
+
"""300 = exactly 3x avg(100). Strict > means not a spike."""
|
|
314
|
+
# Previous deltas avg = 100, latest = 300 = 3.0x (not > 3x)
|
|
315
|
+
assert detect_spike([100, 100, 100, 100, 300], 200000) is False
|
|
316
|
+
|
|
317
|
+
def test_relative_just_above_3x_is_spike(self):
|
|
318
|
+
assert detect_spike([100, 100, 100, 100, 301], 200000) is True
|
|
319
|
+
|
|
320
|
+
def test_zero_avg_no_relative_spike(self):
|
|
321
|
+
"""avg=0 skips relative check. 100 < 30000 so no absolute spike."""
|
|
322
|
+
assert detect_spike([0, 0, 0, 0, 100], 200000) is False
|
|
323
|
+
|
|
324
|
+
def test_zero_context_window_only_relative(self):
|
|
325
|
+
"""Absolute check skipped (ctx=0), but 500 > 3*100 triggers relative."""
|
|
326
|
+
assert detect_spike([100, 100, 100, 100, 500], 0) is True
|
|
327
|
+
|
|
328
|
+
def test_window_parameter_limits_lookback(self):
|
|
329
|
+
"""With window=2, only last 2 previous deltas used for average."""
|
|
330
|
+
# Previous 2 deltas: [100, 100], avg=100, latest=500 > 300
|
|
331
|
+
assert detect_spike([1000, 100, 100, 500], 200000, window=2) is True
|
|
332
|
+
|
|
333
|
+
def test_single_delta_below_absolute(self):
|
|
334
|
+
"""Single delta with no previous for relative; below 15% threshold."""
|
|
335
|
+
assert detect_spike([1000], 200000) is False
|
|
336
|
+
|
|
337
|
+
def test_single_delta_above_absolute(self):
|
|
338
|
+
assert detect_spike([35000], 200000) is True
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Class 6: Zone Thresholds (via render_summary)
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class TestZoneThresholds:
|
|
347
|
+
"""Tests for zone classification in render_summary.
|
|
348
|
+
|
|
349
|
+
Zone boundaries (usage_percentage):
|
|
350
|
+
< 40% → Smart Zone
|
|
351
|
+
< 80% → Dumb Zone
|
|
352
|
+
>= 80% → Wrap Up Zone
|
|
353
|
+
|
|
354
|
+
Math for context_window_size=200000:
|
|
355
|
+
usage% = 100 - (remaining * 100 // 200000)
|
|
356
|
+
remaining = max(0, 200000 - current_used_tokens)
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
def test_zero_usage_smart_zone(self):
|
|
360
|
+
entries = [_make_entry(
|
|
361
|
+
current_input_tokens=0, cache_creation=0, cache_read=0,
|
|
362
|
+
context_window_size=200000,
|
|
363
|
+
)]
|
|
364
|
+
output = _render_summary_output(entries)
|
|
365
|
+
assert "SMART ZONE" in output
|
|
366
|
+
assert "DUMB ZONE" not in output
|
|
367
|
+
assert "WRAP UP ZONE" not in output
|
|
368
|
+
|
|
369
|
+
def test_usage_39_pct_smart_zone(self):
|
|
370
|
+
# current_used=78000, remaining=122000, remaining%=61, usage%=39
|
|
371
|
+
entries = [_make_entry(
|
|
372
|
+
current_input_tokens=78000, cache_creation=0, cache_read=0,
|
|
373
|
+
context_window_size=200000,
|
|
374
|
+
)]
|
|
375
|
+
output = _render_summary_output(entries)
|
|
376
|
+
assert "SMART ZONE" in output
|
|
377
|
+
assert "DUMB ZONE" not in output
|
|
378
|
+
|
|
379
|
+
def test_usage_40_pct_dumb_zone(self):
|
|
380
|
+
# current_used=78001, remaining=121999, remaining%=60, usage%=40
|
|
381
|
+
entries = [_make_entry(
|
|
382
|
+
current_input_tokens=78001, cache_creation=0, cache_read=0,
|
|
383
|
+
context_window_size=200000,
|
|
384
|
+
)]
|
|
385
|
+
output = _render_summary_output(entries)
|
|
386
|
+
assert "DUMB ZONE" in output
|
|
387
|
+
assert "SMART ZONE" not in output
|
|
388
|
+
assert "WRAP UP ZONE" not in output
|
|
389
|
+
|
|
390
|
+
def test_usage_79_pct_dumb_zone(self):
|
|
391
|
+
# current_used=158000, remaining=42000, remaining%=21, usage%=79
|
|
392
|
+
entries = [_make_entry(
|
|
393
|
+
current_input_tokens=158000, cache_creation=0, cache_read=0,
|
|
394
|
+
context_window_size=200000,
|
|
395
|
+
)]
|
|
396
|
+
output = _render_summary_output(entries)
|
|
397
|
+
assert "DUMB ZONE" in output
|
|
398
|
+
assert "WRAP UP ZONE" not in output
|
|
399
|
+
|
|
400
|
+
def test_usage_80_pct_wrap_up_zone(self):
|
|
401
|
+
# current_used=158001, remaining=41999, remaining%=20, usage%=80
|
|
402
|
+
entries = [_make_entry(
|
|
403
|
+
current_input_tokens=158001, cache_creation=0, cache_read=0,
|
|
404
|
+
context_window_size=200000,
|
|
405
|
+
)]
|
|
406
|
+
output = _render_summary_output(entries)
|
|
407
|
+
assert "WRAP UP ZONE" in output
|
|
408
|
+
assert "DUMB ZONE" not in output
|
|
409
|
+
|
|
410
|
+
def test_usage_100_pct_wrap_up_zone(self):
|
|
411
|
+
entries = [_make_entry(
|
|
412
|
+
current_input_tokens=200000, cache_creation=0, cache_read=0,
|
|
413
|
+
context_window_size=200000,
|
|
414
|
+
)]
|
|
415
|
+
output = _render_summary_output(entries)
|
|
416
|
+
assert "WRAP UP ZONE" in output
|
|
417
|
+
|
|
418
|
+
def test_usage_exceeds_context_window(self):
|
|
419
|
+
"""Remaining clamped to 0 when usage exceeds window."""
|
|
420
|
+
entries = [_make_entry(
|
|
421
|
+
current_input_tokens=250000, cache_creation=0, cache_read=0,
|
|
422
|
+
context_window_size=200000,
|
|
423
|
+
)]
|
|
424
|
+
output = _render_summary_output(entries)
|
|
425
|
+
assert "WRAP UP ZONE" in output
|
|
426
|
+
|
|
427
|
+
def test_cache_tokens_contribute_to_usage(self):
|
|
428
|
+
"""cache_creation + cache_read push usage past 40% boundary."""
|
|
429
|
+
# current_used = 40000 + 20000 + 18001 = 78001 → usage=40% → Dumb Zone
|
|
430
|
+
entries = [_make_entry(
|
|
431
|
+
current_input_tokens=40000, cache_creation=20000, cache_read=18001,
|
|
432
|
+
context_window_size=200000,
|
|
433
|
+
)]
|
|
434
|
+
output = _render_summary_output(entries)
|
|
435
|
+
assert "DUMB ZONE" in output
|
|
436
|
+
assert "SMART ZONE" not in output
|
|
437
|
+
|
|
438
|
+
def test_zero_context_window_no_zone_output(self):
|
|
439
|
+
"""No zone displayed when context_window_size is 0."""
|
|
440
|
+
entries = [_make_entry(context_window_size=0)]
|
|
441
|
+
output = _render_summary_output(entries)
|
|
442
|
+
assert "ZONE" not in output
|
|
443
|
+
|
|
444
|
+
def test_empty_entries_no_output(self):
|
|
445
|
+
output = _render_summary_output([])
|
|
446
|
+
assert output == ""
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Tests for state file rotation and session ID validation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from claude_statusline.core.state import StateFile, _validate_session_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Helpers
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_csv_line(index: int) -> str:
|
|
18
|
+
"""Generate a deterministic CSV state line for a given index."""
|
|
19
|
+
return (
|
|
20
|
+
f"{1710288000 + index},100,200,300,400,500,600,0.01,"
|
|
21
|
+
f"10,5,sess-{index},model,/tmp/proj,200000"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# State File Rotation
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestStateFileRotation:
|
|
31
|
+
"""Tests for _maybe_rotate() in StateFile."""
|
|
32
|
+
|
|
33
|
+
def test_below_threshold_no_rotation(self, tmp_path, monkeypatch):
|
|
34
|
+
"""File with fewer than 10,000 lines is not rotated."""
|
|
35
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
36
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
37
|
+
(tmp_path / "old").mkdir()
|
|
38
|
+
|
|
39
|
+
sf = StateFile("test-session")
|
|
40
|
+
# Write 9,999 lines
|
|
41
|
+
lines = [_make_csv_line(i) + "\n" for i in range(9_999)]
|
|
42
|
+
sf.file_path.write_text("".join(lines))
|
|
43
|
+
|
|
44
|
+
sf._maybe_rotate()
|
|
45
|
+
|
|
46
|
+
result_lines = sf.file_path.read_text().splitlines()
|
|
47
|
+
assert len(result_lines) == 9_999
|
|
48
|
+
|
|
49
|
+
def test_at_threshold_no_rotation(self, tmp_path, monkeypatch):
|
|
50
|
+
"""File with exactly 10,000 lines is not rotated."""
|
|
51
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
52
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
53
|
+
(tmp_path / "old").mkdir()
|
|
54
|
+
|
|
55
|
+
sf = StateFile("test-session")
|
|
56
|
+
lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
|
|
57
|
+
sf.file_path.write_text("".join(lines))
|
|
58
|
+
|
|
59
|
+
sf._maybe_rotate()
|
|
60
|
+
|
|
61
|
+
result_lines = sf.file_path.read_text().splitlines()
|
|
62
|
+
assert len(result_lines) == 10_000
|
|
63
|
+
|
|
64
|
+
def test_exceeds_threshold_truncates_to_5000(self, tmp_path, monkeypatch):
|
|
65
|
+
"""File with 10,001 lines is truncated to 5,000."""
|
|
66
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
67
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
68
|
+
(tmp_path / "old").mkdir()
|
|
69
|
+
|
|
70
|
+
sf = StateFile("test-session")
|
|
71
|
+
lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
|
|
72
|
+
sf.file_path.write_text("".join(lines))
|
|
73
|
+
|
|
74
|
+
sf._maybe_rotate()
|
|
75
|
+
|
|
76
|
+
result_lines = sf.file_path.read_text().splitlines()
|
|
77
|
+
assert len(result_lines) == 5_000
|
|
78
|
+
|
|
79
|
+
def test_retained_lines_are_most_recent(self, tmp_path, monkeypatch):
|
|
80
|
+
"""After rotation, the retained lines are the last 5,000 of the original."""
|
|
81
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
82
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
83
|
+
(tmp_path / "old").mkdir()
|
|
84
|
+
|
|
85
|
+
sf = StateFile("test-session")
|
|
86
|
+
total = 10_001
|
|
87
|
+
lines = [_make_csv_line(i) + "\n" for i in range(total)]
|
|
88
|
+
sf.file_path.write_text("".join(lines))
|
|
89
|
+
|
|
90
|
+
sf._maybe_rotate()
|
|
91
|
+
|
|
92
|
+
result_lines = sf.file_path.read_text().splitlines()
|
|
93
|
+
# First retained line should be the one at index (total - 5000) = 5001
|
|
94
|
+
assert f"sess-{total - 5000}" in result_lines[0]
|
|
95
|
+
# Last retained line should be the last original line
|
|
96
|
+
assert f"sess-{total - 1}" in result_lines[-1]
|
|
97
|
+
|
|
98
|
+
def test_rotation_via_append_entry(self, tmp_path, monkeypatch):
|
|
99
|
+
"""append_entry triggers rotation when threshold is exceeded."""
|
|
100
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
101
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
102
|
+
(tmp_path / "old").mkdir()
|
|
103
|
+
|
|
104
|
+
sf = StateFile("test-session")
|
|
105
|
+
# Write exactly 10,000 lines (at threshold, no rotation yet)
|
|
106
|
+
lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
|
|
107
|
+
sf.file_path.write_text("".join(lines))
|
|
108
|
+
|
|
109
|
+
# Import StateEntry to create a valid entry
|
|
110
|
+
from claude_statusline.core.state import StateEntry
|
|
111
|
+
|
|
112
|
+
entry = StateEntry(
|
|
113
|
+
timestamp=1710298000,
|
|
114
|
+
total_input_tokens=100,
|
|
115
|
+
total_output_tokens=200,
|
|
116
|
+
current_input_tokens=300,
|
|
117
|
+
current_output_tokens=400,
|
|
118
|
+
cache_creation=500,
|
|
119
|
+
cache_read=600,
|
|
120
|
+
cost_usd=0.01,
|
|
121
|
+
lines_added=10,
|
|
122
|
+
lines_removed=5,
|
|
123
|
+
session_id="test-session",
|
|
124
|
+
model_id="model",
|
|
125
|
+
workspace_project_dir="/tmp/proj",
|
|
126
|
+
context_window_size=200000,
|
|
127
|
+
)
|
|
128
|
+
sf.append_entry(entry)
|
|
129
|
+
|
|
130
|
+
# Now file had 10,001 lines -> should have been rotated to 5,000
|
|
131
|
+
result_lines = sf.file_path.read_text().splitlines()
|
|
132
|
+
assert len(result_lines) == 5_000
|
|
133
|
+
|
|
134
|
+
def test_no_temp_files_left_after_rotation(self, tmp_path, monkeypatch):
|
|
135
|
+
"""No .tmp files remain after successful rotation."""
|
|
136
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
137
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
138
|
+
(tmp_path / "old").mkdir()
|
|
139
|
+
|
|
140
|
+
sf = StateFile("test-session")
|
|
141
|
+
lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
|
|
142
|
+
sf.file_path.write_text("".join(lines))
|
|
143
|
+
|
|
144
|
+
sf._maybe_rotate()
|
|
145
|
+
|
|
146
|
+
tmp_files = list(tmp_path.glob("*.tmp"))
|
|
147
|
+
assert tmp_files == []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Session ID Validation
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestSessionIdValidation:
|
|
156
|
+
"""Tests for _validate_session_id and StateFile constructor validation."""
|
|
157
|
+
|
|
158
|
+
def test_reject_forward_slash(self):
|
|
159
|
+
with pytest.raises(ValueError, match="/"):
|
|
160
|
+
_validate_session_id("../../etc/passwd")
|
|
161
|
+
|
|
162
|
+
def test_reject_backslash(self):
|
|
163
|
+
with pytest.raises(ValueError, match=re.escape("\\")):
|
|
164
|
+
_validate_session_id("..\\..\\etc\\passwd")
|
|
165
|
+
|
|
166
|
+
def test_reject_dot_dot(self):
|
|
167
|
+
with pytest.raises(ValueError, match=r"\.\."):
|
|
168
|
+
_validate_session_id("..hidden")
|
|
169
|
+
|
|
170
|
+
def test_reject_null_byte(self):
|
|
171
|
+
with pytest.raises(ValueError):
|
|
172
|
+
_validate_session_id("session\0id")
|
|
173
|
+
|
|
174
|
+
def test_accept_valid_uuid(self):
|
|
175
|
+
_validate_session_id("abc-123-def-456") # Should not raise
|
|
176
|
+
|
|
177
|
+
def test_accept_hyphens_underscores(self):
|
|
178
|
+
_validate_session_id("my_session-id_123") # Should not raise
|
|
179
|
+
|
|
180
|
+
def test_accept_alphanumeric(self):
|
|
181
|
+
_validate_session_id("abcdef1234567890") # Should not raise
|
|
182
|
+
|
|
183
|
+
def test_statefile_rejects_invalid_session(self, tmp_path, monkeypatch):
|
|
184
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
185
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
186
|
+
(tmp_path / "old").mkdir()
|
|
187
|
+
|
|
188
|
+
with pytest.raises(ValueError):
|
|
189
|
+
StateFile("../../etc/passwd")
|
|
190
|
+
|
|
191
|
+
def test_statefile_accepts_none_session(self, tmp_path, monkeypatch):
|
|
192
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
193
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
194
|
+
(tmp_path / "old").mkdir()
|
|
195
|
+
|
|
196
|
+
sf = StateFile(None) # Should not raise
|
|
197
|
+
assert sf.session_id is None
|
|
198
|
+
|
|
199
|
+
def test_statefile_accepts_valid_session(self, tmp_path, monkeypatch):
|
|
200
|
+
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
201
|
+
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
202
|
+
(tmp_path / "old").mkdir()
|
|
203
|
+
|
|
204
|
+
sf = StateFile("valid-session-123") # Should not raise
|
|
205
|
+
assert sf.session_id == "valid-session-123"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# CLI Session ID Rejection (subprocess test)
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestCliSessionIdRejection:
|
|
214
|
+
"""Test that context-stats CLI rejects invalid session IDs."""
|
|
215
|
+
|
|
216
|
+
def test_cli_rejects_path_traversal(self):
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
[sys.executable, "-m", "claude_statusline.cli.context_stats", "../../etc/passwd"],
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
)
|
|
222
|
+
assert result.returncode != 0
|
|
223
|
+
assert "Invalid session_id" in result.stderr
|
|
224
|
+
|
|
225
|
+
def test_cli_rejects_backslash(self):
|
|
226
|
+
result = subprocess.run(
|
|
227
|
+
[sys.executable, "-m", "claude_statusline.cli.context_stats", "test\\bad"],
|
|
228
|
+
capture_output=True,
|
|
229
|
+
text=True,
|
|
230
|
+
)
|
|
231
|
+
assert result.returncode != 0
|
|
232
|
+
assert "Invalid session_id" in result.stderr
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Display ASCII graph of token usage for current session
|
|
3
|
-
argument-hint: [session_id] [--type cumulative|delta|both] [--no-color]
|
|
4
|
-
allowed-tools: Bash(*)
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# Context Stats
|
|
8
|
-
|
|
9
|
-
Display the token consumption history as ASCII graphs.
|
|
10
|
-
|
|
11
|
-
**IMPORTANT**: This command executes a bash script and displays its output directly. Do NOT analyze or interpret the results - simply show them to the user.
|
|
12
|
-
|
|
13
|
-
Execute the token graph script:
|
|
14
|
-
|
|
15
|
-
!`bash /Users/montimage/buildspace/luongnv89/claude-statusline/scripts/context-stats.sh $ARGUMENTS`
|
|
16
|
-
|
|
17
|
-
The output above shows the token usage visualization. No further analysis is needed.
|