# 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 """ 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)