"""Tests for tools/ralph_usage.py — rate limit budget tracking. Acoperă: - extract_usage_entry: shape corect, missing fields, JSON corupt → None - parse_usage_jsonl: skip linii corupte, file lipsă → [] - aggregate_by_day / aggregate_by_project: sume corecte, deduplicare - filter_by_days: window inclusiv vs exclusiv - summarize: today_cost/today_runs corecte - append_entry: atomic write, JSONL roundtrip - CLI append: idempotent la JSON corupt (no-op + exit 0) """ from __future__ import annotations import json import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] TOOLS = PROJECT_ROOT / "tools" if str(TOOLS) not in sys.path: sys.path.insert(0, str(TOOLS)) import ralph_usage # noqa: E402 # ── Sample claude -p --output-format json envelopes ──────────────── def _claude_envelope( *, cost: float = 0.55, input_tokens: int = 1234, output_tokens: int = 567, cache_read: int = 890, duration_ms: int = 49000, model: str = "claude-opus-4-7-20260101", ) -> dict: return { "type": "result", "subtype": "completed", "session_id": "abc123", "result": "Story implementat", "is_error": False, "total_cost_usd": cost, "duration_ms": duration_ms, "num_turns": 5, "usage": { "input_tokens": input_tokens, "output_tokens": output_tokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": cache_read, }, "model": model, } # ── extract_usage_entry ──────────────────────────────────────────── class TestExtractEntry: def test_full_envelope_extracts_all_fields(self): env = _claude_envelope() entry = ralph_usage.extract_usage_entry( env, slug="proj-a", story_id="US-001", iter_n=3, ts="2026-04-26T12:00:00+00:00", ) assert entry == { "ts": "2026-04-26T12:00:00+00:00", "slug": "proj-a", "story_id": "US-001", "iter": 3, "total_cost_usd": 0.55, "input_tokens": 1234, "output_tokens": 567, "cache_read": 890, "model": "claude-opus-4-7-20260101", "duration_ms": 49000, } def test_accepts_raw_string(self): env = _claude_envelope() entry = ralph_usage.extract_usage_entry( json.dumps(env), slug="x", story_id=None, iter_n=None, ts="2026-04-26T00:00:00+00:00", ) assert entry is not None assert entry["story_id"] is None assert entry["iter"] is None assert entry["total_cost_usd"] == 0.55 def test_corrupt_json_returns_none(self): assert ralph_usage.extract_usage_entry("{not json", slug="x") is None assert ralph_usage.extract_usage_entry("", slug="x") is None assert ralph_usage.extract_usage_entry("null", slug="x") is None def test_missing_usage_field_zeros(self): env = {"total_cost_usd": 0.1, "duration_ms": 1000} entry = ralph_usage.extract_usage_entry(env, slug="x") assert entry["input_tokens"] == 0 assert entry["output_tokens"] == 0 assert entry["cache_read"] == 0 assert entry["model"] is None def test_missing_cost_defaults_zero(self): env = {"usage": {"input_tokens": 100}} entry = ralph_usage.extract_usage_entry(env, slug="x") assert entry["total_cost_usd"] == 0.0 assert entry["input_tokens"] == 100 def test_non_dict_returns_none(self): assert ralph_usage.extract_usage_entry([], slug="x") is None assert ralph_usage.extract_usage_entry(123, slug="x") is None def test_alternative_cache_field_name(self): # Defensive: dacă viitor schema folosește `cache_read` env = {"usage": {"cache_read": 42}, "total_cost_usd": 0.1} entry = ralph_usage.extract_usage_entry(env, slug="x") assert entry["cache_read"] == 42 # ── parse_usage_jsonl ────────────────────────────────────────────── class TestParseJsonl: def test_file_missing_returns_empty(self, tmp_path): assert ralph_usage.parse_usage_jsonl(tmp_path / "ghost.jsonl") == [] def test_skips_corrupt_lines(self, tmp_path): p = tmp_path / "u.jsonl" p.write_text( '{"slug": "a", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.1}\n' "{not json}\n" '{"slug": "b", "ts": "2026-04-26T01:00:00+00:00", "total_cost_usd": 0.2}\n' "\n" "[]\n", # not a dict encoding="utf-8", ) entries = ralph_usage.parse_usage_jsonl(p) slugs = [e["slug"] for e in entries] assert slugs == ["a", "b"] def test_empty_file_returns_empty(self, tmp_path): p = tmp_path / "u.jsonl" p.write_text("", encoding="utf-8") assert ralph_usage.parse_usage_jsonl(p) == [] # ── aggregate_by_day / aggregate_by_project ─────────────────────── class TestAggregate: @pytest.fixture def entries(self): return [ {"slug": "proj-a", "ts": "2026-04-26T10:00:00+00:00", "total_cost_usd": 0.5, "input_tokens": 100, "output_tokens": 50, "cache_read": 200}, {"slug": "proj-a", "ts": "2026-04-26T11:00:00+00:00", "total_cost_usd": 0.3, "input_tokens": 80, "output_tokens": 30, "cache_read": 100}, {"slug": "proj-b", "ts": "2026-04-25T22:00:00+00:00", "total_cost_usd": 1.2, "input_tokens": 500, "output_tokens": 200, "cache_read": 0}, ] def test_aggregate_by_day(self, entries): result = ralph_usage.aggregate_by_day(entries) assert result["2026-04-26"]["cost_usd"] == 0.8 assert result["2026-04-26"]["runs"] == 2 assert result["2026-04-26"]["input_tokens"] == 180 assert result["2026-04-26"]["output_tokens"] == 80 assert result["2026-04-26"]["cache_read"] == 300 assert result["2026-04-25"]["cost_usd"] == 1.2 assert result["2026-04-25"]["runs"] == 1 # Sortare descrescătoare în iteration order keys = list(result.keys()) assert keys == ["2026-04-26", "2026-04-25"] def test_aggregate_by_project(self, entries): result = ralph_usage.aggregate_by_project(entries) assert result["proj-a"]["cost_usd"] == 0.8 assert result["proj-a"]["runs"] == 2 assert result["proj-b"]["cost_usd"] == 1.2 assert result["proj-b"]["runs"] == 1 def test_aggregate_handles_missing_slug(self): entries = [{"ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.1}] result = ralph_usage.aggregate_by_project(entries) assert "unknown" in result def test_aggregate_handles_missing_ts(self): entries = [{"slug": "x", "total_cost_usd": 0.1}] # Missing ts → skipped from by_day result = ralph_usage.aggregate_by_day(entries) assert result == {} def test_aggregate_empty_entries(self): assert ralph_usage.aggregate_by_day([]) == {} assert ralph_usage.aggregate_by_project([]) == {} # ── filter_by_days ───────────────────────────────────────────────── class TestFilterByDays: def test_window_inclusive_today(self): entries = [ {"ts": "2026-04-26T00:00:00+00:00", "slug": "a"}, {"ts": "2026-04-25T00:00:00+00:00", "slug": "a"}, {"ts": "2026-04-20T00:00:00+00:00", "slug": "a"}, ] kept = ralph_usage.filter_by_days(entries, 7, today="2026-04-26") # 7-day window inclusiv de la today: 2026-04-20 .. 2026-04-26 slugs = [e["ts"][:10] for e in kept] assert slugs == ["2026-04-26", "2026-04-25", "2026-04-20"] def test_window_exclusive_older(self): entries = [ {"ts": "2026-04-26T00:00:00+00:00"}, {"ts": "2026-04-19T00:00:00+00:00"}, # 7 days before today → exclus ] kept = ralph_usage.filter_by_days(entries, 7, today="2026-04-26") assert len(kept) == 1 assert kept[0]["ts"] == "2026-04-26T00:00:00+00:00" def test_zero_days_empty(self): entries = [{"ts": "2026-04-26T00:00:00+00:00"}] assert ralph_usage.filter_by_days(entries, 0, today="2026-04-26") == [] def test_corrupt_ts_skipped(self): entries = [{"ts": "garbage"}] assert ralph_usage.filter_by_days(entries, 7, today="2026-04-26") == [] # ── summarize ────────────────────────────────────────────────────── class TestSummarize: def test_summary_shape_and_today_split(self): entries = [ {"ts": "2026-04-26T10:00:00+00:00", "slug": "a", "total_cost_usd": 0.5, "input_tokens": 100, "output_tokens": 50, "cache_read": 0}, {"ts": "2026-04-26T11:00:00+00:00", "slug": "a", "total_cost_usd": 0.3, "input_tokens": 80, "output_tokens": 30, "cache_read": 0}, {"ts": "2026-04-25T00:00:00+00:00", "slug": "b", "total_cost_usd": 1.0, "input_tokens": 0, "output_tokens": 0, "cache_read": 0}, ] s = ralph_usage.summarize(entries, days=7, today="2026-04-26") assert s["today"] == "2026-04-26" assert s["today_cost"] == 0.8 assert s["today_runs"] == 2 assert s["window_days"] == 7 assert s["window_runs"] == 3 assert "by_project" in s assert "by_day" in s assert s["total_runs"] == 3 assert s["by_project"]["a"]["runs"] == 2 assert s["by_project"]["b"]["runs"] == 1 def test_summary_empty_entries(self): s = ralph_usage.summarize([], days=7, today="2026-04-26") assert s["today_cost"] == 0 assert s["today_runs"] == 0 assert s["by_project"] == {} assert s["by_day"] == {} assert s["total_runs"] == 0 # ── append_entry ─────────────────────────────────────────────────── class TestAppendEntry: def test_append_creates_file_with_jsonl_format(self, tmp_path): usage = tmp_path / "usage.jsonl" entry = {"slug": "x", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.1} ralph_usage.append_entry(usage, entry) text = usage.read_text(encoding="utf-8") assert text.endswith("\n") loaded = json.loads(text.strip()) assert loaded == entry def test_append_preserves_existing_entries(self, tmp_path): usage = tmp_path / "usage.jsonl" usage.write_text( '{"slug": "a", "ts": "2026-04-25T00:00:00+00:00", "total_cost_usd": 0.5}\n', encoding="utf-8", ) ralph_usage.append_entry(usage, {"slug": "b", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.3}) entries = ralph_usage.parse_usage_jsonl(usage) assert len(entries) == 2 assert entries[0]["slug"] == "a" assert entries[1]["slug"] == "b" def test_append_handles_missing_trailing_newline(self, tmp_path): usage = tmp_path / "usage.jsonl" usage.write_text( '{"slug": "a", "ts": "2026-04-25T00:00:00+00:00"}', # no trailing \n encoding="utf-8", ) ralph_usage.append_entry(usage, {"slug": "b", "ts": "2026-04-26T00:00:00+00:00"}) entries = ralph_usage.parse_usage_jsonl(usage) assert [e["slug"] for e in entries] == ["a", "b"] # ── CLI: append subcommand ───────────────────────────────────────── class TestCliAppend: def test_append_from_log_file(self, tmp_path): log = tmp_path / "iter.log" log.write_text(json.dumps(_claude_envelope(cost=0.42)), encoding="utf-8") usage = tmp_path / "usage.jsonl" rc = ralph_usage.main([ "append", str(usage), str(log), "--slug", "proj-a", "--story-id", "US-001", "--iter", "3", ]) assert rc == 0 entries = ralph_usage.parse_usage_jsonl(usage) assert len(entries) == 1 e = entries[0] assert e["slug"] == "proj-a" assert e["story_id"] == "US-001" assert e["iter"] == 3 assert e["total_cost_usd"] == 0.42 def test_append_corrupt_log_no_op(self, tmp_path): log = tmp_path / "iter.log" log.write_text("not json", encoding="utf-8") usage = tmp_path / "usage.jsonl" rc = ralph_usage.main([ "append", str(usage), str(log), "--slug", "proj-a", ]) # Idempotent: corrupt JSON → exit 0, no entry written assert rc == 0 assert not usage.exists() or ralph_usage.parse_usage_jsonl(usage) == [] def test_append_missing_log_no_op(self, tmp_path): usage = tmp_path / "usage.jsonl" rc = ralph_usage.main([ "append", str(usage), str(tmp_path / "missing.log"), "--slug", "x", ]) assert rc == 0 # ── CLI: summarize subcommand ────────────────────────────────────── class TestCliSummarize: def test_summarize_outputs_json(self, tmp_path, capsys): usage = tmp_path / "usage.jsonl" usage.write_text( json.dumps({"slug": "x", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.5}) + "\n", encoding="utf-8", ) rc = ralph_usage.main(["summarize", str(usage), "--days", "7"]) assert rc == 0 out = json.loads(capsys.readouterr().out) assert "today" in out assert "by_project" in out assert "by_day" in out