cct-cli 0.7.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT v0.7.9.5 — STABILITY / THEME / REQUEST-LIFECYCLE regression suite.
|
|
3
|
+
|
|
4
|
+
Covers the major bug-fix pass end to end (pure logic + headless where
|
|
5
|
+
possible, mirroring the style of test_v079_speed.py):
|
|
6
|
+
|
|
7
|
+
A. THEME CATALOG
|
|
8
|
+
1. every requested theme is registered under its canonical name
|
|
9
|
+
2. legacy aliases ('dark', 'light') still resolve and apply
|
|
10
|
+
3. every theme compiles to a complete legacy variable set
|
|
11
|
+
4. contrast validation for EVERY theme (text/muted/accent/selection)
|
|
12
|
+
5. light themes are flagged light, dark themes dark
|
|
13
|
+
6. set_theme persists to BOTH stores; load_saved_theme restores any
|
|
14
|
+
registered name (the old loader only understood 'light')
|
|
15
|
+
7. unknown theme names fall back safely instead of crashing
|
|
16
|
+
|
|
17
|
+
B. THEME INTERACTION (picker semantics, pure-logic level)
|
|
18
|
+
8. preview-then-restore leaves the saved selection untouched
|
|
19
|
+
9. click-to-apply persists the clicked theme
|
|
20
|
+
10. exactly one ✓ mark: ThemesPanel row prefix logic
|
|
21
|
+
|
|
22
|
+
C. REQUEST LIFECYCLE (the '...' fix)
|
|
23
|
+
11. simple response streams through unchanged
|
|
24
|
+
12. streaming response arrives incrementally
|
|
25
|
+
13. empty response is surfaced as an error signature
|
|
26
|
+
14. provider timeout signature detected + retryable
|
|
27
|
+
15. provider exception falls back to the next provider
|
|
28
|
+
16. fallback walks primary -> backup and succeeds
|
|
29
|
+
17. mid-stream failure keeps partial answer + honest note
|
|
30
|
+
18. cancelled request stops cleanly (socket close registry)
|
|
31
|
+
19. model returning no tokens yields a final error (never silent)
|
|
32
|
+
20. all providers failing still returns a real error string
|
|
33
|
+
21. retries bounded per provider with exponential backoff
|
|
34
|
+
22. provider cooldown demotes a just-failed provider
|
|
35
|
+
23. configurable timeouts by size class
|
|
36
|
+
|
|
37
|
+
D. ROUTER CLASSES
|
|
38
|
+
24. trivial/simple/normal/complex/agent/research classification
|
|
39
|
+
25. 'hi' takes the fast path with zero agent machinery
|
|
40
|
+
26. complex prompts still route to agent capabilities
|
|
41
|
+
|
|
42
|
+
E. SHUTDOWN
|
|
43
|
+
27. shutdown_session is idempotent (goodbye rendered once)
|
|
44
|
+
|
|
45
|
+
Run: python -m pytest calc_terminal/test_stability.py -q
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
import unittest
|
|
53
|
+
from unittest import mock
|
|
54
|
+
|
|
55
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
56
|
+
|
|
57
|
+
from calc_terminal import theme # noqa: E402
|
|
58
|
+
from calc_terminal import config as cct_config # noqa: E402
|
|
59
|
+
from calc_terminal import aicore # noqa: E402
|
|
60
|
+
from calc_terminal import model_router # noqa: E402
|
|
61
|
+
|
|
62
|
+
REQUIRED_THEMES = [
|
|
63
|
+
"ansi-dark", "ansi-light", "atom-one-dark", "atom-one-light",
|
|
64
|
+
"catppuccin-frappe", "catppuccin-latte", "catppuccin-macchiato",
|
|
65
|
+
"catppuccin-mocha", "dracula", "flexoki", "gruvbox", "monokai",
|
|
66
|
+
"nord", "rose-pine", "rose-pine-dawn", "rose-pine-moon",
|
|
67
|
+
"solarized-dark", "solarized-light", "textual-dark", "textual-light",
|
|
68
|
+
"tokyo-night",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
LIGHT_THEMES = ["ansi-light", "atom-one-light", "catppuccin-latte",
|
|
72
|
+
"rose-pine-dawn", "solarized-light", "textual-light"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _contrast(a, b):
|
|
76
|
+
return theme.contrast_ratio(a, b)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _FakeResponse:
|
|
80
|
+
"""Minimal stand-in for requests.Response (cancel path)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.closed = False
|
|
84
|
+
|
|
85
|
+
def close(self):
|
|
86
|
+
self.closed = True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestThemeCatalog(unittest.TestCase):
|
|
90
|
+
"""A — every requested theme exists with the normalized schema."""
|
|
91
|
+
|
|
92
|
+
def test_all_requested_themes_registered(self):
|
|
93
|
+
available = theme.available_themes()
|
|
94
|
+
for name in REQUIRED_THEMES:
|
|
95
|
+
self.assertIn(name, available,
|
|
96
|
+
f"required theme missing from registry: {name}")
|
|
97
|
+
|
|
98
|
+
def test_legacy_aliases_resolve(self):
|
|
99
|
+
self.assertEqual(theme.resolve_theme_name("dark"), "tokyo-night")
|
|
100
|
+
self.assertEqual(theme.resolve_theme_name("light"), "github-light")
|
|
101
|
+
# fuzzy variants users may type
|
|
102
|
+
self.assertEqual(theme.resolve_theme_name("Tokyo Night"), "tokyo-night")
|
|
103
|
+
self.assertEqual(theme.resolve_theme_name("Rose_Pine Dawn"),
|
|
104
|
+
"rose-pine-dawn")
|
|
105
|
+
|
|
106
|
+
def test_legacy_dark_look_preserved(self):
|
|
107
|
+
theme.set_theme("dark", persist=False, paint_bg=False)
|
|
108
|
+
# byte-for-byte parity with the pre-overhaul Tokyo Night palette
|
|
109
|
+
self.assertEqual(theme.CYAN, (125, 207, 255))
|
|
110
|
+
self.assertEqual(theme.TEXT, (232, 235, 250))
|
|
111
|
+
self.assertEqual(theme.OC_BG_PANEL, (30, 32, 48))
|
|
112
|
+
self.assertEqual(theme.BG_USER, (40, 62, 92))
|
|
113
|
+
|
|
114
|
+
def test_legacy_light_look_preserved(self):
|
|
115
|
+
theme.set_theme("light", persist=False, paint_bg=False)
|
|
116
|
+
self.assertEqual(theme.TEXT, (24, 28, 34))
|
|
117
|
+
self.assertEqual(theme.OC_BG_ELEMENT, (240, 243, 247))
|
|
118
|
+
self.assertTrue(theme.is_light())
|
|
119
|
+
|
|
120
|
+
def test_every_theme_compiles_full_variable_set(self):
|
|
121
|
+
required_vars = set(theme._THEME_COLOR_VARS)
|
|
122
|
+
for name in theme.available_themes():
|
|
123
|
+
t = theme.get_theme_obj(name)
|
|
124
|
+
compiled = theme.compile_theme_vars(t)
|
|
125
|
+
missing = required_vars - set(compiled)
|
|
126
|
+
self.assertFalse(missing, f"{name}: uncompiled vars {missing}")
|
|
127
|
+
for var in required_vars:
|
|
128
|
+
v = compiled[var]
|
|
129
|
+
self.assertIsInstance(v, tuple, f"{name}.{var} not rgb")
|
|
130
|
+
self.assertEqual(len(v), 3, f"{name}.{var} not rgb triple")
|
|
131
|
+
|
|
132
|
+
def test_normalized_schema_fields(self):
|
|
133
|
+
for name in theme.available_themes():
|
|
134
|
+
t = theme.get_theme_obj(name)
|
|
135
|
+
for field in ("background", "surface", "surface_hover", "text",
|
|
136
|
+
"text_muted", "border", "accent", "input_background",
|
|
137
|
+
"selection_background", "selection_text",
|
|
138
|
+
"success", "warning", "error"):
|
|
139
|
+
v = getattr(t, field)
|
|
140
|
+
self.assertIsInstance(v, tuple, f"{name}.{field}")
|
|
141
|
+
self.assertEqual(len(v), 3, f"{name}.{field}")
|
|
142
|
+
|
|
143
|
+
def test_polarity_flags(self):
|
|
144
|
+
for name in LIGHT_THEMES:
|
|
145
|
+
self.assertTrue(theme.get_theme_obj(name).dark is False,
|
|
146
|
+
f"{name} must be flagged light")
|
|
147
|
+
for name in REQUIRED_THEMES:
|
|
148
|
+
if name not in LIGHT_THEMES:
|
|
149
|
+
self.assertTrue(theme.get_theme_obj(name).dark is True,
|
|
150
|
+
f"{name} must be flagged dark")
|
|
151
|
+
|
|
152
|
+
def test_contrast_every_theme(self):
|
|
153
|
+
"""Requirement #17: no invisible text in ANY shipped theme."""
|
|
154
|
+
failures = []
|
|
155
|
+
for name in theme.available_themes():
|
|
156
|
+
t = theme.get_theme_obj(name)
|
|
157
|
+
checks = {
|
|
158
|
+
"text/background": (_contrast(t.text, t.background), 4.5),
|
|
159
|
+
"text/surface": (_contrast(t.text, t.surface), 4.0),
|
|
160
|
+
"muted/background": (_contrast(t.text_muted, t.background), 2.5),
|
|
161
|
+
"accent/background": (
|
|
162
|
+
_contrast(theme.fit_contrast(t.accent, t.background, 3.0),
|
|
163
|
+
t.background), 3.0),
|
|
164
|
+
"success/background": (
|
|
165
|
+
_contrast(theme.fit_contrast(t.success, t.background, 3.0),
|
|
166
|
+
t.background), 3.0),
|
|
167
|
+
"warning/background": (
|
|
168
|
+
_contrast(theme.fit_contrast(t.warning, t.background, 3.0),
|
|
169
|
+
t.background), 3.0),
|
|
170
|
+
"error/background": (
|
|
171
|
+
_contrast(theme.fit_contrast(t.error, t.background, 3.0),
|
|
172
|
+
t.background), 3.0),
|
|
173
|
+
"selection": (_contrast(t.selection_text, t.selection_background), 3.0),
|
|
174
|
+
}
|
|
175
|
+
for key, (ratio, floor) in checks.items():
|
|
176
|
+
if ratio < floor:
|
|
177
|
+
failures.append(f"{name}/{key}={ratio:.2f}<{floor}")
|
|
178
|
+
self.assertEqual(failures, [], "contrast violations: " + "; ".join(failures))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestThemePersistence(unittest.TestCase):
|
|
182
|
+
"""A6/A7 + B9 — persistence across both stores, safe fallbacks."""
|
|
183
|
+
|
|
184
|
+
def setUp(self):
|
|
185
|
+
import tempfile
|
|
186
|
+
fd, self._tmp = tempfile.mkstemp(prefix="cct_theme_", suffix=".json")
|
|
187
|
+
os.close(fd)
|
|
188
|
+
self._orig_file = theme._THEME_FILE
|
|
189
|
+
theme._THEME_FILE = self._tmp
|
|
190
|
+
self._orig_cfg = cct_config._cached
|
|
191
|
+
cct_config._cached = None
|
|
192
|
+
|
|
193
|
+
def tearDown(self):
|
|
194
|
+
theme._THEME_FILE = self._orig_file
|
|
195
|
+
cct_config._cached = self._orig_cfg
|
|
196
|
+
try:
|
|
197
|
+
os.remove(self._tmp)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
theme.set_theme("tokyo-night", persist=False, paint_bg=False)
|
|
201
|
+
|
|
202
|
+
def test_set_theme_persists_both_stores(self):
|
|
203
|
+
cfg = cct_config.get_config()
|
|
204
|
+
cfg.default_theme = "tokyo-night"
|
|
205
|
+
theme.set_theme("nord", persist=True, paint_bg=False)
|
|
206
|
+
with open(self._tmp, encoding="utf-8") as f:
|
|
207
|
+
saved = json.load(f)
|
|
208
|
+
self.assertEqual(saved["theme"], "nord")
|
|
209
|
+
self.assertEqual(cct_config.get_config().default_theme, "nord")
|
|
210
|
+
|
|
211
|
+
def test_load_saved_theme_restores_any_name(self):
|
|
212
|
+
with open(self._tmp, "w", encoding="utf-8") as f:
|
|
213
|
+
json.dump({"theme": "dracula"}, f)
|
|
214
|
+
theme.load_saved_theme()
|
|
215
|
+
self.assertEqual(theme.get_theme(), "dracula")
|
|
216
|
+
|
|
217
|
+
def test_preview_does_not_persist(self):
|
|
218
|
+
"""B8 — hover preview applies temporarily only."""
|
|
219
|
+
theme.set_theme("gruvbox", persist=True, paint_bg=False)
|
|
220
|
+
theme.set_theme("monokai", persist=False, paint_bg=False) # preview
|
|
221
|
+
self.assertEqual(theme.get_theme(), "monokai")
|
|
222
|
+
with open(self._tmp, encoding="utf-8") as f:
|
|
223
|
+
self.assertEqual(json.load(f)["theme"], "gruvbox")
|
|
224
|
+
theme.set_theme("gruvbox", persist=False, paint_bg=False) # restore
|
|
225
|
+
self.assertEqual(theme.get_theme(), "gruvbox")
|
|
226
|
+
|
|
227
|
+
def test_unknown_theme_falls_back_safely(self):
|
|
228
|
+
theme.set_theme("tokyo-night", persist=False, paint_bg=False)
|
|
229
|
+
result = theme.set_theme("no-such-theme", persist=False, paint_bg=False)
|
|
230
|
+
self.assertEqual(result, "tokyo-night")
|
|
231
|
+
|
|
232
|
+
def test_is_light_matches_registry(self):
|
|
233
|
+
theme.set_theme("solarized-light", persist=False, paint_bg=False)
|
|
234
|
+
self.assertTrue(theme.is_light())
|
|
235
|
+
theme.set_theme("solarized-dark", persist=False, paint_bg=False)
|
|
236
|
+
self.assertFalse(theme.is_light())
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TestThemePickerSemantics(unittest.TestCase):
|
|
240
|
+
"""B10 — exactly one permanent ✓, hover marker never shifts width."""
|
|
241
|
+
|
|
242
|
+
def test_row_marks_have_equal_cell_width(self):
|
|
243
|
+
from calc_terminal.ui.nav_screens import _ThemeRow
|
|
244
|
+
for current, hovered in ((False, False), (True, False),
|
|
245
|
+
(False, True), (True, True)):
|
|
246
|
+
row = _ThemeRow("nord", "Nord", "nord" if current else "dracula")
|
|
247
|
+
if hovered and not current:
|
|
248
|
+
row.on_enter(event=None)
|
|
249
|
+
rendered = row.render()
|
|
250
|
+
plain = rendered.plain if hasattr(rendered, "plain") else str(rendered)
|
|
251
|
+
label_cells = len(plain) - len(row.label)
|
|
252
|
+
self.assertEqual(label_cells, _ThemeRow.MARK_W,
|
|
253
|
+
f"current={current} hovered={hovered}: "
|
|
254
|
+
f"mark width {label_cells}")
|
|
255
|
+
|
|
256
|
+
def test_sync_current_moves_single_checkmark(self):
|
|
257
|
+
from calc_terminal.ui.nav_screens import _ThemeRow
|
|
258
|
+
rows = [_ThemeRow(n, n, "a") for n in ("a", "b", "c")]
|
|
259
|
+
marks = []
|
|
260
|
+
for r in rows:
|
|
261
|
+
r.sync_current("b")
|
|
262
|
+
rendered = r.render()
|
|
263
|
+
plain = rendered.plain if hasattr(rendered, "plain") else str(rendered)
|
|
264
|
+
marks.append("\u2713" in plain)
|
|
265
|
+
self.assertEqual(marks, [False, True, False])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestRequestLifecycle(unittest.TestCase):
|
|
269
|
+
"""C — the '...' bug: every path terminates with real content or a
|
|
270
|
+
real error, loading states can always clear."""
|
|
271
|
+
|
|
272
|
+
def setUp(self):
|
|
273
|
+
self._orig_once = aicore._stream_ai_once
|
|
274
|
+
self._orig_backoff = aicore._backoff_sleep
|
|
275
|
+
self._orig_chain = aicore._backup_chain
|
|
276
|
+
aicore._backoff_sleep = lambda attempt: None # tests stay fast
|
|
277
|
+
# Hermetic backup chain — never depends on the machine's real
|
|
278
|
+
# provider configuration.
|
|
279
|
+
aicore._backup_chain = lambda: [
|
|
280
|
+
({"provider": "backup1", "model": "b1"}, {"id": "b1"}),
|
|
281
|
+
({"provider": "backup2", "model": "b2"}, {"id": "b2"}),
|
|
282
|
+
]
|
|
283
|
+
aicore._PROVIDER_LAST_FAILURE.clear()
|
|
284
|
+
|
|
285
|
+
def tearDown(self):
|
|
286
|
+
aicore._stream_ai_once = self._orig_once
|
|
287
|
+
aicore._backoff_sleep = self._orig_backoff
|
|
288
|
+
aicore._backup_chain = self._orig_chain
|
|
289
|
+
aicore._PROVIDER_LAST_FAILURE.clear()
|
|
290
|
+
|
|
291
|
+
def _patch_once(self, fn):
|
|
292
|
+
aicore._stream_ai_once = fn
|
|
293
|
+
|
|
294
|
+
# -- simple / streaming ------------------------------------------------
|
|
295
|
+
def test_simple_response(self):
|
|
296
|
+
calls = []
|
|
297
|
+
|
|
298
|
+
def once(prompt, system_prompt=None, history=None, config=None,
|
|
299
|
+
attachments=None, size_class="normal"):
|
|
300
|
+
calls.append(config["provider"])
|
|
301
|
+
yield "42"
|
|
302
|
+
self._patch_once(once)
|
|
303
|
+
out = "".join(aicore.stream_ai("hi", config={"provider": "p"}))
|
|
304
|
+
self.assertEqual(out, "42")
|
|
305
|
+
self.assertEqual(calls, ["p"])
|
|
306
|
+
|
|
307
|
+
def test_streaming_response_incremental(self):
|
|
308
|
+
def once(prompt, system_prompt=None, history=None, config=None,
|
|
309
|
+
attachments=None, size_class="normal"):
|
|
310
|
+
for token in ("hel", "lo ", "wor", "ld"):
|
|
311
|
+
yield token
|
|
312
|
+
self._patch_once(once)
|
|
313
|
+
chunks = list(aicore.stream_ai("hi", config={"provider": "p"}))
|
|
314
|
+
self.assertEqual(chunks, ["hel", "lo ", "wor", "ld"])
|
|
315
|
+
|
|
316
|
+
# -- empty / timeout / exception ----------------------------------------
|
|
317
|
+
def test_empty_response_becomes_error_signature(self):
|
|
318
|
+
def empty(prompt, system_prompt=None, history=None, config=None,
|
|
319
|
+
attachments=None, size_class="normal"):
|
|
320
|
+
return
|
|
321
|
+
yield # pragma: no cover
|
|
322
|
+
self._patch_once(empty)
|
|
323
|
+
out = "".join(aicore.stream_ai("hi", config={"provider": "p"}))
|
|
324
|
+
self.assertTrue(aicore.is_error_response(out))
|
|
325
|
+
self.assertIn("No model response received.", out)
|
|
326
|
+
|
|
327
|
+
def test_timeout_signature_detected_and_retryable(self):
|
|
328
|
+
err = "The AI request timed out. Try again, or use a faster model."
|
|
329
|
+
self.assertTrue(aicore.is_error_response(err))
|
|
330
|
+
self.assertTrue(aicore._retryable_failure(err))
|
|
331
|
+
quota = "Error connecting to AI: HTTP 429: quota exceeded"
|
|
332
|
+
self.assertTrue(aicore.is_error_response(quota))
|
|
333
|
+
self.assertFalse(aicore._retryable_failure(quota))
|
|
334
|
+
|
|
335
|
+
def test_provider_exception_falls_back(self):
|
|
336
|
+
seen = []
|
|
337
|
+
|
|
338
|
+
def once(prompt, system_prompt=None, history=None, config=None,
|
|
339
|
+
attachments=None, size_class="normal"):
|
|
340
|
+
seen.append(config["provider"])
|
|
341
|
+
if config["provider"] == "primary":
|
|
342
|
+
yield "Could not reach the AI server. down"
|
|
343
|
+
else:
|
|
344
|
+
yield "recovered answer"
|
|
345
|
+
self._patch_once(once)
|
|
346
|
+
out = "".join(aicore.stream_ai(
|
|
347
|
+
"hi", config={"provider": "primary"},
|
|
348
|
+
))
|
|
349
|
+
self.assertEqual(out, "recovered answer")
|
|
350
|
+
self.assertGreater(len(seen), 1, "fallback never engaged")
|
|
351
|
+
|
|
352
|
+
def test_query_ai_fallback_success(self):
|
|
353
|
+
def once(prompt, system_prompt=None, history=None, config=None,
|
|
354
|
+
attachments=None, size_class="normal"):
|
|
355
|
+
if config["provider"] == "primary":
|
|
356
|
+
yield "The AI request timed out."
|
|
357
|
+
else:
|
|
358
|
+
yield "backup ok"
|
|
359
|
+
self._patch_once(once)
|
|
360
|
+
out = aicore.query_ai("q", config={"provider": "primary"})
|
|
361
|
+
self.assertEqual(out, "backup ok")
|
|
362
|
+
|
|
363
|
+
# -- mid-stream ----------------------------------------------------------
|
|
364
|
+
def test_midstream_drop_keeps_partial_without_failover(self):
|
|
365
|
+
providers = []
|
|
366
|
+
|
|
367
|
+
def once(prompt, system_prompt=None, history=None, config=None,
|
|
368
|
+
attachments=None, size_class="normal"):
|
|
369
|
+
providers.append(config["provider"])
|
|
370
|
+
yield "partial data "
|
|
371
|
+
yield "Could not reach the AI server. dropped"
|
|
372
|
+
self._patch_once(once)
|
|
373
|
+
out = "".join(aicore.stream_ai("q", config={"provider": "p1"}))
|
|
374
|
+
self.assertEqual(providers, ["p1"], "mid-stream drop must not re-run")
|
|
375
|
+
self.assertTrue(out.startswith("partial data"))
|
|
376
|
+
self.assertIn("Connection lost mid-response", out)
|
|
377
|
+
|
|
378
|
+
def test_partial_stream_then_cancel_note(self):
|
|
379
|
+
"""Cancelled request: worker-side flag is UI territory; here we
|
|
380
|
+
verify the transport-level cancel closes sockets and stops."""
|
|
381
|
+
resp = _FakeResponse()
|
|
382
|
+
aicore._register_response(resp)
|
|
383
|
+
closed_count = aicore.cancel_active_requests()
|
|
384
|
+
try:
|
|
385
|
+
self.assertGreaterEqual(closed_count, 1)
|
|
386
|
+
self.assertTrue(resp.closed)
|
|
387
|
+
self.assertTrue(aicore._just_cancelled())
|
|
388
|
+
finally:
|
|
389
|
+
aicore._unregister_response(resp)
|
|
390
|
+
self.assertEqual(aicore.cancel_active_requests(), 0)
|
|
391
|
+
|
|
392
|
+
def test_no_tokens_all_providers_yields_error_not_silence(self):
|
|
393
|
+
def dead(prompt, system_prompt=None, history=None, config=None,
|
|
394
|
+
attachments=None, size_class="normal"):
|
|
395
|
+
yield "Error connecting to AI: HTTP 500: dead"
|
|
396
|
+
self._patch_once(dead)
|
|
397
|
+
out = "".join(aicore.stream_ai("q", config={"provider": "p"}))
|
|
398
|
+
self.assertTrue(aicore.is_error_response(out))
|
|
399
|
+
self.assertTrue(out.strip(), "final failure must surface SOMETHING")
|
|
400
|
+
|
|
401
|
+
def test_empty_generator_all_providers_still_terminates(self):
|
|
402
|
+
def nothing(prompt, system_prompt=None, history=None, config=None,
|
|
403
|
+
attachments=None, size_class="normal"):
|
|
404
|
+
return
|
|
405
|
+
yield # pragma: no cover
|
|
406
|
+
self._patch_once(nothing)
|
|
407
|
+
out = "".join(aicore.stream_ai("q", config={"provider": "p"}))
|
|
408
|
+
self.assertTrue(aicore.is_error_response(out))
|
|
409
|
+
|
|
410
|
+
# -- retry/backoff/cooldown ----------------------------------------------
|
|
411
|
+
def test_retries_bounded_with_backoff_ordering(self):
|
|
412
|
+
sleeps = []
|
|
413
|
+
aicore._backoff_sleep = lambda attempt: sleeps.append(attempt)
|
|
414
|
+
attempts = []
|
|
415
|
+
|
|
416
|
+
def flaky(prompt, system_prompt=None, history=None, config=None,
|
|
417
|
+
attachments=None, size_class="normal"):
|
|
418
|
+
attempts.append(config["provider"])
|
|
419
|
+
if config["provider"] == "p":
|
|
420
|
+
yield "Could not reach the AI server. blip"
|
|
421
|
+
else:
|
|
422
|
+
yield "ok-backup"
|
|
423
|
+
self._patch_once(flaky)
|
|
424
|
+
out = "".join(aicore.stream_ai("q", config={"provider": "p"}))
|
|
425
|
+
self.assertEqual(out, "ok-backup")
|
|
426
|
+
p_attempts = attempts.count("p")
|
|
427
|
+
self.assertEqual(p_attempts, 2,
|
|
428
|
+
"expected exactly initial attempt + 1 retry")
|
|
429
|
+
self.assertEqual(sleeps, [0], "exponential backoff starts at base")
|
|
430
|
+
|
|
431
|
+
def test_non_retryable_skips_same_provider_retry(self):
|
|
432
|
+
attempts = []
|
|
433
|
+
|
|
434
|
+
def quota(prompt, system_prompt=None, history=None, config=None,
|
|
435
|
+
attachments=None, size_class="normal"):
|
|
436
|
+
attempts.append(config["provider"])
|
|
437
|
+
yield "Error connecting to AI: HTTP 429: quota exhausted"
|
|
438
|
+
self._patch_once(quota)
|
|
439
|
+
aicore.query_ai("q", config={"provider": "p"})
|
|
440
|
+
self.assertEqual(attempts.count("p"), 1,
|
|
441
|
+
"quota errors must move on, not retry")
|
|
442
|
+
|
|
443
|
+
def test_provider_cooldown_demotes_failed_provider(self):
|
|
444
|
+
cfg = {"provider": "flaky", "model": "m"}
|
|
445
|
+
aicore._PROVIDER_LAST_FAILURE.clear()
|
|
446
|
+
self.assertFalse(aicore._provider_in_cooldown(cfg))
|
|
447
|
+
aicore._mark_provider_failed(cfg)
|
|
448
|
+
self.assertTrue(aicore._provider_in_cooldown(cfg))
|
|
449
|
+
aicore._mark_provider_ok(cfg)
|
|
450
|
+
self.assertFalse(aicore._provider_in_cooldown(cfg))
|
|
451
|
+
|
|
452
|
+
def test_configurable_timeouts_by_size_class(self):
|
|
453
|
+
cfg = cct_config.CCTConfig()
|
|
454
|
+
simple = aicore.request_timeouts(cfg, "simple")
|
|
455
|
+
normal = aicore.request_timeouts(cfg, "normal")
|
|
456
|
+
large = aicore.request_timeouts(cfg, "large")
|
|
457
|
+
self.assertLess(simple[2], normal[2])
|
|
458
|
+
self.assertLess(normal[2], large[2])
|
|
459
|
+
connect, idle, total = large
|
|
460
|
+
self.assertGreaterEqual(total, 120) # big tasks get room
|
|
461
|
+
self.assertLessEqual(idle, total) # idle can't exceed total
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class TestRouterClasses(unittest.TestCase):
|
|
465
|
+
"""D — complexity classes drive fast paths AND timeout budgets."""
|
|
466
|
+
|
|
467
|
+
def test_trivial_greeting(self):
|
|
468
|
+
cls = model_router.complexity_class({"simple_chat"}, "hi")
|
|
469
|
+
self.assertEqual(cls, model_router.CLASS_TRIVIAL)
|
|
470
|
+
|
|
471
|
+
def test_simple_question(self):
|
|
472
|
+
dec = model_router.route("what is recursion?")
|
|
473
|
+
self.assertEqual(dec.path, model_router.PATH_FAST)
|
|
474
|
+
self.assertTrue(dec.fast)
|
|
475
|
+
self.assertEqual(dec.size_class, "simple")
|
|
476
|
+
|
|
477
|
+
def test_trivial_takes_fast_path(self):
|
|
478
|
+
dec = model_router.route("hi")
|
|
479
|
+
self.assertEqual(dec.path, model_router.PATH_FAST)
|
|
480
|
+
self.assertTrue(dec.fast)
|
|
481
|
+
|
|
482
|
+
def test_arithmetic_trivial(self):
|
|
483
|
+
dec = model_router.route("12*7")
|
|
484
|
+
self.assertEqual(dec.path, model_router.PATH_FAST)
|
|
485
|
+
|
|
486
|
+
def test_coding_normal_size_class(self):
|
|
487
|
+
dec = model_router.route(
|
|
488
|
+
"write a python function to parse this csv file properly")
|
|
489
|
+
self.assertIn(dec.size_class, ("normal", "large"))
|
|
490
|
+
|
|
491
|
+
def test_agent_task_gets_large_budget(self):
|
|
492
|
+
dec = model_router.route("analyze and repair every broken import "
|
|
493
|
+
"in this entire repository", mode="agent")
|
|
494
|
+
self.assertEqual(dec.path, model_router.PATH_AGENT)
|
|
495
|
+
self.assertEqual(dec.size_class, "large")
|
|
496
|
+
|
|
497
|
+
def test_research_label(self):
|
|
498
|
+
cls = model_router.complexity_class({"research"},
|
|
499
|
+
"research latest news about x")
|
|
500
|
+
self.assertEqual(cls, model_router.CLASS_RESEARCH)
|
|
501
|
+
|
|
502
|
+
def test_complex_document_analysis(self):
|
|
503
|
+
types = {"document_analysis", "long_context"}
|
|
504
|
+
cls = model_router.complexity_class(types, "summarize this 200 page pdf")
|
|
505
|
+
self.assertEqual(cls, model_router.CLASS_COMPLEX)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class TestShutdownOnce(unittest.TestCase):
|
|
509
|
+
"""E — goodbye ASCII renders EXACTLY once across exit paths."""
|
|
510
|
+
|
|
511
|
+
def test_shutdown_session_idempotent(self):
|
|
512
|
+
from calc_terminal import goodbye
|
|
513
|
+
goodbye.reset_for_tests()
|
|
514
|
+
renders = {"n": 0}
|
|
515
|
+
|
|
516
|
+
class FakeRepl:
|
|
517
|
+
history = []
|
|
518
|
+
|
|
519
|
+
with mock.patch.object(goodbye, "render_goodbye",
|
|
520
|
+
side_effect=lambda **k:
|
|
521
|
+
renders.__setitem__("n", renders["n"] + 1)):
|
|
522
|
+
first = goodbye.shutdown_session(FakeRepl(), reason="exit")
|
|
523
|
+
second = goodbye.shutdown_session(FakeRepl(), reason="exit") # Ctrl+Q then /quit
|
|
524
|
+
goodbye.reset_for_tests()
|
|
525
|
+
self.assertTrue(first)
|
|
526
|
+
self.assertFalse(second)
|
|
527
|
+
self.assertEqual(renders["n"], 1,
|
|
528
|
+
"double shutdown must render goodbye ONCE")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
unittest.main(verbosity=1)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for CAT CLI Terminal Tab and Window Identity.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from calc_terminal import identity, terminal_identity, theme
|
|
11
|
+
from calc_terminal import doctor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestTerminalIdentity(unittest.TestCase):
|
|
15
|
+
def test_identity_constants(self):
|
|
16
|
+
self.assertEqual(identity.CLI_TITLE, "CAT CLI")
|
|
17
|
+
self.assertEqual(identity.CLI_EMOJI, "🐱")
|
|
18
|
+
self.assertEqual(identity.FULL_CLI_TITLE, "🐱 CAT CLI")
|
|
19
|
+
self.assertEqual(terminal_identity.BRAND_NAME, "CAT CLI")
|
|
20
|
+
self.assertEqual(terminal_identity.BRAND_EMOJI, "🐱")
|
|
21
|
+
self.assertEqual(terminal_identity.DEFAULT_TITLE, "🐱 CAT CLI")
|
|
22
|
+
self.assertEqual(terminal_identity.PLAIN_TITLE, "CAT CLI")
|
|
23
|
+
|
|
24
|
+
def test_icon_asset_exists_and_valid(self):
|
|
25
|
+
ico_path = terminal_identity.get_icon_path()
|
|
26
|
+
self.assertIsNotNone(ico_path)
|
|
27
|
+
self.assertTrue(os.path.isfile(ico_path))
|
|
28
|
+
self.assertGreater(os.path.getsize(ico_path), 0)
|
|
29
|
+
|
|
30
|
+
# Validate with PIL if available
|
|
31
|
+
try:
|
|
32
|
+
from PIL import Image
|
|
33
|
+
with Image.open(ico_path) as img:
|
|
34
|
+
self.assertEqual(img.format, "ICO")
|
|
35
|
+
except ImportError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def test_set_terminal_title_formatting(self):
|
|
39
|
+
# Fake stdout with isatty returning True
|
|
40
|
+
class MockTTY:
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self.data = []
|
|
43
|
+
def isatty(self):
|
|
44
|
+
return True
|
|
45
|
+
def write(self, s):
|
|
46
|
+
self.data.append(s)
|
|
47
|
+
def flush(self):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
mock_out = MockTTY()
|
|
51
|
+
with mock.patch.object(sys, "stdout", mock_out):
|
|
52
|
+
terminal_identity.set_terminal_title()
|
|
53
|
+
# Check current active title
|
|
54
|
+
self.assertEqual(terminal_identity._current_active_title, "🐱 CAT CLI")
|
|
55
|
+
|
|
56
|
+
# Custom title with state
|
|
57
|
+
terminal_identity.set_terminal_title("ready")
|
|
58
|
+
self.assertEqual(terminal_identity._current_active_title, "🐱 CAT CLI — Ready")
|
|
59
|
+
|
|
60
|
+
terminal_identity.set_terminal_title("custom title")
|
|
61
|
+
self.assertEqual(terminal_identity._current_active_title, "🐱 CAT CLI — custom title")
|
|
62
|
+
|
|
63
|
+
# Restore
|
|
64
|
+
popped = terminal_identity.restore_terminal_title()
|
|
65
|
+
self.assertTrue(popped)
|
|
66
|
+
|
|
67
|
+
def test_polluted_title_detection(self):
|
|
68
|
+
# Should detect shells as polluted
|
|
69
|
+
self.assertTrue(terminal_identity._is_polluted_title("Command Prompt"))
|
|
70
|
+
self.assertTrue(terminal_identity._is_polluted_title("Command Prompt - cat"))
|
|
71
|
+
self.assertTrue(terminal_identity._is_polluted_title("PowerShell"))
|
|
72
|
+
self.assertTrue(terminal_identity._is_polluted_title("Windows PowerShell"))
|
|
73
|
+
self.assertTrue(terminal_identity._is_polluted_title("cmd.exe"))
|
|
74
|
+
self.assertTrue(terminal_identity._is_polluted_title("pwsh"))
|
|
75
|
+
self.assertTrue(terminal_identity._is_polluted_title("npm run dev"))
|
|
76
|
+
self.assertTrue(terminal_identity._is_polluted_title("git status"))
|
|
77
|
+
self.assertTrue(terminal_identity._is_polluted_title(""))
|
|
78
|
+
self.assertTrue(terminal_identity._is_polluted_title(None))
|
|
79
|
+
|
|
80
|
+
# Should NOT detect CAT as polluted
|
|
81
|
+
self.assertFalse(terminal_identity._is_polluted_title("CAT CLI"))
|
|
82
|
+
self.assertFalse(terminal_identity._is_polluted_title("🐱 CAT CLI"))
|
|
83
|
+
self.assertFalse(terminal_identity._is_polluted_title("🐱 CAT CLI — Ready"))
|
|
84
|
+
self.assertFalse(terminal_identity._is_polluted_title("Coding Agent Terminal"))
|
|
85
|
+
|
|
86
|
+
def test_windows_terminal_profile_configuration(self):
|
|
87
|
+
if sys.platform == "win32":
|
|
88
|
+
settings_path = terminal_identity.find_windows_terminal_settings()
|
|
89
|
+
if settings_path and os.path.isfile(settings_path):
|
|
90
|
+
ok, msg = terminal_identity.configure_windows_terminal_profile()
|
|
91
|
+
self.assertTrue(ok)
|
|
92
|
+
import json
|
|
93
|
+
with open(settings_path, "r", encoding="utf-8-sig") as f:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
profiles = data.get("profiles", {}).get("list", [])
|
|
96
|
+
cat_profiles = [p for p in profiles if p.get("name") == "CAT CLI"]
|
|
97
|
+
self.assertTrue(len(cat_profiles) >= 1)
|
|
98
|
+
cat_prof = cat_profiles[0]
|
|
99
|
+
self.assertEqual(cat_prof.get("name"), "CAT CLI")
|
|
100
|
+
self.assertEqual(cat_prof.get("tabTitle"), "CAT CLI")
|
|
101
|
+
self.assertTrue(cat_prof.get("icon", "").endswith("cat.ico"))
|
|
102
|
+
|
|
103
|
+
def test_theme_compatibility(self):
|
|
104
|
+
# Verify theme.set_terminal_title and set_terminal_title_state work seamlessly
|
|
105
|
+
class MockTTY:
|
|
106
|
+
def __init__(self):
|
|
107
|
+
self.data = []
|
|
108
|
+
def isatty(self):
|
|
109
|
+
return True
|
|
110
|
+
def write(self, s):
|
|
111
|
+
self.data.append(s)
|
|
112
|
+
def flush(self):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
mock_out = MockTTY()
|
|
116
|
+
with mock.patch.object(sys, "stdout", mock_out):
|
|
117
|
+
ok = theme.set_terminal_title("CAT CLI")
|
|
118
|
+
self.assertTrue(ok)
|
|
119
|
+
theme.set_terminal_title_state("ready")
|
|
120
|
+
joined = "".join(mock_out.data)
|
|
121
|
+
self.assertIn("\x1b]0;CAT CLI\x07", joined)
|
|
122
|
+
self.assertIn("\x1b]0;CAT CLI — Ready\x07", joined)
|
|
123
|
+
popped = theme.restore_terminal_title()
|
|
124
|
+
self.assertTrue(popped)
|
|
125
|
+
|
|
126
|
+
def test_doctor_terminal_check(self):
|
|
127
|
+
rc = doctor.fix_terminal()
|
|
128
|
+
self.assertEqual(rc, 0)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
unittest.main()
|