From 95d80268f419522663a42535a23ffbba06d83348 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Fri, 21 Aug 2026 09:04:53 +0700 Subject: [PATCH] feat(ui): wire state contract v1.1 fields --- CODEX_CONTRACT_V11_REPORT.md | 69 ++++++ .../router/hermes_hub_app.py | 3 +- .../router/ui/components.py | 80 +++++- .../router/ui/views/dashboard_view.py | 21 +- .../router/ui/views/routing_view.py | 19 +- .../router/ui/views/team_view.py | 20 +- tests/test_ui_contract_v11.py | 227 ++++++++++++++++++ 7 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 CODEX_CONTRACT_V11_REPORT.md create mode 100644 tests/test_ui_contract_v11.py diff --git a/CODEX_CONTRACT_V11_REPORT.md b/CODEX_CONTRACT_V11_REPORT.md new file mode 100644 index 0000000..04e125c --- /dev/null +++ b/CODEX_CONTRACT_V11_REPORT.md @@ -0,0 +1,69 @@ +# Hermes Hub — подключение UI-контракта v1.1 + +Дата: 2026-08-21 + +Ветка: `codex/contract-v11` + +BASE_SHA: `9ffc815ed59f7fd364e176ffef20bf29d545bc46` + +FINAL_SHA: `git rev-parse codex/contract-v11` в момент handoff. Точный SHA указан в итоговом сообщении, поскольку commit не может содержать собственный SHA. + +## Изменённые файлы + +- `src/antigravity_provider/router/hermes_hub_app.py` +- `src/antigravity_provider/router/ui/components.py` +- `src/antigravity_provider/router/ui/views/dashboard_view.py` +- `src/antigravity_provider/router/ui/views/routing_view.py` +- `src/antigravity_provider/router/ui/views/team_view.py` +- `tests/test_ui_contract_v11.py` +- `CODEX_CONTRACT_V11_REPORT.md` + +Файлы backend/state/adapters, installer, scripts, config, legacy и чужие тесты не изменялись. + +## Что подключено + +- `ProfileViewModel.plan_code` и `plan_source`: подтверждённый провайдером тариф показан акцентным `PlanBadge`; `inferred` явно помечен как «выведено»; `unknown` скрыт. +- `QuotaSnapshot.unavailable_reason` и совместимый fallback на `QuotaBucket.unavailable_reason`: причина отсутствия данных показана под корзиной. +- `QuotaBar` и `QuotaBucketWidget`: `None` показан как «Н/Д» нейтральным цветом, реальный `0%` — как исчерпанная квота красным цветом. +- `AgentViewModel.active_quota_status`, `active_quota_label` и `session_id`: активная квота и сессия видны в карточке агента. +- `PipelineNode.quota_status`, `failover_reason` и `account_identity`: состояние квоты и реальная причина показаны у узла, с которого ушёл трафик. +- `HubSnapshot.seq` и `is_stale`: номер snapshot и предупреждение об устаревших данных показаны на Dashboard и в общей строке состояния. +- Устранено обращение Dashboard/маршрутизации к отсутствующему `PipelineNode.provider_display_name`; используется контрактное поле `provider`. + +## Проверки + +### Без UI-зависимостей + +```powershell +uv run --isolated --no-project --with pytest --with pyyaml --with pydantic --with requests --with httpx python -m pytest -q +``` + +Результат: `169 passed, 22 skipped, 3 deselected in 8.87s`. `customtkinter`, Pillow и psutil намеренно отсутствуют, UI-тесты пропущены. + +### С UI-зависимостями + +```powershell +uv run --extra dev python -m pytest -q +``` + +Результат: `216 passed, 2 skipped, 3 deselected in 24.22s`, включая `5 passed` в `tests/test_ui_contract_v11.py`. + +### Ruff и release gate + +```powershell +uv run ruff check . +uv run --extra dev python scripts/release_gate.py +``` + +Результат: Ruff — `All checks passed!`; release gate — `PASSED`. Публичный manifest доступен, package URL по-прежнему имеет статус pending upload/404, как и до B3. + +## Что осталось + +- Все поля, перечисленные в задании B3, подключены. Сбор данных в UI не добавлялся. +- Тег `v0.1.1`, release и manifest не создавались и не изменялись. + +## Backend gaps + +1. В фактической модели v1.1 `unavailable_reason` находится на `QuotaSnapshot`, а не на `QuotaBucket`, хотя таблица задания называет `QuotaBucket`. UI поддерживает оба расположения без собственного сбора данных. +2. Baseline-корзины провайдеров не содержат числовых лимитов: до provider claim/runtime event UI честно показывает «Н/Д», а не синтетический процент. +3. Live release package остаётся внешней задачей: release gate сообщает `PACKAGE_LIVE=False (Pending Upload 404)`. diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index d0640b5..2122cdd 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -437,8 +437,9 @@ class HermesHubApp(ctk.CTk): snap = HubStateStore.get().get_snapshot() readiness = snapshot_or_readiness + freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}" self.status_left.configure( - text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}" + text=f"{freshness} | Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}" ) r_color = ( diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index f52eba8..84be929 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -600,7 +600,25 @@ class _TextBadge(ctk.CTkFrame): class PlanBadge(_TextBadge): - """Provider plan badge. Unknown/empty plans should not instantiate it.""" + """Provider plan badge that makes provider truth distinct from inference.""" + + TRUSTED_SOURCES = {"provider_api", "jwt_claim", "provider_auth"} + + def set_plan(self, plan_code: str, plan_source: str) -> bool: + """Update the badge and return whether it should be visible.""" + code = str(plan_code or "UNKNOWN").strip().upper() + source = str(plan_source or "unknown").strip().lower() + if code == "UNKNOWN" or source == "unknown": + return False + if source in self.TRUSTED_SOURCES: + self.set_text(f"Тариф {code}") + self.configure(border_color=Theme.BORDER_ACCENT, fg_color=Theme.SURFACE_MUTED) + self.label.configure(text_color=Theme.TEXT_ACCENT) + else: + self.set_text(f"Тариф {code} • выведено") + self.configure(border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED) + self.label.configure(text_color=Theme.TEXT_MUTED) + return True class QuotaBar(ctk.CTkFrame): @@ -668,7 +686,15 @@ class QuotaBucketWidget(HubCard): self.bar = QuotaBar(self, label="Остаток") self.bar.pack(fill="x", padx=Theme.CARD_PAD_X) self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) + self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) + self.unavailable_reason = ctk.CTkLabel( + self, + text="", + font=Theme.font_micro(), + text_color=Theme.COLOR_CAUTION, + wraplength=320, + justify="left", + ) self.update_bucket(label, remaining_ratio, reset_text, is_estimated) def update_bucket( @@ -678,11 +704,19 @@ class QuotaBucketWidget(HubCard): reset_text: str = "", is_estimated: bool = False, detail: Optional[str] = None, + unavailable_reason: Optional[str] = None, ) -> None: suffix = " • оценка" if is_estimated else "" self.title.configure(text=f"{label}{suffix}") self.bar.set_value(remaining_ratio, detail or ("Н/Д" if remaining_ratio is None else None)) self.reset.configure(text=reset_text or "Сброс: Н/Д") + if unavailable_reason: + self.reset.pack_configure(pady=(Theme.SPACE_XS, 0)) + self.unavailable_reason.configure(text=f"Недоступно: {unavailable_reason}") + self.unavailable_reason.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) + else: + self.unavailable_reason.pack_forget() + self.reset.pack_configure(pady=(Theme.SPACE_XS, Theme.SPACE_SM)) class SearchField(HubEntry): @@ -764,13 +798,38 @@ class RouteTargetWidget(HubCard): self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) self.subtitle = ctk.CTkLabel(self, text=subtitle, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_SM)) + self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X) + self.quota = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) + self.quota.pack(anchor="w", padx=Theme.CARD_PAD_X) + self.failover = ctk.CTkLabel( + self, text="", font=Theme.font_micro(), text_color=Theme.COLOR_CAUTION, wraplength=260, justify="left" + ) self.update_target(rank, title, subtitle, status) - def update_target(self, rank: str, title: str, subtitle: str, status: str = "unknown") -> None: + def update_target( + self, + rank: str, + title: str, + subtitle: str, + status: str = "unknown", + quota_status: str = "unknown", + failover_reason: Optional[str] = None, + ) -> None: self.rank.configure(text=rank) self.title.configure(text=title) self.subtitle.configure(text=subtitle) + quota_labels = { + "healthy": "Квота: доступна", + "warning": "Квота: заканчивается", + "exhausted": "Квота: исчерпана", + } + self.quota.configure(text=quota_labels.get(quota_status, "Квота: Н/Д")) + if failover_reason: + self.failover.configure(text=f"Переключено: {failover_reason}") + self.failover.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) + else: + self.failover.pack_forget() + self.quota.pack_configure(pady=(0, Theme.SPACE_SM)) self.set_status(status) def set_status(self, status: str) -> None: @@ -811,6 +870,7 @@ class AccountCardWidget(HubCard): self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X) self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) self.meta.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) + self.plan_badge = PlanBadge(self, text="") self.status = StatusBadge(self, status) self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) @@ -874,6 +934,17 @@ class AccountCardWidget(HubCard): self.provider.configure(text=profile.provider_display_name or profile.provider) roles = ", ".join(getattr(profile, "assigned_roles", []) or []) or "Роль: Н/Д" self.meta.configure(text=f"{profile.display_name} • {roles}") + if self.plan_badge.set_plan( + getattr(profile, "plan_code", "UNKNOWN"), getattr(profile, "plan_source", "unknown") + ): + self.plan_badge.pack( + anchor="w", + padx=Theme.CARD_PAD_X, + pady=(Theme.SPACE_SM, 0), + before=self.status, + ) + else: + self.plan_badge.pack_forget() self.status.set_status(profile.health_state, getattr(profile, "health_label_ru", None)) self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER) @@ -904,6 +975,7 @@ class AccountCardWidget(HubCard): reset or "Сброс: Н/Д", estimated, detail, + getattr(bucket, "unavailable_reason", None) or getattr(snapshot, "unavailable_reason", None), ) for key in list(self._quota_widgets): diff --git a/src/antigravity_provider/router/ui/views/dashboard_view.py b/src/antigravity_provider/router/ui/views/dashboard_view.py index 2d6c082..9e7ceb3 100644 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ b/src/antigravity_provider/router/ui/views/dashboard_view.py @@ -58,6 +58,15 @@ class DashboardView(ctk.CTkFrame): self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) + self.snapshot_freshness = ctk.CTkLabel( + self.scroll, + text="Snapshot: Н/Д", + font=Theme.font_caption(), + text_color=Theme.TEXT_MUTED, + anchor="w", + ) + self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_SM)) + metrics = ctk.CTkFrame(self.scroll, fg_color="transparent") metrics.pack(fill="x", pady=(0, Theme.SECTION_GAP)) for column in range(4): @@ -110,6 +119,16 @@ class DashboardView(ctk.CTkFrame): def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: if not isinstance(snapshot, HubSnapshot): return + if snapshot.is_stale: + self.snapshot_freshness.configure( + text=f"⚠ Snapshot #{snapshot.seq}: данные устарели", + text_color=Theme.STATUS_WARNING, + ) + else: + self.snapshot_freshness.configure( + text=f"Snapshot #{snapshot.seq}: актуальные данные", + text_color=Theme.TEXT_MUTED, + ) readiness = snapshot.readiness self.system_metric.val_label.configure(text=readiness.title_ru) self.system_metric.sub_label.configure(text=readiness.summary_ru) @@ -130,7 +149,7 @@ class DashboardView(ctk.CTkFrame): route_rows = {} for role_id, pipeline in snapshot.routing.items(): active = next((node for node in pipeline.nodes if node.is_active), None) - detail = f"{active.provider_display_name} • {active.model}" if active else "Активный узел: Н/Д" + detail = f"{active.provider} • {active.model}" if active else "Активный узел: Н/Д" route_rows[role_id] = (pipeline.role_name_ru, detail, "healthy" if active else "warning") self._sync_rows(self.routes_card, self._route_rows, route_rows) diff --git a/src/antigravity_provider/router/ui/views/routing_view.py b/src/antigravity_provider/router/ui/views/routing_view.py index 3d32b73..e16e9b1 100644 --- a/src/antigravity_provider/router/ui/views/routing_view.py +++ b/src/antigravity_provider/router/ui/views/routing_view.py @@ -48,17 +48,30 @@ class RoutingRoleWidget(HubCard): self._nodes.pop(profile_id).destroy() for index, node in enumerate(pipeline.nodes): rank = "Основной" if index == 0 else f"Резерв {index}" - subtitle = f"{node.provider_display_name} • {node.model}" + identity = f" • {node.account_identity}" if node.account_identity else "" + subtitle = f"{node.provider} • {node.model}{identity}" widget = self._nodes.get(node.profile_id) if widget is None: widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle) self._nodes[node.profile_id] = widget - widget.update_target(rank, node.display_name, subtitle, "active" if node.is_active else node.status) + widget.update_target( + rank, + node.display_name, + subtitle, + "active" if node.is_active else node.status, + node.quota_status, + node.failover_reason, + ) widget.grid(row=0, column=index, padx=Theme.SPACE_XS, pady=Theme.SPACE_SM, sticky="nsew") self.chain.grid_columnconfigure(index, weight=1) active = next((node for node in pipeline.nodes if node.is_active), None) + reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason] self.footer.configure( - text=(f"Активен: {active.display_name} • причина переключения: Н/Д" if active else "Активный узел: Н/Д") + text=( + f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'Н/Д'}" + if active + else "Активный узел: Н/Д" + ) ) diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py index e109ac4..eaf31ab 100644 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ b/src/antigravity_provider/router/ui/views/team_view.py @@ -76,6 +76,11 @@ class AgentCardWidget(HubCard): ) self.identity_lbl.pack(anchor="w") + self.quota_lbl = ctk.CTkLabel( + self.line4, text="Квота: Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED + ) + self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0)) + # ── Line 5: Role Tag Pills ── self.line5 = ctk.CTkFrame(self, fg_color="transparent") self.line5.pack(fill="x", padx=14, pady=(4, 6)) @@ -161,8 +166,21 @@ class AgentCardWidget(HubCard): else: self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED) - # AgentViewModel has account identity, but no active-session or quota fields. self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н/Д") + quota_color = ( + Theme.STATUS_HEALTHY + if a.active_quota_status == "healthy" + else ( + Theme.STATUS_WARNING + if a.active_quota_status == "warning" + else Theme.STATUS_ERROR + if a.active_quota_status == "exhausted" + else Theme.TEXT_MUTED + ) + ) + quota_label = a.active_quota_label or "Н/Д" + session = f" • сессия {a.session_id}" if a.session_id else "" + self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color) # Pills self.pill1_lbl.configure(text=a.role_id) diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py new file mode 100644 index 0000000..3a93fd2 --- /dev/null +++ b/tests/test_ui_contract_v11.py @@ -0,0 +1,227 @@ +"""Acceptance coverage for UI state contract v1.1 fields.""" + +from __future__ import annotations + +import time + +import pytest + +pytest.importorskip("customtkinter") + +import customtkinter as ctk + +from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot +from antigravity_provider.router.state_store import HubSnapshot +from antigravity_provider.router.ui.components import AccountCardWidget, QuotaBucketWidget +from antigravity_provider.router.ui.theme import Theme +from antigravity_provider.router.ui.views.dashboard_view import DashboardView +from antigravity_provider.router.ui.views.routing_view import RoutingRoleWidget +from antigravity_provider.router.ui.views.team_view import AgentCardWidget +from antigravity_provider.router.unified_health import ( + AgentViewModel, + PipelineNode, + ProfileViewModel, + RolePipeline, + SystemReadiness, +) + + +@pytest.fixture(scope="module") +def ui_root(): + root = ctk.CTk() + root.withdraw() + yield root + root.destroy() + + +def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> ProfileViewModel: + return ProfileViewModel( + profile_id="account-1", + display_name="Primary", + account_identity="user@example.test", + provider="antigravity", + provider_display_name="Google Antigravity", + assigned_roles=["coder-primary"], + primary_role="coder-primary", + is_main_account=True, + is_main_orchestrator=False, + auth_state="AUTHENTICATED", + health_state="healthy", + health_label_ru="Работает", + model_states={}, + cooldown_remaining_sec=0, + last_checked_at="12:00:00", + enabled=True, + is_cold_spare=False, + is_empty_slot=False, + plan_code=plan_code, + plan_source=plan_source, + ) + + +def _readiness() -> SystemReadiness: + return SystemReadiness( + state="healthy", + title_ru="Система готова", + summary_ru="Все роли доступны", + roles_ready_count=1, + total_roles=1, + accounts_connected_count=1, + total_accounts=1, + providers_ready_count=1, + total_providers=1, + ) + + +@pytest.mark.ui +def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None: + card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity") + try: + card.pack() + card.update_account(_profile("PRO", "provider_api")) + ui_root.update_idletasks() + assert card.plan_badge.label.cget("text") == "Тариф PRO" + assert card.plan_badge.winfo_manager() == "pack" + + card.update_account(_profile("PRO", "inferred")) + ui_root.update_idletasks() + assert card.plan_badge.label.cget("text") == "Тариф PRO • выведено" + assert card.plan_badge.label.cget("text_color") == Theme.TEXT_MUTED + + card.update_account(_profile("UNKNOWN", "unknown")) + ui_root.update_idletasks() + assert card.plan_badge.winfo_manager() == "" + finally: + card.destroy() + + +@pytest.mark.ui +def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None: + widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h") + try: + widget.pack() + widget.update_bucket("Claude 5h", None, unavailable_reason="Провайдер не вернул лимит") + ui_root.update_idletasks() + assert widget.bar.detail.cget("text") == "Н/Д" + assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEUTRAL + assert "Провайдер не вернул лимит" in widget.unavailable_reason.cget("text") + + widget.update_bucket("Claude 5h", 0.0) + ui_root.update_idletasks() + assert widget.bar.detail.cget("text") == "0%" + assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEGATIVE + assert widget.unavailable_reason.winfo_manager() == "" + finally: + widget.destroy() + + +@pytest.mark.ui +def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None: + agent = AgentViewModel( + role_id="coder-primary", + role_name_ru="Кодер 1", + role_description_ru="Основной кодер", + assigned_profile_id="account-2", + assigned_display_name="Reserve", + provider="codex", + provider_display_name="OpenAI Codex", + model="gpt-5", + account_identity="reserve@example.test", + routing_position="Fallback 1", + status="healthy", + status_label_ru="Работает", + is_active=True, + is_main_orchestrator=False, + session_id="session-42", + active_quota_status="warning", + active_quota_label="Осталось 12%", + ) + team_card = AgentCardWidget(ui_root) + pipeline = RolePipeline( + role_id="coder-primary", + role_name_ru="Кодер 1", + default_model="gpt-5", + max_failover=2, + session_affinity=True, + active_profile_id="account-2", + nodes=[ + PipelineNode( + profile_id="account-1", + display_name="Primary", + provider="Google Antigravity", + model="gemini-2.5-pro", + status="quota_exhausted", + status_label_ru="Исчерпан", + is_active=False, + account_identity="primary@example.test", + quota_status="exhausted", + failover_reason="Исчерпана квота (429)", + ), + PipelineNode( + profile_id="account-2", + display_name="Reserve", + provider="OpenAI Codex", + model="gpt-5", + status="healthy", + status_label_ru="Работает", + is_active=True, + account_identity="reserve@example.test", + quota_status="warning", + ), + ], + ) + route = RoutingRoleWidget(ui_root, pipeline) + try: + team_card.pack() + route.pack() + team_card.update_agent(agent) + route.update_from_pipeline(pipeline) + ui_root.update_idletasks() + assert "Осталось 12%" in team_card.quota_lbl.cget("text") + assert "session-42" in team_card.quota_lbl.cget("text") + assert "Исчерпана квота (429)" in route._nodes["account-1"].failover.cget("text") + assert route._nodes["account-2"].failover.cget("text") == "" + assert "Квота: исчерпана" == route._nodes["account-1"].quota.cget("text") + finally: + route.destroy() + team_card.destroy() + + +@pytest.mark.ui +def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None: + snapshot = HubSnapshot( + generation=7, + seq=11, + timestamp=time.time() - 301, + profiles_by_provider={}, + all_profiles={}, + readiness=_readiness(), + agents=[], + providers=[], + routing={}, + quotas={}, + is_stale=True, + ) + view = DashboardView(ui_root) + try: + view.pack() + view.update_data(snapshot) + ui_root.update_idletasks() + label = view.snapshot_freshness.cget("text") + assert "#11" in label + assert "устарели" in label + assert view.snapshot_freshness.cget("text_color") == Theme.STATUS_WARNING + finally: + view.destroy() + + +@pytest.mark.unit +def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None: + snapshot = QuotaSnapshot( + account_id="account-1", + provider="antigravity", + buckets=[QuotaBucket(id="b", display_name="Gemini 5h")], + unavailable_reason="Авторизация недоступна", + ) + assert snapshot.buckets[0].remaining_percent is None + assert snapshot.unavailable_reason == "Авторизация недоступна"