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.
@@ -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.