import io import json import pathlib import sys import time from types import SimpleNamespace REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() sys.path.insert(0, str(REPO_ROOT)) PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") class _FakeHandler: def __init__(self): self.status = None self.sent_headers = [] self.body = bytearray() self.wfile = self self.rfile = io.BytesIO() self.headers = {} self.request = None def send_response(self, status): self.status = status def send_header(self, name, value): self.sent_headers.append((name, value)) def end_headers(self): pass def write(self, data): self.body.extend(data) def json_body(self): return json.loads(bytes(self.body).decode("utf-8")) def _call_insights(monkeypatch, tmp_path, entries, days="7", now=None): import api.routes as routes session_dir = tmp_path / "sessions" session_dir.mkdir() (session_dir / "_index.json").write_text(json.dumps(entries), encoding="utf-8") monkeypatch.setattr(routes, "SESSION_DIR", session_dir) if now is not None: monkeypatch.setattr(time, "time", lambda: now) handler = _FakeHandler() parsed = SimpleNamespace(query=f"days={days}") routes._handle_insights(handler, parsed) assert handler.status == 200 return handler.json_body() def _day(ts): return time.strftime("%Y-%m-%d", time.localtime(ts)) def test_insights_daily_tokens_zero_fills_selected_range_and_parses_cost(monkeypatch, tmp_path): now = time.mktime((2026, 5, 4, 12, 0, 0, 0, 0, -1)) two_days_ago = now - (2 * 86400) entries = [ { "session_id": "today", "updated_at": now, "created_at": now, "message_count": 4, "input_tokens": 1200, "output_tokens": 300, "estimated_cost": "$0.0123", "model": "gpt-5.5", }, { "session_id": "old", "updated_at": two_days_ago, "created_at": two_days_ago, "message_count": 2, "input_tokens": 500, "output_tokens": 250, "estimated_cost": "0.0200", "model": "gpt-5.5", }, ] data = _call_insights(monkeypatch, tmp_path, entries, days="7", now=now) assert len(data["daily_tokens"]) == 7 assert data["daily_tokens"][0]["date"] == _day(now - 6 * 86400) assert data["daily_tokens"][-1]["date"] == _day(now) by_date = {row["date"]: row for row in data["daily_tokens"]} assert by_date[_day(now)] == { "date": _day(now), "input_tokens": 1200, "output_tokens": 300, "sessions": 1, "cost": 0.0123, } assert by_date[_day(now - 86400)] == { "date": _day(now - 86400), "input_tokens": 0, "output_tokens": 0, "sessions": 0, "cost": 0.0, } assert by_date[_day(two_days_ago)]["input_tokens"] == 500 assert by_date[_day(two_days_ago)]["output_tokens"] == 250 assert by_date[_day(two_days_ago)]["cost"] == 0.02 assert data["total_cost"] == 0.0323 def test_insights_model_breakdown_tracks_tokens_cost_and_shares(monkeypatch, tmp_path): now = time.mktime((2026, 5, 4, 12, 0, 0, 0, 0, -1)) entries = [ {"updated_at": now, "message_count": 1, "model": "cheap", "input_tokens": 200, "output_tokens": 50, "estimated_cost": 0.01}, {"updated_at": now, "message_count": 1, "model": "costly", "input_tokens": 100, "output_tokens": 50, "estimated_cost": "0.20"}, {"updated_at": now, "message_count": 1, "model": "cheap", "input_tokens": 300, "output_tokens": 150, "estimated_cost": "$0.04"}, ] data = _call_insights(monkeypatch, tmp_path, entries, days="7", now=now) models = data["models"] assert [m["model"] for m in models] == ["costly", "cheap"] costly, cheap = models assert costly["sessions"] == 1 assert costly["input_tokens"] == 100 assert costly["output_tokens"] == 50 assert costly["total_tokens"] == 150 assert costly["cost"] == 0.2 assert costly["session_share"] == 33 assert costly["token_share"] == 18 assert costly["cost_share"] == 80 assert cheap["sessions"] == 2 assert cheap["input_tokens"] == 500 assert cheap["output_tokens"] == 200 assert cheap["total_tokens"] == 700 assert cheap["cost"] == 0.05 def test_insights_frontend_renders_daily_token_chart_and_model_usage_table(): assert "daily_tokens" in PANELS_JS assert "insights_daily_tokens" in PANELS_JS assert "insights-daily-token-chart" in PANELS_JS assert "insights-daily-bar-input" in PANELS_JS assert "insights-daily-bar-output" in PANELS_JS assert "insights_model_tokens" in PANELS_JS assert "insights_model_cost" in PANELS_JS assert "insights_model_share" in PANELS_JS assert "insights_model_team" not in PANELS_JS assert "m.profile || m.team" not in PANELS_JS assert "insights_no_usage_data" in PANELS_JS def test_insights_frontend_has_daily_chart_styles_and_range_switching_hooks(): assert "insightsPeriod" in INDEX_HTML assert 'option value="7"' in INDEX_HTML assert 'option value="30"' in INDEX_HTML assert 'option value="90"' in INDEX_HTML assert "loadInsights()" in INDEX_HTML assert "/api/insights?days=${period}" in PANELS_JS assert ".insights-daily-token-chart" in STYLE_CSS assert ".insights-daily-bar-output" in STYLE_CSS assert ".insights-model-cost" in STYLE_CSS assert ".insights-model-team" not in STYLE_CSS def _make_daily_rows(n): rows = [] for i in range(n): rows.append({ 'date': f'2026-01-{i+1:02d}', 'input_tokens': (i + 1) * 100, 'output_tokens': (i + 1) * 50, 'sessions': 1, 'cost': (i + 1) * 0.01, }) return rows # Python reference implementation of the JS bucketing logic, so we can # verify the JS implementation produces the same behavior without needing # a JS runtime. def _py_bucket(rows): if not isinstance(rows, list) or len(rows) == 0: return [] n = len(rows) if n <= 30: return list(rows) # unchanged if n <= 90: bucket_size = 2 elif n <= 180: bucket_size = 3 elif n <= 365: bucket_size = 8 # ≤52 bars for 365 days; shrink-safe with minmax(0,1fr) else: bucket_size = 8 # fallback for >365 (shouldn't occur in practice) result = [] for i in range(0, n, bucket_size): sl = rows[i:i + bucket_size] inp = sum(r['input_tokens'] for r in sl) out = sum(r['output_tokens'] for r in sl) sess = sum(r['sessions'] for r in sl) cost = sum(r['cost'] for r in sl) first = sl[0]['date'] last = sl[-1]['date'] first_lbl = first[5:] # MM-DD last_lbl = last[5:] result.append({ 'label': (first_lbl if first == last else first_lbl + '--' + last_lbl), 'title': first + (' -- ' + last if first != last else ''), 'date': first, 'input_tokens': inp, 'output_tokens': out, 'sessions': sess, 'cost': cost, }) return result def test_insights_bucketing_helper_preserves_short_ranges(): # _bucketDailyTokensForChart must exist in panels.js assert '_bucketDailyTokensForChart' in PANELS_JS # 7-day: unchanged (≤ 30 threshold) rows7 = _make_daily_rows(7) bucketed7 = _py_bucket(rows7) assert len(bucketed7) == 7, f'7-day should stay 7 bars, got {len(bucketed7)}' assert bucketed7[0]['input_tokens'] == 100 # 30-day: exactly 30 → unchanged rows30 = _make_daily_rows(30) bucketed30 = _py_bucket(rows30) assert len(bucketed30) == 30, f'30-day should stay 30 bars, got {len(bucketed30)}' # 31-day: bucketed rows31 = _make_daily_rows(31) bucketed31 = _py_bucket(rows31) assert len(bucketed31) < 31, f'31-day should be bucketed, got {len(bucketed31)}' assert len(bucketed31) <= 16 # ceil(31/2) def test_insights_bucketing_helper_bounds_long_ranges(): # 90-day → 2-day buckets → 45 bars rows90 = _make_daily_rows(90) bucketed90 = _py_bucket(rows90) assert len(bucketed90) <= 45, f'90-day should be <=45 bars, got {len(bucketed90)}' assert len(bucketed90) > 0 # 365-day → 8-day buckets → 46 bars (≤52 threshold) rows365 = _make_daily_rows(365) bucketed365 = _py_bucket(rows365) assert len(bucketed365) <= 52, f'365-day should be <=52 bars, got {len(bucketed365)}' assert len(bucketed365) > 0 # First bucket has 8 days: 100+200+300+400+500+600+700+800 = 3600 assert bucketed365[0]['input_tokens'] == 3600 assert bucketed365[0]['sessions'] == 8 def test_insights_bucketing_helper_preserves_label_and_title_fields(): # Short range → rows unchanged; no .label/.title keys rows10 = _make_daily_rows(10) bucketed10 = _py_bucket(rows10) assert bucketed10[0]['date'] == '2026-01-01' assert 'label' not in bucketed10[0] assert 'title' not in bucketed10[0] # 90-day → bucket rows have .label and .title rows90 = _make_daily_rows(90) bucketed90 = _py_bucket(rows90) assert 'label' in bucketed90[0], 'bucket row must have .label' assert 'title' in bucketed90[0], 'bucket row must have .title' assert '2026-01-01' in bucketed90[0]['title'], f'title should include start date, got {bucketed90[0]["title"]}' assert len(bucketed90[0]['label']) <= 12, f'label should be short, got {bucketed90[0]["label"]}' def test_insights_render_loop_uses_bucket_helper(): src = PANELS_JS daily_section_start = src.find('// Daily token trend') daily_section_end = src.find('// Models table', daily_section_start) daily_section = src[daily_section_start:daily_section_end] assert '_bucketDailyTokensForChart' in daily_section, '_bucketDailyTokensForChart must be called in the render loop' assert 'const chartRows' in daily_section, 'chartRows variable must be used instead of dailyTokens.map directly' def test_insights_css_chart_shrink_safe(): assert '.insights-daily-token-chart' in STYLE_CSS chart_line = [line for line in STYLE_CSS.splitlines() if '.insights-daily-token-chart' in line][0] # minmax(0,1fr) instead of minmax(12px,1fr) lets long-range bars shrink to fit the card assert 'minmax(0,1fr)' in chart_line, f'chart must use minmax(0,1fr) for shrink-safe columns, got: {chart_line}' assert 'overflow:hidden' in chart_line, 'chart must have overflow:hidden to prevent horizontal scroll' assert 'max-width:100%' in chart_line or 'max-width' in chart_line, 'chart should constrain max-width' def test_insights_mobile_layout_stacks_usage_grid(): # Regression test for issue #2104: Token Breakdown + Models should # stack on mobile instead of being side-by-side causing horizontal overflow assert 'insights-usage-grid' in PANELS_JS # Scoped mobile breakpoint that forces single-column layout assert '@media (max-width: 640px)' in STYLE_CSS assert '.insights-usage-grid' in STYLE_CSS assert 'grid-template-columns: 1fr' in STYLE_CSS def test_insights_mobile_models_table_has_contained_overflow(): # Regression test for issue #2104: Models table should have contained # horizontal scrolling instead of pushing the whole page off-screen assert 'insights-model-table' in PANELS_JS # The mobile rule should include overflow-x handling for the models card/table # Search for the specific mobile rule that contains insights-usage-grid insights_mobile = '/* ── Mobile layout for Token Breakdown + Models' assert insights_mobile in STYLE_CSS, 'Issue #2104 mobile rules should exist in CSS' # Get the block from our specific mobile section to the next section comment section_start = STYLE_CSS.find(insights_mobile) section_end = STYLE_CSS.find('/* ── Checkpoints', section_start) section_block = STYLE_CSS[section_start:section_end] assert 'overflow-x' in section_block, 'Mobile rule should include overflow-x handling' assert 'insights-model-table' in section_block or 'insights-card' in section_block # ── #3189: CLI/gateway sessions in Insights + webui double-count guard ────── def _call_insights_with_state_db(monkeypatch, tmp_path, entries, state_rows, days="7", now=None): """Like _call_insights but also seeds an agent state.db with `sessions` rows and points _active_state_db_path at it, so the CLI second-pass is exercised.""" import sqlite3 import api.routes as routes import api.models as models session_dir = tmp_path / "sessions" session_dir.mkdir() (session_dir / "_index.json").write_text(json.dumps(entries), encoding="utf-8") monkeypatch.setattr(routes, "SESSION_DIR", session_dir) if now is not None: monkeypatch.setattr(time, "time", lambda: now) db_path = tmp_path / "state.db" conn = sqlite3.connect(str(db_path)) conn.execute( """CREATE TABLE sessions ( id TEXT PRIMARY KEY, source TEXT, model TEXT, message_count INTEGER, input_tokens INTEGER, output_tokens INTEGER, estimated_cost_usd REAL, started_at REAL, ended_at REAL )""" ) for r in state_rows: conn.execute( "INSERT INTO sessions (id, source, model, message_count, input_tokens, " "output_tokens, estimated_cost_usd, started_at, ended_at) " "VALUES (?,?,?,?,?,?,?,?,?)", (r["id"], r.get("source"), r.get("model"), r.get("message_count", 0), r.get("input_tokens", 0), r.get("output_tokens", 0), r.get("estimated_cost_usd", 0.0), r.get("started_at"), r.get("ended_at")), ) conn.commit() conn.close() # _handle_insights does `from api.models import _active_state_db_path`, so patch on the module. monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) handler = _FakeHandler() parsed = SimpleNamespace(query=f"days={days}") routes._handle_insights(handler, parsed) assert handler.status == 200 return handler.json_body() def test_insights_includes_cli_and_gateway_sessions(monkeypatch, tmp_path): """CLI / Telegram / Discord sessions in state.db should appear in Insights totals.""" now = time.mktime((2026, 5, 30, 12, 0, 0, 0, 0, -1)) webui_entries = [ {"session_id": "w1", "updated_at": now, "created_at": now, "message_count": 2, "input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.01, "model": "gpt-5.5"}, ] state_rows = [ {"id": "cli1", "source": "cli", "model": "gpt-5.5", "message_count": 3, "input_tokens": 200, "output_tokens": 80, "estimated_cost_usd": 0.02, "started_at": now, "ended_at": now}, {"id": "tg1", "source": "telegram", "model": "gpt-5.5", "message_count": 1, "input_tokens": 60, "output_tokens": 20, "estimated_cost_usd": 0.005, "started_at": now, "ended_at": now}, ] data = _call_insights_with_state_db(monkeypatch, tmp_path, webui_entries, state_rows, days="7", now=now) # 1 webui + 2 cli/gateway = 3 sessions counted assert data["total_sessions"] == 3 # input tokens summed across all three: 100 + 200 + 60 = 360 assert data["total_input_tokens"] == 360 def test_insights_does_not_double_count_webui_state_db_rows(monkeypatch, tmp_path): """WebUI sessions persist to BOTH _index.json AND state.db (source='webui'). The CLI second-pass must exclude source='webui' so they aren't counted twice.""" now = time.mktime((2026, 5, 30, 12, 0, 0, 0, 0, -1)) webui_entries = [ {"session_id": "w1", "updated_at": now, "created_at": now, "message_count": 2, "input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.01, "model": "gpt-5.5"}, ] # The SAME webui session also has a state.db row (source='webui') + one real cli row. state_rows = [ {"id": "w1", "source": "webui", "model": "gpt-5.5", "message_count": 2, "input_tokens": 100, "output_tokens": 50, "estimated_cost_usd": 0.01, "started_at": now, "ended_at": now}, {"id": "cli1", "source": "cli", "model": "gpt-5.5", "message_count": 3, "input_tokens": 200, "output_tokens": 80, "estimated_cost_usd": 0.02, "started_at": now, "ended_at": now}, ] data = _call_insights_with_state_db(monkeypatch, tmp_path, webui_entries, state_rows, days="7", now=now) # webui session counted once (from _index.json), cli once = 2, NOT 3. assert data["total_sessions"] == 2 # webui tokens counted once (100) + cli (200) = 300, NOT 400. assert data["total_input_tokens"] == 300