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.
- package/package.json +9 -1
- package/scripts/context-stats.sh +1 -1
- package/scripts/statusline.js +128 -18
- package/.editorconfig +0 -60
- package/.eslintrc.json +0 -35
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
- package/.github/dependabot.yml +0 -44
- package/.github/workflows/ci.yml +0 -294
- package/.github/workflows/release.yml +0 -151
- package/.pre-commit-config.yaml +0 -74
- package/.prettierrc +0 -33
- package/.shellcheckrc +0 -10
- package/CHANGELOG.md +0 -163
- package/CLAUDE.md +0 -66
- package/CODE_OF_CONDUCT.md +0 -59
- package/CONTRIBUTING.md +0 -240
- package/RELEASE_NOTES.md +0 -19
- package/SECURITY.md +0 -44
- package/TODOS.md +0 -72
- package/assets/logo/favicon.svg +0 -19
- package/assets/logo/logo-black.svg +0 -24
- package/assets/logo/logo-full.svg +0 -40
- package/assets/logo/logo-icon.svg +0 -27
- package/assets/logo/logo-mark.svg +0 -28
- package/assets/logo/logo-white.svg +0 -24
- package/assets/logo/logo-wordmark.svg +0 -6
- package/config/settings-example.json +0 -7
- package/config/settings-node.json +0 -7
- package/config/settings-python.json +0 -7
- package/docs/ARCHITECTURE.md +0 -128
- package/docs/CSV_FORMAT.md +0 -42
- package/docs/DEPLOYMENT.md +0 -71
- package/docs/DEVELOPMENT.md +0 -161
- package/docs/configuration.md +0 -118
- package/docs/context-stats.md +0 -143
- package/docs/installation.md +0 -255
- package/docs/scripts.md +0 -140
- package/docs/troubleshooting.md +0 -278
- package/images/claude-statusline-token-graph.gif +0 -0
- package/images/claude-statusline.png +0 -0
- package/images/context-status-dumbzone.png +0 -0
- package/images/context-status.png +0 -0
- package/images/statusline-detail.png +0 -0
- package/images/token-graph.jpeg +0 -0
- package/images/token-graph.png +0 -0
- package/images/v1.6.1.png +0 -0
- package/install +0 -351
- package/install.sh +0 -298
- package/jest.config.js +0 -11
- package/pyproject.toml +0 -115
- package/requirements-dev.txt +0 -12
- package/scripts/statusline-full.sh +0 -304
- package/scripts/statusline-git.sh +0 -88
- package/scripts/statusline-minimal.sh +0 -67
- package/scripts/statusline.py +0 -485
- package/src/claude_statusline/__init__.py +0 -11
- package/src/claude_statusline/__main__.py +0 -6
- package/src/claude_statusline/cli/__init__.py +0 -1
- package/src/claude_statusline/cli/context_stats.py +0 -512
- package/src/claude_statusline/cli/explain.py +0 -228
- package/src/claude_statusline/cli/statusline.py +0 -169
- package/src/claude_statusline/core/__init__.py +0 -1
- package/src/claude_statusline/core/colors.py +0 -124
- package/src/claude_statusline/core/config.py +0 -148
- package/src/claude_statusline/core/git.py +0 -78
- package/src/claude_statusline/core/state.py +0 -323
- package/src/claude_statusline/formatters/__init__.py +0 -1
- package/src/claude_statusline/formatters/layout.py +0 -67
- package/src/claude_statusline/formatters/time.py +0 -50
- package/src/claude_statusline/formatters/tokens.py +0 -70
- package/src/claude_statusline/graphs/__init__.py +0 -1
- package/src/claude_statusline/graphs/renderer.py +0 -366
- package/src/claude_statusline/graphs/statistics.py +0 -92
- package/src/claude_statusline/ui/__init__.py +0 -1
- package/src/claude_statusline/ui/icons.py +0 -93
- package/src/claude_statusline/ui/waiting.py +0 -62
- package/tests/bash/test_delta_parity.bats +0 -199
- package/tests/bash/test_install.bats +0 -29
- package/tests/bash/test_parity.bats +0 -315
- package/tests/bash/test_statusline_full.bats +0 -139
- package/tests/bash/test_statusline_git.bats +0 -42
- package/tests/bash/test_statusline_minimal.bats +0 -37
- package/tests/fixtures/json/comma_in_path.json +0 -31
- package/tests/fixtures/json/high_usage.json +0 -17
- package/tests/fixtures/json/low_usage.json +0 -17
- package/tests/fixtures/json/medium_usage.json +0 -17
- package/tests/fixtures/json/valid_full.json +0 -30
- package/tests/fixtures/json/valid_minimal.json +0 -9
- package/tests/node/rotation.test.js +0 -89
- package/tests/node/statusline.test.js +0 -240
- package/tests/python/conftest.py +0 -84
- package/tests/python/test_colors.py +0 -105
- package/tests/python/test_config_colors.py +0 -78
- package/tests/python/test_data_pipeline.py +0 -446
- package/tests/python/test_explain.py +0 -177
- package/tests/python/test_icons.py +0 -152
- package/tests/python/test_layout.py +0 -127
- package/tests/python/test_state_rotation_validation.py +0 -232
- package/tests/python/test_statusline.py +0 -215
- 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 == ""
|