cc-context-stats 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +9 -1
  2. package/scripts/context-stats.sh +1 -1
  3. package/scripts/statusline.js +128 -18
  4. package/.editorconfig +0 -60
  5. package/.eslintrc.json +0 -35
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  8. package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
  9. package/.github/dependabot.yml +0 -44
  10. package/.github/workflows/ci.yml +0 -294
  11. package/.github/workflows/release.yml +0 -151
  12. package/.pre-commit-config.yaml +0 -74
  13. package/.prettierrc +0 -33
  14. package/.shellcheckrc +0 -10
  15. package/CHANGELOG.md +0 -163
  16. package/CLAUDE.md +0 -66
  17. package/CODE_OF_CONDUCT.md +0 -59
  18. package/CONTRIBUTING.md +0 -240
  19. package/RELEASE_NOTES.md +0 -19
  20. package/SECURITY.md +0 -44
  21. package/TODOS.md +0 -72
  22. package/assets/logo/favicon.svg +0 -19
  23. package/assets/logo/logo-black.svg +0 -24
  24. package/assets/logo/logo-full.svg +0 -40
  25. package/assets/logo/logo-icon.svg +0 -27
  26. package/assets/logo/logo-mark.svg +0 -28
  27. package/assets/logo/logo-white.svg +0 -24
  28. package/assets/logo/logo-wordmark.svg +0 -6
  29. package/config/settings-example.json +0 -7
  30. package/config/settings-node.json +0 -7
  31. package/config/settings-python.json +0 -7
  32. package/docs/ARCHITECTURE.md +0 -128
  33. package/docs/CSV_FORMAT.md +0 -42
  34. package/docs/DEPLOYMENT.md +0 -71
  35. package/docs/DEVELOPMENT.md +0 -161
  36. package/docs/configuration.md +0 -118
  37. package/docs/context-stats.md +0 -143
  38. package/docs/installation.md +0 -255
  39. package/docs/scripts.md +0 -140
  40. package/docs/troubleshooting.md +0 -278
  41. package/images/claude-statusline-token-graph.gif +0 -0
  42. package/images/claude-statusline.png +0 -0
  43. package/images/context-status-dumbzone.png +0 -0
  44. package/images/context-status.png +0 -0
  45. package/images/statusline-detail.png +0 -0
  46. package/images/token-graph.jpeg +0 -0
  47. package/images/token-graph.png +0 -0
  48. package/images/v1.6.1.png +0 -0
  49. package/install +0 -351
  50. package/install.sh +0 -298
  51. package/jest.config.js +0 -11
  52. package/pyproject.toml +0 -115
  53. package/requirements-dev.txt +0 -12
  54. package/scripts/statusline-full.sh +0 -304
  55. package/scripts/statusline-git.sh +0 -88
  56. package/scripts/statusline-minimal.sh +0 -67
  57. package/scripts/statusline.py +0 -485
  58. package/src/claude_statusline/__init__.py +0 -11
  59. package/src/claude_statusline/__main__.py +0 -6
  60. package/src/claude_statusline/cli/__init__.py +0 -1
  61. package/src/claude_statusline/cli/context_stats.py +0 -512
  62. package/src/claude_statusline/cli/explain.py +0 -228
  63. package/src/claude_statusline/cli/statusline.py +0 -169
  64. package/src/claude_statusline/core/__init__.py +0 -1
  65. package/src/claude_statusline/core/colors.py +0 -124
  66. package/src/claude_statusline/core/config.py +0 -148
  67. package/src/claude_statusline/core/git.py +0 -78
  68. package/src/claude_statusline/core/state.py +0 -323
  69. package/src/claude_statusline/formatters/__init__.py +0 -1
  70. package/src/claude_statusline/formatters/layout.py +0 -67
  71. package/src/claude_statusline/formatters/time.py +0 -50
  72. package/src/claude_statusline/formatters/tokens.py +0 -70
  73. package/src/claude_statusline/graphs/__init__.py +0 -1
  74. package/src/claude_statusline/graphs/renderer.py +0 -366
  75. package/src/claude_statusline/graphs/statistics.py +0 -92
  76. package/src/claude_statusline/ui/__init__.py +0 -1
  77. package/src/claude_statusline/ui/icons.py +0 -93
  78. package/src/claude_statusline/ui/waiting.py +0 -62
  79. package/tests/bash/test_delta_parity.bats +0 -199
  80. package/tests/bash/test_install.bats +0 -29
  81. package/tests/bash/test_parity.bats +0 -315
  82. package/tests/bash/test_statusline_full.bats +0 -139
  83. package/tests/bash/test_statusline_git.bats +0 -42
  84. package/tests/bash/test_statusline_minimal.bats +0 -37
  85. package/tests/fixtures/json/comma_in_path.json +0 -31
  86. package/tests/fixtures/json/high_usage.json +0 -17
  87. package/tests/fixtures/json/low_usage.json +0 -17
  88. package/tests/fixtures/json/medium_usage.json +0 -17
  89. package/tests/fixtures/json/valid_full.json +0 -30
  90. package/tests/fixtures/json/valid_minimal.json +0 -9
  91. package/tests/node/rotation.test.js +0 -89
  92. package/tests/node/statusline.test.js +0 -240
  93. package/tests/python/conftest.py +0 -84
  94. package/tests/python/test_colors.py +0 -105
  95. package/tests/python/test_config_colors.py +0 -78
  96. package/tests/python/test_data_pipeline.py +0 -446
  97. package/tests/python/test_explain.py +0 -177
  98. package/tests/python/test_icons.py +0 -152
  99. package/tests/python/test_layout.py +0 -127
  100. package/tests/python/test_state_rotation_validation.py +0 -232
  101. package/tests/python/test_statusline.py +0 -215
  102. package/tests/python/test_waiting.py +0 -127
@@ -1,105 +0,0 @@
1
- """Tests for configurable colors."""
2
-
3
- from claude_statusline.core.colors import (
4
- BLUE,
5
- CYAN,
6
- GREEN,
7
- MAGENTA,
8
- RED,
9
- YELLOW,
10
- ColorManager,
11
- parse_color,
12
- )
13
-
14
-
15
- class TestParseColor:
16
- """Tests for parse_color()."""
17
-
18
- def test_named_color_red(self):
19
- assert parse_color("red") == "\033[0;31m"
20
-
21
- def test_named_color_green(self):
22
- assert parse_color("green") == "\033[0;32m"
23
-
24
- def test_named_color_bright_cyan(self):
25
- assert parse_color("bright_cyan") == "\033[0;96m"
26
-
27
- def test_named_color_case_insensitive(self):
28
- assert parse_color("RED") == "\033[0;31m"
29
- assert parse_color("Green") == "\033[0;32m"
30
-
31
- def test_hex_color(self):
32
- result = parse_color("#ff5733")
33
- assert result == "\033[38;2;255;87;51m"
34
-
35
- def test_hex_color_uppercase(self):
36
- result = parse_color("#FF5733")
37
- assert result == "\033[38;2;255;87;51m"
38
-
39
- def test_hex_color_black(self):
40
- result = parse_color("#000000")
41
- assert result == "\033[38;2;0;0;0m"
42
-
43
- def test_hex_color_white(self):
44
- result = parse_color("#ffffff")
45
- assert result == "\033[38;2;255;255;255m"
46
-
47
- def test_invalid_color_returns_none(self):
48
- assert parse_color("nonexistent") is None
49
-
50
- def test_empty_string_returns_none(self):
51
- assert parse_color("") is None
52
-
53
- def test_invalid_hex_returns_none(self):
54
- assert parse_color("#xyz") is None
55
- assert parse_color("#12345") is None
56
- assert parse_color("#1234567") is None
57
-
58
- def test_strips_whitespace(self):
59
- assert parse_color(" red ") == "\033[0;31m"
60
- assert parse_color(" #ff5733 ") == "\033[38;2;255;87;51m"
61
-
62
-
63
- class TestColorManager:
64
- """Tests for ColorManager with overrides."""
65
-
66
- def test_defaults_without_overrides(self):
67
- cm = ColorManager(enabled=True)
68
- assert cm.green == GREEN
69
- assert cm.yellow == YELLOW
70
- assert cm.red == RED
71
- assert cm.blue == BLUE
72
- assert cm.magenta == MAGENTA
73
- assert cm.cyan == CYAN
74
-
75
- def test_override_single_color(self):
76
- custom = "\033[38;2;255;0;0m"
77
- cm = ColorManager(enabled=True, overrides={"green": custom})
78
- assert cm.green == custom
79
- # Others unchanged
80
- assert cm.yellow == YELLOW
81
- assert cm.red == RED
82
-
83
- def test_override_multiple_colors(self):
84
- overrides = {
85
- "green": "\033[38;2;0;255;0m",
86
- "red": "\033[38;2;255;0;0m",
87
- }
88
- cm = ColorManager(enabled=True, overrides=overrides)
89
- assert cm.green == overrides["green"]
90
- assert cm.red == overrides["red"]
91
- assert cm.yellow == YELLOW # not overridden
92
-
93
- def test_disabled_returns_empty(self):
94
- overrides = {"green": "\033[38;2;0;255;0m"}
95
- cm = ColorManager(enabled=False, overrides=overrides)
96
- assert cm.green == ""
97
- assert cm.yellow == ""
98
- assert cm.bold == ""
99
- assert cm.reset == ""
100
-
101
- def test_bold_dim_reset_not_overridable(self):
102
- """bold, dim, reset are always the standard ANSI codes."""
103
- cm = ColorManager(enabled=True, overrides={"bold": "custom"})
104
- # bold is not in the _get path, it uses the hardcoded value
105
- assert cm.bold == "\033[1m"
@@ -1,78 +0,0 @@
1
- """Tests for color configuration in Config."""
2
-
3
- from claude_statusline.core.config import Config
4
-
5
-
6
- class TestConfigColorOverrides:
7
- """Tests for loading color overrides from config file."""
8
-
9
- def test_no_color_overrides_by_default(self, tmp_path):
10
- config_file = tmp_path / "statusline.conf"
11
- config_file.write_text("autocompact=true\n")
12
- config = Config.load(config_path=config_file)
13
- assert config.color_overrides == {}
14
-
15
- def test_named_color_override(self, tmp_path):
16
- config_file = tmp_path / "statusline.conf"
17
- config_file.write_text("color_green=bright_cyan\n")
18
- config = Config.load(config_path=config_file)
19
- assert "green" in config.color_overrides
20
- assert config.color_overrides["green"] == "\033[0;96m"
21
-
22
- def test_hex_color_override(self, tmp_path):
23
- config_file = tmp_path / "statusline.conf"
24
- config_file.write_text("color_red=#f7768e\n")
25
- config = Config.load(config_path=config_file)
26
- assert "red" in config.color_overrides
27
- assert config.color_overrides["red"] == "\033[38;2;247;118;142m"
28
-
29
- def test_multiple_color_overrides(self, tmp_path):
30
- config_file = tmp_path / "statusline.conf"
31
- config_file.write_text("color_green=#7dcfff\ncolor_red=#f7768e\ncolor_blue=bright_blue\n")
32
- config = Config.load(config_path=config_file)
33
- assert len(config.color_overrides) == 3
34
- assert "green" in config.color_overrides
35
- assert "red" in config.color_overrides
36
- assert "blue" in config.color_overrides
37
-
38
- def test_invalid_color_ignored(self, tmp_path, capsys):
39
- config_file = tmp_path / "statusline.conf"
40
- config_file.write_text("color_green=nonexistent_color\n")
41
- config = Config.load(config_path=config_file)
42
- assert config.color_overrides == {}
43
-
44
- def test_color_overrides_mixed_with_booleans(self, tmp_path):
45
- config_file = tmp_path / "statusline.conf"
46
- config_file.write_text("autocompact=false\ntoken_detail=true\ncolor_yellow=#e0af68\n")
47
- config = Config.load(config_path=config_file)
48
- assert config.autocompact is False
49
- assert config.token_detail is True
50
- assert "yellow" in config.color_overrides
51
- assert config.color_overrides["yellow"] == "\033[38;2;224;175;104m"
52
-
53
- def test_color_overrides_in_to_dict(self, tmp_path):
54
- config_file = tmp_path / "statusline.conf"
55
- config_file.write_text("color_cyan=#00ffff\n")
56
- config = Config.load(config_path=config_file)
57
- d = config.to_dict()
58
- assert "color_overrides" in d
59
- assert "cyan" in d["color_overrides"]
60
-
61
- def test_unknown_color_key_ignored(self, tmp_path):
62
- config_file = tmp_path / "statusline.conf"
63
- config_file.write_text("color_purple=magenta\n")
64
- config = Config.load(config_path=config_file)
65
- assert config.color_overrides == {}
66
-
67
- def test_all_six_color_slots(self, tmp_path):
68
- config_file = tmp_path / "statusline.conf"
69
- config_file.write_text(
70
- "color_green=green\n"
71
- "color_yellow=yellow\n"
72
- "color_red=red\n"
73
- "color_blue=blue\n"
74
- "color_magenta=magenta\n"
75
- "color_cyan=cyan\n"
76
- )
77
- config = Config.load(config_path=config_file)
78
- assert len(config.color_overrides) == 6
@@ -1,446 +0,0 @@
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 == ""