cc-context-stats 1.5.0 → 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.
Files changed (45) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +33 -0
  4. package/.github/workflows/ci.yml +39 -2
  5. package/.github/workflows/release.yml +3 -1
  6. package/CHANGELOG.md +16 -8
  7. package/CLAUDE.md +54 -0
  8. package/CODE_OF_CONDUCT.md +59 -0
  9. package/LICENSE +21 -0
  10. package/README.md +9 -0
  11. package/RELEASE_NOTES.md +16 -6
  12. package/SECURITY.md +44 -0
  13. package/TODOS.md +72 -0
  14. package/assets/logo/favicon.svg +17 -14
  15. package/assets/logo/logo-black.svg +19 -18
  16. package/assets/logo/logo-full.svg +39 -29
  17. package/assets/logo/logo-icon.svg +20 -19
  18. package/assets/logo/logo-mark.svg +21 -19
  19. package/assets/logo/logo-white.svg +19 -18
  20. package/assets/logo/logo-wordmark.svg +5 -6
  21. package/docs/ARCHITECTURE.md +101 -0
  22. package/docs/CSV_FORMAT.md +40 -0
  23. package/docs/DEPLOYMENT.md +60 -0
  24. package/docs/DEVELOPMENT.md +125 -0
  25. package/package.json +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/statusline-full.sh +11 -3
  28. package/scripts/statusline-git.sh +8 -1
  29. package/scripts/statusline-minimal.sh +8 -1
  30. package/scripts/statusline.js +62 -8
  31. package/scripts/statusline.py +24 -10
  32. package/src/claude_statusline/__init__.py +1 -1
  33. package/src/claude_statusline/cli/context_stats.py +20 -1
  34. package/src/claude_statusline/core/config.py +5 -4
  35. package/src/claude_statusline/core/state.py +64 -7
  36. package/src/claude_statusline/formatters/layout.py +17 -2
  37. package/tests/bash/test_parity.bats +315 -0
  38. package/tests/fixtures/json/comma_in_path.json +31 -0
  39. package/tests/node/rotation.test.js +89 -0
  40. package/tests/python/test_data_pipeline.py +446 -0
  41. package/tests/python/test_layout.py +19 -2
  42. package/tests/python/test_state_rotation_validation.py +232 -0
  43. package/tests/python/test_statusline.py +2 -0
  44. package/.claude/commands/context-stats.md +0 -17
  45. package/.claude/settings.local.json +0 -117
@@ -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 == ""
@@ -38,8 +38,25 @@ class TestVisibleWidth:
38
38
  class TestGetTerminalWidth:
39
39
  """Tests for get_terminal_width()."""
40
40
 
41
- def test_fallback_to_80(self):
42
- with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
41
+ def test_fallback_to_200_when_no_columns_env(self):
42
+ """When COLUMNS is not set and shutil returns 80, use 200 (Claude Code subprocess)."""
43
+ with (
44
+ patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
45
+ patch.dict("os.environ", {}, clear=False),
46
+ ):
47
+ # Remove COLUMNS if present
48
+ import os
49
+
50
+ os.environ.pop("COLUMNS", None)
51
+ mock.return_value = type("Size", (), {"columns": 80})()
52
+ assert get_terminal_width() == 200
53
+
54
+ def test_respects_columns_env_80(self):
55
+ """When COLUMNS=80 is explicitly set, trust it."""
56
+ with (
57
+ patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
58
+ patch.dict("os.environ", {"COLUMNS": "80"}),
59
+ ):
43
60
  mock.return_value = type("Size", (), {"columns": 80})()
44
61
  assert get_terminal_width() == 80
45
62