Files
Hermes Agent 13c3646c55 Merge pull request #2121 into stage-344
fix: stack analytics usage cards on mobile (refs #2104) — TEST CONFLICT EXPECTED

# Conflicts:
#	tests/test_insights.py
2026-05-12 16:12:40 +00:00

321 lines
12 KiB
Python

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_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
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