Pipeline/backend/tests/test_runtime_usage_scrapers.py

390 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ruff: noqa: INP001
"""Tests for provider usage scraper parsers.
All tests are pure-Python — no subprocess, no tmux, no gateway connection.
Each fixture string represents a realistic sample of `claude /usage` output.
Tests are written first and drive the parser implementation.
"""
from __future__ import annotations
import pytest
# We import the parse function directly — it is pure and has no side effects.
from datetime import UTC, datetime
from app.services.openclaw.usage_scrapers import (
parse_claude_statusline_usage,
parse_claude_usage,
)
# ---------------------------------------------------------------------------
# Fixture text samples
# ---------------------------------------------------------------------------
# Standard output from `claude /usage` in Claude Code (subscription plan)
FIXTURE_STANDARD = """
╭──────────────────────────────────────────────────────────────────╮
│ Claude Code Usage │
╰──────────────────────────────────────────────────────────────────╯
Usage window (resets in 2h 47m):
67% of limit used
This week (Mon Sun):
Messages: 234
Input tokens: 1,234,567
Output tokens: 456,789
Est. cost: $4.23
"""
# Minimal output — just the percentage and reset time
FIXTURE_MINIMAL = """
Rate limit: 45% used
Resets in: 3h 15m
"""
# Output with messages limit shown as X/Y
FIXTURE_WITH_LIMIT = """
Usage window resets in 1h 5m:
Messages: 178 / 500 (35% used)
Input tokens: 890,123
Output tokens: 234,567
Weekly usage:
Messages: 892
Cost: $8.76
"""
# Sub-minute remaining time
FIXTURE_ALMOST_RESET = """
Usage: 99% of window used
Resets in: 42m
"""
# Only minutes remaining without hours
FIXTURE_MINUTES_ONLY = """
Context: 72% used
Time remaining: 28m
"""
# Days + hours format
FIXTURE_DAYS_HOURS = """
Next reset: in 1 day 4h
Current usage: 12%
"""
# "< 1 minute" edge case
FIXTURE_UNDER_ONE_MINUTE = """
Usage: 100%
Resets in: < 1m
"""
# Output without any percentage but with reset time
FIXTURE_NO_PCT = """
Session active.
Window resets in: 4h 0m
No usage data available.
"""
# Output with zero usage
FIXTURE_ZERO_USAGE = """
Usage window (resets in 5h 0m):
0% of limit used
Weekly: 0 messages, $0.00
"""
# Completely empty
FIXTURE_EMPTY = ""
# Garbage / error text
FIXTURE_ERROR = "Error: claude: command not found"
# Multi-line noise around the real data
FIXTURE_NOISY = """
...
Checking session...
Fetching usage stats...
Usage (resets in 2h 0m): 55% used
Messages this week: 301
...Done.
"""
# Alternate key casing variants that some versions might use
FIXTURE_ALT_KEYS = """
rate limit window: 89% Used
RESETS IN: 0h 30m
weekly messages: 412
weekly tokens: 9,876,543
"""
FIXTURE_SECTIONED_WINDOWS = """
Current session
77% used
Resets in: 23m
All models
21% used
Resets in: 12h 23m
Messages: 102 / 500
Input tokens: 450,000
Output tokens: 90,000
Est. cost: $2.34
Sonnet
64% used
Resets in: 3h 5m
"""
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestParseClaudeUsagePercentage:
def test_standard_percentage(self):
r = parse_claude_usage(FIXTURE_STANDARD)
assert r.current_pct == pytest.approx(67.0)
def test_minimal_percentage(self):
r = parse_claude_usage(FIXTURE_MINIMAL)
assert r.current_pct == pytest.approx(45.0)
def test_with_limit_percentage(self):
r = parse_claude_usage(FIXTURE_WITH_LIMIT)
assert r.current_pct == pytest.approx(35.0)
def test_near_full_percentage(self):
r = parse_claude_usage(FIXTURE_ALMOST_RESET)
assert r.current_pct == pytest.approx(99.0)
def test_zero_percentage(self):
r = parse_claude_usage(FIXTURE_ZERO_USAGE)
assert r.current_pct == pytest.approx(0.0)
def test_no_percentage_returns_none(self):
r = parse_claude_usage(FIXTURE_NO_PCT)
assert r.current_pct is None
def test_empty_returns_none(self):
r = parse_claude_usage(FIXTURE_EMPTY)
assert r.current_pct is None
def test_error_text_returns_none(self):
r = parse_claude_usage(FIXTURE_ERROR)
assert r.current_pct is None
def test_alt_key_casing(self):
r = parse_claude_usage(FIXTURE_ALT_KEYS)
assert r.current_pct == pytest.approx(89.0)
def test_noisy_output(self):
r = parse_claude_usage(FIXTURE_NOISY)
assert r.current_pct == pytest.approx(55.0)
class TestParseClaudeUsageRemainingTime:
def test_hours_and_minutes(self):
r = parse_claude_usage(FIXTURE_STANDARD)
# 2h 47m = 10020 seconds = 10,020,000 ms
assert r.remaining_ms == pytest.approx(10_020_000, rel=1e-3)
assert r.remaining_label == "2h 47m"
def test_hours_and_minutes_variant(self):
r = parse_claude_usage(FIXTURE_MINIMAL)
# 3h 15m = 11700 seconds
assert r.remaining_ms == pytest.approx(11_700_000, rel=1e-3)
def test_minutes_only(self):
r = parse_claude_usage(FIXTURE_MINUTES_ONLY)
# 28m = 1680 seconds
assert r.remaining_ms == pytest.approx(1_680_000, rel=1e-3)
assert r.remaining_label == "28m"
def test_days_and_hours(self):
r = parse_claude_usage(FIXTURE_DAYS_HOURS)
# 1 day 4h = 28 hours = 100800 seconds
assert r.remaining_ms == pytest.approx(100_800_000, rel=1e-3)
def test_under_one_minute(self):
r = parse_claude_usage(FIXTURE_UNDER_ONE_MINUTE)
assert r.remaining_ms is not None
assert r.remaining_ms < 60_000 # less than 1 minute
def test_short_reset(self):
r = parse_claude_usage(FIXTURE_ALMOST_RESET)
# 42m = 2520 seconds
assert r.remaining_ms == pytest.approx(2_520_000, rel=1e-3)
def test_no_time_returns_none(self):
r = parse_claude_usage(FIXTURE_ERROR)
assert r.remaining_ms is None
assert r.remaining_label is None
def test_empty_returns_none(self):
r = parse_claude_usage(FIXTURE_EMPTY)
assert r.remaining_ms is None
def test_with_limit_time(self):
r = parse_claude_usage(FIXTURE_WITH_LIMIT)
# 1h 5m = 3900 seconds
assert r.remaining_ms == pytest.approx(3_900_000, rel=1e-3)
def test_hours_only(self):
r = parse_claude_usage(FIXTURE_NO_PCT)
# 4h 0m = 14400 seconds
assert r.remaining_ms == pytest.approx(14_400_000, rel=1e-3)
def test_alt_keys_30m(self):
r = parse_claude_usage(FIXTURE_ALT_KEYS)
# "0h 30m" = 30m = 1800s
assert r.remaining_ms == pytest.approx(1_800_000, rel=1e-3)
class TestParseClaudeUsageWeeklyStats:
def test_weekly_messages_no_limit(self):
r = parse_claude_usage(FIXTURE_STANDARD)
assert r.weekly_messages_used == 234
assert r.weekly_messages_limit is None # no limit shown
def test_weekly_messages_with_limit(self):
r = parse_claude_usage(FIXTURE_WITH_LIMIT)
assert r.weekly_messages_used == 892
def test_weekly_tokens(self):
r = parse_claude_usage(FIXTURE_STANDARD)
# Input + output = 1,234,567 + 456,789 = 1,691,356
assert r.weekly_tokens_used == 1_691_356
def test_weekly_cost(self):
r = parse_claude_usage(FIXTURE_STANDARD)
assert r.weekly_cost_usd == pytest.approx(4.23, rel=1e-3)
def test_zero_weekly(self):
r = parse_claude_usage(FIXTURE_ZERO_USAGE)
assert r.weekly_messages_used == 0
assert r.weekly_cost_usd == pytest.approx(0.0)
def test_no_weekly_returns_none(self):
r = parse_claude_usage(FIXTURE_MINIMAL)
assert r.weekly_messages_used is None
assert r.weekly_cost_usd is None
def test_alt_keys_weekly_tokens(self):
r = parse_claude_usage(FIXTURE_ALT_KEYS)
assert r.weekly_tokens_used == 9_876_543
class TestParseClaudeUsageEdgeCases:
def test_returns_dataclass_always(self):
"""parse_claude_usage never raises — it always returns a result."""
for fixture in [
FIXTURE_STANDARD, FIXTURE_MINIMAL, FIXTURE_EMPTY,
FIXTURE_ERROR, FIXTURE_NOISY, FIXTURE_ALT_KEYS,
]:
result = parse_claude_usage(fixture)
assert result is not None
def test_error_field_on_empty(self):
r = parse_claude_usage(FIXTURE_EMPTY)
assert r.error is not None # signals "no parseable content"
def test_no_error_on_good_output(self):
r = parse_claude_usage(FIXTURE_STANDARD)
assert r.error is None
def test_raw_text_preserved(self):
r = parse_claude_usage(FIXTURE_MINIMAL)
assert r.raw_text == FIXTURE_MINIMAL
def test_messages_comma_separated_numbers(self):
"""Numbers like 1,234,567 must be parsed correctly."""
r = parse_claude_usage(FIXTURE_STANDARD)
assert r.weekly_tokens_used == 1_691_356 # 1,234,567 + 456,789
class TestParseClaudeUsageSectionedWindows:
def test_parses_independent_windows(self):
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
by_key = {window.key: window for window in r.windows}
assert "current_session" in by_key
assert "weekly_all_models" in by_key
assert "weekly_sonnet" in by_key
current = by_key["current_session"]
all_models = by_key["weekly_all_models"]
sonnet = by_key["weekly_sonnet"]
assert current.pct_used == pytest.approx(77.0)
assert current.remaining_label == "23m"
assert current.remaining_ms == pytest.approx(1_380_000, rel=1e-3)
assert all_models.pct_used == pytest.approx(21.0)
assert all_models.remaining_label == "12h 23m"
assert all_models.remaining_ms == pytest.approx(44_580_000, rel=1e-3)
assert sonnet.pct_used == pytest.approx(64.0)
assert sonnet.remaining_label == "3h 5m"
assert sonnet.remaining_ms == pytest.approx(11_100_000, rel=1e-3)
def test_flattened_fields_prioritize_current_session(self):
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
assert r.current_pct == pytest.approx(77.0)
assert r.remaining_label == "23m"
assert r.remaining_ms == pytest.approx(1_380_000, rel=1e-3)
def test_weekly_stats_prefer_all_models_section(self):
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
assert r.weekly_messages_used == 102
assert r.weekly_messages_limit == 500
assert r.weekly_tokens_used == 540_000
assert r.weekly_cost_usd == pytest.approx(2.34, rel=1e-3)
class TestParseClaudeStatuslineUsage:
def test_parses_official_rate_limits_fields(self):
now = datetime(2026, 5, 21, 12, 0, tzinfo=UTC)
r = parse_claude_statusline_usage(
{
"rate_limits": {
"five_hour": {
"used_percentage": 77,
"resets_at": int(now.timestamp()) + (23 * 60),
},
"seven_day": {
"used_percentage": 21,
"resets_at": int(now.timestamp()) + (12 * 3600) + (23 * 60),
},
},
},
now=now,
)
by_key = {window.key: window for window in r.windows}
assert r.error is None
assert by_key["current_session"].pct_used == pytest.approx(77.0)
assert by_key["current_session"].remaining_ms == 1_380_000
assert by_key["current_session"].remaining_label == "23m"
assert by_key["weekly_all_models"].pct_used == pytest.approx(21.0)
assert by_key["weekly_all_models"].remaining_label == "12h 23m"
assert r.current_pct == pytest.approx(77.0)
assert r.source == "provider_native"
assert r.confidence == "high"
def test_missing_rate_limits_is_visible_error(self):
r = parse_claude_statusline_usage({})
assert r.windows == []
assert r.error == "status-line payload did not include rate_limits"