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
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 395 KiB |
@@ -462,7 +462,9 @@ def run_textual_interactive(
|
||||
def on_mount(self) -> None:
|
||||
self._render_welcome()
|
||||
self._render_status()
|
||||
self.query_one("#prompt", ChatTextArea).focus()
|
||||
prompt = self.query_one("#prompt", ChatTextArea)
|
||||
prompt.before_submit = self._handle_completion_enter
|
||||
prompt.focus()
|
||||
# Show resume status
|
||||
if self._resume_warning:
|
||||
self._append_system(self._resume_warning, style="yellow")
|
||||
@@ -1799,18 +1801,28 @@ def run_textual_interactive(
|
||||
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
|
||||
self._apply_selected_completion()
|
||||
|
||||
def on_key(self, event: Any) -> None:
|
||||
def _handle_completion_enter(self) -> bool:
|
||||
"""Called by ChatTextArea before submitting on Enter.
|
||||
|
||||
If a completion is active and an item is selected, apply it
|
||||
and suppress the submit. If the list is visible but nothing
|
||||
is selected (index == -1), select the first item instead of
|
||||
submitting the raw prefix.
|
||||
|
||||
Returns:
|
||||
True to suppress submit, False to allow it.
|
||||
"""
|
||||
comp_widget = self.query_one("#completions", Static)
|
||||
if not (comp_widget.display and self._comp_items):
|
||||
return
|
||||
return False
|
||||
|
||||
# Up/down are handled by priority bindings (action_edit_queued /
|
||||
# action_down_delegate) — only enter needs on_key handling.
|
||||
if event.key == "enter" and self._comp_index >= 0:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self._apply_selected_completion()
|
||||
self._hide_completions()
|
||||
# If no item highlighted yet, select the first one
|
||||
if self._comp_index < 0:
|
||||
self._comp_index = 0
|
||||
|
||||
self._apply_selected_completion()
|
||||
self._hide_completions()
|
||||
return True
|
||||
|
||||
def _apply_selected_completion(self) -> None:
|
||||
"""Apply the currently selected completion to the input field."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import events
|
||||
@@ -17,6 +18,10 @@ class ChatTextArea(TextArea):
|
||||
Emits :class:`ChatTextArea.Submitted` when the user presses Enter
|
||||
with non-empty text. Modifier+Enter (Option+Enter on macOS,
|
||||
Ctrl+J everywhere) inserts a literal newline instead.
|
||||
|
||||
An optional *before_submit* callback can be set to intercept Enter
|
||||
before submission. If it returns ``True`` the submit is suppressed
|
||||
(the callback handled the event itself).
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
@@ -58,6 +63,7 @@ class ChatTextArea(TextArea):
|
||||
*,
|
||||
placeholder: str = "",
|
||||
id: str | None = None,
|
||||
before_submit: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
id=id,
|
||||
@@ -66,6 +72,7 @@ class ChatTextArea(TextArea):
|
||||
soft_wrap=True,
|
||||
)
|
||||
self._placeholder = placeholder
|
||||
self.before_submit: Callable[[], bool] | None = before_submit
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
@@ -84,10 +91,18 @@ class ChatTextArea(TextArea):
|
||||
self.insert("\n")
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None:
|
||||
"""Handle Enter as submit."""
|
||||
"""Handle Enter as submit.
|
||||
|
||||
If *before_submit* is set and returns ``True``, the submit is
|
||||
suppressed (the callback already handled the Enter press, e.g.
|
||||
to apply a completion selection).
|
||||
"""
|
||||
if event.key == "enter":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
# Let the host intercept Enter (e.g. for completion selection)
|
||||
if self.before_submit and self.before_submit():
|
||||
return
|
||||
value = self.text.strip()
|
||||
if value:
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
@@ -110,6 +110,7 @@ STEPS = [
|
||||
"Thinking",
|
||||
"Skills",
|
||||
"MCP Servers",
|
||||
"LaTeX",
|
||||
"Channels",
|
||||
]
|
||||
|
||||
@@ -1549,6 +1550,246 @@ def _ensure_npx(reason: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TinyTeX (LaTeX) helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _check_latex_components() -> dict[str, bool]:
|
||||
"""Check which LaTeX components are available.
|
||||
|
||||
Returns:
|
||||
Dict mapping component name to availability:
|
||||
``{"pdflatex": bool, "latexmk": bool, "tlmgr": bool}``.
|
||||
"""
|
||||
result: dict[str, bool] = {}
|
||||
for cmd in ("pdflatex", "latexmk", "tlmgr"):
|
||||
exe = shutil.which(cmd)
|
||||
if not exe:
|
||||
result[cmd] = False
|
||||
continue
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[exe, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
result[cmd] = proc.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
result[cmd] = False
|
||||
return result
|
||||
|
||||
|
||||
def _check_tinytex() -> bool:
|
||||
"""Check if a usable LaTeX distribution is available.
|
||||
|
||||
Returns:
|
||||
True if pdflatex is found and working.
|
||||
"""
|
||||
return _check_latex_components().get("pdflatex", False)
|
||||
|
||||
|
||||
def _detect_tinytex_install_method() -> tuple[str, str]:
|
||||
"""Detect the best way to install TinyTeX for this platform.
|
||||
|
||||
Returns:
|
||||
Tuple of (method_name, install_command_or_url).
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
if shutil.which("choco"):
|
||||
return "choco", "choco install tinytex -y"
|
||||
if shutil.which("scoop"):
|
||||
return "scoop", "scoop install tinytex"
|
||||
return "manual", "https://yihui.org/tinytex/"
|
||||
|
||||
# macOS and Linux: use the official install script
|
||||
if shutil.which("curl"):
|
||||
return (
|
||||
"curl",
|
||||
'curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh',
|
||||
)
|
||||
if shutil.which("wget"):
|
||||
return (
|
||||
"wget",
|
||||
'wget -qO- "https://yihui.org/tinytex/install-bin-unix.sh" | sh',
|
||||
)
|
||||
|
||||
return "manual", "https://yihui.org/tinytex/"
|
||||
|
||||
|
||||
def _install_tinytex(method: str, command: str) -> bool:
|
||||
"""Install TinyTeX using the detected method.
|
||||
|
||||
Returns:
|
||||
True if installation succeeded.
|
||||
"""
|
||||
if method == "manual":
|
||||
return False
|
||||
|
||||
if method in ("curl", "wget"):
|
||||
# Pipe-to-shell commands must run through the shell
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
shell=True, # user confirmed install in wizard
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print(" [red]✗ Installation timed out[/red]")
|
||||
return False
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||||
return False
|
||||
|
||||
# choco / scoop
|
||||
parts = command.split()
|
||||
exe = shutil.which(parts[0]) or parts[0]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[exe, *parts[1:]],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except FileNotFoundError:
|
||||
console.print(f" [red]✗ {method} not found[/red]")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print(" [red]✗ Installation timed out[/red]")
|
||||
return False
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _print_latex_status(components: dict[str, bool]) -> None:
|
||||
"""Print a single-line status showing all LaTeX components."""
|
||||
parts: list[str] = []
|
||||
for cmd, _role in (
|
||||
("pdflatex", "compiler"),
|
||||
("latexmk", "build tool"),
|
||||
("tlmgr", "package manager"),
|
||||
):
|
||||
if components.get(cmd, False):
|
||||
parts.append(f"[green]✓ {cmd}[/green]")
|
||||
else:
|
||||
parts.append(f"[yellow]✗ {cmd}[/yellow]")
|
||||
console.print(" " + " ".join(parts))
|
||||
|
||||
|
||||
def _auto_install_latexmk() -> None:
|
||||
"""Auto-install latexmk via tlmgr when it is missing."""
|
||||
console.print(" [dim]Installing latexmk via tlmgr...[/dim]")
|
||||
tlmgr = shutil.which("tlmgr")
|
||||
if not tlmgr:
|
||||
return
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[tlmgr, "install", "latexmk"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if proc.returncode == 0 and shutil.which("latexmk"):
|
||||
console.print(" [green]✓ latexmk installed[/green]")
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]⚠ Failed to install latexmk"
|
||||
" (run: tlmgr install latexmk)[/yellow]"
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
console.print(
|
||||
" [yellow]⚠ Failed to install latexmk"
|
||||
" (run: tlmgr install latexmk)[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
def _step_tinytex() -> None:
|
||||
"""Step 9: Prepare LaTeX environment (TinyTeX).
|
||||
|
||||
Asks the user whether they want to set up LaTeX for paper compilation.
|
||||
If yes, checks for an existing installation and offers to install TinyTeX
|
||||
when none is found. The agent can auto-install missing LaTeX packages at
|
||||
runtime via ``tlmgr``, so only the base TinyTeX is needed here.
|
||||
"""
|
||||
prepare = questionary.confirm(
|
||||
"Prepare LaTeX environment? (needed to compile .tex → .pdf)",
|
||||
default=True,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=QMARK,
|
||||
).ask()
|
||||
|
||||
if prepare is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if not prepare:
|
||||
_print_step_skipped("LaTeX", "skipped")
|
||||
console.print(
|
||||
" [dim]Install later:"
|
||||
' curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh[/dim]'
|
||||
)
|
||||
return
|
||||
|
||||
# User wants LaTeX — check existing installation
|
||||
console.print(" [dim]Checking LaTeX environment...[/dim]")
|
||||
|
||||
components = _check_latex_components()
|
||||
|
||||
if components["pdflatex"]:
|
||||
# Already installed — show detailed status
|
||||
_print_latex_status(components)
|
||||
# Auto-fix missing latexmk if tlmgr is available
|
||||
if not components["latexmk"] and components["tlmgr"]:
|
||||
_auto_install_latexmk()
|
||||
return
|
||||
|
||||
# Not installed — detect install method and offer
|
||||
console.print(" [yellow]✗ pdflatex not found[/yellow]")
|
||||
method, command = _detect_tinytex_install_method()
|
||||
|
||||
if method == "manual":
|
||||
_print_step_skipped("LaTeX", "manual install needed")
|
||||
console.print(f" [dim]Install TinyTeX: {command}[/dim]")
|
||||
return
|
||||
|
||||
install = questionary.confirm(
|
||||
f"Install TinyTeX via {method}?",
|
||||
default=True,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=f" {QMARK}",
|
||||
).ask()
|
||||
|
||||
if install is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if not install:
|
||||
_print_step_skipped("LaTeX", "skipped")
|
||||
console.print(f" [dim]Install later: {command}[/dim]")
|
||||
return
|
||||
|
||||
console.print(" [dim]Installing TinyTeX (this may take a minute)...[/dim]")
|
||||
if _install_tinytex(method, command):
|
||||
post = _check_latex_components()
|
||||
if post["pdflatex"]:
|
||||
_print_latex_status(post)
|
||||
_print_step_result("LaTeX", "TinyTeX installed")
|
||||
else:
|
||||
console.print(" [green]✓ TinyTeX installed[/green]")
|
||||
console.print(
|
||||
" [yellow]⚠ Restart your terminal"
|
||||
" for pdflatex to appear in PATH[/yellow]"
|
||||
)
|
||||
_print_step_result("LaTeX", "installed (restart terminal for PATH)")
|
||||
else:
|
||||
console.print(f" [dim]Install manually: {command}[/dim]")
|
||||
_print_step_result("LaTeX", "installation failed", success=False)
|
||||
|
||||
|
||||
def _step_skills() -> list[str]:
|
||||
"""Step 7: Optionally install recommended skills.
|
||||
|
||||
@@ -2553,7 +2794,10 @@ def run_onboard(skip_validation: bool = False) -> bool:
|
||||
# Step 8: MCP Servers
|
||||
_step_mcp_servers()
|
||||
|
||||
# Step 9: Channels
|
||||
# Step 9: LaTeX (TinyTeX)
|
||||
_step_tinytex()
|
||||
|
||||
# Step 10: Channels
|
||||
channel_updates = _step_channels(config)
|
||||
for key, value in channel_updates.items():
|
||||
setattr(config, key, value)
|
||||
|
||||
+511
-3
@@ -21,9 +21,9 @@ from EvoScientist.config.onboard import (
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_steps_has_ten_items(self):
|
||||
"""Test that STEPS contains exactly 10 steps."""
|
||||
assert len(STEPS) == 10
|
||||
def test_steps_has_eleven_items(self):
|
||||
"""Test that STEPS contains exactly 11 steps."""
|
||||
assert len(STEPS) == 11
|
||||
assert STEPS == [
|
||||
"UI",
|
||||
"Provider",
|
||||
@@ -34,6 +34,7 @@ class TestConstants:
|
||||
"Thinking",
|
||||
"Skills",
|
||||
"MCP Servers",
|
||||
"LaTeX",
|
||||
"Channels",
|
||||
]
|
||||
|
||||
@@ -839,6 +840,7 @@ class TestRunOnboard:
|
||||
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()
|
||||
@@ -877,6 +879,7 @@ class TestRunOnboard:
|
||||
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()
|
||||
|
||||
@@ -896,6 +899,7 @@ class TestRunOnboard:
|
||||
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()
|
||||
|
||||
@@ -920,3 +924,507 @@ class TestRunOnboard:
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user