From 8cc31a2c87bf636e494e88c9cb964c4b96eab2b1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 9 Nov 2026 08:02:55 +0100 Subject: [PATCH] =?UTF-8?q?HLTB=20rate=20limit=20(429)=20wipes=20confidenc?= =?UTF-8?q?e=20data=20in=20hl=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo: 9b9342ba --- requirements.txt | 6 +- steam_backlog_enforcer/_hltb_auth_refresh.py | 62 ++ steam_backlog_enforcer/_hltb_cached.py | 7 +- steam_backlog_enforcer/_hltb_comp_extract.py | 23 +- steam_backlog_enforcer/_hltb_confidence.py | 86 ++- steam_backlog_enforcer/_hltb_failures.py | 154 ++++ steam_backlog_enforcer/_hltb_pacing.py | 21 + steam_backlog_enforcer/_hltb_search.py | 175 ++--- steam_backlog_enforcer/_hltb_search_api.py | 31 +- steam_backlog_enforcer/_hltb_search_one.py | 198 +++++ steam_backlog_enforcer/_hltb_types.py | 3 +- .../_scanning_confidence.py | 29 +- steam_backlog_enforcer/_stats.py | 21 + .../tests/_hltb_fake_transport.py | 93 +++ .../tests/_isolate_control_plane.py | 21 +- steam_backlog_enforcer/tests/test_hltb.py | 2 +- .../tests/test_hltb_auth_refresh.py | 194 +++++ .../tests/test_hltb_build_search_payload.py | 2 +- .../test_hltb_confidence_failure_paths.py | 93 +++ .../tests/test_hltb_confidence_helpers.py | 11 +- .../tests/test_hltb_failure_reporting.py | 131 ++++ .../tests/test_hltb_pacing.py | 126 +++ .../test_hltb_refetch_failure_keeps_cache.py | 75 ++ .../tests/test_hltb_search.py | 6 +- .../tests/test_hltb_search_batch_failures.py | 124 +++ .../test_hltb_search_failure_keeps_cache.py | 124 +++ .../test_hltb_search_fetch_batch_hltb.py | 7 +- .../test_hltb_search_zero_comp_100_skipped.py | 4 +- ...ls_tracking_backfill_polls_for_finished.py | 64 +- .../tests/test_polls_tracking_part2.py | 36 +- ...eport_poll_confidence_chosen_equals_min.py | 43 +- .../tests/test_scanning_confidence_helpers.py | 33 +- web/package-lock.json | 730 +++++++----------- web/package.json | 14 +- web/pnpm-lock.yaml | 560 ++++++-------- 35 files changed, 2236 insertions(+), 1073 deletions(-) create mode 100644 steam_backlog_enforcer/_hltb_auth_refresh.py create mode 100644 steam_backlog_enforcer/_hltb_failures.py create mode 100644 steam_backlog_enforcer/_hltb_pacing.py create mode 100644 steam_backlog_enforcer/_hltb_search_one.py create mode 100644 steam_backlog_enforcer/tests/_hltb_fake_transport.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_auth_refresh.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_confidence_failure_paths.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_failure_reporting.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_pacing.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_search_batch_failures.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_search_failure_keeps_cache.py diff --git a/requirements.txt b/requirements.txt index 3197564..ffd0cc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ aiohttp==3.14.5 bandit==1.9.4 codespell==2.4.3 -coverage==7.17.0 +coverage==7.17.2 earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time howlongtobeatpy==1.0.24 leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.7 @@ -14,11 +14,11 @@ pre-commit==4.7.0 pylint==4.1.3 pytest==9.2.0 pytest-cov==7.1.0 -pytest-randomly==5.0.0 +pytest-randomly==5.1.0 pytest-sugar==1.1.1 pytest-timeout==2.4.0 pytest-xdist==3.8.0 requests==2.34.2 -ruff==0.17.3 +ruff==0.17.4 types-requests==2.33.0.20261030 websockets==17.4 diff --git a/steam_backlog_enforcer/_hltb_auth_refresh.py b/steam_backlog_enforcer/_hltb_auth_refresh.py new file mode 100644 index 0000000..3d20823 --- /dev/null +++ b/steam_backlog_enforcer/_hltb_auth_refresh.py @@ -0,0 +1,62 @@ +"""Renew the HLTB auth token during a long, paced search batch. + +The token from ``/api/search/site/init`` stops working a few minutes after it +is issued: on 2026-11-08 every search ~3 min in came back 403 while a fresh +token got 200 for the same game. Unpaced batches finished before that; a +paced ~800-game refresh takes 15+ min, so the token is renewed by age. +""" + +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._hltb_pacing import pause_after_request +from steam_backlog_enforcer._hltb_search_api import _get_auth_info + +if TYPE_CHECKING: + from steam_backlog_enforcer._hltb_search_one import _SearchCtx + from steam_backlog_enforcer._hltb_types import _AuthInfo + +logger = logging.getLogger(__name__) + +# Well inside the observed ~3 min lifetime (still valid at ~146 s). +AUTH_MAX_AGE_S = 90.0 +# Indirection so tests can drive a fake clock. +_now = time.monotonic + + +def apply_auth(headers: dict[str, str], auth: _AuthInfo) -> None: + """Put *auth* into the search request headers, replacing any old values.""" + headers["x-auth-token"] = auth.token + if auth.hp_key: + headers["x-hp-key"] = auth.hp_key + headers["x-hp-val"] = auth.hp_val + else: + headers.pop("x-hp-key", None) + headers.pop("x-hp-val", None) + + +async def ensure_fresh_auth(ctx: _SearchCtx) -> str | None: + """Renew ``ctx.auth`` once it is too old. + + Returns ``None`` when the auth is usable, else the failure reason. A + failed renewal trips the breaker: the stale token is never used again. + """ + if _now() - ctx.auth_at < AUTH_MAX_AGE_S: + return None + async with ctx.auth_lock: + if _now() - ctx.auth_at < AUTH_MAX_AGE_S: + return None # another lookup renewed it while we waited + failures: list[str] = [] + auth = await _get_auth_info(ctx.search_url, ctx.session, failures) + await pause_after_request() + if auth is None: + ctx.breaker.tripped = True + return f"auth refresh {failures[0] if failures else 'no token'}" + ctx.auth = auth + apply_auth(ctx.headers, auth) + ctx.auth_at = _now() + logger.info("HLTB auth token refreshed.") + return None diff --git a/steam_backlog_enforcer/_hltb_cached.py b/steam_backlog_enforcer/_hltb_cached.py index d3813b8..f24d9c8 100644 --- a/steam_backlog_enforcer/_hltb_cached.py +++ b/steam_backlog_enforcer/_hltb_cached.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from steam_backlog_enforcer._hltb_failures import HLTBUnavailableError from steam_backlog_enforcer._hltb_types import ( ProgressCb, _HLTBExtras, @@ -83,7 +84,11 @@ def fetch_hltb_times_cached( report = reporting_progress_cb( "Fetching HLTB times", len(uncached), progress_cb ) - elapsed = fetch_hltb_times_timed(uncached, cache, polls, report, extras) + try: + elapsed = fetch_hltb_times_timed(uncached, cache, polls, report, extras) + except HLTBUnavailableError: + # Already logged and tallied by the batch; the cache is untouched. + return cache # Final save. save_hltb_cache(cache, polls, extras) diff --git a/steam_backlog_enforcer/_hltb_comp_extract.py b/steam_backlog_enforcer/_hltb_comp_extract.py index a1c0435..7b4f82e 100644 --- a/steam_backlog_enforcer/_hltb_comp_extract.py +++ b/steam_backlog_enforcer/_hltb_comp_extract.py @@ -13,6 +13,13 @@ from typing import TYPE_CHECKING, Any import aiohttp +from steam_backlog_enforcer._hltb_failures import ( + detail_status_failed, + exc_reason, + record_failure, + status_reason, +) +from steam_backlog_enforcer._hltb_pacing import pause_after_request from steam_backlog_enforcer._hltb_page_parse import ( _as_positive_int, _parse_game_page, @@ -110,10 +117,16 @@ async def _fetch_detail_one( if resp.status == HTTPStatus.OK: html = await resp.text() return _parse_game_page(html) + if not detail_status_failed(resp.status): + return None # no such page: a real answer, not a failure + reason = status_reason(resp.status) except (TimeoutError, aiohttp.ClientError) as exc: - logger.debug( - "HLTB detail fetch failed for game_id=%d: %s", - hltb_game_id, - exc, - ) + reason = exc_reason(exc) + finally: + await pause_after_request() + # The caller keeps the cached hours when this returns None. + logger.warning( + "HLTB detail fetch failed for game_id=%d (%s)", hltb_game_id, reason + ) + record_failure(f"detail {reason}") return None diff --git a/steam_backlog_enforcer/_hltb_confidence.py b/steam_backlog_enforcer/_hltb_confidence.py index 3d35939..077a70e 100644 --- a/steam_backlog_enforcer/_hltb_confidence.py +++ b/steam_backlog_enforcer/_hltb_confidence.py @@ -10,6 +10,7 @@ import logging import time from steam_backlog_enforcer._hltb_cached import reporting_progress_cb +from steam_backlog_enforcer._hltb_failures import HLTBUnavailableError from steam_backlog_enforcer._hltb_types import ( HLTB_BASE_URL, ProgressCb, @@ -38,28 +39,36 @@ logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────── -def refetch_poll_counts(missing: list[tuple[int, str]]) -> dict[int, int]: - """Re-search ``missing`` for poll counts and return the refreshed polls cache. +def refresh_confidence( + games: list[tuple[int, str]], +) -> tuple[dict[int, float], dict[int, int], dict[int, int]]: + """Re-search ``games`` (cached or not) and save hours, polls and count_comp. - The search-level fetch skips anything already in the hours cache, so the - hours entries are dropped first to force it -- then put back where the - refetch found nothing better, since a prior leisure/DLC estimate is - trusted over a miss. + Nothing is dropped from the cache first: a failed lookup writes nothing, + so the old entry survives. Prior hours beat a clean miss, since a prior + leisure/DLC estimate is trusted over "not found". When HLTB is unavailable + the cache is returned unchanged and nothing is saved. + + Returns: + The (hours, polls, count_comp) caches after the refresh. """ cache = load_hltb_cache() polls = load_hltb_polls_cache() - preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache} - for aid, _name in missing: - cache.pop(aid, None) - save_hltb_cache(cache, polls) + count_comp = load_hltb_count_comp_cache() + prior_hours = {aid: cache[aid] for aid, _ in games if aid in cache} + try: + fetch_hltb_confidence(games, cache=cache, polls=polls, count_comp=count_comp) + except HLTBUnavailableError: + # Already logged and tallied by the batch; the cache is untouched. + return cache, polls, count_comp + restore_prior_hours(cache, prior_hours) + save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp)) + return cache, polls, count_comp - fetch_hltb_confidence_cached(missing) - refreshed_hours = load_hltb_cache() - refreshed_polls = load_hltb_polls_cache() - restore_prior_hours(refreshed_hours, preserved_hours) - save_hltb_cache(refreshed_hours, refreshed_polls) - return refreshed_polls +def refetch_poll_counts(missing: list[tuple[int, str]]) -> dict[int, int]: + """Re-search ``missing`` for poll counts and return the refreshed polls cache.""" + return refresh_confidence(missing)[1] def fetch_hltb_confidence_cached( @@ -79,15 +88,19 @@ def fetch_hltb_confidence_cached( len(games) - len(uncached), ) t0 = time.monotonic() - fetch_hltb_confidence( - uncached, - cache=cache, - polls=polls, - progress_cb=reporting_progress_cb( - "Fetching HLTB poll counts", len(uncached), progress_cb - ), - count_comp=count_comp, - ) + try: + fetch_hltb_confidence( + uncached, + cache=cache, + polls=polls, + progress_cb=reporting_progress_cb( + "Fetching HLTB poll counts", len(uncached), progress_cb + ), + count_comp=count_comp, + ) + except HLTBUnavailableError: + # Already logged and tallied by the batch; the cache is untouched. + return cache elapsed = time.monotonic() - t0 save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp)) @@ -116,9 +129,9 @@ def fetch_hltb_detail_missing( Also backfills ``hltb_game_id`` for any game that already has rush/leisure data but whose HLTB game ID was never stored (e.g. from an old cache). Games with both rush data and a game_id are skipped entirely. For the - rest, temporarily removes them from the hours cache so ``fetch_hltb_times`` - will visit their detail pages. Restores prior hours for any game the - re-fetch doesn't find. + rest, ``fetch_hltb_times`` re-searches them and visits their detail pages + (it never skips cached games). Prior hours win over a clean miss; a failed + lookup writes nothing, so its old entry survives untouched. Args: games: list of (app_id, name) tuples to check. @@ -152,10 +165,7 @@ def fetch_hltb_detail_missing( hltb_game_id=game_id_cache, ) - # Remove from hours cache so fetch_hltb_times will visit the detail page. - prior_hours: dict[int, float] = {} - for app_id, _ in missing: - prior_hours[app_id] = cache.pop(app_id, -1.0) + prior_hours = {aid: cache[aid] for aid, _ in missing if aid in cache} n_rush = len(missing_rush) n_id = len(missing_id_only) @@ -173,7 +183,11 @@ def fetch_hltb_detail_missing( else: logger.info("Backfilling HLTB game ID for %d game(s)...", n_id) report = reporting_progress_cb("Fetching HLTB details", len(missing), progress_cb) - elapsed = fetch_hltb_times_timed(missing, cache, polls, report, extras) + try: + elapsed = fetch_hltb_times_timed(missing, cache, polls, report, extras) + except HLTBUnavailableError: + # Already logged and tallied by the batch; the cache is untouched. + return 0 restore_prior_hours(cache, prior_hours) @@ -200,7 +214,11 @@ def get_hltb_submit_url(game_name: str) -> str | None: URL like ``https://howlongtobeat.com/submit/game/12345``, or ``None`` if the game wasn't found. """ - results = fetch_hltb_times([(0, game_name)]) + try: + results = fetch_hltb_times([(0, game_name)]) + except HLTBUnavailableError: + # Already logged and tallied by the batch; the cache is untouched. + return None if results and results[0].hltb_game_id: return f"{HLTB_BASE_URL}/submit/game/{results[0].hltb_game_id}" return None diff --git a/steam_backlog_enforcer/_hltb_failures.py b/steam_backlog_enforcer/_hltb_failures.py new file mode 100644 index 0000000..a3849bd --- /dev/null +++ b/steam_backlog_enforcer/_hltb_failures.py @@ -0,0 +1,154 @@ +"""Failure accounting for HLTB lookups. + +A failed request (non-200, timeout, connection error, non-JSON body) is not +evidence that a game is missing from HLTB, so the search path must keep the +cached entry instead of writing a miss. This module tells the two apart, counts +the failures for the end-of-run summary, and holds the per-batch circuit +breaker that stops hammering HLTB once it starts rate-limiting us. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from http import HTTPStatus +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +# Consecutive 429s/403s after which the command sends no more requests. +BREAKER_LIMIT = 5 +_BLOCKED = frozenset({HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.FORBIDDEN}) +SKIPPED_AFTER_RATE_LIMIT = "skipped after rate-limit" +# A detail page that does not exist is a real answer, not a failed lookup. +_DETAIL_NOT_A_FAILURE = frozenset({HTTPStatus.OK, HTTPStatus.NOT_FOUND}) +_PLURAL = {"timeout": "timeouts", "connection error": "connection errors"} + + +class HLTBUnavailableError(RuntimeError): + """Nothing in an HLTB batch could be refreshed; the cache was left alone.""" + + +@dataclass +class Breaker: + """Circuit breaker on consecutive HTTP 429 / 403 answers. + + 403 counts too: a rejected token fails every later request the same way, + and on 2026-11-08 one let 44 doomed requests through before a manual stop. + """ + + consecutive_blocked: int = 0 + tripped: bool = False + + def note(self, status: int | None) -> None: + """Record one request outcome (``None`` = no HTTP status at all).""" + if status in _BLOCKED: + self.consecutive_blocked += 1 + if self.consecutive_blocked >= BREAKER_LIMIT: + self.tripped = True + else: + self.consecutive_blocked = 0 + + +@dataclass +class _Tally: + """Failures since the last :func:`reset_failures` (one CLI command).""" + + reasons: Counter[str] = field(default_factory=Counter) + # App ids, so a game skipped by several passes of one command counts once. + breaker_games: set[int] = field(default_factory=set) + fatal: list[str] = field(default_factory=list) + # Lookups that got a real answer (match or clean miss). + successes: int = 0 + # Shared by every batch of the command: once HLTB rate-limits us, later + # batches must not start hammering it again. + breaker: Breaker = field(default_factory=Breaker) + + +_TALLY = _Tally() + + +def reset_failures() -> None: + """Start a fresh tally (call at the start of a command).""" + _TALLY.reasons.clear() + _TALLY.breaker_games.clear() + _TALLY.fatal.clear() + _TALLY.successes = 0 + _TALLY.breaker = Breaker() + + +def shared_breaker() -> Breaker: + """The command-wide circuit breaker (fresh after :func:`reset_failures`).""" + return _TALLY.breaker + + +def record_success() -> None: + """Count one lookup that got a real answer from HLTB.""" + _TALLY.successes += 1 + + +def record_failure(reason: str) -> None: + """Count one failed lookup under *reason*.""" + _TALLY.reasons[reason] += 1 + + +def record_breaker(app_ids: Iterable[int]) -> None: + """Remember the games a tripped breaker left unrefreshed.""" + _TALLY.breaker_games.update(app_ids) + + +def record_fatal(message: str) -> None: + """Remember a batch that refreshed nothing at all.""" + _TALLY.fatal.append(message) + + +def had_total_failure() -> bool: + """True when some batch failed outright and no lookup at all succeeded.""" + return bool(_TALLY.fatal) and not _TALLY.successes + + +def status_reason(status: int) -> str: + """Label for a non-200 answer.""" + return f"HTTP {status}" + + +def exc_reason(exc: BaseException) -> str: + """Label for a request that raised instead of answering cleanly.""" + if isinstance(exc, TimeoutError): + return "timeout" + if isinstance(exc, ValueError): # json.JSONDecodeError and friends + return "bad JSON" + return "connection error" + + +def detail_status_failed(status: int) -> bool: + """True when a detail-page answer is a failure rather than a real verdict.""" + return status not in _DETAIL_NOT_A_FAILURE + + +def _label(reason: str) -> str: + """Plural form for the summary (``detail timeout`` -> ``detail timeouts``).""" + for singular, plural in _PLURAL.items(): + if reason.endswith(singular): + return reason[: -len(singular)] + plural + return reason + + +def failure_summary_lines() -> list[str]: + """One-line summaries of every failure since the last reset (empty if none).""" + lines: list[str] = [] + total = sum(_TALLY.reasons.values()) + if total: + parts = ", ".join( + f"{_label(reason)} x{count}" + for reason, count in _TALLY.reasons.most_common() + ) + lines.append( + f"HLTB: {total} lookups failed ({parts}), cache kept for those games" + ) + if _TALLY.breaker_games: + n_games = len(_TALLY.breaker_games) + lines.append(f"HLTB: rate-limited, {n_games} games not refreshed") + lines.extend(dict.fromkeys(_TALLY.fatal)) # each batch's verdict once + return lines diff --git a/steam_backlog_enforcer/_hltb_pacing.py b/steam_backlog_enforcer/_hltb_pacing.py new file mode 100644 index 0000000..c6c42ce --- /dev/null +++ b/steam_backlog_enforcer/_hltb_pacing.py @@ -0,0 +1,21 @@ +"""Pacing for HLTB requests. + +HLTB answers 429 ("Slow your roll!") after about 60 requests in a minute: +unpaced bursts and a 2 req/s run (2026-11-08) both stopped near 60. A full +library refresh is ~800 games, so the single request slot +(``MAX_CONCURRENT``) waits ``REQUEST_INTERVAL_S`` after every request before +the next one goes out: about 55 requests per minute. +""" + +from __future__ import annotations + +import asyncio + +REQUEST_INTERVAL_S = 1.1 +# Indirection so tests can drop the wait without touching asyncio itself. +_sleep = asyncio.sleep + + +async def pause_after_request() -> None: + """Hold the caller's request slot for the pacing interval.""" + await _sleep(REQUEST_INTERVAL_S) diff --git a/steam_backlog_enforcer/_hltb_search.py b/steam_backlog_enforcer/_hltb_search.py index e83a488..e91deb6 100644 --- a/steam_backlog_enforcer/_hltb_search.py +++ b/steam_backlog_enforcer/_hltb_search.py @@ -3,147 +3,76 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass, field from http import HTTPStatus import logging import aiohttp +from steam_backlog_enforcer._hltb_auth_refresh import apply_auth from steam_backlog_enforcer._hltb_detail import ( _fetch_leisure_times, ) -from steam_backlog_enforcer._hltb_matching import ( - _build_result_from_best, - _build_search_variants, +from steam_backlog_enforcer._hltb_failures import ( + SKIPPED_AFTER_RATE_LIMIT, + HLTBUnavailableError, + record_breaker, + record_failure, + record_fatal, + shared_breaker, + status_reason, ) from steam_backlog_enforcer._hltb_search_api import ( - _build_search_payload, - _collect_candidates, _get_auth_info, _get_hltb_search_url, - _pick_best_hltb_entry, forget_hltb_search_url, ) +from steam_backlog_enforcer._hltb_search_one import _search_one, _SearchCtx from steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, MAX_CONCURRENT, HLTBResult, ProgressCb, - _AuthInfo, _HLTBExtras, - save_hltb_cache, ) logger = logging.getLogger(__name__) -# When extended entry has ≥ this many times more hours than the exact match, -# prefer it even if its confidence count is lower. -_EXTENDED_DOMINANCE_RATIO = 4.0 -# Minimum combined confidence for the dominance path (avoids picking entries -# that have almost no data at all). -_EXTENDED_MIN_CONFIDENCE = 3 - - -# ────────────────────────────────────────────────────────────── -# HLTB API setup (done once, not per-request like the library) -# ────────────────────────────────────────────────────────────── - - # ────────────────────────────────────────────────────────────── # Async fetching with shared session & progress # ────────────────────────────────────────────────────────────── -@dataclass -class _SearchCtx: - """Shared context for HLTB search requests.""" - - session: aiohttp.ClientSession - search_url: str - headers: dict[str, str] - cache: dict[int, float] - polls: dict[int, int] = field(default_factory=dict) - extras: _HLTBExtras = field(default_factory=_HLTBExtras) - auth: _AuthInfo | None = None - counter: dict[str, int] = field(default_factory=dict) - total: int = 0 - progress_cb: ProgressCb | None = None +def _fail_batch(message: str) -> HLTBUnavailableError: + """Log and remember a batch that refreshed nothing; return the error.""" + logger.warning("%s", message) + record_fatal(message) + return HLTBUnavailableError(message) -async def _search_one( - sem: asyncio.Semaphore, - ctx: _SearchCtx, - app_id: int, - name: str, -) -> HLTBResult | None: - """Search HLTB for one game via direct POST, update cache.""" - async with sem: - result: HLTBResult | None = None - for query_name in _build_search_variants(name): - payload = _build_search_payload(query_name, ctx.auth) - try: - async with ctx.session.post( - ctx.search_url, - headers=ctx.headers, - data=payload, - ) as resp: - if resp.status != HTTPStatus.OK: - continue - data = await resp.json() - candidates = _collect_candidates(query_name, data) - # When we stripped ": subtitle" from the original name to - # get query_name, only keep full-edition entries (those - # whose HLTB name starts with query_name + ":"/"-") or - # exact name/alias matches. This prevents "Vox Populi" - # (stripped from "Vox Populi: Poland 2023") from falsely - # matching "Vox Populi Vox Dei 2". - if ":" in name and ":" not in query_name: - lower_q = query_name.lower() - candidates = [ - (e, s) - for e, s in candidates - if (e.get("game_name") or "").lower() == lower_q - or (e.get("game_alias") or "").lower() == lower_q - or (e.get("game_name") or "") - .lower() - .startswith(lower_q + ":") - or (e.get("game_name") or "") - .lower() - .startswith(lower_q + " -") - ] - best = _pick_best_hltb_entry(query_name, candidates) - if best is None: - continue - result = _build_result_from_best(app_id, name, query_name, best) - break - except (TimeoutError, aiohttp.ClientError) as exc: - logger.debug("HLTB search failed for '%s': %s", query_name, exc) +def _skip_rate_limited(games: list[tuple[int, str]]) -> HLTBUnavailableError: + """Count a whole batch as not refreshed because the breaker already tripped.""" + for _game in games: + record_failure(SKIPPED_AFTER_RATE_LIMIT) + record_breaker(app_id for app_id, _name in games) + msg = f"HLTB: rate-limited, skipped {len(games)} lookups" + logger.warning("%s", msg) + return HLTBUnavailableError(msg) - # Update cache immediately (miss = -1). - if result is not None: - ctx.cache[app_id] = result.completionist_hours - ctx.polls[app_id] = result.comp_100_count - ctx.extras.count_comp[app_id] = result.count_comp - if result.hltb_game_id > 0: - ctx.extras.hltb_game_id[app_id] = result.hltb_game_id - ctx.counter["found"] += 1 - else: - ctx.cache[app_id] = -1 - ctx.polls[app_id] = 0 - ctx.extras.count_comp[app_id] = 0 - ctx.counter["done"] += 1 - done = ctx.counter["done"] - - # Incremental save every _SAVE_INTERVAL lookups. - if not done % _SAVE_INTERVAL: - save_hltb_cache(ctx.cache, ctx.polls, ctx.extras) - - # Report progress. - if ctx.progress_cb is not None: - ctx.progress_cb(done, ctx.total, ctx.counter["found"], name) - - return result +def _check_batch(ctx: _SearchCtx) -> None: + """Report a batch's failures; raise when not a single lookup succeeded.""" + failed = ctx.counter["failed"] + if not failed: + return + logger.warning( + "HLTB: %d/%d lookups failed in this batch; cache kept for them", + failed, + ctx.total, + ) + if ctx.breaker.tripped: + record_breaker(ctx.failed_ids) + if failed == ctx.total: + msg = f"HLTB: all {failed} lookups failed, nothing refreshed" + raise _fail_batch(msg) async def _search_batch( @@ -157,7 +86,16 @@ async def _search_batch( Hours, poll counts and the extras come from the search results alone; :func:`_fetch_batch` layers the per-game detail pages on top of this. + + Raises: + HLTBUnavailableError: auth init failed, or every lookup failed. The + caches are untouched in both cases. """ + if not games: + return [] + breaker = shared_breaker() + if breaker.tripped: + raise _skip_rate_limited(games) # 1. Discover the search URL (sync, one-time). search_url = _get_hltb_search_url() logger.info("HLTB search URL: %s", search_url) @@ -165,12 +103,16 @@ async def _search_batch( timeout = aiohttp.ClientTimeout(total=20, sock_read=15) # 2. Get auth info (separate session — avoids reuse issues). + auth_failures: list[str] = [] async with aiohttp.ClientSession(timeout=timeout) as init_session: - auth = await _get_auth_info(search_url, init_session) + auth = await _get_auth_info(search_url, init_session, auth_failures) if auth is None: - logger.warning("Could not get HLTB auth info, aborting fetch.") forget_hltb_search_url() - return [] + why = auth_failures[0] if auth_failures else "no token" + if why == status_reason(HTTPStatus.TOO_MANY_REQUESTS): + breaker.tripped = True + msg = f"HLTB: rate-limited, nothing refreshed (auth init: {why})" + raise _fail_batch(msg) logger.info("HLTB auth token acquired.") # 3. Build shared headers for all search requests. @@ -181,11 +123,8 @@ async def _search_batch( "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" ), "referer": "https://howlongtobeat.com/", - "x-auth-token": auth.token, } - if auth.hp_key: - headers["x-hp-key"] = auth.hp_key - headers["x-hp-val"] = auth.hp_val + apply_auth(headers, auth) # 4. Fire all searches through a single persistent session. sem = asyncio.Semaphore(MAX_CONCURRENT) @@ -205,12 +144,14 @@ async def _search_batch( polls=polls, extras=extras, auth=auth, - counter={"done": 0, "found": 0}, + counter={"done": 0, "found": 0, "failed": 0}, total=len(games), progress_cb=progress_cb, + breaker=breaker, ) tasks = [_search_one(sem, ctx, app_id, name) for app_id, name in games] results = await asyncio.gather(*tasks) + _check_batch(ctx) return [r for r in results if r is not None] diff --git a/steam_backlog_enforcer/_hltb_search_api.py b/steam_backlog_enforcer/_hltb_search_api.py index d1673fa..a34e47a 100644 --- a/steam_backlog_enforcer/_hltb_search_api.py +++ b/steam_backlog_enforcer/_hltb_search_api.py @@ -15,6 +15,7 @@ from typing import Any import aiohttp from howlongtobeatpy.HTMLRequests import HTMLRequests +from steam_backlog_enforcer._hltb_failures import exc_reason, status_reason from steam_backlog_enforcer._hltb_matching import ( _find_best_extended, _find_exact_match, @@ -91,14 +92,20 @@ def forget_hltb_search_url() -> None: async def _get_auth_info( search_url: str, session: aiohttp.ClientSession, + failures: list[str] | None = None, ) -> _AuthInfo | None: - """Fetch the HLTB auth token and honeypot key/val (one GET request).""" + """Fetch the HLTB auth token and honeypot key/val (one GET request). + + On failure returns ``None`` and, when *failures* is given, appends why + (``HTTP 429``, ``timeout``, ...) so the caller can report it. + """ init_url = search_url + "/init" ts = int(time.time() * 1000) headers = { "User-Agent": _USER_AGENT, "referer": "https://howlongtobeat.com/", } + reason = "no token" try: async with session.get( init_url, @@ -108,15 +115,19 @@ async def _get_auth_info( if resp.status == HTTPStatus.OK: data = await resp.json() token: str | None = data.get("token") - if token is None: - return None - return _AuthInfo( - token=token, - hp_key=data.get("hpKey", ""), - hp_val=data.get("hpVal", ""), - ) - except TimeoutError, aiohttp.ClientError: - logger.warning("Failed to get HLTB auth token") + if token is not None: + return _AuthInfo( + token=token, + hp_key=data.get("hpKey", ""), + hp_val=data.get("hpVal", ""), + ) + else: + reason = status_reason(resp.status) + except (TimeoutError, aiohttp.ClientError, ValueError) as exc: + reason = exc_reason(exc) + logger.warning("Failed to get HLTB auth token (%s)", reason) + if failures is not None: + failures.append(reason) return None diff --git a/steam_backlog_enforcer/_hltb_search_one.py b/steam_backlog_enforcer/_hltb_search_one.py new file mode 100644 index 0000000..a7aa262 --- /dev/null +++ b/steam_backlog_enforcer/_hltb_search_one.py @@ -0,0 +1,198 @@ +"""One HLTB game lookup: query each name variant, classify, write the cache. + +Split out of :mod:`steam_backlog_enforcer._hltb_search` to keep both files +under the 250-line cap. A failed request never writes a miss: only a lookup +whose every variant answered a clean 200 with nothing usable does. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from http import HTTPStatus +import logging +import time +from typing import Any + +import aiohttp + +from steam_backlog_enforcer._hltb_auth_refresh import ensure_fresh_auth +from steam_backlog_enforcer._hltb_failures import ( + SKIPPED_AFTER_RATE_LIMIT, + Breaker, + exc_reason, + record_failure, + record_success, + status_reason, +) +from steam_backlog_enforcer._hltb_matching import ( + _build_result_from_best, + _build_search_variants, +) +from steam_backlog_enforcer._hltb_pacing import pause_after_request +from steam_backlog_enforcer._hltb_search_api import ( + _build_search_payload, + _collect_candidates, + _pick_best_hltb_entry, +) +from steam_backlog_enforcer._hltb_types import ( + _SAVE_INTERVAL, + HLTBResult, + ProgressCb, + _AuthInfo, + _HLTBExtras, + save_hltb_cache, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class _SearchCtx: + """Shared context for HLTB search requests.""" + + session: aiohttp.ClientSession + search_url: str + headers: dict[str, str] + cache: dict[int, float] + polls: dict[int, int] = field(default_factory=dict) + extras: _HLTBExtras = field(default_factory=_HLTBExtras) + auth: _AuthInfo | None = None + counter: dict[str, int] = field(default_factory=dict) + total: int = 0 + progress_cb: ProgressCb | None = None + breaker: Breaker = field(default_factory=Breaker) + # When ``auth`` was issued (monotonic) and the lock for renewing it. + auth_at: float = field(default_factory=time.monotonic) + auth_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + # Games whose lookup failed (the breaker reports them by id). + failed_ids: set[int] = field(default_factory=set) + # Only flush incrementally once something was actually written. + dirty: bool = False + + +_Best = tuple[dict[str, Any], float] + + +def _full_edition_candidates( + name: str, query_name: str, candidates: list[_Best] +) -> list[_Best]: + """Drop unrelated entries when ``query_name`` is ``name`` minus a subtitle. + + When we stripped ": subtitle" from the original name to get query_name, + only keep full-edition entries (those whose HLTB name starts with + query_name + ":"/"-") or exact name/alias matches. This prevents "Vox + Populi" (stripped from "Vox Populi: Poland 2023") from falsely matching + "Vox Populi Vox Dei 2". + """ + if ":" not in name or ":" in query_name: + return candidates + lower_q = query_name.lower() + kept: list[_Best] = [] + for entry, sim in candidates: + game_name = (entry.get("game_name") or "").lower() + alias = (entry.get("game_alias") or "").lower() + if lower_q in {game_name, alias} or game_name.startswith( + (lower_q + ":", lower_q + " -") + ): + kept.append((entry, sim)) + return kept + + +async def _query_variant( + ctx: _SearchCtx, name: str, query_name: str +) -> tuple[_Best | None, str | None]: + """Run one search variant. + + Returns ``(best, None)`` on a match, ``(None, None)`` on a clean 200 with + no usable entry, and ``(None, reason)`` when the request itself failed. + """ + payload = _build_search_payload(query_name, ctx.auth) + try: + async with ctx.session.post( + ctx.search_url, headers=ctx.headers, data=payload + ) as resp: + ctx.breaker.note(resp.status) + if resp.status != HTTPStatus.OK: + return None, status_reason(resp.status) + data = await resp.json() + except (TimeoutError, aiohttp.ClientError, ValueError) as exc: + ctx.breaker.note(None) + return None, exc_reason(exc) + finally: + await pause_after_request() + if not isinstance(data, dict): + return None, "bad JSON" + candidates = _full_edition_candidates( + name, query_name, _collect_candidates(query_name, data) + ) + return _pick_best_hltb_entry(query_name, candidates), None + + +def _record_outcome( + ctx: _SearchCtx, app_id: int, result: HLTBResult | None, failure: str | None +) -> None: + """Write a match or a clean miss; a failed lookup keeps the old entry.""" + if result is not None: + ctx.cache[app_id] = result.completionist_hours + ctx.polls[app_id] = result.comp_100_count + ctx.extras.count_comp[app_id] = result.count_comp + if result.hltb_game_id > 0: + ctx.extras.hltb_game_id[app_id] = result.hltb_game_id + ctx.counter["found"] += 1 + ctx.dirty = True + record_success() + elif failure is not None: + ctx.counter["failed"] += 1 + ctx.failed_ids.add(app_id) + record_failure(failure) + else: + # Every variant answered cleanly with nothing usable: a real miss. + ctx.cache[app_id] = -1 + ctx.polls[app_id] = 0 + ctx.extras.count_comp[app_id] = 0 + ctx.dirty = True + record_success() + + +async def _search_one( + sem: asyncio.Semaphore, + ctx: _SearchCtx, + app_id: int, + name: str, +) -> HLTBResult | None: + """Search HLTB for one game via direct POST, update cache.""" + async with sem: + result: HLTBResult | None = None + failure: str | None = None + for query_name in _build_search_variants(name): + if ctx.breaker.tripped: + failure = failure or SKIPPED_AFTER_RATE_LIMIT + break + auth_failure = await ensure_fresh_auth(ctx) + if auth_failure is not None: + failure = failure or auth_failure + break + best, reason = await _query_variant(ctx, name, query_name) + if best is not None: + result = _build_result_from_best(app_id, name, query_name, best) + break + failure = failure or reason + if result is None and failure not in {None, SKIPPED_AFTER_RATE_LIMIT}: + logger.warning( + "HLTB lookup failed for '%s' (%s), cache kept", name, failure + ) + _record_outcome(ctx, app_id, result, failure) + + ctx.counter["done"] += 1 + done = ctx.counter["done"] + + # Incremental save every _SAVE_INTERVAL lookups. + if ctx.dirty and not done % _SAVE_INTERVAL: + save_hltb_cache(ctx.cache, ctx.polls, ctx.extras) + + # Report progress. + if ctx.progress_cb is not None: + ctx.progress_cb(done, ctx.total, ctx.counter["found"], name) + + return result diff --git a/steam_backlog_enforcer/_hltb_types.py b/steam_backlog_enforcer/_hltb_types.py index 3ba4e65..bcd5588 100644 --- a/steam_backlog_enforcer/_hltb_types.py +++ b/steam_backlog_enforcer/_hltb_types.py @@ -13,7 +13,8 @@ from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write logger = logging.getLogger(__name__) HLTB_CACHE_FILE = CONFIG_DIR / "hltb_cache.json" -MAX_CONCURRENT = 60 # parallel requests to HLTB +# Requests in flight to HLTB; each slot is paced by _hltb_pacing. +MAX_CONCURRENT = 1 _SAVE_INTERVAL = 50 # flush cache to disk every N results MIN_SIMILARITY = 0.5 HLTB_BASE_URL = "https://howlongtobeat.com" diff --git a/steam_backlog_enforcer/_scanning_confidence.py b/steam_backlog_enforcer/_scanning_confidence.py index ebdf1d6..d4a1c64 100644 --- a/steam_backlog_enforcer/_scanning_confidence.py +++ b/steam_backlog_enforcer/_scanning_confidence.py @@ -5,14 +5,10 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from steam_backlog_enforcer._hltb_confidence import fetch_hltb_confidence_cached +from steam_backlog_enforcer._hltb_confidence import refresh_confidence from steam_backlog_enforcer._hltb_types import ( - _HLTBExtras, - load_hltb_cache, load_hltb_count_comp_cache, load_hltb_polls_cache, - restore_prior_hours, - save_hltb_cache, ) from steam_backlog_enforcer._polls_reporting import ( _backfill_polls_for_finished, @@ -105,27 +101,8 @@ def _refresh_candidate_confidence_batch( else: _echo(f" Refreshing HLTB confidence for {len(refresh_slice)} candidate(s)...") - cache = load_hltb_cache() - polls = load_hltb_polls_cache() - count_comp = load_hltb_count_comp_cache() - app_ids = [game.app_id for game in refresh_slice] - names = [(game.app_id, game.name) for game in refresh_slice] - prior_hours = {aid: cache.get(aid, -1) for aid in app_ids} - - for aid in app_ids: - cache.pop(aid, None) - polls.pop(aid, None) - count_comp.pop(aid, None) - save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp)) - - fetch_hltb_confidence_cached(names) - - refreshed_hours = load_hltb_cache() - refreshed_polls = load_hltb_polls_cache() - refreshed_count_comp = load_hltb_count_comp_cache() - restore_prior_hours(refreshed_hours, prior_hours) - save_hltb_cache( - refreshed_hours, refreshed_polls, _HLTBExtras(count_comp=refreshed_count_comp) + _, refreshed_polls, refreshed_count_comp = refresh_confidence( + [(game.app_id, game.name) for game in refresh_slice] ) for game in refresh_slice: diff --git a/steam_backlog_enforcer/_stats.py b/steam_backlog_enforcer/_stats.py index cd498f3..2d824c1 100644 --- a/steam_backlog_enforcer/_stats.py +++ b/steam_backlog_enforcer/_stats.py @@ -3,8 +3,14 @@ from __future__ import annotations import logging +import sys from typing import TYPE_CHECKING +from steam_backlog_enforcer._hltb_failures import ( + failure_summary_lines, + had_total_failure, + reset_failures, +) from steam_backlog_enforcer._hltb_types import ( _read_raw_cache, ) @@ -131,7 +137,22 @@ def cmd_stats(_config: Config, state: State) -> None: 3. Leisure — comp_100_h (slowest 100 %) + DLC leisure per HLTB. 4. Worst — absolute maximum recorded time (any category) per HLTB. 5. Your play style — extrapolated from completed-game calibration vs HLTB. + + Ends with one line per kind of HLTB failure (the cache is kept for those + games) and exits 1 when some HLTB batch refreshed nothing at all. """ + reset_failures() + try: + _print_stats(_config, state) + finally: + for line in failure_summary_lines(): + _echo(line) + if had_total_failure(): + sys.exit(1) + + +def _print_stats(_config: Config, state: State) -> None: + """Gather the stats data and print every scenario for :func:`cmd_stats`.""" games = require_snapshot_games() if games is None: return diff --git a/steam_backlog_enforcer/tests/_hltb_fake_transport.py b/steam_backlog_enforcer/tests/_hltb_fake_transport.py new file mode 100644 index 0000000..f2c996e --- /dev/null +++ b/steam_backlog_enforcer/tests/_hltb_fake_transport.py @@ -0,0 +1,93 @@ +"""Fake aiohttp transport for HLTB search tests: no request leaves the process.""" + +from __future__ import annotations + +import json +from typing import Any, Self + +MATCH = { + "data": [ + { + "game_name": "Vox Populi", + "game_alias": "", + "game_type": "game", + "game_id": 77, + "comp_100": 36000, + "comp_100_count": 42, + "count_comp": 300, + } + ] +} +NO_MATCH: dict[str, Any] = {"data": []} + + +class FakeResp: + """Minimal aiohttp response stand-in.""" + + def __init__(self, status: int, body: dict[str, Any] | None = None) -> None: + """Store the canned status and JSON body (``None`` = not JSON).""" + self.status = status + self._body = body + + async def __aenter__(self) -> Self: + """Enter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Exit.""" + + async def json(self) -> dict[str, Any]: + """Return the body, or fail like a garbage payload would.""" + if self._body is None: + msg = "Expecting value" + raise json.JSONDecodeError(msg, "", 0) + return self._body + + +class FakeSession: + """Fake session routing each search POST by its first search term.""" + + def __init__(self, routes: dict[str, object]) -> None: + """Map a query's first word to a _Resp or an exception to raise.""" + self.routes = routes + self.posts: list[str] = [] + + def post(self, _url: str, *, headers: object, data: str) -> FakeResp: + """Return (or raise) the canned outcome for this query.""" + del headers + terms = json.loads(data)["searchTerms"] + query = " ".join(terms) + self.posts.append(query) + outcome = self.routes.get(query, self.routes.get("*")) + if isinstance(outcome, BaseException): + raise outcome + assert isinstance(outcome, FakeResp) + return outcome + + +class FakeInitSession: + """Fake ClientSession: GET /init answers ``status``; POSTs use ``routes``.""" + + def __init__(self, status: int | BaseException, routes: dict[str, object]) -> None: + """Store the canned init outcome and search routes.""" + self.status = status + self.search = FakeSession(routes) + self.gets = 0 + + async def __aenter__(self) -> Self: + """Enter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Exit.""" + + def get(self, *_a: object, **_k: object) -> FakeResp: + """Answer the auth init.""" + self.gets += 1 + if isinstance(self.status, BaseException): + raise self.status + return FakeResp(self.status, {"token": "tok"}) + + def post(self, url: str, *, headers: object, data: str) -> FakeResp: + """Delegate searches.""" + return self.search.post(url, headers=headers, data=data) diff --git a/steam_backlog_enforcer/tests/_isolate_control_plane.py b/steam_backlog_enforcer/tests/_isolate_control_plane.py index dee4823..de42a5f 100644 --- a/steam_backlog_enforcer/tests/_isolate_control_plane.py +++ b/steam_backlog_enforcer/tests/_isolate_control_plane.py @@ -16,6 +16,7 @@ from unittest.mock import patch import pytest +from steam_backlog_enforcer._hltb_failures import reset_failures from steam_backlog_enforcer._hltb_search_api import forget_hltb_search_url if TYPE_CHECKING: @@ -65,16 +66,8 @@ def _isolate_cover_art(tmp_path: Path) -> Iterator[None]: yield -@pytest.fixture(autouse=True) -def _forget_hltb_search_url() -> Iterator[None]: - """Start every test without a remembered HLTB search endpoint. - - ``_get_hltb_search_url`` memoises a discovered URL for half an hour; a - value left behind by one test would hide the discovery path in the next. - """ - forget_hltb_search_url() - yield - forget_hltb_search_url() +async def _no_pause(_seconds: float) -> None: + """Stand-in for the HLTB pacing sleep.""" @pytest.fixture(autouse=True) @@ -83,7 +76,13 @@ def _forget_hltb_search_url() -> Iterator[None]: ``_get_hltb_search_url`` memoises a discovered URL for half an hour; a value left behind by one test would hide the discovery path in the next. + The HLTB failure tally and its shared circuit breaker are reset too: a + breaker tripped by one test would make the next one skip every lookup. + The per-request HLTB pacing pause is dropped so tests never wait on it. """ forget_hltb_search_url() - yield + reset_failures() + with patch("steam_backlog_enforcer._hltb_pacing._sleep", _no_pause): + yield forget_hltb_search_url() + reset_failures() diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py index bcddd0f..e41d47e 100644 --- a/steam_backlog_enforcer/tests/test_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb.py @@ -10,9 +10,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import aiohttp from steam_backlog_enforcer._hltb_matching import _similarity -from steam_backlog_enforcer._hltb_search import _AuthInfo from steam_backlog_enforcer._hltb_search_api import _get_auth_info, _get_hltb_search_url from steam_backlog_enforcer._hltb_types import ( + _AuthInfo, load_hltb_cache, save_hltb_cache, ) diff --git a/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py b/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py new file mode 100644 index 0000000..91921e7 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py @@ -0,0 +1,194 @@ +"""A long paced batch renews the HLTB token before it expires. + +Seen 2026-11-08: at ~55 requests/min every search ~3 min after /init came +back 403 while a fresh token got 200 for the same game. Fake transport and a +fake clock only: no request leaves the process and nothing sleeps. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Self, cast +from unittest.mock import patch + +from steam_backlog_enforcer import _hltb_auth_refresh +from steam_backlog_enforcer._hltb_failures import ( + BREAKER_LIMIT, + SKIPPED_AFTER_RATE_LIMIT, + Breaker, + failure_summary_lines, +) +from steam_backlog_enforcer._hltb_search_one import _search_one, _SearchCtx +from steam_backlog_enforcer._hltb_types import _AuthInfo +from steam_backlog_enforcer.tests._hltb_fake_transport import MATCH, FakeResp + +if TYPE_CHECKING: + import aiohttp + +_AGE = _hltb_auth_refresh.AUTH_MAX_AGE_S + + +class _RotatingSession: + """Fake session: each /init GET hands out a new token; POSTs record auth.""" + + def __init__(self, init_status: int = 200, post_status: int = 200) -> None: + self.init_status = init_status + self.post_status = post_status + self.gets = 0 + # (x-auth-token header, honeypot value in the body) per POST. + self.posts: list[tuple[str | None, str | None]] = [] + + def get(self, *_a: object, **_k: object) -> FakeResp: + self.gets += 1 + n = self.gets + return FakeResp( + self.init_status, {"token": f"tok{n}", "hpKey": "hp", "hpVal": f"v{n}"} + ) + + def post(self, _url: str, *, headers: dict[str, str], data: str) -> FakeResp: + self.posts.append((headers.get("x-auth-token"), json.loads(data).get("hp"))) + body = MATCH if self.post_status == 200 else None + return FakeResp(self.post_status, body) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *args: object) -> None: + pass + + +class _ListBodySession(_RotatingSession): + """Fake session whose searches answer 200 with a JSON list, not an object.""" + + def post(self, _url: str, *, headers: dict[str, str], data: str) -> FakeResp: + del headers, data + return FakeResp(200, cast("dict[str, object]", [])) + + +class _Clock: + """Fake monotonic clock advanced by the test.""" + + def __init__(self) -> None: + self.t = 1000.0 + + def __call__(self) -> float: + return self.t + + +def _ctx(session: _RotatingSession, clock: _Clock) -> _SearchCtx: + return _SearchCtx( + session=cast("aiohttp.ClientSession", session), + search_url="https://example.invalid/api/finder", + headers={"x-auth-token": "tok0", "x-hp-key": "hp", "x-hp-val": "v0"}, + cache={}, + auth=_AuthInfo("tok0", "hp", "v0"), + counter={"done": 0, "found": 0, "failed": 0}, + breaker=Breaker(), + auth_at=clock.t, + ) + + +def _lookups(ctx: _SearchCtx, clock: _Clock, steps: list[float]) -> None: + """Run one lookup per step, advancing the clock by that many seconds first.""" + + async def run() -> None: + sem = asyncio.Semaphore(1) + for i, step in enumerate(steps): + clock.t += step + await _search_one(sem, ctx, i + 1, "Vox Populi") + + with patch.object(_hltb_auth_refresh, "_now", clock): + asyncio.run(run()) + + +def test_young_token_is_not_refreshed() -> None: + """Within the max age no extra /init request is made.""" + session, clock = _RotatingSession(), _Clock() + _lookups(_ctx(session, clock), clock, [0, _AGE / 2]) + assert session.gets == 0 + assert session.posts == [("tok0", "v0"), ("tok0", "v0")] + + +def test_aged_token_is_refreshed_into_headers_and_body() -> None: + """Past the max age the next POST carries the new token and honeypot.""" + session, clock = _RotatingSession(), _Clock() + ctx = _ctx(session, clock) + _lookups(ctx, clock, [0, _AGE + 1]) + assert session.gets == 1 + assert session.posts == [("tok0", "v0"), ("tok1", "v1")] + assert ctx.auth == _AuthInfo("tok1", "hp", "v1") + + +def test_one_refresh_per_elapsed_interval() -> None: + """Many lookups inside one interval share a single refresh.""" + session, clock = _RotatingSession(), _Clock() + _lookups(_ctx(session, clock), clock, [_AGE + 1, 1, 1, 1, _AGE + 1, 1]) + assert session.gets == 2 + assert [tok for tok, _hp in session.posts] == [ + "tok1", + "tok1", + "tok1", + "tok1", + "tok2", + "tok2", + ] + + +def test_failed_refresh_stops_sending_and_trips_the_breaker() -> None: + """A refresh that gets 429 never falls back to the stale token.""" + session, clock = _RotatingSession(init_status=429), _Clock() + ctx = _ctx(session, clock) + _lookups(ctx, clock, [_AGE + 1, 1, 1]) + assert session.gets == 1 + assert session.posts == [] + assert ctx.breaker.tripped + assert ctx.counter["failed"] == 3 + summary = failure_summary_lines()[0] + assert "auth refresh HTTP 429 x1" in summary + assert f"{SKIPPED_AFTER_RATE_LIMIT} x2" in summary + + +def test_consecutive_403s_trip_the_breaker() -> None: + """A rejected token cannot turn into hundreds of doomed requests.""" + session, clock = _RotatingSession(post_status=403), _Clock() + ctx = _ctx(session, clock) + _lookups(ctx, clock, [0] * (BREAKER_LIMIT + 3)) + assert len(session.posts) == BREAKER_LIMIT + assert ctx.breaker.tripped + assert ctx.counter["failed"] == BREAKER_LIMIT + 3 + + +def test_concurrent_lookups_share_one_refresh() -> None: + """A lookup that waited on the lock reuses the token renewed meanwhile.""" + session, clock = _RotatingSession(), _Clock() + ctx = _ctx(session, clock) + clock.t += _AGE + 1 + + async def yielding_pause(_seconds: float) -> None: + await asyncio.sleep(0) # let the other lookup reach the lock + + async def run() -> list[str | None]: + return list( + await asyncio.gather( + _hltb_auth_refresh.ensure_fresh_auth(ctx), + _hltb_auth_refresh.ensure_fresh_auth(ctx), + ) + ) + + with ( + patch.object(_hltb_auth_refresh, "_now", clock), + patch("steam_backlog_enforcer._hltb_pacing._sleep", yielding_pause), + ): + assert asyncio.run(run()) == [None, None] + assert session.gets == 1 + + +def test_non_object_json_is_a_failure() -> None: + """A 200 whose body is not a JSON object is "bad JSON", never a miss.""" + session, clock = _ListBodySession(), _Clock() + ctx = _ctx(session, clock) + ctx.cache[1] = 50.0 + _lookups(ctx, clock, [0]) + assert ctx.cache[1] == 50.0 + assert "bad JSON x1" in failure_summary_lines()[0] diff --git a/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py b/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py index b1a0c19..5ca0ef7 100644 --- a/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py +++ b/steam_backlog_enforcer/tests/test_hltb_build_search_payload.py @@ -4,8 +4,8 @@ from __future__ import annotations import json -from steam_backlog_enforcer._hltb_search import _AuthInfo from steam_backlog_enforcer._hltb_search_api import _build_search_payload +from steam_backlog_enforcer._hltb_types import _AuthInfo class TestBuildSearchPayload: diff --git a/steam_backlog_enforcer/tests/test_hltb_confidence_failure_paths.py b/steam_backlog_enforcer/tests/test_hltb_confidence_failure_paths.py new file mode 100644 index 0000000..b5b49b9 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_confidence_failure_paths.py @@ -0,0 +1,93 @@ +"""A failed HLTB batch leaves every cached entry point's cache alone. + +No network: the batch functions are faked at the module boundary. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer import _hltb_cached, _hltb_confidence +from steam_backlog_enforcer._hltb_failures import HLTBUnavailableError + +_CONF = "steam_backlog_enforcer._hltb_confidence" +_DOWN = HLTBUnavailableError("HLTB: rate-limited, nothing refreshed") + + +def _found( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, +) -> list[object]: + """Fake search that finds every game.""" + for aid, _name in games: + cache[aid], polls[aid], count_comp[aid] = 5.0, 7, 40 + return [] + + +def test_cached_fetch_saves_new_results() -> None: + """Uncached games are searched and the result is saved.""" + with ( + patch(f"{_CONF}.fetch_hltb_confidence", side_effect=_found), + patch(f"{_CONF}.save_hltb_cache") as save, + ): + result = _hltb_confidence.fetch_hltb_confidence_cached([(1, "Celeste")]) + assert result == {1: 5.0} + save.assert_called_once() + + +def test_cached_fetch_keeps_cache_when_hltb_is_down() -> None: + """An unavailable batch returns the cache as loaded and saves nothing.""" + with ( + patch(f"{_CONF}.load_hltb_cache", return_value={2: 9.0}), + patch(f"{_CONF}.fetch_hltb_confidence", side_effect=_DOWN), + patch(f"{_CONF}.save_hltb_cache") as save, + ): + result = _hltb_confidence.fetch_hltb_confidence_cached([(1, "Celeste")]) + assert result == {2: 9.0} + save.assert_not_called() + + +def test_detail_missing_reports_nothing_fetched_when_hltb_is_down() -> None: + """The detail backfill returns 0 and writes nothing when HLTB is down.""" + with ( + patch(f"{_CONF}.fetch_hltb_times_timed", side_effect=_DOWN), + patch(f"{_CONF}.save_hltb_cache") as save, + ): + assert _hltb_confidence.fetch_hltb_detail_missing([(1, "Celeste")]) == 0 + save.assert_not_called() + + +def test_submit_url_is_none_when_hltb_is_down() -> None: + """No URL instead of a crash when the lookup cannot run.""" + with patch(f"{_CONF}.fetch_hltb_times", side_effect=_DOWN): + assert _hltb_confidence.get_hltb_submit_url("Celeste") is None + + +def test_refresh_keeps_cache_when_hltb_is_down() -> None: + """A re-search that cannot run returns the loaded caches and saves nothing.""" + with ( + patch(f"{_CONF}.load_hltb_cache", return_value={1: 9.0}), + patch(f"{_CONF}.load_hltb_polls_cache", return_value={1: 30}), + patch(f"{_CONF}.load_hltb_count_comp_cache", return_value={1: 80}), + patch(f"{_CONF}.fetch_hltb_confidence", side_effect=_DOWN), + patch(f"{_CONF}.save_hltb_cache") as save, + ): + result = _hltb_confidence.refresh_confidence([(1, "Celeste")]) + assert result == ({1: 9.0}, {1: 30}, {1: 80}) + save.assert_not_called() + + +def test_times_cached_keeps_cache_when_hltb_is_down() -> None: + """The times fetch returns the cache as loaded and saves nothing.""" + cached = "steam_backlog_enforcer._hltb_cached" + with ( + patch(f"{cached}.load_hltb_cache", return_value={2: 9.0}), + patch(f"{cached}.fetch_hltb_times_timed", side_effect=_DOWN), + patch(f"{cached}.save_hltb_cache") as save, + ): + result = _hltb_cached.fetch_hltb_times_cached([(1, "Celeste")]) + assert result == {2: 9.0} + save.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py b/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py index 24b7c4c..f68cfb0 100644 --- a/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py +++ b/steam_backlog_enforcer/tests/test_hltb_confidence_helpers.py @@ -6,8 +6,11 @@ import asyncio from typing import Self from unittest.mock import patch +import pytest + from steam_backlog_enforcer._hltb_confidence import fetch_hltb_confidence_cached -from steam_backlog_enforcer._hltb_search import _AuthInfo +from steam_backlog_enforcer._hltb_failures import HLTBUnavailableError +from steam_backlog_enforcer._hltb_types import _AuthInfo from steam_backlog_enforcer.hltb import ( _fetch_batch_confidence_only, fetch_hltb_confidence, @@ -39,11 +42,9 @@ class TestConfidenceHelpers: patch(f"{PKG}.aiohttp.TCPConnector"), patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), patch(f"{PKG}._get_auth_info", return_value=None), + pytest.raises(HLTBUnavailableError, match="nothing refreshed"), ): - result = asyncio.run( - _fetch_batch_confidence_only([(1, "Game")], {}, {}, None), - ) - assert result == [] + asyncio.run(_fetch_batch_confidence_only([(1, "Game")], {}, {}, None)) def test_fetch_batch_confidence_only_handles_empty_hp_and_default_counts( self, diff --git a/steam_backlog_enforcer/tests/test_hltb_failure_reporting.py b/steam_backlog_enforcer/tests/test_hltb_failure_reporting.py new file mode 100644 index 0000000..2e3d499 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_failure_reporting.py @@ -0,0 +1,131 @@ +"""Detail-page failures are counted; ``stats`` prints the tally and fails loud.""" + +from __future__ import annotations + +import asyncio +from typing import Self +from unittest.mock import MagicMock, patch + +import aiohttp +import pytest + +from steam_backlog_enforcer import _stats +from steam_backlog_enforcer._hltb_comp_extract import _fetch_detail_one +from steam_backlog_enforcer._hltb_failures import ( + failure_summary_lines, + record_failure, + record_fatal, + record_success, + reset_failures, +) + + +@pytest.fixture(autouse=True) +def _fresh_tally() -> None: + reset_failures() + + +class _Page: + """Fake detail-page response.""" + + def __init__(self, status: int) -> None: + """Store the status.""" + self.status = status + + async def __aenter__(self) -> Self: + """Enter.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Exit.""" + + async def text(self) -> str: + """Empty page.""" + return "" + + +def _detail(outcome: object) -> None: + session = MagicMock() + if isinstance(outcome, BaseException): + session.get.side_effect = outcome + else: + session.get.return_value = outcome + asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 77)) + + +@pytest.mark.parametrize( + ("outcome", "label"), + [ + (_Page(429), "detail HTTP 429"), + (_Page(500), "detail HTTP 500"), + (TimeoutError(), "detail timeouts"), + (aiohttp.ClientConnectionError(), "detail connection errors"), + ], +) +def test_detail_failure_is_counted(outcome: object, label: str) -> None: + """A failed detail fetch shows up in the summary line.""" + _detail(outcome) + assert f"{label} x1" in failure_summary_lines()[0] + + +def test_missing_detail_page_is_not_a_failure() -> None: + """A 404 is HLTB's real answer, not a failed lookup.""" + _detail(_Page(404)) + assert failure_summary_lines() == [] + + +def _stats_with(effect: object) -> None: + with patch.object(_stats, "_print_stats", side_effect=effect): + _stats.cmd_stats(MagicMock(), MagicMock()) + + +def test_stats_ends_with_failure_line_and_exits_zero( + capsys: pytest.CaptureFixture[str], +) -> None: + """Old data survived: report the failures, exit 0.""" + _stats_with(lambda *_a: record_failure("HTTP 429")) + out = capsys.readouterr().out.rstrip().splitlines() + assert out[-1] == ( + "HLTB: 1 lookups failed (HTTP 429 x1), cache kept for those games" + ) + + +def test_stats_exits_nonzero_when_a_batch_refreshed_nothing( + capsys: pytest.CaptureFixture[str], +) -> None: + """A total failure (auth init or every lookup) is a non-zero exit.""" + msg = "HLTB: rate-limited, nothing refreshed (auth init: HTTP 429)" + with pytest.raises(SystemExit) as exc: + _stats_with(lambda *_a: record_fatal(msg)) + assert exc.value.code == 1 + assert capsys.readouterr().out.rstrip().splitlines()[-1] == msg + + +def test_stats_resets_the_tally_first(capsys: pytest.CaptureFixture[str]) -> None: + """Failures from an earlier command in the same process are not re-reported.""" + record_fatal("stale") + _stats_with(None) + assert "stale" not in capsys.readouterr().out + + +def test_stats_exits_zero_when_another_lookup_succeeded( + capsys: pytest.CaptureFixture[str], +) -> None: + """One refreshed game anywhere in the command means old data survived: exit 0.""" + msg = "HLTB: all 3 lookups failed, nothing refreshed" + + def one_success_then_a_fatal_batch(*_a: object) -> None: + record_success() + record_fatal(msg) + + _stats_with(one_success_then_a_fatal_batch) + assert capsys.readouterr().out.rstrip().splitlines()[-1] == msg + + +def test_repeated_fatal_lines_print_once() -> None: + """Four batches failing auth the same way give one line, not four.""" + for _ in range(4): + record_fatal("HLTB: rate-limited, nothing refreshed (auth init: timeout)") + assert failure_summary_lines() == [ + "HLTB: rate-limited, nothing refreshed (auth init: timeout)" + ] diff --git a/steam_backlog_enforcer/tests/test_hltb_pacing.py b/steam_backlog_enforcer/tests/test_hltb_pacing.py new file mode 100644 index 0000000..ef6be64 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_pacing.py @@ -0,0 +1,126 @@ +"""HLTB requests are paced: one at a time, 1.1 s gap after each request (~55/min). + +HLTB rate-limits a burst after ~60 lookups; a full refresh is ~800 games. +Fake transport only: no request ever leaves the process. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _hltb_pacing +from steam_backlog_enforcer._hltb_comp_extract import _fetch_detail_one +from steam_backlog_enforcer._hltb_failures import BREAKER_LIMIT, shared_breaker +from steam_backlog_enforcer._hltb_search_one import _search_one, _SearchCtx +from steam_backlog_enforcer._hltb_types import MAX_CONCURRENT, _AuthInfo +from steam_backlog_enforcer.tests._hltb_fake_transport import ( + MATCH, + NO_MATCH, + FakeResp, + FakeSession, +) + +if TYPE_CHECKING: + import aiohttp + +_INTERVAL = 1.1 + + +class _DetailSession: + """Fake session answering every detail GET with a 404.""" + + def __init__(self) -> None: + self.gets = 0 + + def get(self, _url: str, *, headers: object) -> FakeResp: + del headers + self.gets += 1 + return FakeResp(404) + + +def _ctx(session: FakeSession) -> _SearchCtx: + return _SearchCtx( + session=cast("aiohttp.ClientSession", session), + search_url="https://example.invalid/api/finder", + headers={}, + cache={}, + auth=_AuthInfo("t"), + counter={"done": 0, "found": 0, "failed": 0}, + ) + + +def _record_sleeps(sem: asyncio.Semaphore) -> tuple[list[float], object]: + """Fake ``asyncio.sleep`` that records each pause and that a slot is held.""" + pauses: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + assert sem.locked(), "the pause must hold the request slot" + pauses.append(seconds) + + return pauses, fake_sleep + + +def test_one_request_in_flight() -> None: + """HLTB sees one request in flight at a time.""" + assert MAX_CONCURRENT == 1 + + +def test_default_interval_stays_under_the_limit() -> None: + """1.1 s between requests keeps us at ~55/min, under the ~60/min limit.""" + assert _hltb_pacing.REQUEST_INTERVAL_S == _INTERVAL + + +@pytest.mark.parametrize("body", [MATCH, NO_MATCH]) +def test_each_search_post_is_followed_by_a_pause(body: dict[str, object]) -> None: + """Every search POST, match or not, waits the interval inside its slot.""" + session = FakeSession({"*": FakeResp(200, body)}) + ctx = _ctx(session) + + async def run() -> list[float]: + sem = asyncio.Semaphore(1) + pauses, fake_sleep = _record_sleeps(sem) + with patch.object(_hltb_pacing, "_sleep", fake_sleep): + await _search_one(sem, ctx, 1, "Vox Populi: Poland 2023") + return pauses + + pauses = asyncio.run(run()) + assert len(session.posts) >= 1 + assert pauses == [_INTERVAL] * len(session.posts) + + +def test_skipped_lookup_after_breaker_does_not_pause() -> None: + """Once the breaker has tripped nothing is sent, so nothing waits.""" + session = FakeSession({"*": FakeResp(200, MATCH)}) + ctx = _ctx(session) + ctx.breaker = shared_breaker() + for _ in range(BREAKER_LIMIT): + ctx.breaker.note(429) + + async def run() -> list[float]: + sem = asyncio.Semaphore(1) + pauses, fake_sleep = _record_sleeps(sem) + with patch.object(_hltb_pacing, "_sleep", fake_sleep): + await _search_one(sem, ctx, 1, "Celeste") + return pauses + + assert asyncio.run(run()) == [] + assert session.posts == [] + + +def test_each_detail_get_is_followed_by_a_pause() -> None: + """Detail-page GETs are paced the same way as search POSTs.""" + session = _DetailSession() + + async def run() -> list[float]: + sem = asyncio.Semaphore(1) + pauses, fake_sleep = _record_sleeps(sem) + with patch.object(_hltb_pacing, "_sleep", fake_sleep): + await _fetch_detail_one(sem, cast("aiohttp.ClientSession", session), 77) + return pauses + + assert asyncio.run(run()) == [_INTERVAL] + assert session.gets == 1 diff --git a/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py b/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py new file mode 100644 index 0000000..277b8cc --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py @@ -0,0 +1,75 @@ +"""Refetch paths must not lose polls/count_comp when the refetch writes nothing. + +The fetch itself is stubbed to a no-op, which is exactly what a failed HLTB +lookup now does: it leaves the in-memory caches untouched. +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +from steam_backlog_enforcer import _hltb_confidence, _hltb_types, _scanning_confidence +from steam_backlog_enforcer.steam_api import GameInfo + +_ENTRY = { + "hours": 20.0, + "polls": 100, + "count_comp": 500, + "rush_hours": 15.0, + "leisure_100h": 30.0, + "hltb_game_id": 77, +} + + +def _seed(entries: dict[int, dict[str, float]]) -> None: + _hltb_types.HLTB_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _hltb_types.HLTB_CACHE_FILE.write_text( + json.dumps({str(k): v for k, v in entries.items()}), encoding="utf-8" + ) + + +def _raw() -> dict[str, dict[str, float]]: + data: dict[str, dict[str, float]] = json.loads( + _hltb_types.HLTB_CACHE_FILE.read_text(encoding="utf-8") + ) + return data + + +def test_refetch_poll_counts_keeps_polls_on_failure() -> None: + """refetch_poll_counts used to drop the entry and restore only its hours.""" + _seed({1: dict(_ENTRY)}) + with patch.object(_hltb_confidence, "fetch_hltb_confidence"): + polls = _hltb_confidence.refetch_poll_counts([(1, "Game")]) + assert polls[1] == 100 + assert _raw()["1"] == _ENTRY + + +def test_confidence_refresh_keeps_entry_on_failure() -> None: + """The candidate confidence refresh popped hours/polls/count_comp to disk.""" + _seed({1: dict(_ENTRY)}) + game = GameInfo( + app_id=1, + name="Game", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + ) + with patch.object(_hltb_confidence, "fetch_hltb_confidence"): + _scanning_confidence._refresh_candidate_confidence_batch([game], force=True) + assert _raw()["1"] == _ENTRY + assert (game.comp_100_count, game.count_comp) == (100, 500) + + +def test_detail_missing_keeps_known_miss_on_failure() -> None: + """A cached miss (-1) must not vanish from the cache when the refetch fails.""" + miss = dict( + _ENTRY, hours=-1.0, rush_hours=-1.0, leisure_100h=-1.0, polls=0, count_comp=0 + ) + _seed({1: miss, 2: dict(_ENTRY, rush_hours=-1.0)}) + with patch.object(_hltb_confidence, "fetch_hltb_times_timed", return_value=0.0): + _hltb_confidence.fetch_hltb_detail_missing([(1, "Gone"), (2, "Known")]) + raw = _raw() + assert raw["1"]["hours"] == -1 + assert raw["2"]["hours"] == 20.0 + assert raw["2"]["polls"] == 100 diff --git a/steam_backlog_enforcer/tests/test_hltb_search.py b/steam_backlog_enforcer/tests/test_hltb_search.py index ed8750f..0b8cdc7 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search.py +++ b/steam_backlog_enforcer/tests/test_hltb_search.py @@ -54,7 +54,7 @@ def _make_ctx( search_url="https://example.com/search", headers={}, cache=cache if cache is not None else {}, - counter={"done": 0, "found": 0}, + counter={"done": 0, "found": 0, "failed": 0}, total=1, progress_cb=progress_cb, ) @@ -118,6 +118,8 @@ class TestSearchOne: ctx = _make_ctx(session) result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) assert result is None + assert 440 not in ctx.cache # a failed lookup is not a miss + assert ctx.counter["failed"] == 1 def test_non_200(self) -> None: """Test non 200.""" @@ -125,6 +127,8 @@ class TestSearchOne: ctx = _make_ctx(_make_session(resp)) result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) assert result is None + assert 440 not in ctx.cache # a failed lookup is not a miss + assert ctx.counter["failed"] == 1 def test_fallback_name_without_year_suffix(self) -> None: """Test fallback name without year suffix.""" diff --git a/steam_backlog_enforcer/tests/test_hltb_search_batch_failures.py b/steam_backlog_enforcer/tests/test_hltb_search_batch_failures.py new file mode 100644 index 0000000..20ea989 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_search_batch_failures.py @@ -0,0 +1,124 @@ +"""Batch-level HLTB failure handling: auth init, total failure, circuit breaker.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import aiohttp +import pytest + +from steam_backlog_enforcer import _hltb_search +from steam_backlog_enforcer._hltb_failures import ( + HLTBUnavailableError, + failure_summary_lines, + reset_failures, +) +from steam_backlog_enforcer._hltb_search import _search_batch +from steam_backlog_enforcer._hltb_types import _HLTBExtras +from steam_backlog_enforcer.tests._hltb_fake_transport import ( + MATCH, + FakeInitSession, + FakeResp, +) + + +@pytest.fixture(autouse=True) +def _fresh_tally() -> None: + reset_failures() + + +def _batch( + status: int, + routes: dict[str, object], + games: list[tuple[int, str]], + fake: FakeInitSession | None = None, +) -> FakeInitSession: + fake = fake or FakeInitSession(status, routes) + with ( + patch.object( + _hltb_search, "_get_hltb_search_url", return_value="https://x.invalid/f" + ), + patch.object(_hltb_search.aiohttp, "ClientSession", lambda **_k: fake), + patch.object(_hltb_search.aiohttp, "TCPConnector", lambda **_k: None), + ): + asyncio.run(_search_batch(games, {}, {}, None, _HLTBExtras())) + return fake + + +@pytest.mark.parametrize( + "init", [429, aiohttp.ClientConnectionError("no network")], ids=["429", "offline"] +) +def test_auth_init_failure_fails_the_batch_up_front( + init: int | BaseException, +) -> None: + """A failed /init means nothing can refresh: raise instead of returning [].""" + with pytest.raises(HLTBUnavailableError, match="rate-limited, nothing refreshed"): + _batch(init, {"*": FakeResp(200, MATCH)}, [(1, "Vox Populi")]) + assert any("nothing refreshed" in ln for ln in failure_summary_lines()) + + +def test_empty_batch_is_not_a_failure() -> None: + """No games: no request, no error.""" + fake = _batch(429, {}, []) + assert fake.search.posts == [] + assert failure_summary_lines() == [] + + +def test_breaker_trips_after_five_consecutive_429s() -> None: + """After 5 straight 429s the rest of the batch sends no request at all.""" + fake = FakeInitSession(200, {"*": FakeResp(429)}) + games = [(aid, "Celeste") for aid in range(10, 18)] + with ( + patch.object( + _hltb_search, "_get_hltb_search_url", return_value="https://x.invalid/f" + ), + patch.object(_hltb_search.aiohttp, "ClientSession", lambda **_k: fake), + patch.object(_hltb_search.aiohttp, "TCPConnector", lambda **_k: None), + pytest.raises(HLTBUnavailableError, match="all 8 lookups failed"), + ): + asyncio.run(_search_batch(games, {}, {}, None, _HLTBExtras())) + assert len(fake.search.posts) == 5 + lines = failure_summary_lines() + assert lines[0] == ( + "HLTB: 8 lookups failed (HTTP 429 x5, skipped after rate-limit x3)," + " cache kept for those games" + ) + assert "HLTB: rate-limited, 8 games not refreshed" in lines + + +def test_batch_where_every_lookup_failed_raises() -> None: + """Every lookup failing is a total failure.""" + with pytest.raises(HLTBUnavailableError, match="all 2 lookups failed"): + _batch(200, {"*": FakeResp(500)}, [(1, "Celeste"), (2, "Celeste")]) + + +def test_batch_with_one_success_does_not_raise() -> None: + """At least one success: the batch completes and failures are only reported.""" + _batch( + 200, + {"Vox Populi": FakeResp(200, MATCH), "*": FakeResp(500)}, + [(1, "Vox Populi"), (2, "Celeste")], + ) + assert failure_summary_lines()[0].startswith("HLTB: 1 lookups failed (HTTP 500 x1)") + + +def test_tripped_breaker_skips_later_batches_of_the_same_command() -> None: + """Once HLTB rate-limits us, later batches send no request (not even /init).""" + with pytest.raises(HLTBUnavailableError): + _batch(200, {"*": FakeResp(429)}, [(aid, "Celeste") for aid in range(5)]) + fake = FakeInitSession(200, {"*": FakeResp(200, MATCH)}) + with pytest.raises(HLTBUnavailableError, match="rate-limited"): + _batch(200, {}, [(1, "Vox"), (2, "Vox")], fake) + assert (fake.gets, fake.search.posts) == (0, []) + # Games 1 and 2 were in both batches: count games, not lookups. + assert "HLTB: rate-limited, 5 games not refreshed" in failure_summary_lines() + + +def test_reset_clears_a_tripped_breaker() -> None: + """A new command starts with HLTB assumed reachable again.""" + with pytest.raises(HLTBUnavailableError): + _batch(200, {"*": FakeResp(429)}, [(aid, "Celeste") for aid in range(5)]) + reset_failures() + fake = _batch(200, {"*": FakeResp(200, MATCH)}, [(1, "Vox Populi")]) + assert len(fake.search.posts) == 1 diff --git a/steam_backlog_enforcer/tests/test_hltb_search_failure_keeps_cache.py b/steam_backlog_enforcer/tests/test_hltb_search_failure_keeps_cache.py new file mode 100644 index 0000000..adb80b6 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_search_failure_keeps_cache.py @@ -0,0 +1,124 @@ +"""A failed HLTB search must keep the cached entry; only a clean no-match is a miss. + +Fake transport only: no request ever leaves the process. +""" + +from __future__ import annotations + +import asyncio +from typing import cast + +import aiohttp +import pytest + +from steam_backlog_enforcer._hltb_failures import ( + failure_summary_lines, + reset_failures, +) +from steam_backlog_enforcer._hltb_search_one import _search_one, _SearchCtx +from steam_backlog_enforcer._hltb_types import _AuthInfo, _HLTBExtras +from steam_backlog_enforcer.tests._hltb_fake_transport import ( + MATCH, + NO_MATCH, + FakeResp, + FakeSession, +) + + +def _ctx(session: FakeSession, total: int = 1) -> _SearchCtx: + return _SearchCtx( + session=cast("aiohttp.ClientSession", session), + search_url="https://example.invalid/api/finder", + headers={}, + cache={1: 50.0}, + polls={1: 120}, + extras=_HLTBExtras(count_comp={1: 900}), + auth=_AuthInfo("t"), + counter={"done": 0, "found": 0, "failed": 0}, + total=total, + ) + + +def _run(ctx: _SearchCtx, app_id: int, name: str) -> None: + asyncio.run(_search_one(asyncio.Semaphore(1), ctx, app_id, name)) + + +@pytest.fixture(autouse=True) +def _fresh_tally() -> None: + reset_failures() + + +_FAILURES = { + "429": FakeResp(429), + "500": FakeResp(500), + "403": FakeResp(403), + "timeout": TimeoutError(), + "client_error": aiohttp.ClientConnectionError("refused"), + "bad_json": FakeResp(200, None), +} + + +@pytest.mark.parametrize("kind", sorted(_FAILURES)) +def test_failed_search_keeps_prior_entry(kind: str) -> None: + """Any non-clean answer leaves hours, polls and count_comp untouched.""" + ctx = _ctx(FakeSession({"*": _FAILURES[kind]})) + _run(ctx, 1, "Celeste") + assert ctx.cache[1] == 50.0 + assert ctx.polls[1] == 120 + assert ctx.extras.count_comp[1] == 900 + assert ctx.counter["failed"] == 1 + assert ctx.counter["found"] == 0 + + +def test_failed_variant_blocks_a_miss() -> None: + """One variant no-match + one variant 429 is a failure, not a miss.""" + routes = {"Vox Populi Poland 2023": FakeResp(200, NO_MATCH), "*": FakeResp(429)} + ctx = _ctx(FakeSession(routes)) + _run(ctx, 1, "Vox Populi: Poland 2023") + assert (ctx.cache[1], ctx.polls[1], ctx.extras.count_comp[1]) == (50.0, 120, 900) + assert ctx.counter["failed"] == 1 + + +def test_match_beats_failed_sibling_variant() -> None: + """A real match replaces the entry even when another variant failed.""" + routes = {"Vox Populi Poland 2023": FakeResp(429), "*": FakeResp(200, MATCH)} + ctx = _ctx(FakeSession(routes)) + _run(ctx, 1, "Vox Populi: Poland 2023") + assert (ctx.cache[1], ctx.polls[1], ctx.extras.count_comp[1]) == (10.0, 42, 300) + assert ctx.counter == {"done": 1, "found": 1, "failed": 0} + + +def test_clean_no_match_still_writes_a_miss() -> None: + """Every variant answered 200 with nothing usable: that is a real miss.""" + ctx = _ctx(FakeSession({"*": FakeResp(200, NO_MATCH)})) + _run(ctx, 1, "Vox Populi: Poland 2023") + assert (ctx.cache[1], ctx.polls[1], ctx.extras.count_comp[1]) == (-1, 0, 0) + assert ctx.counter["failed"] == 0 + + +def test_failure_without_prior_entry_writes_nothing() -> None: + """An uncached game that fails stays uncached so the next run retries it.""" + ctx = _ctx(FakeSession({"*": FakeResp(429)})) + _run(ctx, 2, "Celeste") + assert 2 not in ctx.cache + assert 2 not in ctx.polls + assert 2 not in ctx.extras.count_comp + assert ctx.counter == {"done": 1, "found": 0, "failed": 1} + + +def test_summary_line_counts_reasons() -> None: + """The end-of-run line names every failure reason with its count.""" + ctx = _ctx(FakeSession({"*": FakeResp(429)}), total=2) + _run(ctx, 1, "Celeste") + ctx.session = cast("aiohttp.ClientSession", FakeSession({"*": TimeoutError()})) + _run(ctx, 1, "Celeste") + assert failure_summary_lines()[0] == ( + "HLTB: 2 lookups failed (HTTP 429 x1, timeouts x1), cache kept for those games" + ) + + +def test_no_failures_no_summary() -> None: + """A clean run prints no failure line.""" + ctx = _ctx(FakeSession({"*": FakeResp(200, MATCH)})) + _run(ctx, 1, "Vox Populi") + assert failure_summary_lines() == [] diff --git a/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py index a93e3ac..8533f4d 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py @@ -6,6 +6,9 @@ import asyncio from typing import TYPE_CHECKING, Any, Self from unittest.mock import AsyncMock, MagicMock, patch +import pytest + +from steam_backlog_enforcer._hltb_failures import HLTBUnavailableError from steam_backlog_enforcer._hltb_search import ( _fetch_batch, _SearchCtx, @@ -73,9 +76,9 @@ class TestFetchBatchHltb: new_callable=AsyncMock, return_value=None, ), + pytest.raises(HLTBUnavailableError, match="nothing refreshed"), ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] + asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) class TestPickBestEntry: diff --git a/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py index d200d42..6f46e7d 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py @@ -147,7 +147,9 @@ class TestSearchOneGroup2: # Set done to one less than _SAVE_INTERVAL so it triggers save ctx.counter["done"] = _SAVE_INTERVAL - 1 - with patch("steam_backlog_enforcer._hltb_search.save_hltb_cache") as mock_save: + with patch( + "steam_backlog_enforcer._hltb_search_one.save_hltb_cache" + ) as mock_save: asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) mock_save.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py index 2eaef09..ef113e5 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py @@ -57,19 +57,26 @@ class TestBackfillPollsForFinished: json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8" ) - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - """Test fake fetch.""" - data = json.loads(cache_file.read_text(encoding="utf-8")) + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: write into the caches it is handed.""" for aid, _name in games: - data[str(aid)] = {"hours": 2.0, "polls": 9} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 2.0 for aid, _ in games} + cache[aid], polls[aid], count_comp[aid] = 2.0, 9, 0 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), - patch(f"{_CONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch( + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", + side_effect=fake_fetch, + ), patch(f"{_CMD}._echo"), ): result = _cmd_done._backfill_polls_for_finished(_state([1])) @@ -82,19 +89,26 @@ class TestBackfillPollsForFinished: json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8" ) - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - """Test fake fetch.""" - data = json.loads(cache_file.read_text(encoding="utf-8")) + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: write into the caches it is handed.""" for aid, _name in games: - data[str(aid)] = {"hours": 1.0, "polls": 4} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 1.0 for aid, _ in games} + cache[aid], polls[aid], count_comp[aid] = 1.0, 4, 0 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), - patch(f"{_CONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch( + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", + side_effect=fake_fetch, + ), patch(f"{_CMD}._echo"), ): result = _cmd_done._backfill_polls_for_finished( @@ -109,20 +123,26 @@ class TestBackfillPollsForFinished: json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8" ) - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - # Simulate a refetch returning a miss (hours -1, polls 0). - """Test fake fetch.""" - data = json.loads(cache_file.read_text(encoding="utf-8")) + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: a clean miss (hours -1, polls 0).""" for aid, _name in games: - data[str(aid)] = {"hours": -1, "polls": 0} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: -1 for aid, _ in games} + cache[aid], polls[aid], count_comp[aid] = -1, 0, 0 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), - patch(f"{_CONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch( + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", + side_effect=fake_fetch, + ), patch(f"{_CMD}._echo"), ): _cmd_done._backfill_polls_for_finished(_state([3])) diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py index 5b37a63..0730066 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py @@ -149,19 +149,23 @@ class TestScanningPollsIntegration: json.dumps({"2": {"hours": 3.0, "polls": 0}}), encoding="utf-8" ) - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - """Test fake fetch.""" - data = json.loads(cache_file.read_text(encoding="utf-8")) + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: write into the caches it is handed.""" for aid, _name in games: - data[str(aid)] = {"hours": 3.0, "polls": 8} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 3.0 for aid, _ in games} + cache[aid], polls[aid], count_comp[aid] = 3.0, 8, 0 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch( - f"{_CONF}.fetch_hltb_confidence_cached", + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", side_effect=fake_fetch, ), ): @@ -186,19 +190,23 @@ class TestScanningPollsIntegration: json.dumps({"2": {"hours": 9.0, "polls": 0}}), encoding="utf-8" ) - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - """Test fake fetch.""" - data = json.loads(cache_file.read_text(encoding="utf-8")) + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: write into the caches it is handed.""" for aid, _name in games: - data[str(aid)] = {"hours": -1, "polls": 0} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: -1 for aid, _ in games} + cache[aid], polls[aid], count_comp[aid] = -1, 0, 0 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch( - f"{_CONF}.fetch_hltb_confidence_cached", + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", side_effect=fake_fetch, ), ): diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py index ff5b635..2cc2c5b 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py @@ -76,7 +76,9 @@ class TestScanningPollsIntegrationGroup2Group2: comp_100_count=3, count_comp=15, ) - with patch(f"{_SCANCONF}.fetch_hltb_confidence_cached") as mock_fetch: + with patch( + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence" + ) as mock_fetch: _scanning_confidence._refresh_candidate_confidence(game) mock_fetch.assert_not_called() @@ -99,18 +101,23 @@ class TestScanningPollsIntegrationGroup2Group2: count_comp=0, ) - def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]: - """Test fake fetch.""" - data = json.loads(cache_file.read_text(encoding="utf-8")) - data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {1: 4.0} + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: write into the caches it is handed.""" + del games + cache[1], polls[1], count_comp[1] = 4.0, 3, 15 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch( - f"{_SCANCONF}.fetch_hltb_confidence_cached", + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", side_effect=fake_fetch, ), patch(f"{_SCANCONF}._echo"), @@ -151,20 +158,24 @@ class TestScanningPollsIntegrationGroup2Group2: count_comp=0, ) - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - """Test fake fetch.""" + def fake_fetch( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + count_comp: dict[int, int], + **_kw: object, + ) -> list[object]: + """Test fake search: write into the caches it is handed.""" assert sorted(games) == [(1, "A"), (2, "B")] - data = json.loads(cache_file.read_text(encoding="utf-8")) - data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} - data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {1: 4.0, 2: 5.0} + cache[1], polls[1], count_comp[1] = 4.0, 3, 15 + cache[2], polls[2], count_comp[2] = 5.0, 3, 15 + return [] with ( patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), patch(f"{_TYPES}.CONFIG_DIR", tmp_path), patch( - f"{_SCANCONF}.fetch_hltb_confidence_cached", + "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", side_effect=fake_fetch, ) as mock_fetch, patch(f"{_SCANCONF}._echo"), diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py index ff85cb9..e2d542c 100644 --- a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py +++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py @@ -52,7 +52,7 @@ class TestConfidenceHelpers: game.comp_100_count = 3 game.count_comp = 15 with patch( - "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", + "steam_backlog_enforcer._scanning_confidence.refresh_confidence", ) as mock_fetch: _refresh_candidate_confidence_batch([game], force=False) mock_fetch.assert_not_called() @@ -62,32 +62,17 @@ class TestConfidenceHelpers: game = _game(app_id=30, name="C", hours=9.5) game.comp_100_count = 0 game.count_comp = 0 - with ( - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_cache", - side_effect=[{30: 9.5}, {30: -1.0}], - ), - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", - return_value={30: 0}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", - return_value={30: 0}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", - return_value={30: -1.0}, - ), - patch( - "steam_backlog_enforcer._scanning_confidence.save_hltb_cache", - ) as mock_save, - ): + # refresh_confidence owns the hours-preservation rule (see + # test_hltb_refetch_failure_keeps_cache); this layer only applies polls. + with patch( + "steam_backlog_enforcer._scanning_confidence.refresh_confidence", + return_value=({30: 9.5}, {30: 4}, {30: 7}), + ) as mock_refresh: _refresh_candidate_confidence_batch([game], force=True) + mock_refresh.assert_called_once_with([(30, "C")]) assert game.completionist_hours == 9.5 - saved_cache = mock_save.call_args.args[0] - assert saved_cache[30] == 9.5 + assert (game.comp_100_count, game.count_comp) == (4, 7) def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None: """Test filter hltb confident candidates skips low confidence.""" diff --git a/web/package-lock.json b/web/package-lock.json index 16b08ea..8a573fb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,13 +8,13 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@tanstack/react-query": "5.105.1", + "@tanstack/react-query": "5.107.1", "@tanstack/react-router": "1.170.46", "react": "19.3.1", "react-dom": "19.3.1" }, "devDependencies": { - "@babel/core": "8.0.10", + "@babel/core": "8.1.0", "@eslint/js": "10.0.1", "@rolldown/plugin-babel": "0.3.0", "@testing-library/dom": "10.4.2", @@ -24,18 +24,18 @@ "@types/node": "26.6.6", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@vitejs/plugin-react": "6.1.2", - "@vitest/coverage-v8": "5.0.5", + "@vitejs/plugin-react": "6.2.1", + "@vitest/coverage-v8": "5.1.0", "babel-plugin-react-compiler": "1.0.0", "eslint": "10.14.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.7", "globals": "17.14.0", - "jsdom": "30.1.3", + "jsdom": "31.0.0", "typescript": "6.0.3", "typescript-eslint": "8.72.1", - "vite": "8.3.5", - "vitest": "5.0.5" + "vite": "8.3.6", + "vitest": "5.1.0" } }, "node_modules/@adobe/css-tools": { @@ -45,59 +45,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@asamuzakjp/css-color": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", - "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.4.2", - "@csstools/css-color-parser": "^4.2.5", - "@csstools/css-parser-algorithms": "^4.0.2", - "@csstools/css-tokenizer": "^4.0.2", - "lru-cache": "^11.5.3" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.3.tgz", - "integrity": "sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bidi-js": "^1.1.0", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.3" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -124,20 +71,20 @@ } }, "node_modules/@babel/core": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.10.tgz", - "integrity": "sha512-T6dEcoMJ3L1hyLuNfRXcVlqQrN79kU/SB5bDygVgQNIOUgStdyEGzigtRA9K+9Coh7SZUSt543oWRBxvqYdyuQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.1.0.tgz", + "integrity": "sha512-3jqm7xh3kJCmIeOK8sk6/EvOWdSzvOqvjt0tJEDOHHmzWjcQSvIh2/tq1I9RA741tl0QZdMzPJWCulWE+Dy5ww==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^8.0.6", - "@babel/generator": "^8.0.10", + "@babel/generator": "^8.1.0", "@babel/helper-compilation-targets": "^8.0.7", - "@babel/helpers": "^8.0.10", - "@babel/parser": "^8.0.10", + "@babel/helpers": "^8.1.0", + "@babel/parser": "^8.1.0", "@babel/template": "^8.0.0", - "@babel/traverse": "^8.0.10", - "@babel/types": "^8.0.10", + "@babel/traverse": "^8.1.0", + "@babel/types": "^8.1.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", @@ -190,13 +137,13 @@ } }, "node_modules/@babel/core/node_modules/@babel/parser": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.10.tgz", - "integrity": "sha512-h+ah2Kg0ne1Vii892dShj9gky9I5jPzkd8x4s/3xgN/P2bMWmHS3qq59tum3mCVjCBXI8uqfYyYtrfp1Mz6+1w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", + "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^8.0.10" + "@babel/types": "^8.1.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -206,9 +153,9 @@ } }, "node_modules/@babel/core/node_modules/@babel/types": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.10.tgz", - "integrity": "sha512-KV8Y+teoRsmXeEPS4sCJY1kwV+hcPzHP1qQqgCxrjKOehLdBaxpsFBfoJ8MXWFV80y6Jw56+TM95wR0Per8dYw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", "dev": true, "license": "MIT", "dependencies": { @@ -227,14 +174,14 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.10.tgz", - "integrity": "sha512-iC9p3U4Y3vp0qKjRcfFeNzpj+kkK8BVYZTbQepBWVD7vOBLH6ji0drNzKPTPtumFd2w2r/hbEKYjG4pscKa4bg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.1.0.tgz", + "integrity": "sha512-7fiBJ61wDV3AHbg7n/EfLBv3RU+lsdRP8QNJnydkKWA7e48FnnvKQWbVHsJQCBE7JHTj4xlzZL9fvQYsBUip2A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^8.0.10", - "@babel/types": "^8.0.10", + "@babel/parser": "^8.1.0", + "@babel/types": "^8.1.0", "@jridgewell/gen-mapping": "0.4.0-beta.0", "@jridgewell/trace-mapping": "^0.3.31", "@types/jsesc": "^2.5.0", @@ -265,13 +212,13 @@ } }, "node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.10.tgz", - "integrity": "sha512-h+ah2Kg0ne1Vii892dShj9gky9I5jPzkd8x4s/3xgN/P2bMWmHS3qq59tum3mCVjCBXI8uqfYyYtrfp1Mz6+1w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", + "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^8.0.10" + "@babel/types": "^8.1.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -281,9 +228,9 @@ } }, "node_modules/@babel/generator/node_modules/@babel/types": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.10.tgz", - "integrity": "sha512-KV8Y+teoRsmXeEPS4sCJY1kwV+hcPzHP1qQqgCxrjKOehLdBaxpsFBfoJ8MXWFV80y6Jw56+TM95wR0Per8dYw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", "dev": true, "license": "MIT", "dependencies": { @@ -438,14 +385,14 @@ } }, "node_modules/@babel/helpers": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.10.tgz", - "integrity": "sha512-tBG9yuaR26JJWFafcl/Dk4eib6bkRJ9H5e6lENcwZqFudA9ImNCuMXLjB3/t+Ub7/DOZv2Rmi5LCOi8q2DCekQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.1.0.tgz", + "integrity": "sha512-949bjw10dhaRdtQeCpfVShFA4jIcto97BkqmWnRjpzyQuHVZ7EDriaoCn/4D8h1NqSKlmI20xPU0vHf3jQbQRg==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^8.0.0", - "@babel/types": "^8.0.10" + "@babel/types": "^8.1.0" }, "engines": { "node": "^22.18.0 || >=24.11.0" @@ -472,9 +419,9 @@ } }, "node_modules/@babel/helpers/node_modules/@babel/types": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.10.tgz", - "integrity": "sha512-KV8Y+teoRsmXeEPS4sCJY1kwV+hcPzHP1qQqgCxrjKOehLdBaxpsFBfoJ8MXWFV80y6Jw56+TM95wR0Per8dYw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", "dev": true, "license": "MIT", "dependencies": { @@ -561,13 +508,13 @@ } }, "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.10.tgz", - "integrity": "sha512-h+ah2Kg0ne1Vii892dShj9gky9I5jPzkd8x4s/3xgN/P2bMWmHS3qq59tum3mCVjCBXI8uqfYyYtrfp1Mz6+1w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", + "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^8.0.10" + "@babel/types": "^8.1.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -577,9 +524,9 @@ } }, "node_modules/@babel/template/node_modules/@babel/types": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.10.tgz", - "integrity": "sha512-KV8Y+teoRsmXeEPS4sCJY1kwV+hcPzHP1qQqgCxrjKOehLdBaxpsFBfoJ8MXWFV80y6Jw56+TM95wR0Per8dYw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", "dev": true, "license": "MIT", "dependencies": { @@ -598,18 +545,18 @@ "license": "MIT" }, "node_modules/@babel/traverse": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.10.tgz", - "integrity": "sha512-t5DaGzMi9WlhMSFT2YavI8gl8yGihoQymSQUkIq9+MUh5tO3Vx9KHwr5gMCzeBdYkvs00xNvr18PxtQTRayucA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.1.0.tgz", + "integrity": "sha512-oQ0QI6MR4cm6bOR/Ua5HLPxvO7vborT7Le5HNt9GmZUKpNsaPl2yj4gsQ440lXaZjc4vmUbSeVgEcK4cHuCL2A==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^8.0.6", - "@babel/generator": "^8.0.10", + "@babel/generator": "^8.1.0", "@babel/helper-globals": "^8.0.6", - "@babel/parser": "^8.0.10", + "@babel/parser": "^8.1.0", "@babel/template": "^8.0.0", - "@babel/types": "^8.0.10", + "@babel/types": "^8.1.0", "obug": "^3.0.0" }, "engines": { @@ -651,13 +598,13 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/parser": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.10.tgz", - "integrity": "sha512-h+ah2Kg0ne1Vii892dShj9gky9I5jPzkd8x4s/3xgN/P2bMWmHS3qq59tum3mCVjCBXI8uqfYyYtrfp1Mz6+1w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.1.0.tgz", + "integrity": "sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^8.0.10" + "@babel/types": "^8.1.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -667,9 +614,9 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.10.tgz", - "integrity": "sha512-KV8Y+teoRsmXeEPS4sCJY1kwV+hcPzHP1qQqgCxrjKOehLdBaxpsFBfoJ8MXWFV80y6Jw56+TM95wR0Per8dYw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.1.0.tgz", + "integrity": "sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==", "dev": true, "license": "MIT", "dependencies": { @@ -711,19 +658,6 @@ "node": ">=18" } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -748,146 +682,6 @@ "keyv": "^5.6.0" } }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", - "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", - "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.2", - "@csstools/css-tokenizer": "^4.0.2" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", - "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.2", - "@csstools/css-calc": "^3.4.3" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.2", - "@csstools/css-tokenizer": "^4.0.2" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", - "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.2" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", - "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", - "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1186,9 +980,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", - "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -1196,9 +990,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", - "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -1213,9 +1007,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", - "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -1230,9 +1024,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", - "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -1247,9 +1041,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", - "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -1264,9 +1058,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", - "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -1281,9 +1075,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", - "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -1298,9 +1092,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", - "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -1318,9 +1112,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", - "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -1338,9 +1132,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", - "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -1358,9 +1152,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", - "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -1378,9 +1172,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", - "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -1398,9 +1192,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", - "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -1418,9 +1212,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", - "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -1435,9 +1229,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", - "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -1452,9 +1246,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", - "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -1521,9 +1315,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.105.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.105.1.tgz", - "integrity": "sha512-B/rNlZoZ54w1dlddiUt9oEe1SLM+37c00SKLJEg9YH+7as3Y54P81o9Fh+2ujZ36tXPnQJ5oVe7AcyEWkyFhPg==", + "version": "5.107.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.107.1.tgz", + "integrity": "sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==", "license": "MIT", "funding": { "type": "github", @@ -1531,12 +1325,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.105.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.105.1.tgz", - "integrity": "sha512-qcu+/+PtBOjW8qU4/0l47QVmNG7hPOWsCLzP0bTRhE1X7HYa757C4732wXTUH0QhCjTV1yedX8onsCiEwlNYrA==", + "version": "5.107.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.107.1.tgz", + "integrity": "sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.105.1" + "@tanstack/query-core": "5.107.1" }, "funding": { "type": "github", @@ -1794,7 +1588,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2054,9 +1848,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", - "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.2.1.tgz", + "integrity": "sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==", "dev": true, "license": "MIT", "dependencies": { @@ -2066,9 +1860,9 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0 || ^0.3.0", "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.152.0", + "oxc-transform-react": ">=0.152.0 <0.157.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -2084,15 +1878,15 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", - "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.1.0.tgz", + "integrity": "sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/istanbul-lib-coverage": "^1.0.2", - "@vitest/istanbul-lib-report": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^2.1.0", + "@vitest/istanbul-lib-report": "^2.1.0", "ast-v8-to-istanbul": "^1.0.7", "magicast": "^0.5.5", "obug": "^3.0.0", @@ -2103,8 +1897,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.5", - "vitest": "5.0.5" + "@vitest/browser": "5.1.0", + "vitest": "5.1.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2113,9 +1907,9 @@ } }, "node_modules/@vitest/istanbul-lib-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", - "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-2.1.0.tgz", + "integrity": "sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==", "dev": true, "license": "MIT", "engines": { @@ -2123,27 +1917,27 @@ } }, "node_modules/@vitest/istanbul-lib-report": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", - "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-2.1.0.tgz", + "integrity": "sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/istanbul-lib-coverage": "1.0.2" + "@vitest/istanbul-lib-coverage": "2.1.0" }, "engines": { "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", - "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.5", + "@vitest/spy": "5.1.0", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, @@ -2164,9 +1958,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", - "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", "dev": true, "license": "MIT", "funding": { @@ -2322,14 +2116,31 @@ "node": ">=6.0.0" } }, - "node_modules/bidi-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", - "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "node_modules/bcp-47": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.2.tgz", + "integrity": "sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==", "dev": true, "license": "MIT", "dependencies": { - "require-from-string": "^2.0.2" + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/brace-expansion": { @@ -2452,20 +2263,6 @@ "node": ">= 8" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2477,7 +2274,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2571,9 +2368,9 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3227,6 +3024,43 @@ "node": ">=8" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3281,41 +3115,39 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", - "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-31.0.0.tgz", + "integrity": "sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^7.1.3", - "@asamuzakjp/dom-selector": "^10.0.3", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.15", "@exodus/bytes": "^1.16.0", - "css-tree": "^3.2.1", + "bcp-47": "^2.1.2", + "bcp-47-match": "^2.0.3", "data-urls": "^8.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.3", - "parse5": "^8.0.1", + "parse5": "^8.1.0", + "parse5-parser-stream": "^8.1.0", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", - "undici": "^8.12.1", + "undici": "^8.12.3", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.2.0", + "whatwg-url": "^17.3.0", "xml-name-validator": "^5.0.0" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.2.3" + "@napi-rs/canvas": "^1.0.13" }, "peerDependenciesMeta": { - "canvas": { + "@napi-rs/canvas": { "optional": true } } @@ -3692,9 +3524,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { @@ -3713,13 +3545,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3854,13 +3679,26 @@ } }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.1.tgz", + "integrity": "sha512-EcR/6WDcU7b2N3vQSxKALCk9lXGtUMQxXio008OEMBE0kavu/7JLfBEeFPzi43nMqVDSbEn3QCK+BMCc9zO//w==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-8.1.1.tgz", + "integrity": "sha512-hMcVbbUJcQmP8rURWUdYrT1flRiBiBVjq/RYGguJpDUgIXSqdYKlE0CxthmdMWoSlBAuL5bUBa73g0pttSy7Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.1.1" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -3907,9 +3745,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -4032,24 +3870,14 @@ "node": ">=8" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", - "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.155.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4059,21 +3887,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.15", - "@rolldown/binding-android-arm64": "1.2.15", - "@rolldown/binding-darwin-arm64": "1.2.15", - "@rolldown/binding-darwin-x64": "1.2.15", - "@rolldown/binding-freebsd-x64": "1.2.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", - "@rolldown/binding-linux-arm64-gnu": "1.2.15", - "@rolldown/binding-linux-arm64-musl": "1.2.15", - "@rolldown/binding-linux-ppc64-gnu": "1.2.15", - "@rolldown/binding-linux-s390x-gnu": "1.2.15", - "@rolldown/binding-linux-x64-gnu": "1.2.15", - "@rolldown/binding-linux-x64-musl": "1.2.15", - "@rolldown/binding-openharmony-arm64": "1.2.15", - "@rolldown/binding-win32-arm64-msvc": "1.2.15", - "@rolldown/binding-win32-x64-msvc": "1.2.15" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/saxes": { @@ -4337,9 +4165,9 @@ } }, "node_modules/undici": { - "version": "8.12.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", - "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "dev": true, "license": "MIT", "engines": { @@ -4417,16 +4245,16 @@ } }, "node_modules/vite": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", - "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.30", - "rolldown": "~1.2.14", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", "tinyglobby": "^0.2.17" }, "bin": { @@ -4495,14 +4323,14 @@ } }, "node_modules/vitest": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", - "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.5", + "@vitest/mocker": "5.1.0", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -4528,12 +4356,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.5", - "@vitest/browser-preview": "5.0.5", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.5", - "@vitest/coverage-v8": "5.0.5", - "@vitest/ui": "5.0.5", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -4611,9 +4439,9 @@ } }, "node_modules/whatwg-url": { - "version": "17.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", - "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index b3444d7..9da8c09 100644 --- a/web/package.json +++ b/web/package.json @@ -14,13 +14,13 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "@tanstack/react-query": "5.105.1", + "@tanstack/react-query": "5.107.1", "@tanstack/react-router": "1.170.46", "react": "19.3.1", "react-dom": "19.3.1" }, "devDependencies": { - "@babel/core": "8.0.10", + "@babel/core": "8.1.0", "@eslint/js": "10.0.1", "@rolldown/plugin-babel": "0.3.0", "@testing-library/dom": "10.4.2", @@ -30,17 +30,17 @@ "@types/node": "26.6.6", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@vitejs/plugin-react": "6.1.2", - "@vitest/coverage-v8": "5.0.5", + "@vitejs/plugin-react": "6.2.1", + "@vitest/coverage-v8": "5.1.0", "babel-plugin-react-compiler": "1.0.0", "eslint": "10.14.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.7", "globals": "17.14.0", - "jsdom": "30.1.3", + "jsdom": "31.0.0", "typescript": "6.0.3", "typescript-eslint": "8.72.1", - "vite": "8.3.5", - "vitest": "5.0.5" + "vite": "8.3.6", + "vitest": "5.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d5cb05d..cf3ad6a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@tanstack/react-query': - specifier: 5.105.1 - version: 5.105.1(@types/react@19.3.0)(react@19.3.1) + specifier: 5.107.1 + version: 5.107.1(@types/react@19.3.0)(react@19.3.1) '@tanstack/react-router': specifier: 1.170.46 version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -22,20 +22,20 @@ importers: version: 19.3.1(react@19.3.1) devDependencies: '@babel/core': - specifier: 8.0.10 - version: 8.0.10 + specifier: 8.1.0 + version: 8.1.0 '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.14.0(supports-color@7.2.0)) '@rolldown/plugin-babel': specifier: 0.3.0 - version: 0.3.0(@babel/core@8.0.10)(@babel/runtime@7.29.7)(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.6)) + version: 0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)) '@testing-library/dom': specifier: 10.4.2 version: 10.4.2 '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.5) + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0) '@testing-library/react': specifier: 16.3.3 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -52,11 +52,11 @@ importers: specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': - specifier: 6.1.2 - version: 6.1.2(@rolldown/plugin-babel@0.3.0(@babel/core@8.0.10)(@babel/runtime@7.29.7)(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.6)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.5(@types/node@26.6.6)) + specifier: 6.2.1 + version: 6.2.1(@rolldown/plugin-babel@0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.6(@types/node@26.6.6)) '@vitest/coverage-v8': - specifier: 5.0.5 - version: 5.0.5(vitest@5.0.5) + specifier: 5.1.0 + version: 5.1.0(vitest@5.1.0) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -73,8 +73,8 @@ importers: specifier: 17.14.0 version: 17.14.0 jsdom: - specifier: 30.1.3 - version: 30.1.3 + specifier: 31.0.0 + version: 31.0.0 typescript: specifier: 6.0.3 version: 6.0.3 @@ -82,25 +82,17 @@ importers: specifier: 8.72.1 version: 8.72.1(eslint@10.14.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vite: - specifier: 8.3.5 - version: 8.3.5(@types/node@26.6.6) + specifier: 8.3.6 + version: 8.3.6(@types/node@26.6.6) vitest: - specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)) + specifier: 5.1.0 + version: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)) packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@asamuzakjp/css-color@7.1.3': - resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@asamuzakjp/dom-selector@10.0.3': - resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -121,16 +113,16 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/core@8.0.10': - resolution: {integrity: sha512-T6dEcoMJ3L1hyLuNfRXcVlqQrN79kU/SB5bDygVgQNIOUgStdyEGzigtRA9K+9Coh7SZUSt543oWRBxvqYdyuQ==} + '@babel/core@8.1.0': + resolution: {integrity: sha512-3jqm7xh3kJCmIeOK8sk6/EvOWdSzvOqvjt0tJEDOHHmzWjcQSvIh2/tq1I9RA741tl0QZdMzPJWCulWE+Dy5ww==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/generator@8.0.10': - resolution: {integrity: sha512-iC9p3U4Y3vp0qKjRcfFeNzpj+kkK8BVYZTbQepBWVD7vOBLH6ji0drNzKPTPtumFd2w2r/hbEKYjG4pscKa4bg==} + '@babel/generator@8.1.0': + resolution: {integrity: sha512-7fiBJ61wDV3AHbg7n/EfLBv3RU+lsdRP8QNJnydkKWA7e48FnnvKQWbVHsJQCBE7JHTj4xlzZL9fvQYsBUip2A==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/helper-compilation-targets@7.29.7': @@ -187,16 +179,17 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/helpers@8.0.10': - resolution: {integrity: sha512-tBG9yuaR26JJWFafcl/Dk4eib6bkRJ9H5e6lENcwZqFudA9ImNCuMXLjB3/t+Ub7/DOZv2Rmi5LCOi8q2DCekQ==} + '@babel/helpers@8.1.0': + resolution: {integrity: sha512-949bjw10dhaRdtQeCpfVShFA4jIcto97BkqmWnRjpzyQuHVZ7EDriaoCn/4D8h1NqSKlmI20xPU0vHf3jQbQRg==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} + hasBin: true - '@babel/parser@8.0.10': - resolution: {integrity: sha512-h+ah2Kg0ne1Vii892dShj9gky9I5jPzkd8x4s/3xgN/P2bMWmHS3qq59tum3mCVjCBXI8uqfYyYtrfp1Mz6+1w==} + '@babel/parser@8.1.0': + resolution: {integrity: sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true @@ -216,68 +209,28 @@ packages: resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/traverse@8.0.10': - resolution: {integrity: sha512-t5DaGzMi9WlhMSFT2YavI8gl8yGihoQymSQUkIq9+MUh5tO3Vx9KHwr5gMCzeBdYkvs00xNvr18PxtQTRayucA==} + '@babel/traverse@8.1.0': + resolution: {integrity: sha512-oQ0QI6MR4cm6bOR/Ua5HLPxvO7vborT7Le5HNt9GmZUKpNsaPl2yj4gsQ440lXaZjc4vmUbSeVgEcK4cHuCL2A==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.10': - resolution: {integrity: sha512-KV8Y+teoRsmXeEPS4sCJY1kwV+hcPzHP1qQqgCxrjKOehLdBaxpsFBfoJ8MXWFV80y6Jw56+TM95wR0Per8dYw==} + '@babel/types@8.1.0': + resolution: {integrity: sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==} engines: {node: ^22.18.0 || >=24.11.0} '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@bramus/specificity@2.4.2': - resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} - hasBin: true - '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - '@csstools/color-helpers@6.1.2': - resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} - engines: {node: '>=20.19.0'} - - '@csstools/css-calc@3.4.3': - resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.2 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-color-parser@4.2.6': - resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.2 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-parser-algorithms@4.0.2': - resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.15': - resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-tokenizer@4.0.2': - resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} - engines: {node: '>=20.19.0'} - '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -374,101 +327,101 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@oxc-project/types@0.155.0': - resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} - '@rolldown/binding-android-arm-eabi@1.2.15': - resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.15': - resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.15': - resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.15': - resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.15': - resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.15': - resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.15': - resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.15': - resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.15': - resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.15': - resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.15': - resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.15': - resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.15': - resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.15': - resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.15': - resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -497,11 +450,11 @@ packages: resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.105.1': - resolution: {integrity: sha512-B/rNlZoZ54w1dlddiUt9oEe1SLM+37c00SKLJEg9YH+7as3Y54P81o9Fh+2ujZ36tXPnQJ5oVe7AcyEWkyFhPg==} + '@tanstack/query-core@5.107.1': + resolution: {integrity: sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==} - '@tanstack/react-query@5.105.1': - resolution: {integrity: sha512-qcu+/+PtBOjW8qU4/0l47QVmNG7hPOWsCLzP0bTRhE1X7HYa757C4732wXTUH0QhCjTV1yedX8onsCiEwlNYrA==} + '@tanstack/react-query@5.107.1': + resolution: {integrity: sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==} peerDependencies: '@types/react': ^18 || ^19 react: ^18 || ^19 @@ -658,13 +611,13 @@ packages: resolution: {integrity: sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@6.1.2': - resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + '@vitejs/plugin-react@6.2.1': + resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0 babel-plugin-react-compiler: ^1.0.0 - oxc-transform-react: ^0.152.0 + oxc-transform-react: '>=0.152.0 <0.157.0' vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': @@ -674,25 +627,25 @@ packages: oxc-transform-react: optional: true - '@vitest/coverage-v8@5.0.5': - resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + '@vitest/coverage-v8@5.1.0': + resolution: {integrity: sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==} peerDependencies: - '@vitest/browser': 5.0.5 - vitest: 5.0.5 + '@vitest/browser': 5.1.0 + vitest: 5.1.0 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/istanbul-lib-coverage@1.0.2': - resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + '@vitest/istanbul-lib-coverage@2.1.0': + resolution: {integrity: sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==} engines: {node: '>=22'} - '@vitest/istanbul-lib-report@1.0.2': - resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + '@vitest/istanbul-lib-report@2.1.0': + resolution: {integrity: sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==} engines: {node: '>=22'} - '@vitest/mocker@5.0.5': - resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + '@vitest/mocker@5.1.0': + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} peerDependencies: msw: ^2.4.9 || ^3.0.0 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -702,8 +655,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.5': - resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -756,8 +709,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - bidi-js@1.1.0: - resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.2: + resolution: {integrity: sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==} brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} @@ -788,10 +744,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -838,8 +790,8 @@ packages: resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} engines: {node: '>=14'} - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + entities@9.0.0: + resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==} engines: {node: '>=20.19.0'} es-module-lexer@2.3.2: @@ -1008,6 +960,15 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1032,13 +993,13 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - jsdom@30.1.3: - resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + jsdom@31.0.0: + resolution: {integrity: sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: - canvas: ^3.2.3 + '@napi-rs/canvas': ^1.0.13 peerDependenciesMeta: - canvas: + '@napi-rs/canvas': optional: true jsesc@3.1.0: @@ -1158,9 +1119,6 @@ packages: magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1200,8 +1158,11 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parse5-parser-stream@8.1.0: + resolution: {integrity: sha512-58MPfNTA+DsIzro41rU94xghnu/h2N4yEPdPdGPlZVHM/QvA5jlVGYVo7IzlHbFLHYx3cZUnr9s9HBrA0tEYyw==} + + parse5@8.1.0: + resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==} path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} @@ -1218,8 +1179,8 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.31: - resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -1254,12 +1215,8 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - rolldown@1.2.15: - resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1366,8 +1323,8 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici@8.12.1: - resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==} + undici@8.12.4: + resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==} engines: {node: '>=22.19.0'} update-browserslist-db@1.3.1: @@ -1388,8 +1345,8 @@ packages: resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} engines: {node: '>=18.12.0'} - vite@8.3.5: - resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} + vite@8.3.6: + resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1431,20 +1388,20 @@ packages: yaml: optional: true - vitest@5.0.5: - resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + vitest@5.1.0: + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.5 - '@vitest/browser-preview': 5.0.5 + '@vitest/browser-playwright': 5.1.0 + '@vitest/browser-preview': 5.1.0 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.5 - '@vitest/coverage-v8': 5.0.5 - '@vitest/ui': 5.0.5 + '@vitest/coverage-istanbul': 5.1.0 + '@vitest/coverage-v8': 5.1.0 + '@vitest/ui': 5.1.0 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -1484,8 +1441,8 @@ packages: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} - whatwg-url@17.2.0: - resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==} + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} engines: {node: ^22.14.0 || >=24.0.0} which@2.0.2: @@ -1529,21 +1486,6 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@asamuzakjp/css-color@7.1.3': - dependencies: - '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - lru-cache: 11.5.3 - - '@asamuzakjp/dom-selector@10.0.3': - dependencies: - bidi-js: 1.1.0 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.3 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1579,16 +1521,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@8.0.10': + '@babel/core@8.1.0': dependencies: '@babel/code-frame': 8.0.6 - '@babel/generator': 8.0.10 + '@babel/generator': 8.1.0 '@babel/helper-compilation-targets': 8.0.7 - '@babel/helpers': 8.0.10 - '@babel/parser': 8.0.10 + '@babel/helpers': 8.1.0 + '@babel/parser': 8.1.0 '@babel/template': 8.0.0 - '@babel/traverse': 8.0.10 - '@babel/types': 8.0.10 + '@babel/traverse': 8.1.0 + '@babel/types': 8.1.0 '@types/gensync': 1.0.5 convert-source-map: 2.0.0 empathic: 2.1.0 @@ -1606,10 +1548,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@8.0.10': + '@babel/generator@8.1.0': dependencies: - '@babel/parser': 8.0.10 - '@babel/types': 8.0.10 + '@babel/parser': 8.1.0 + '@babel/types': 8.1.0 '@jridgewell/gen-mapping': 0.4.0-beta.0 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 @@ -1668,18 +1610,18 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.8 - '@babel/helpers@8.0.10': + '@babel/helpers@8.1.0': dependencies: '@babel/template': 8.0.0 - '@babel/types': 8.0.10 + '@babel/types': 8.1.0 '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 - '@babel/parser@8.0.10': + '@babel/parser@8.1.0': dependencies: - '@babel/types': 8.0.10 + '@babel/types': 8.1.0 '@babel/runtime@7.29.7': {} @@ -1692,8 +1634,8 @@ snapshots: '@babel/template@8.0.0': dependencies: '@babel/code-frame': 8.0.6 - '@babel/parser': 8.0.10 - '@babel/types': 8.0.10 + '@babel/parser': 8.1.0 + '@babel/types': 8.1.0 '@babel/traverse@7.29.8(supports-color@7.2.0)': dependencies: @@ -1707,14 +1649,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@8.0.10': + '@babel/traverse@8.1.0': dependencies: '@babel/code-frame': 8.0.6 - '@babel/generator': 8.0.10 + '@babel/generator': 8.1.0 '@babel/helper-globals': 8.0.6 - '@babel/parser': 8.0.10 + '@babel/parser': 8.1.0 '@babel/template': 8.0.0 - '@babel/types': 8.0.10 + '@babel/types': 8.1.0 obug: 3.0.0 '@babel/types@7.29.8': @@ -1722,17 +1664,13 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.10': + '@babel/types@8.1.0': dependencies: '@babel/helper-string-parser': 8.0.8 '@babel/helper-validator-identifier': 8.0.6 '@bcoe/v8-coverage@1.0.2': {} - '@bramus/specificity@2.4.2': - dependencies: - css-tree: 3.2.1 - '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -1745,30 +1683,6 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@csstools/color-helpers@6.1.2': {} - - '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/color-helpers': 6.1.2 - '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - - '@csstools/css-tokenizer@4.0.2': {} - '@eslint-community/eslint-utils@4.10.1(eslint@10.14.0(supports-color@7.2.0))': dependencies: eslint: 10.14.0(supports-color@7.2.0) @@ -1853,72 +1767,72 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@oxc-project/types@0.155.0': {} + '@oxc-project/types@0.156.0': {} - '@rolldown/binding-android-arm-eabi@1.2.15': + '@rolldown/binding-android-arm-eabi@1.2.16': optional: true - '@rolldown/binding-android-arm64@1.2.15': + '@rolldown/binding-android-arm64@1.2.16': optional: true - '@rolldown/binding-darwin-arm64@1.2.15': + '@rolldown/binding-darwin-arm64@1.2.16': optional: true - '@rolldown/binding-darwin-x64@1.2.15': + '@rolldown/binding-darwin-x64@1.2.16': optional: true - '@rolldown/binding-freebsd-x64@1.2.15': + '@rolldown/binding-freebsd-x64@1.2.16': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.15': + '@rolldown/binding-linux-arm64-gnu@1.2.16': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.15': + '@rolldown/binding-linux-arm64-musl@1.2.16': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.15': + '@rolldown/binding-linux-ppc64-gnu@1.2.16': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.15': + '@rolldown/binding-linux-s390x-gnu@1.2.16': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.15': + '@rolldown/binding-linux-x64-gnu@1.2.16': optional: true - '@rolldown/binding-linux-x64-musl@1.2.15': + '@rolldown/binding-linux-x64-musl@1.2.16': optional: true - '@rolldown/binding-openharmony-arm64@1.2.15': + '@rolldown/binding-openharmony-arm64@1.2.16': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.15': + '@rolldown/binding-win32-arm64-msvc@1.2.16': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.15': + '@rolldown/binding-win32-x64-msvc@1.2.16': optional: true - '@rolldown/plugin-babel@0.3.0(@babel/core@8.0.10)(@babel/runtime@7.29.7)(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.6))': + '@rolldown/plugin-babel@0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6))': dependencies: - '@babel/core': 8.0.10 + '@babel/core': 8.1.0 artichokie: 0.4.5 picomatch: 4.0.7 - rolldown: 1.2.15 + rolldown: 1.2.16 optionalDependencies: '@babel/runtime': 7.29.7 - vite: 8.3.5(@types/node@26.6.6) + vite: 8.3.6(@types/node@26.6.6) '@rolldown/pluginutils@1.0.1': {} '@tanstack/history@1.162.5': {} - '@tanstack/query-core@5.105.1': {} + '@tanstack/query-core@5.107.1': {} - '@tanstack/react-query@5.105.1(@types/react@19.3.0)(react@19.3.1)': + '@tanstack/react-query@5.107.1(@types/react@19.3.0)(react@19.3.1)': dependencies: - '@tanstack/query-core': 5.105.1 + '@tanstack/query-core': 5.107.1 react: 19.3.1 optionalDependencies: '@types/react': 19.3.0 @@ -1959,7 +1873,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.5)': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0)': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.2 @@ -1969,7 +1883,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.5(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)) + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: @@ -2107,42 +2021,42 @@ snapshots: '@typescript-eslint/types': 8.72.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.3.0(@babel/core@8.0.10)(@babel/runtime@7.29.7)(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.6)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.5(@types/node@26.6.6))': + '@vitejs/plugin-react@6.2.1(@rolldown/plugin-babel@0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.6(@types/node@26.6.6))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.5(@types/node@26.6.6) + vite: 8.3.6(@types/node@26.6.6) optionalDependencies: - '@rolldown/plugin-babel': 0.3.0(@babel/core@8.0.10)(@babel/runtime@7.29.7)(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.6)) + '@rolldown/plugin-babel': 0.3.0(@babel/core@8.1.0)(@babel/runtime@7.29.7)(rolldown@1.2.16)(vite@8.3.6(@types/node@26.6.6)) babel-plugin-react-compiler: 1.0.0 - '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + '@vitest/coverage-v8@5.1.0(vitest@5.1.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/istanbul-lib-report': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 + '@vitest/istanbul-lib-report': 2.1.0 ast-v8-to-istanbul: 1.0.7 magicast: 0.5.5 obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)) + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)) - '@vitest/istanbul-lib-coverage@1.0.2': {} + '@vitest/istanbul-lib-coverage@2.1.0': {} - '@vitest/istanbul-lib-report@1.0.2': + '@vitest/istanbul-lib-report@2.1.0': dependencies: - '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 - '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.6))': + '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@26.6.6))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.5 + '@vitest/spy': 5.1.0 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.5(@types/node@26.6.6) + vite: 8.3.6(@types/node@26.6.6) - '@vitest/spy@5.0.5': {} + '@vitest/spy@5.1.0': {} acorn-jsx@5.3.2(acorn@8.18.0): dependencies: @@ -2185,9 +2099,13 @@ snapshots: baseline-browser-mapping@2.11.19: {} - bidi-js@1.1.0: + bcp-47-match@2.0.3: {} + + bcp-47@2.1.2: dependencies: - require-from-string: 2.0.2 + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 brace-expansion@5.0.9: dependencies: @@ -2223,11 +2141,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.2 - css.escape@1.5.1: {} csstype@3.2.3: {} @@ -2235,7 +2148,7 @@ snapshots: data-urls@8.0.0: dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 17.2.0 + whatwg-url: 17.3.0 transitivePeerDependencies: - '@noble/hashes' @@ -2261,7 +2174,7 @@ snapshots: empathic@2.1.0: {} - entities@8.0.0: {} + entities@9.0.0: {} es-module-lexer@2.3.2: {} @@ -2427,6 +2340,15 @@ snapshots: indent-string@4.0.0: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2443,27 +2365,25 @@ snapshots: js-tokens@4.0.0: {} - jsdom@30.1.3: + jsdom@31.0.0: dependencies: - '@asamuzakjp/css-color': 7.1.3 - '@asamuzakjp/dom-selector': 10.0.3 - '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) '@exodus/bytes': 1.16.0 - css-tree: 3.2.1 + bcp-47: 2.1.2 + bcp-47-match: 2.0.3 data-urls: 8.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 7.0.0 is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 - parse5: 8.0.1 + parse5: 8.1.0 + parse5-parser-stream: 8.1.0 saxes: 6.0.0 tough-cookie: 6.0.2 - undici: 8.12.1 + undici: 8.12.4 w3c-xmlserializer: 6.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 17.2.0 + whatwg-url: 17.3.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -2556,8 +2476,6 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 - mdn-data@2.27.1: {} - min-indent@1.0.1: {} minimatch@10.2.6: @@ -2591,9 +2509,13 @@ snapshots: dependencies: p-limit: 3.1.0 - parse5@8.0.1: + parse5-parser-stream@8.1.0: dependencies: - entities: 8.0.0 + parse5: 8.1.0 + + parse5@8.1.0: + dependencies: + entities: 9.0.0 path-exists@4.0.0: {} @@ -2603,7 +2525,7 @@ snapshots: picomatch@4.0.7: {} - postcss@8.5.31: + postcss@8.5.32: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 @@ -2637,28 +2559,26 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - require-from-string@2.0.2: {} - - rolldown@1.2.15: + rolldown@1.2.16: dependencies: - '@oxc-project/types': 0.155.0 + '@oxc-project/types': 0.156.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.15 - '@rolldown/binding-android-arm64': 1.2.15 - '@rolldown/binding-darwin-arm64': 1.2.15 - '@rolldown/binding-darwin-x64': 1.2.15 - '@rolldown/binding-freebsd-x64': 1.2.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 - '@rolldown/binding-linux-arm64-gnu': 1.2.15 - '@rolldown/binding-linux-arm64-musl': 1.2.15 - '@rolldown/binding-linux-ppc64-gnu': 1.2.15 - '@rolldown/binding-linux-s390x-gnu': 1.2.15 - '@rolldown/binding-linux-x64-gnu': 1.2.15 - '@rolldown/binding-linux-x64-musl': 1.2.15 - '@rolldown/binding-openharmony-arm64': 1.2.15 - '@rolldown/binding-win32-arm64-msvc': 1.2.15 - '@rolldown/binding-win32-x64-msvc': 1.2.15 + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 saxes@6.0.0: dependencies: @@ -2743,7 +2663,7 @@ snapshots: undici-types@8.9.0: {} - undici@8.12.1: {} + undici@8.12.4: {} update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: @@ -2761,21 +2681,21 @@ snapshots: verkit@0.5.0: {} - vite@8.3.5(@types/node@26.6.6): + vite@8.3.6(@types/node@26.6.6): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.31 - rolldown: 1.2.15 + postcss: 8.5.32 + rolldown: 1.2.16 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.6 fsevents: 2.3.3 - vitest@5.0.5(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)): + vitest@5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.6)) + '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@26.6.6)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2786,12 +2706,12 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.5(@types/node@26.6.6) + vite: 8.3.6(@types/node@26.6.6) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.6 - '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) - jsdom: 30.1.3 + '@vitest/coverage-v8': 5.1.0(vitest@5.1.0) + jsdom: 31.0.0 transitivePeerDependencies: - msw @@ -2803,7 +2723,7 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@17.2.0: + whatwg-url@17.3.0: dependencies: '@exodus/bytes': 1.16.0 tr46: 7.0.0