mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
165 lines
5.5 KiB
Python
165 lines
5.5 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
|