Files
EvoScientist-Multi/tests/test_onboard.py
T
Xi Zhang cc266dd6b1 Feat/latex onboard setup and tui completion fix (#88)
* feat(onboard): add LaTeX setup steps and TinyTeX installation helpers

* fix(onboard): improve LaTeX status output to a single-line summary

* fix(assets): update wechat_group image
2026-03-23 10:51:42 +00:00

1431 lines
54 KiB
Python

"""Tests for EvoScientist onboarding wizard."""
import subprocess
from unittest.mock import Mock, patch
import pytest
from EvoScientist.config import EvoScientistConfig
from EvoScientist.config.onboard import (
CONFIRM_STYLE,
STEPS,
WIZARD_STYLE,
ChoiceValidator,
IntegerValidator,
render_progress,
)
# =============================================================================
# Test STEPS and WIZARD_STYLE constants
# =============================================================================
class TestConstants:
def test_steps_has_eleven_items(self):
"""Test that STEPS contains exactly 11 steps."""
assert len(STEPS) == 11
assert STEPS == [
"UI",
"Provider",
"API Key",
"Model",
"Tavily Key",
"Workspace",
"Thinking",
"Skills",
"MCP Servers",
"LaTeX",
"Channels",
]
def test_wizard_style_is_style_instance(self):
"""Test that WIZARD_STYLE is a prompt_toolkit Style."""
from prompt_toolkit.styles import Style
assert isinstance(WIZARD_STYLE, Style)
def test_confirm_style_is_style_instance(self):
"""Test that CONFIRM_STYLE is a prompt_toolkit Style."""
from prompt_toolkit.styles import Style
assert isinstance(CONFIRM_STYLE, Style)
def test_confirm_style_differs_from_wizard(self):
"""Test that CONFIRM_STYLE has a different qmark color (orange)."""
assert CONFIRM_STYLE is not WIZARD_STYLE
# =============================================================================
# Test render_progress
# =============================================================================
class TestRenderProgress:
def test_renders_first_step_active(self):
"""Test that first step is shown as active."""
panel = render_progress(current_step=0, completed=set())
# Panel should contain the title
assert panel.title is not None
# The renderable content should contain step names
content_str = str(panel.renderable)
assert "Provider" in content_str
def test_renders_completed_steps(self):
"""Test that completed steps are marked differently."""
panel = render_progress(current_step=2, completed={0, 1})
content_str = str(panel.renderable)
# All step names should be present
for step in STEPS:
assert step in content_str
def test_panel_has_title(self):
"""Test that the panel has the expected title."""
panel = render_progress(current_step=0, completed=set())
assert "EvoScientist Setup" in str(panel.title)
def test_panel_has_blue_border(self):
"""Test that the panel has a blue border style."""
panel = render_progress(current_step=0, completed=set())
assert panel.border_style == "blue"
# =============================================================================
# Test Validators
# =============================================================================
class TestIntegerValidator:
def test_accepts_valid_integer(self):
"""Test that valid integers are accepted."""
validator = IntegerValidator(min_value=1, max_value=10)
class Doc:
text = "5"
# Should not raise
validator.validate(Doc())
def test_accepts_empty_for_default(self):
"""Test that empty string is accepted for using default."""
validator = IntegerValidator(min_value=1, max_value=10)
class Doc:
text = ""
# Should not raise
validator.validate(Doc())
def test_rejects_non_integer(self):
"""Test that non-integers are rejected."""
from prompt_toolkit.validation import ValidationError
validator = IntegerValidator(min_value=1, max_value=10)
class Doc:
text = "abc"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "valid integer" in str(exc_info.value.message)
def test_rejects_below_min(self):
"""Test that values below min are rejected."""
from prompt_toolkit.validation import ValidationError
validator = IntegerValidator(min_value=5, max_value=10)
class Doc:
text = "3"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "between" in str(exc_info.value.message)
def test_rejects_above_max(self):
"""Test that values above max are rejected."""
from prompt_toolkit.validation import ValidationError
validator = IntegerValidator(min_value=1, max_value=5)
class Doc:
text = "10"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "between" in str(exc_info.value.message)
class TestChoiceValidator:
def test_accepts_valid_choice(self):
"""Test that valid choices are accepted."""
validator = ChoiceValidator(choices=["apple", "banana", "cherry"])
class Doc:
text = "banana"
# Should not raise
validator.validate(Doc())
def test_accepts_case_insensitive(self):
"""Test that choices are case-insensitive."""
validator = ChoiceValidator(choices=["Apple", "Banana"])
class Doc:
text = "APPLE"
# Should not raise
validator.validate(Doc())
def test_accepts_empty_when_allowed(self):
"""Test that empty is accepted when allow_empty=True."""
validator = ChoiceValidator(choices=["a", "b"], allow_empty=True)
class Doc:
text = ""
# Should not raise
validator.validate(Doc())
def test_rejects_empty_when_not_allowed(self):
"""Test that empty is rejected when allow_empty=False."""
from prompt_toolkit.validation import ValidationError
validator = ChoiceValidator(choices=["a", "b"], allow_empty=False)
class Doc:
text = ""
with pytest.raises(ValidationError):
validator.validate(Doc())
def test_rejects_invalid_choice(self):
"""Test that invalid choices are rejected."""
from prompt_toolkit.validation import ValidationError
validator = ChoiceValidator(choices=["a", "b"])
class Doc:
text = "c"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "one of" in str(exc_info.value.message)
# =============================================================================
# Test Step Functions (Mocked questionary)
# =============================================================================
class TestStepProvider:
def test_returns_selected_provider(self):
"""Test that _step_provider returns selected provider."""
from EvoScientist.config.onboard import _step_provider
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "anthropic"
result = _step_provider(config)
assert result == "anthropic"
mock_q.select.assert_called_once()
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test that _step_provider raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard import _step_provider
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_provider(config)
class TestStepModel:
def test_returns_selected_model(self):
"""Test that _step_model returns selected model."""
from EvoScientist.config.onboard import _step_model
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "claude-sonnet-4-5"
result = _step_model(config, "anthropic")
assert result == "claude-sonnet-4-5"
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test that _step_model raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard import _step_model
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_model(config, "anthropic")
class TestStepWorkspace:
def test_returns_daemon_mode(self):
"""Test workspace step returns selected mode."""
from EvoScientist.config.onboard import _step_workspace
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "daemon"
result = _step_workspace(config)
assert result == "daemon"
def test_returns_run_mode(self):
"""Test workspace step returns run mode."""
from EvoScientist.config.onboard import _step_workspace
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "run"
result = _step_workspace(config)
assert result == "run"
class TestPromptAndValidateApiKey:
def test_keep_existing_key_still_validates(self):
"""Pressing Enter to keep current key should validate the existing key."""
from EvoScientist.config.onboard import _prompt_and_validate_api_key
validate_fn = Mock(return_value=(True, "Valid"))
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.console"),
):
mock_q.password.return_value.ask.return_value = "" # keep existing
result = _prompt_and_validate_api_key(
"Enter key:",
current="existing-key",
validate_fn=validate_fn,
skip_validation=False,
)
assert result is None # None means "keep existing, don't overwrite"
validate_fn.assert_called_once_with("existing-key")
def test_new_key_still_validates(self):
"""Entering a new key should still run validation."""
from EvoScientist.config.onboard import _prompt_and_validate_api_key
validate_fn = Mock(return_value=(True, "valid"))
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.password.return_value.ask.return_value = "new-key"
result = _prompt_and_validate_api_key(
"Enter key:",
current="old-key",
validate_fn=validate_fn,
skip_validation=False,
)
assert result == "new-key"
validate_fn.assert_called_once_with("new-key")
class TestValidateImessage:
def test_valid_when_cli_found_with_rpc(self):
"""Test validate_imessage returns valid when imsg CLI found and RPC works."""
from EvoScientist.config.onboard import validate_imessage
version_result = Mock(returncode=0, stdout="imsg 1.2.3")
rpc_result = Mock(returncode=0)
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil,
patch("EvoScientist.config.onboard.subprocess") as mock_sub,
):
mock_sys.platform = "darwin"
mock_shutil.which.return_value = "/opt/homebrew/bin/imsg"
mock_sub.run.side_effect = [version_result, rpc_result]
valid, msg = validate_imessage()
assert valid is True
assert "imsg" in msg
assert "1.2.3" in msg
def test_invalid_when_cli_not_found(self):
"""Test validate_imessage returns not_installed when imsg CLI missing."""
from EvoScientist.config.onboard import validate_imessage
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil,
):
mock_sys.platform = "darwin"
mock_shutil.which.return_value = None
valid, msg = validate_imessage()
assert valid is False
assert msg == "not_installed"
def test_invalid_on_non_macos(self):
"""Test validate_imessage returns invalid on non-macOS."""
from EvoScientist.config.onboard import validate_imessage
with patch("EvoScientist.config.onboard.sys") as mock_sys:
mock_sys.platform = "linux"
valid, msg = validate_imessage()
assert valid is False
assert "macOS" in msg
def test_invalid_when_rpc_not_supported(self):
"""Test validate_imessage returns invalid when RPC check fails."""
from EvoScientist.config.onboard import validate_imessage
version_result = Mock(returncode=0, stdout="imsg 0.1.0")
rpc_result = Mock(returncode=1)
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil,
patch("EvoScientist.config.onboard.subprocess") as mock_sub,
):
mock_sys.platform = "darwin"
mock_shutil.which.return_value = "/usr/local/bin/imsg"
mock_sub.run.side_effect = [version_result, rpc_result]
valid, msg = validate_imessage()
assert valid is False
assert "RPC not supported" in msg
class TestInstallImsg:
def test_install_success(self):
"""Test _install_imsg returns True on success."""
from EvoScientist.config.onboard import _install_imsg
with patch("EvoScientist.config.onboard.subprocess") as mock_sub:
mock_sub.run.return_value = Mock(returncode=0)
mock_sub.TimeoutExpired = subprocess.TimeoutExpired
result = _install_imsg()
assert result is True
def test_install_brew_not_found(self):
"""Test _install_imsg handles missing Homebrew."""
from EvoScientist.config.onboard import _install_imsg
with (
patch("EvoScientist.config.onboard.subprocess") as mock_sub,
patch("EvoScientist.config.onboard.console"),
):
mock_sub.run.side_effect = FileNotFoundError()
mock_sub.TimeoutExpired = subprocess.TimeoutExpired
result = _install_imsg()
assert result is False
def test_install_failure(self):
"""Test _install_imsg returns False on non-zero exit."""
from EvoScientist.config.onboard import _install_imsg
with patch("EvoScientist.config.onboard.subprocess") as mock_sub:
mock_sub.run.return_value = Mock(returncode=1)
mock_sub.TimeoutExpired = subprocess.TimeoutExpired
result = _install_imsg()
assert result is False
class TestSetupImessage:
def test_already_installed(self):
"""Test _setup_imessage returns True when already installed."""
from EvoScientist.config.onboard import _setup_imessage
with (
patch(
"EvoScientist.config.onboard.validate_imessage",
return_value=(True, "imsg at /bin/imsg"),
),
patch("EvoScientist.config.onboard.console"),
):
result = _setup_imessage()
assert result is True
def test_not_macos(self):
"""Test _setup_imessage returns False on non-macOS."""
from EvoScientist.config.onboard import _setup_imessage
with (
patch(
"EvoScientist.config.onboard.validate_imessage",
return_value=(False, "iMessage requires macOS"),
),
patch("EvoScientist.config.onboard.console"),
):
result = _setup_imessage()
assert result is False
def test_install_then_valid(self):
"""Test _setup_imessage installs and re-validates successfully."""
from EvoScientist.config.onboard import _setup_imessage
with (
patch("EvoScientist.config.onboard.validate_imessage") as mock_val,
patch("EvoScientist.config.onboard._install_imsg", return_value=True),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.console"),
):
mock_val.side_effect = [
(False, "not_installed"), # First check
(True, "imsg at /bin/imsg"), # After install
]
mock_q.confirm.return_value.ask.return_value = True # Yes, install
result = _setup_imessage()
assert result is True
def test_user_declines_install(self):
"""Test _setup_imessage returns False when user declines install."""
from EvoScientist.config.onboard import _setup_imessage
with (
patch(
"EvoScientist.config.onboard.validate_imessage",
return_value=(False, "not_installed"),
),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.console"),
):
mock_q.confirm.return_value.ask.return_value = False
result = _setup_imessage()
assert result is False
class TestStepSkills:
def test_returns_empty_when_none_selected(self):
"""Test skills step returns empty list when user selects nothing."""
from EvoScientist.config.onboard import _step_skills
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.console"),
):
mock_q.checkbox.return_value.ask.return_value = []
result = _step_skills()
assert result == []
def test_installs_selected_skills(self):
"""Test skills step installs selected skills and returns sources."""
from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills
source = _RECOMMENDED_SKILLS[0]["source"]
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.console"),
patch("EvoScientist.tools.skills_manager.install_skill") as mock_install,
):
mock_q.checkbox.return_value.ask.return_value = [source]
mock_install.return_value = {"success": True, "name": "test"}
result = _step_skills()
assert result == [source]
mock_install.assert_called_once_with(source)
def test_handles_install_failure(self):
"""Test skills step handles installation errors gracefully."""
from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills
source = _RECOMMENDED_SKILLS[0]["source"]
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.console"),
patch("EvoScientist.tools.skills_manager.install_skill") as mock_install,
):
mock_q.checkbox.return_value.ask.return_value = [source]
mock_install.side_effect = Exception("network error")
result = _step_skills()
assert result == []
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test skills step raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard import _step_skills
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.checkbox.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_skills()
class TestStepChannels:
def test_returns_disabled_when_skip(self):
"""Test channels step returns empty dict when user selects nothing."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.checkbox.return_value.ask.return_value = []
result = _step_channels(config)
assert result == {"channel_enabled": "", "imessage_enabled": False}
def test_returns_enabled_when_setup_passes(self):
"""Test channels step returns enabled when iMessage setup succeeds."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._setup_imessage", return_value=True),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.text.return_value.ask.return_value = ""
result = _step_channels(config)
assert result["channel_enabled"] == "imessage"
assert result["imessage_enabled"] is True
def test_returns_enabled_with_senders(self):
"""Test channels step returns enabled with specific senders."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._setup_imessage", return_value=True),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.text.return_value.ask.return_value = "+1234567890,+0987654321"
result = _step_channels(config)
assert result["channel_enabled"] == "imessage"
assert result["imessage_enabled"] is True
assert result["imessage_allowed_senders"] == "+1234567890,+0987654321"
def test_setup_fails_user_declines(self):
"""Test channels step skips iMessage when setup fails and user declines."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._setup_imessage", return_value=False),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.confirm.return_value.ask.return_value = False
result = _step_channels(config)
assert result["channel_enabled"] == ""
assert result["imessage_enabled"] is False
def test_setup_fails_user_enables_anyway(self):
"""Test channels step enables iMessage when setup fails but user confirms."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._setup_imessage", return_value=False),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.confirm.return_value.ask.return_value = True
mock_q.text.return_value.ask.return_value = ""
result = _step_channels(config)
assert result["channel_enabled"] == "imessage"
assert result["imessage_enabled"] is True
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test channels step raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.checkbox.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_channels(config)
def test_telegram_channel_selected(self):
"""Test channels step handles Telegram selection."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
_real_import = (
__builtins__.__import__
if hasattr(__builtins__, "__import__")
else __import__
)
def _fake_import(name, *args, **kwargs):
if name == "telegram":
return # pretend installed
return _real_import(name, *args, **kwargs)
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._probe_channel"),
patch("builtins.__import__", side_effect=_fake_import),
):
mock_q.checkbox.return_value.ask.return_value = ["telegram"]
mock_q.text.return_value.ask.return_value = "test-token"
result = _step_channels(config)
assert result["channel_enabled"] == "telegram"
assert result["telegram_bot_token"] == "test-token"
def test_discord_channel_selected(self):
"""Test channels step handles Discord selection."""
from EvoScientist.config.onboard import _step_channels
config = EvoScientistConfig()
_real_import = (
__builtins__.__import__
if hasattr(__builtins__, "__import__")
else __import__
)
def _fake_import(name, *args, **kwargs):
if name == "discord":
return # pretend installed
return _real_import(name, *args, **kwargs)
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._probe_channel"),
patch("builtins.__import__", side_effect=_fake_import),
):
mock_q.checkbox.return_value.ask.return_value = ["discord"]
mock_q.text.return_value.ask.return_value = "discord-token"
result = _step_channels(config)
assert result["channel_enabled"] == "discord"
assert result["discord_bot_token"] == "discord-token"
class TestStepMcpServersNpxFailure:
def _make_test_servers(self):
from EvoScientist.mcp.registry import MCPServerEntry
return [
MCPServerEntry(
name="npx-server",
label="NPX Server",
tags=["onboarding"],
command="npx",
args=["-y", "test-server"],
),
MCPServerEntry(
name="url-server",
label="URL Server",
tags=["onboarding"],
transport="streamable_http",
url="https://example.com/mcp",
),
]
def test_npx_failure_skips_npx_servers(self):
"""When _ensure_npx returns False, npx-dependent servers must be skipped."""
from EvoScientist.config.onboard import _step_mcp_servers
servers = self._make_test_servers()
with (
patch(
"EvoScientist.mcp.registry.fetch_marketplace_index",
return_value=servers,
),
patch(
"EvoScientist.config.onboard._checkbox_ask",
return_value=["npx-server", "url-server"],
),
patch("EvoScientist.config.onboard._ensure_npx", return_value=False),
patch("EvoScientist.config.onboard._check_npx", return_value=False),
patch("EvoScientist.mcp.client._load_user_config", return_value={}),
patch("EvoScientist.mcp.client.add_mcp_server") as mock_add,
patch("EvoScientist.config.onboard.console"),
):
result = _step_mcp_servers()
# The npx server must NOT have been added
added_names = [call.args[0] for call in mock_add.call_args_list]
assert "npx-server" not in added_names
# The URL server should still be added
assert "url-server" in added_names
assert "url-server" in result
assert "npx-server" not in result
def test_npx_failure_returns_empty_when_all_npx(self):
"""When all selected servers are npx-based and npx fails, return []."""
from EvoScientist.config.onboard import _step_mcp_servers
servers = self._make_test_servers()
npx_names = [s.name for s in servers if s.command == "npx"]
with (
patch(
"EvoScientist.mcp.registry.fetch_marketplace_index",
return_value=servers,
),
patch("EvoScientist.config.onboard._checkbox_ask", return_value=npx_names),
patch("EvoScientist.config.onboard._ensure_npx", return_value=False),
patch("EvoScientist.config.onboard._check_npx", return_value=False),
patch("EvoScientist.mcp.client._load_user_config", return_value={}),
patch("EvoScientist.mcp.client.add_mcp_server") as mock_add,
patch("EvoScientist.config.onboard.console"),
):
result = _step_mcp_servers()
assert result == []
mock_add.assert_not_called()
class TestStepThinking:
def test_returns_show_thinking(self):
"""Test thinking step returns selected value."""
from EvoScientist.config.onboard import _step_thinking
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = True
result = _step_thinking(config)
assert result is True
def test_returns_false_when_off(self):
"""Test thinking step returns False when user selects Off."""
from EvoScientist.config.onboard import _step_thinking
config = EvoScientistConfig(show_thinking=False)
with patch("EvoScientist.config.onboard.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = False
result = _step_thinking(config)
assert result is False
# =============================================================================
# Test run_onboard (Integration-like test with mocked questionary)
# =============================================================================
class TestRunOnboard:
def test_returns_true_on_save(self):
"""Test that run_onboard returns True when config is saved."""
from EvoScientist.config.onboard import run_onboard
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.load_config") as mock_load,
patch("EvoScientist.config.onboard.save_config") as mock_save,
patch("EvoScientist.config.onboard.console"),
patch("EvoScientist.config.onboard._step_tinytex"),
):
# Setup mock config
mock_load.return_value = EvoScientistConfig()
# Mock all questionary calls
mock_q.select.return_value.ask.side_effect = [
"tui", # UI backend
"anthropic", # Provider
"claude-sonnet-4-5", # Model
"daemon", # Workspace mode
True, # Show thinking
"skip", # Channels: skip
]
mock_q.password.return_value.ask.side_effect = [
"", # Provider API key (keep current)
"", # Tavily key (keep current)
]
mock_q.confirm.return_value.ask.side_effect = [
True, # Save config
]
mock_q.text.return_value.ask.side_effect = [
"", # Workspace directory (empty = use cwd)
]
mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip
result = run_onboard(skip_validation=True)
assert result is True
mock_save.assert_called_once()
def test_returns_false_on_cancel(self):
"""Test that run_onboard returns False when cancelled."""
from EvoScientist.config.onboard import run_onboard
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.load_config") as mock_load,
patch("EvoScientist.config.onboard.console"),
patch("EvoScientist.config.onboard._step_tinytex"),
):
mock_load.return_value = EvoScientistConfig()
# First selection returns None (Ctrl+C)
mock_q.select.return_value.ask.return_value = None
result = run_onboard(skip_validation=True)
assert result is False
def test_returns_false_when_not_saving(self):
"""Test that run_onboard returns False when user declines to save."""
from EvoScientist.config.onboard import run_onboard
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard.load_config") as mock_load,
patch("EvoScientist.config.onboard.save_config") as mock_save,
patch("EvoScientist.config.onboard.console"),
patch("EvoScientist.config.onboard._step_tinytex"),
):
mock_load.return_value = EvoScientistConfig()
mock_q.select.return_value.ask.side_effect = [
"tui", # UI backend
"anthropic",
"claude-sonnet-4-5",
"daemon",
True, # Show thinking
"skip", # Channels: skip
]
mock_q.password.return_value.ask.side_effect = ["", ""]
mock_q.confirm.return_value.ask.side_effect = [
False, # Save config - NO
]
mock_q.text.return_value.ask.side_effect = [
"", # Workspace directory (empty = use cwd)
]
mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip
result = run_onboard(skip_validation=True)
assert result is False
mock_save.assert_not_called()
# =============================================================================
# Test TinyTeX helpers
# =============================================================================
class TestCheckLatexComponents:
"""Tests for _check_latex_components()."""
def test_all_available(self):
"""All three components found → all True."""
from EvoScientist.config.onboard import _check_latex_components
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
):
mock_sh.which.return_value = "/usr/local/bin/cmd"
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
result = _check_latex_components()
assert result == {"pdflatex": True, "latexmk": True, "tlmgr": True}
def test_only_pdflatex(self):
"""Only pdflatex available."""
from EvoScientist.config.onboard import _check_latex_components
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
):
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/pdflatex" if cmd == "pdflatex" else None
)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
result = _check_latex_components()
assert result == {
"pdflatex": True,
"latexmk": False,
"tlmgr": False,
}
def test_none_available(self):
"""Nothing found → all False."""
from EvoScientist.config.onboard import _check_latex_components
with patch("EvoScientist.config.onboard.shutil") as mock_sh:
mock_sh.which.return_value = None
result = _check_latex_components()
assert result == {
"pdflatex": False,
"latexmk": False,
"tlmgr": False,
}
class TestAutoInstallLatexmk:
"""Tests for _auto_install_latexmk()."""
def test_success(self):
"""tlmgr install latexmk succeeds."""
from EvoScientist.config.onboard import _auto_install_latexmk
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.console") as mock_con,
):
mock_sh.which.side_effect = lambda cmd: f"/usr/local/bin/{cmd}"
mock_sp.run.return_value = Mock(returncode=0)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
_auto_install_latexmk()
success_printed = any(
"latexmk installed" in str(c) for c in mock_con.print.call_args_list
)
assert success_printed
def test_tlmgr_not_found(self):
"""tlmgr not on PATH → does nothing."""
from EvoScientist.config.onboard import _auto_install_latexmk
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.console"),
):
mock_sh.which.return_value = None
_auto_install_latexmk()
mock_sp.run.assert_not_called()
def test_install_fails(self):
"""tlmgr install returns nonzero → warns."""
from EvoScientist.config.onboard import _auto_install_latexmk
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.console") as mock_con,
):
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/tlmgr" if cmd == "tlmgr" else None
)
mock_sp.run.return_value = Mock(returncode=1)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
_auto_install_latexmk()
warn_printed = any(
"Failed" in str(c) for c in mock_con.print.call_args_list
)
assert warn_printed
class TestCheckTinytex:
"""Tests for _check_tinytex()."""
def test_found_pdflatex(self):
"""pdflatex found and working → True."""
from EvoScientist.config.onboard import _check_tinytex
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
):
mock_sh.which.return_value = "/usr/local/bin/pdflatex"
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
assert _check_tinytex() is True
def test_tlmgr_only_not_enough(self):
"""pdflatex missing but tlmgr found → False (pdflatex is required)."""
from EvoScientist.config.onboard import _check_tinytex
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
):
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/tlmgr" if cmd == "tlmgr" else None
)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
assert _check_tinytex() is False
def test_not_found(self):
"""Neither pdflatex nor tlmgr found → False."""
from EvoScientist.config.onboard import _check_tinytex
with patch("EvoScientist.config.onboard.shutil") as mock_sh:
mock_sh.which.return_value = None
assert _check_tinytex() is False
def test_version_timeout(self):
"""Command found but --version times out → False."""
from EvoScientist.config.onboard import _check_tinytex
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
):
mock_sh.which.return_value = "/usr/local/bin/pdflatex"
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.side_effect = subprocess.TimeoutExpired("pdflatex", 10)
assert _check_tinytex() is False
def test_version_nonzero(self):
"""Command found but --version returns nonzero → False."""
from EvoScientist.config.onboard import _check_tinytex
with (
patch("EvoScientist.config.onboard.shutil") as mock_sh,
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
):
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=1)
# pdflatex fails, tlmgr not found
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/pdflatex" if cmd == "pdflatex" else None
)
assert _check_tinytex() is False
class TestDetectTinytexInstallMethod:
"""Tests for _detect_tinytex_install_method()."""
def test_macos_with_curl(self):
"""macOS with curl → curl method."""
from EvoScientist.config.onboard import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.config.onboard.shutil") as mock_sh,
):
mock_sys.platform = "darwin"
mock_sh.which.side_effect = lambda cmd: (
"/usr/bin/curl" if cmd == "curl" else None
)
method, command = _detect_tinytex_install_method()
assert method == "curl"
assert "install-bin-unix.sh" in command
def test_linux_wget_fallback(self):
"""Linux without curl, with wget → wget method."""
from EvoScientist.config.onboard import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.config.onboard.shutil") as mock_sh,
):
mock_sys.platform = "linux"
mock_sh.which.side_effect = lambda cmd: (
"/usr/bin/wget" if cmd == "wget" else None
)
method, command = _detect_tinytex_install_method()
assert method == "wget"
assert "install-bin-unix.sh" in command
def test_windows_choco(self):
"""Windows with choco → choco method."""
from EvoScientist.config.onboard import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.config.onboard.shutil") as mock_sh,
):
mock_sys.platform = "win32"
mock_sh.which.side_effect = lambda cmd: (
"C:\\choco\\choco.exe" if cmd == "choco" else None
)
method, command = _detect_tinytex_install_method()
assert method == "choco"
assert "tinytex" in command
def test_windows_scoop(self):
"""Windows with scoop (no choco) → scoop method."""
from EvoScientist.config.onboard import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.config.onboard.shutil") as mock_sh,
):
mock_sys.platform = "win32"
mock_sh.which.side_effect = lambda cmd: (
"C:\\scoop\\scoop.exe" if cmd == "scoop" else None
)
method, command = _detect_tinytex_install_method()
assert method == "scoop"
assert "tinytex" in command
def test_no_tools(self):
"""No tools available → manual method."""
from EvoScientist.config.onboard import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.sys") as mock_sys,
patch("EvoScientist.config.onboard.shutil") as mock_sh,
):
mock_sys.platform = "linux"
mock_sh.which.return_value = None
method, command = _detect_tinytex_install_method()
assert method == "manual"
assert "yihui.org" in command
class TestInstallTinytex:
"""Tests for _install_tinytex()."""
def test_curl_install_success(self):
"""curl install succeeds → True."""
from EvoScientist.config.onboard import _install_tinytex
with patch("EvoScientist.config.onboard.subprocess") as mock_sp:
mock_sp.run.return_value = Mock(returncode=0)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
assert _install_tinytex("curl", "curl -sL ... | sh") is True
mock_sp.run.assert_called_once()
# Verify shell=True was used for pipe commands
_, kwargs = mock_sp.run.call_args
assert kwargs.get("shell") is True
def test_curl_install_timeout(self):
"""curl install times out → False."""
from EvoScientist.config.onboard import _install_tinytex
with (
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.console"),
):
mock_sp.run.side_effect = subprocess.TimeoutExpired("curl", 300)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
assert _install_tinytex("curl", "curl -sL ... | sh") is False
def test_choco_install_success(self):
"""choco install succeeds → True."""
from EvoScientist.config.onboard import _install_tinytex
with (
patch("EvoScientist.config.onboard.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.shutil") as mock_sh,
):
mock_sh.which.return_value = "C:\\choco\\choco.exe"
mock_sp.run.return_value = Mock(returncode=0)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
assert _install_tinytex("choco", "choco install tinytex -y") is True
def test_manual_returns_false(self):
"""manual method → False immediately."""
from EvoScientist.config.onboard import _install_tinytex
assert _install_tinytex("manual", "https://yihui.org/tinytex/") is False
class TestStepTinytex:
"""Tests for _step_tinytex()."""
def test_user_declines_prepare(self):
"""User says No to 'Prepare LaTeX environment?' → skipped."""
from EvoScientist.config.onboard import _step_tinytex
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._print_step_skipped") as mock_ps,
patch("EvoScientist.config.onboard.console"),
):
mock_q.confirm.return_value.ask.return_value = False
_step_tinytex()
mock_ps.assert_called_once_with("LaTeX", "skipped")
def test_already_installed_all_components(self):
"""User says Yes, all components available → prints detailed status."""
from EvoScientist.config.onboard import _step_tinytex
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch(
"EvoScientist.config.onboard._check_latex_components",
return_value={
"pdflatex": True,
"latexmk": True,
"tlmgr": True,
},
),
patch("EvoScientist.config.onboard._print_latex_status") as mock_status,
patch("EvoScientist.config.onboard.console"),
):
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_status.assert_called_once()
def test_already_installed_missing_latexmk(self):
"""pdflatex + tlmgr present but latexmk missing → auto-installs."""
from EvoScientist.config.onboard import _step_tinytex
with (
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch(
"EvoScientist.config.onboard._check_latex_components",
return_value={
"pdflatex": True,
"latexmk": False,
"tlmgr": True,
},
),
patch("EvoScientist.config.onboard._print_latex_status"),
patch("EvoScientist.config.onboard._auto_install_latexmk") as mock_auto,
patch("EvoScientist.config.onboard.console"),
):
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_auto.assert_called_once()
def test_user_installs_successfully(self):
"""Yes → not found → confirms install → succeeds → re-check passes."""
from EvoScientist.config.onboard import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
all_true = {"pdflatex": True, "latexmk": True, "tlmgr": True}
with (
patch(
"EvoScientist.config.onboard._check_latex_components",
side_effect=[all_false, all_true],
),
patch(
"EvoScientist.config.onboard._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch(
"EvoScientist.config.onboard._install_tinytex",
return_value=True,
),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._print_step_result") as mock_pr,
patch("EvoScientist.config.onboard._print_latex_status"),
patch("EvoScientist.config.onboard.console"),
):
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_pr.assert_called_once_with("LaTeX", "TinyTeX installed")
def test_user_declines_install(self):
"""Yes to prepare → not found → declines install → skipped."""
from EvoScientist.config.onboard import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard._check_latex_components",
return_value=all_false,
),
patch(
"EvoScientist.config.onboard._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._print_step_skipped") as mock_ps,
patch("EvoScientist.config.onboard.console"),
):
# First confirm (prepare) = True, second confirm (install) = False
mock_q.confirm.return_value.ask.side_effect = [True, False]
_step_tinytex()
mock_ps.assert_called_once_with("LaTeX", "skipped")
def test_install_fails(self):
"""Yes → not found → confirms install → install fails."""
from EvoScientist.config.onboard import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard._check_latex_components",
return_value=all_false,
),
patch(
"EvoScientist.config.onboard._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch(
"EvoScientist.config.onboard._install_tinytex",
return_value=False,
),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._print_step_result") as mock_pr,
patch("EvoScientist.config.onboard.console"),
):
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_pr.assert_called_once_with(
"LaTeX", "installation failed", success=False
)
def test_installed_but_not_in_path(self):
"""Install succeeds but pdflatex not yet in PATH → warns user."""
from EvoScientist.config.onboard import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard._check_latex_components",
side_effect=[all_false, all_false],
),
patch(
"EvoScientist.config.onboard._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch(
"EvoScientist.config.onboard._install_tinytex",
return_value=True,
),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._print_step_result") as mock_pr,
patch("EvoScientist.config.onboard.console") as mock_con,
):
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
path_warning_printed = any(
"PATH" in str(call) for call in mock_con.print.call_args_list
)
assert path_warning_printed
mock_pr.assert_called_once_with(
"LaTeX", "installed (restart terminal for PATH)"
)
def test_manual_method(self):
"""Yes to prepare → not found → manual method → prints URL, no install prompt."""
from EvoScientist.config.onboard import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard._check_latex_components",
return_value=all_false,
),
patch(
"EvoScientist.config.onboard._detect_tinytex_install_method",
return_value=("manual", "https://yihui.org/tinytex/"),
),
patch("EvoScientist.config.onboard.questionary") as mock_q,
patch("EvoScientist.config.onboard._print_step_skipped") as mock_ps,
patch("EvoScientist.config.onboard.console"),
):
# Only one confirm call (prepare=True), no second confirm for manual
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_ps.assert_called_once_with("LaTeX", "manual install needed")