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:
Xi Zhang
2026-03-23 11:51:42 +01:00
committed by GitHub
parent 65cec64445
commit cc266dd6b1
5 changed files with 794 additions and 15 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 395 KiB

+22 -10
View File
@@ -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."""
+16 -1
View File
@@ -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))
+245 -1
View File
@@ -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
View File
@@ -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")