diff --git a/scripts/capture_live_a12_screenshots.py b/scripts/capture_live_a12_screenshots.py new file mode 100644 index 0000000..6ab360b --- /dev/null +++ b/scripts/capture_live_a12_screenshots.py @@ -0,0 +1,89 @@ +"""Live screenshot capture script for A12 acceptance criteria.""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +import customtkinter as ctk + +from antigravity_provider.router.hermes_hub_app import HermesHubApp +from antigravity_provider.router.ui.theme import Theme + + +def run_capture(output_dir: Path): + output_dir.mkdir(parents=True, exist_ok=True) + os.environ["HERMES_HUB_DEV_MODE"] = "1" + + app = HermesHubApp() + app.geometry("1366x800+30+30") + app.deiconify() + app.lift() + app.attributes("-topmost", True) + + # Allow startup refresh + for _ in range(15): + app.update() + time.sleep(0.05) + + captured = [] + + def snap(name: str): + app.update_idletasks() + app.update() + time.sleep(0.1) + target = output_dir / f"{name}.png" + try: + from PIL import ImageGrab + left = app.winfo_rootx() + top = app.winfo_rooty() + ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target) + captured.append(target) + print(f"Captured: {target}") + except Exception as exc: + print(f"Screen grab not available in current session ({exc}); view {name} rendered successfully.") + + try: + app._apply_theme("dark") + + # 1. Accounts view with compact cards and visible quotas + app._show_view("accounts") + snap("01_accounts_compact_view") + + # 2. Overview dashboard with 5 providers + app._show_view("overview") + snap("02_overview_all_providers") + + # 3. Providers & models view + app._show_view("providers") + snap("03_providers_and_models") + + # 4. Routing view + app._show_view("routing") + snap("04_routing_chains") + + # 5. Team view + app._show_view("team") + snap("05_team_view") + + # 6. Grok wizard step + app._handle_action("add_account", {}) + app.update_idletasks() + app.update() + time.sleep(0.1) + snap("06_wizard_step1_providers") + + finally: + app.attributes("-topmost", False) + app._shutting_down = True + app.destroy() + + return captured + + +if __name__ == "__main__": + dest = Path("artifacts/a12-screenshots") + res = run_capture(dest) + print(f"Total screenshots completed: {len(res)}") diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index 343d50e..7f3546b 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -109,6 +109,19 @@ class ModelDiscoveryService: "has_cache": True, } + def get_cached(self, provider: str) -> Dict[str, Any]: + """Convenience alias for get_models_with_metadata.""" + return self.get_models_with_metadata(provider) + + def refresh_models( + self, + provider: str, + on_complete: Optional[Callable[[Optional[List[str]]], None]] = None, + timeout: float = 15.0, + ) -> None: + """Trigger background model discovery with strict timeout (alias).""" + return self.refresh_models_async(provider, on_complete=on_complete, timeout=timeout) + # ───────────────────────────────────────────────────────────── # DISCOVERY PROBES WITH TIMEOUT # ───────────────────────────────────────────────────────────── diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index f6c0153..fe3e217 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -914,21 +914,23 @@ class AccountCardWidget(HubCard): self.compact_quota = ctk.CTkFrame(self, fg_color="transparent") self.compact_quota_cells: list[dict[str, Any]] = [] - for index in range(1): + for index in range(4): cell = ctk.CTkFrame(self.compact_quota, fg_color="transparent") - cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 10), pady=(2, 6)) - title = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY, anchor="w") - title.pack(fill="x") - value = ctk.CTkLabel(cell, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, anchor="e") - value.pack(fill="x") + cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 8), pady=(1, 2)) + top_line = ctk.CTkFrame(cell, fg_color="transparent") + top_line.pack(fill="x") + title = ctk.CTkLabel(top_line, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY, anchor="w") + title.pack(side="left") + value = ctk.CTkLabel(top_line, text="", font=Theme.font_micro_bold(), text_color=Theme.TEXT_PRIMARY, anchor="e") + value.pack(side="right") progress = ctk.CTkProgressBar( cell, - height=5, - corner_radius=3, + height=4, + corner_radius=2, fg_color=Theme.SURFACE_MUTED, progress_color=Theme.STATUS_HEALTHY, ) - progress.pack(fill="x", pady=(2, 1)) + progress.pack(fill="x", pady=(1, 0)) progress.set(0) reset = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, anchor="w") reset.pack(fill="x") @@ -1111,32 +1113,49 @@ class AccountCardWidget(HubCard): getattr(bucket, "remaining_percent", None) is None, float(getattr(bucket, "remaining_percent", 101.0) or 0.0), ), - )[:1] - for index, cell in enumerate(self.compact_quota_cells): - if index < len(visible_buckets): - bucket = visible_buckets[index] - remaining = getattr(bucket, "remaining_percent", None) - color = ( - Theme.STATUS_HEALTHY - if getattr(bucket, "status", "unknown") == "healthy" - else Theme.STATUS_WARNING - if getattr(bucket, "status", "unknown") == "warning" - else Theme.TEXT_MUTED - ) - detail = bucket.formatted_remaining() - if detail == "Н/Д" and unavailable_reason: - limit = getattr(bucket, "limit_absolute", None) - unit = getattr(bucket, "unit", None) - prefix = f"Лимит ${limit} • " if limit is not None and unit == "USD" else "" - detail = f"{prefix}{unavailable_reason}" - cell["title"].configure(text=bucket.display_name) - cell["value"].configure(text=detail, text_color=color) - cell["progress"].configure(progress_color=color) - cell["progress"].set(float(remaining) / 100.0 if remaining is not None else 0) - cell["reset"].configure(text=bucket.formatted_reset() or "Период указан провайдером") - cell["frame"].grid() - else: - cell["frame"].grid_remove() + )[:4] + if visible_buckets: + for index, cell in enumerate(self.compact_quota_cells): + if index < len(visible_buckets): + bucket = visible_buckets[index] + remaining = getattr(bucket, "remaining_percent", None) + color = ( + Theme.STATUS_HEALTHY + if getattr(bucket, "status", "unknown") == "healthy" + else Theme.STATUS_WARNING + if getattr(bucket, "status", "unknown") == "warning" + else (Theme.STATUS_ERROR if getattr(bucket, "status", "unknown") == "exhausted" else Theme.TEXT_MUTED) + ) + detail = bucket.formatted_remaining() + if detail == "Н/Д" and unavailable_reason: + limit = getattr(bucket, "limit_absolute", None) + unit = getattr(bucket, "unit", None) + prefix = f"Лимит ${limit} • " if limit is not None and unit == "USD" else "" + detail = f"{prefix}{unavailable_reason}" + cell["title"].configure(text=bucket.display_name) + cell["value"].configure(text=detail, text_color=color) + cell["progress"].configure(progress_color=color) + cell["progress"].set(float(remaining) / 100.0 if remaining is not None else 0) + reset_txt = bucket.formatted_reset() or "Период указан провайдером" + cell["reset"].configure(text=reset_txt) + cell["frame"].grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 8), pady=(1, 2)) + else: + cell["frame"].grid_remove() + else: + # Fallback when no bucket objects exist + reason = unavailable_reason or ( + "Аккаунт не подключён" if profile.health_state in {"not_configured", "auth_required", "auth_expired"} + else "Провайдер не отдаёт лимиты" + ) + cell = self.compact_quota_cells[0] + cell["title"].configure(text="Квота") + cell["value"].configure(text=f"Н/Д", text_color=Theme.TEXT_MUTED) + cell["progress"].configure(progress_color=Theme.COLOR_NEUTRAL) + cell["progress"].set(0) + cell["reset"].configure(text=reason) + cell["frame"].grid(row=0, column=0, columnspan=2, sticky="nsew", padx=(0, 8), pady=(1, 2)) + for other in self.compact_quota_cells[1:]: + other["frame"].grid_remove() seen: set[str] = set() for bucket in buckets: key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket")) diff --git a/src/antigravity_provider/router/ui/theme.py b/src/antigravity_provider/router/ui/theme.py index 08a31b9..a3e53a9 100644 --- a/src/antigravity_provider/router/ui/theme.py +++ b/src/antigravity_provider/router/ui/theme.py @@ -359,6 +359,10 @@ class Theme: def font_mono_sm(cls): return (cls.FONT_FAMILY_MONO, 12) + @classmethod + def font_micro_bold(cls): + return (cls.FONT_FAMILY_UI, 10, "bold") + @classmethod def font_badge_bold(cls): return (cls.FONT_FAMILY_UI, 10, "bold") diff --git a/src/antigravity_provider/router/ui/views/providers_view.py b/src/antigravity_provider/router/ui/views/providers_view.py index 67dca91..41998c2 100644 --- a/src/antigravity_provider/router/ui/views/providers_view.py +++ b/src/antigravity_provider/router/ui/views/providers_view.py @@ -14,8 +14,13 @@ from antigravity_provider.router.ui.theme import Theme class ProviderCard(HubCard): def __init__(self, master: Any): super().__init__(master) + self.provider_id = "" top = ctk.CTkFrame(self, fg_color="transparent") top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)) + + self.icon = ctk.CTkLabel(top, text="", width=24, height=24) + self.icon.pack(side="left", padx=(0, Theme.SPACE_SM)) + self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY) self.title.pack(side="left") self.updated = ctk.CTkLabel(top, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) @@ -33,6 +38,11 @@ class ProviderCard(HubCard): self.models.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) def update_provider(self, summary: Any) -> None: + self.provider_id = summary.provider_id + from antigravity_provider.router.ui.assets import AssetManager + img = AssetManager.get().get_provider_image(summary.provider_id, size=(24, 24)) + self.icon.configure(image=img, text="" if img else "◇") + self.icon.image = img self.title.configure(text=summary.provider_name) self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н/Д — обнаружение ещё не запускалось'}") self.stats.configure( diff --git a/tests/test_ui_claude_grok_connection.py b/tests/test_ui_claude_grok_connection.py new file mode 100644 index 0000000..17aa771 --- /dev/null +++ b/tests/test_ui_claude_grok_connection.py @@ -0,0 +1,97 @@ +"""End-to-end verification for Grok and Claude connection, assignment, and testing.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch +import pytest + +from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy +from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing +from antigravity_provider.router.hermes_hub_app import do_test_profile + + +@pytest.mark.unit +def test_grok_and_claude_free_slots_discovery(): + """Verify free slots for grok and claude can be found from default slots.""" + config = RouterConfig( + profiles={ + "grok-worker-1": RouterProfileConfig(profile_id="grok-worker-1", provider="grok", enabled=True), + "claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", enabled=True), + } + ) + with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config): + grok_slot = AutoAssigner.find_free_slot("grok") + assert grok_slot == "grok-worker-1" + + claude_slot = AutoAssigner.find_free_slot("claude") + assert claude_slot == "claude-orch" + + +@pytest.mark.unit +def test_grok_wizard_definition_and_routing_flow(): + """Verify Grok profile definition and routing pipeline addition.""" + config = RouterConfig( + profiles={}, + roles={"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=[])}, + ) + with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ + patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \ + patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config): + + ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1") + assert ok_def, f"Definition failed: {msg_def}" + assert "grok-worker-1" in config.profiles + assert config.profiles["grok-worker-1"].provider == "grok" + + ok_route, msg_route = ensure_profile_in_routing("grok-worker-1") + assert ok_route, f"Routing failed: {msg_route}" + assert "grok-worker-1" in config.roles["coder-primary"].preferred_chain + + +@pytest.mark.unit +def test_claude_wizard_definition_and_routing_flow(): + """Verify Claude profile definition and routing pipeline addition.""" + config = RouterConfig( + profiles={}, + roles={"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=[])}, + ) + with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ + patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \ + patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config): + + ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch") + assert ok_def, f"Definition failed: {msg_def}" + assert "claude-orch" in config.profiles + assert config.profiles["claude-orch"].provider == "claude" + + ok_route, msg_route = ensure_profile_in_routing("claude-orch") + assert ok_route, f"Routing failed: {msg_route}" + assert "claude-orch" in config.roles["orchestrator"].preferred_chain + + +@pytest.mark.unit +def test_do_test_profile_for_grok_and_claude(): + """Verify profile local test helper handles grok and claude profiles.""" + config = RouterConfig( + profiles={ + "grok-worker-1": RouterProfileConfig(profile_id="grok-worker-1", provider="grok", preferred_models=["grok-beta"]), + "claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]), + } + ) + + mock_adapter = MagicMock() + mock_adapter.health_check.return_value = True + + with patch("antigravity_provider.router.hermes_hub_app.load_router_config", return_value=config), \ + patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \ + patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \ + patch("antigravity_provider.router.hermes_hub_app.get_adapter", return_value=mock_adapter): + + res_grok = do_test_profile("grok", "grok-worker-1") + assert res_grok["success"] is True + assert res_grok["model"] == "grok-beta" + + res_claude = do_test_profile("claude", "claude-orch") + assert res_claude["success"] is True + assert res_claude["model"] == "claude-3-5-sonnet" diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py index dbe6f8e..562f177 100644 --- a/tests/test_ui_contract_v11.py +++ b/tests/test_ui_contract_v11.py @@ -351,3 +351,46 @@ def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None: ) assert snapshot.buckets[0].remaining_percent is None assert snapshot.unavailable_reason == "Авторизация недоступна" + + +@pytest.mark.ui +def test_dashboard_renders_all_five_providers_in_order(ui_root) -> None: + provider_ids = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"] + providers = [ + ProviderSummary( + provider_id=pid, + provider_name=pid.upper(), + total_slots=2, + connected_count=1, + online_count=1, + auth_required_count=0, + quota_exhausted_count=0, + cold_spare_count=0, + discovered_models=["model-1"], + last_refresh_at="12:00:00", + ) + for pid in provider_ids + ] + snapshot = HubSnapshot( + generation=1, + seq=1, + timestamp=time.time(), + profiles_by_provider={}, + all_profiles={}, + readiness=_readiness(), + agents=[], + providers=providers, + routing={}, + quotas={}, + ) + view = DashboardView(ui_root) + try: + view.pack() + view.update_data(snapshot) + ui_root.update_idletasks() + assert len(view._provider_cards) == 5 + assert set(view._provider_cards.keys()) == set(provider_ids) + for slot in view.route_diagram.provider_slots[:5]: + assert slot.winfo_manager() == "place" + finally: + view.destroy() diff --git a/tests/test_ui_model_discovery_reachability.py b/tests/test_ui_model_discovery_reachability.py new file mode 100644 index 0000000..d5769f4 --- /dev/null +++ b/tests/test_ui_model_discovery_reachability.py @@ -0,0 +1,47 @@ +"""Test ensuring ModelDiscoveryService reachability via the agreed entrypoint.""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.unit +def test_model_discovery_entrypoint_reaches_service_directly(): + """Verify that router/model_discovery.py directly exports ModelDiscoveryService with .get().""" + try: + import antigravity_provider.router.model_discovery as md_module + except ImportError as exc: + pytest.fail(f"Could not import agreed module 'antigravity_provider.router.model_discovery': {exc}") + + assert hasattr( + md_module, "ModelDiscoveryService" + ), "Module 'antigravity_provider.router.model_discovery' must expose 'ModelDiscoveryService'" + + from antigravity_provider.router.model_discovery import ModelDiscoveryService + + assert hasattr( + ModelDiscoveryService, "get" + ), "ModelDiscoveryService must provide a '.get()' classmethod/factory" + assert callable(ModelDiscoveryService.get), "ModelDiscoveryService.get must be callable" + + service = ModelDiscoveryService.get() + assert service is not None, "ModelDiscoveryService.get() must return an initialized instance" + assert isinstance( + service, ModelDiscoveryService + ), "ModelDiscoveryService.get() must return an instance of ModelDiscoveryService" + assert service is ModelDiscoveryService.get(), "ModelDiscoveryService.get() must return a singleton instance" + + +@pytest.mark.unit +def test_model_discovery_service_methods_and_cache_interface(): + """Verify standard methods of ModelDiscoveryService instance.""" + from antigravity_provider.router.model_discovery import ModelDiscoveryService + + service = ModelDiscoveryService.get() + assert hasattr(service, "get_cached"), "ModelDiscoveryService must implement get_cached()" + assert hasattr(service, "refresh_models"), "ModelDiscoveryService must implement refresh_models()" + + # Test cache read for a standard provider + cached = service.get_cached("antigravity") + assert isinstance(cached, dict), "get_cached must return a dictionary" + assert "models" in cached, "cached payload must contain 'models' key" diff --git a/tests/test_ui_wizard_finish.py b/tests/test_ui_wizard_finish.py index 9521547..df4af74 100644 --- a/tests/test_ui_wizard_finish.py +++ b/tests/test_ui_wizard_finish.py @@ -19,7 +19,7 @@ from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard @pytest.fixture(scope="module") -def root(): +def ui_root(): app = ctk.CTk() app.withdraw() yield app @@ -38,9 +38,10 @@ def _wizard(root, on_complete=None): return w -def test_finish_closes_wizard_and_reports_result(root): +@pytest.mark.ui +def test_finish_closes_wizard_and_reports_result(ui_root): seen = [] - w = _wizard(root, on_complete=seen.append) + w = _wizard(ui_root, on_complete=seen.append) w._finish() @@ -50,13 +51,14 @@ def test_finish_closes_wizard_and_reports_result(root): ] -def test_finish_closes_even_if_callback_raises(root): +@pytest.mark.ui +def test_finish_closes_even_if_callback_raises(ui_root): """Сбой в обработчике владельца не должен запирать пользователя в мастере.""" def boom(_result): raise RuntimeError("обновление данных упало") - w = _wizard(root, on_complete=boom) + w = _wizard(ui_root, on_complete=boom) w._finish()