2026-05-20 20:55:05 -05:00
|
|
|
|
# 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.
|
2026-05-21 04:25:31 -05:00
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
|
|
from app.services.openclaw.usage_scrapers import (
|
|
|
|
|
|
parse_claude_statusline_usage,
|
|
|
|
|
|
parse_claude_usage,
|
|
|
|
|
|
)
|
2026-05-20 20:55:05 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-21 01:32:59 -05:00
|
|
|
|
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
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-20 20:55:05 -05:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
2026-05-21 01:32:59 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-21 04:25:31 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|