Pipeline/backend/tests/test_runtime_usage_scrapers.py

290 lines
8.7 KiB
Python
Raw Normal View History

# 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 app.services.openclaw.usage_scrapers import 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
"""
# ---------------------------------------------------------------------------
# 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