mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
13c3646c55
fix: stack analytics usage cards on mobile (refs #2104) — TEST CONFLICT EXPECTED # Conflicts: # tests/test_insights.py
321 lines
12 KiB
Python
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
|