feat(ui): wire state contract v1.1 fields
This commit is contained in:
parent
9ffc815ed5
commit
95d80268f4
7 changed files with 429 additions and 10 deletions
69
CODEX_CONTRACT_V11_REPORT.md
Normal file
69
CODEX_CONTRACT_V11_REPORT.md
Normal file
|
|
@ -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)`.
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "Активный узел: Н/Д"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
227
tests/test_ui_contract_v11.py
Normal file
227
tests/test_ui_contract_v11.py
Normal file
|
|
@ -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 == "Авторизация недоступна"
|
||||
Loading…
Reference in a new issue