290 lines
8.7 KiB
Python
290 lines
8.7 KiB
Python
|
|
# 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
|