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.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. 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()