mirror of
https://github.com/dw-0/kiauh.git
synced 2026-08-04 21:37:52 +05:00
feat(tests): add pluggable subprocess and filesystem backends
Introduce core.backends with CommandRunner and FilesystemBackend protocols plus default subprocess/local filesystem implementations. Add fake backends in tests.helpers.fake_backends for isolated unit tests.
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||||
|
# #
|
||||||
|
# https://github.com/dw-0/kiauh #
|
||||||
|
# #
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||||
|
# ======================================================================= #
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Protocol, Sequence, cast, runtime_checkable
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Singleton backends #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# There is exactly ONE ``command_runner`` and ONE ``filesystem`` global in the
|
||||||
|
# whole project, owned by this module. They are assigned (with explicit type
|
||||||
|
# annotations) at the bottom of this file, AFTER the default implementations
|
||||||
|
# are defined. ``utils.fs_utils`` and ``utils.sys_utils`` delegate to these
|
||||||
|
# singletons via the wrapper functions below, so tests patch a single place —
|
||||||
|
# ``core.backends.command_runner`` / ``core.backends.filesystem`` — instead of
|
||||||
|
# per-module duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: str | List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a command through the shared command runner."""
|
||||||
|
return command_runner.run(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def check_output(cmd: str | List[str], **kwargs: Any) -> str | bytes:
|
||||||
|
"""Run a command and return its output through the shared command runner."""
|
||||||
|
return command_runner.check_output(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def call(cmd: str | List[str], **kwargs: Any) -> int:
|
||||||
|
"""Run a command and return its exit code through the shared command runner."""
|
||||||
|
return command_runner.call(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def popen(cmd: str | List[str], **kwargs: Any) -> subprocess.Popen:
|
||||||
|
"""Start a process through the shared command runner."""
|
||||||
|
return command_runner.popen(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CommandRunner(Protocol):
|
||||||
|
"""Pluggable backend for executing system commands."""
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> subprocess.CompletedProcess: ...
|
||||||
|
|
||||||
|
def check_output(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | bytes: ...
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
def popen(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> subprocess.Popen: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessRunner:
|
||||||
|
"""Default command runner backed by the standard subprocess module."""
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(cmd, **kwargs)
|
||||||
|
|
||||||
|
def check_output(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | bytes:
|
||||||
|
return cast("str | bytes", subprocess.check_output(cmd, **kwargs))
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> int:
|
||||||
|
return subprocess.call(cmd, **kwargs)
|
||||||
|
|
||||||
|
def popen(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> subprocess.Popen:
|
||||||
|
return subprocess.Popen(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FilesystemBackend(Protocol):
|
||||||
|
"""Pluggable backend for filesystem operations."""
|
||||||
|
|
||||||
|
def exists(self, path: Path) -> bool: ...
|
||||||
|
|
||||||
|
def is_dir(self, path: Path) -> bool: ...
|
||||||
|
|
||||||
|
def is_file(self, path: Path) -> bool: ...
|
||||||
|
|
||||||
|
def is_symlink(self, path: Path) -> bool: ...
|
||||||
|
|
||||||
|
def mkdir(
|
||||||
|
self, path: Path, *, parents: bool = False, exist_ok: bool = False
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def unlink(self, path: Path) -> None: ...
|
||||||
|
|
||||||
|
def rmtree(self, path: Path) -> None: ...
|
||||||
|
|
||||||
|
def read_text(self, path: Path) -> str: ...
|
||||||
|
|
||||||
|
def write_text(self, path: Path, content: str) -> None: ...
|
||||||
|
|
||||||
|
def copy(self, source: Path, target: Path) -> None: ...
|
||||||
|
|
||||||
|
def home(self) -> Path: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFilesystemBackend:
|
||||||
|
"""Default filesystem backend backed by the local filesystem."""
|
||||||
|
|
||||||
|
def exists(self, path: Path) -> bool:
|
||||||
|
return path.exists()
|
||||||
|
|
||||||
|
def is_dir(self, path: Path) -> bool:
|
||||||
|
return path.is_dir()
|
||||||
|
|
||||||
|
def is_file(self, path: Path) -> bool:
|
||||||
|
return path.is_file()
|
||||||
|
|
||||||
|
def is_symlink(self, path: Path) -> bool:
|
||||||
|
return path.is_symlink()
|
||||||
|
|
||||||
|
def mkdir(
|
||||||
|
self, path: Path, *, parents: bool = False, exist_ok: bool = False
|
||||||
|
) -> None:
|
||||||
|
path.mkdir(parents=parents, exist_ok=exist_ok)
|
||||||
|
|
||||||
|
def unlink(self, path: Path) -> None:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def rmtree(self, path: Path) -> None:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
def read_text(self, path: Path) -> str:
|
||||||
|
return path.read_text()
|
||||||
|
|
||||||
|
def write_text(self, path: Path, content: str) -> None:
|
||||||
|
path.write_text(content)
|
||||||
|
|
||||||
|
def copy(self, source: Path, target: Path) -> None:
|
||||||
|
if source.is_dir():
|
||||||
|
shutil.copytree(source, target)
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
|
||||||
|
def home(self) -> Path:
|
||||||
|
return Path.home()
|
||||||
|
|
||||||
|
|
||||||
|
command_runner: CommandRunner = SubprocessRunner()
|
||||||
|
filesystem: FilesystemBackend = LocalFilesystemBackend()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from core import backends
|
||||||
|
from core.backends import LocalFilesystemBackend, SubprocessRunner
|
||||||
|
from tests.helpers.fake_backends import FakeCommandRunner, FakeFilesystemBackend
|
||||||
|
from utils import fs_utils, sys_utils
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubprocessRunner:
|
||||||
|
def test_run_executes_command(self) -> None:
|
||||||
|
runner = SubprocessRunner()
|
||||||
|
result = runner.run(["true"])
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_check_output_returns_stdout(self) -> None:
|
||||||
|
runner = SubprocessRunner()
|
||||||
|
output = runner.check_output(["echo", "hello"], text=True)
|
||||||
|
assert "hello" in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandRunnerInjection:
|
||||||
|
def test_sys_utils_uses_injected_runner(self, monkeypatch) -> None:
|
||||||
|
fake = FakeCommandRunner({
|
||||||
|
("some", "cmd"): subprocess.CompletedProcess(["some", "cmd"], 0, "", "")
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(backends, "command_runner", fake)
|
||||||
|
|
||||||
|
sys_utils.run(["some", "cmd"], check=True)
|
||||||
|
|
||||||
|
assert fake.calls[0][0] == ["some", "cmd"]
|
||||||
|
assert fake.calls[0][1].get("check") is True
|
||||||
|
|
||||||
|
def test_cmd_sysctl_service_records_command(self, monkeypatch) -> None:
|
||||||
|
expected_cmd = ["sudo", "systemctl", "start", "klipper.service"]
|
||||||
|
fake = FakeCommandRunner({
|
||||||
|
tuple(expected_cmd): subprocess.CompletedProcess(expected_cmd, 0, "", "")
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(backends, "command_runner", fake)
|
||||||
|
|
||||||
|
sys_utils.cmd_sysctl_service("klipper.service", "start")
|
||||||
|
|
||||||
|
assert fake.calls[0][0] == expected_cmd
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module", [sys_utils, fs_utils])
|
||||||
|
def test_single_shared_command_runner_registry(self, monkeypatch, module) -> None:
|
||||||
|
# there is only ONE ``command_runner`` global to patch.
|
||||||
|
# Patching ``core.backends.command_runner`` must affect every wrapper
|
||||||
|
# (sys_utils.run, fs_utils.run, enum helpers) — no per-module duplicates.
|
||||||
|
fake = FakeCommandRunner({
|
||||||
|
("shared", "cmd"): subprocess.CompletedProcess(["shared", "cmd"], 0, "", "")
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(backends, "command_runner", fake)
|
||||||
|
|
||||||
|
module.run(["shared", "cmd"], check=True)
|
||||||
|
|
||||||
|
assert fake.calls[0][0] == ["shared", "cmd"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalFilesystemBackend:
|
||||||
|
def test_write_and_read_text(self, tmp_path: Path) -> None:
|
||||||
|
fs = LocalFilesystemBackend()
|
||||||
|
target = tmp_path / "test.txt"
|
||||||
|
fs.write_text(target, "hello")
|
||||||
|
assert fs.read_text(target) == "hello"
|
||||||
|
|
||||||
|
def test_mkdir_and_exists(self, tmp_path: Path) -> None:
|
||||||
|
fs = LocalFilesystemBackend()
|
||||||
|
target = tmp_path / "new_dir"
|
||||||
|
assert not fs.exists(target)
|
||||||
|
fs.mkdir(target)
|
||||||
|
assert fs.exists(target)
|
||||||
|
assert fs.is_dir(target)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilesystemBackendInjection:
|
||||||
|
def test_create_folders_uses_injected_fs(self, monkeypatch) -> None:
|
||||||
|
fake = FakeFilesystemBackend()
|
||||||
|
monkeypatch.setattr(backends, "filesystem", fake)
|
||||||
|
|
||||||
|
fs_utils.create_folders([Path("/tmp/a"), Path("/tmp/b")])
|
||||||
|
|
||||||
|
assert fake.exists(Path("/tmp/a"))
|
||||||
|
assert fake.exists(Path("/tmp/b"))
|
||||||
|
|
||||||
|
def test_run_remove_routines_uses_injected_fs(self, monkeypatch) -> None:
|
||||||
|
fake = FakeFilesystemBackend()
|
||||||
|
fake.add_file(Path("/tmp/file.txt"), "x")
|
||||||
|
monkeypatch.setattr(backends, "filesystem", fake)
|
||||||
|
|
||||||
|
assert fs_utils.run_remove_routines(Path("/tmp/file.txt")) is True
|
||||||
|
assert not fake.exists(Path("/tmp/file.txt"))
|
||||||
|
|
||||||
|
def test_run_remove_routines_skips_missing_file(self, monkeypatch) -> None:
|
||||||
|
fake = FakeFilesystemBackend()
|
||||||
|
monkeypatch.setattr(backends, "filesystem", fake)
|
||||||
|
|
||||||
|
assert fs_utils.run_remove_routines(Path("/tmp/missing")) is False
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Test-only backends. Not imported by production code. #
|
||||||
|
# ======================================================================= #
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Sequence, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCommandRunner:
|
||||||
|
"""Command runner for tests. Records calls and returns scripted responses.
|
||||||
|
|
||||||
|
By default, running a command that was not explicitly scripted raises an
|
||||||
|
error so missing mocks are caught during development. Pass
|
||||||
|
``strict=False`` to restore the legacy "default success" behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
responses: Dict[Tuple[str, ...], subprocess.CompletedProcess] | None = None,
|
||||||
|
*,
|
||||||
|
strict: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.calls: List[Tuple[str | Sequence[str], Dict[str, Any]]] = []
|
||||||
|
self.responses = responses or {}
|
||||||
|
self.strict = strict
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key(cmd: str | Sequence[str]) -> Tuple[str, ...]:
|
||||||
|
if isinstance(cmd, str):
|
||||||
|
return (cmd,)
|
||||||
|
return tuple(str(c) for c in cmd)
|
||||||
|
|
||||||
|
def _make_response(
|
||||||
|
self, cmd: str | Sequence[str], returncode: int = 0
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=cmd,
|
||||||
|
returncode=returncode,
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _unscripted(self, cmd: str | Sequence[str]) -> subprocess.CompletedProcess:
|
||||||
|
if self.strict:
|
||||||
|
raise RuntimeError(f"Unscripted command: {cmd}")
|
||||||
|
return self._make_response(cmd)
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
self.calls.append((cmd, kwargs))
|
||||||
|
key = self._key(cmd)
|
||||||
|
if key in self.responses:
|
||||||
|
return self.responses[key]
|
||||||
|
return self._unscripted(cmd)
|
||||||
|
|
||||||
|
def check_output(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | bytes:
|
||||||
|
self.calls.append((cmd, kwargs))
|
||||||
|
key = self._key(cmd)
|
||||||
|
if key in self.responses:
|
||||||
|
return self.responses[key].stdout # type: ignore[no-any-return]
|
||||||
|
if self.strict:
|
||||||
|
raise RuntimeError(f"Unscripted command: {cmd}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> int:
|
||||||
|
self.calls.append((cmd, kwargs))
|
||||||
|
key = self._key(cmd)
|
||||||
|
if key in self.responses:
|
||||||
|
return self.responses[key].returncode
|
||||||
|
if self.strict:
|
||||||
|
raise RuntimeError(f"Unscripted command: {cmd}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def popen(
|
||||||
|
self,
|
||||||
|
cmd: str | Sequence[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> subprocess.Popen:
|
||||||
|
raise NotImplementedError("FakeCommandRunner.popen is not implemented")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFilesystemBackend:
|
||||||
|
"""In-memory filesystem backend for tests."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.dirs: set[str] = set()
|
||||||
|
self.files: Dict[str, str] = {}
|
||||||
|
self.symlinks: Dict[str, str] = {}
|
||||||
|
self._home: Path = Path("/home/test")
|
||||||
|
|
||||||
|
def _path(self, path: Path) -> str:
|
||||||
|
return str(Path(path).resolve())
|
||||||
|
|
||||||
|
def exists(self, path: Path) -> bool:
|
||||||
|
key = self._path(path)
|
||||||
|
return key in self.dirs or key in self.files or key in self.symlinks
|
||||||
|
|
||||||
|
def is_dir(self, path: Path) -> bool:
|
||||||
|
return self._path(path) in self.dirs
|
||||||
|
|
||||||
|
def is_file(self, path: Path) -> bool:
|
||||||
|
return self._path(path) in self.files
|
||||||
|
|
||||||
|
def is_symlink(self, path: Path) -> bool:
|
||||||
|
return self._path(path) in self.symlinks
|
||||||
|
|
||||||
|
def mkdir(
|
||||||
|
self, path: Path, *, parents: bool = False, exist_ok: bool = False
|
||||||
|
) -> None:
|
||||||
|
key = self._path(path)
|
||||||
|
if key in self.files and not exist_ok:
|
||||||
|
raise FileExistsError(key)
|
||||||
|
if key in self.dirs and not exist_ok:
|
||||||
|
raise FileExistsError(key)
|
||||||
|
if parents:
|
||||||
|
for parent in reversed(Path(key).parents):
|
||||||
|
self.dirs.add(str(parent))
|
||||||
|
self.dirs.add(key)
|
||||||
|
|
||||||
|
def unlink(self, path: Path) -> None:
|
||||||
|
key = self._path(path)
|
||||||
|
if key in self.files:
|
||||||
|
del self.files[key]
|
||||||
|
elif key in self.symlinks:
|
||||||
|
del self.symlinks[key]
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(key)
|
||||||
|
|
||||||
|
def rmtree(self, path: Path) -> None:
|
||||||
|
key = self._path(path)
|
||||||
|
if key not in self.dirs:
|
||||||
|
raise FileNotFoundError(key)
|
||||||
|
prefix = key + "/"
|
||||||
|
self.dirs = {d for d in self.dirs if not (d == key or d.startswith(prefix))}
|
||||||
|
self.files = {k: v for k, v in self.files.items() if not k.startswith(prefix)}
|
||||||
|
self.symlinks = {
|
||||||
|
k: v for k, v in self.symlinks.items() if not k.startswith(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
def read_text(self, path: Path) -> str:
|
||||||
|
key = self._path(path)
|
||||||
|
if key not in self.files:
|
||||||
|
raise FileNotFoundError(key)
|
||||||
|
return self.files[key]
|
||||||
|
|
||||||
|
def write_text(self, path: Path, content: str) -> None:
|
||||||
|
key = self._path(path)
|
||||||
|
self.files[key] = content
|
||||||
|
self.dirs.discard(key)
|
||||||
|
|
||||||
|
def copy(self, source: Path, target: Path) -> None:
|
||||||
|
content = self.read_text(source)
|
||||||
|
self.write_text(target, content)
|
||||||
|
|
||||||
|
def home(self) -> Path:
|
||||||
|
return self._home
|
||||||
|
|
||||||
|
def add_dir(self, path: Path) -> None:
|
||||||
|
self.dirs.add(self._path(path))
|
||||||
|
|
||||||
|
def add_file(self, path: Path, content: str = "") -> None:
|
||||||
|
self.files[self._path(path)] = content
|
||||||
|
|
||||||
|
def add_symlink(self, path: Path, target: Path) -> None:
|
||||||
|
self.symlinks[self._path(path)] = str(target)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||||
|
# https://github.com/dw-0/kiauh #
|
||||||
|
# #
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||||
|
# ======================================================================= #
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import main as main_module
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMainMenu:
|
||||||
|
"""Minimal stand-in for ``core.menus.main_menu.MainMenu``."""
|
||||||
|
|
||||||
|
instances: List["_FakeMainMenu"] = []
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._run = False
|
||||||
|
type(self).instances.append(self)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
self._run = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls) -> None:
|
||||||
|
cls.instances = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_fake_menu() -> None:
|
||||||
|
_FakeMainMenu.reset()
|
||||||
|
yield
|
||||||
|
_FakeMainMenu.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_tui_seeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Neutralise the heavyweight side-effects triggered when launching the TUI."""
|
||||||
|
monkeypatch.setattr(main_module, "KiauhSettings", lambda: None)
|
||||||
|
monkeypatch.setattr(main_module, "ensure_encoding", lambda: None)
|
||||||
|
monkeypatch.setattr(main_module, "MainMenu", _FakeMainMenu)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainDispatch:
|
||||||
|
def test_no_command_launches_tui(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# rc == -1 means "fall back to the TUI": ``MainMenu().run()`` is called.
|
||||||
|
monkeypatch.setattr(main_module, "run_cli", lambda: -1)
|
||||||
|
_patch_tui_seeds(monkeypatch)
|
||||||
|
|
||||||
|
main_module.main()
|
||||||
|
|
||||||
|
assert _FakeMainMenu.instances
|
||||||
|
assert all(m._run for m in _FakeMainMenu.instances)
|
||||||
|
|
||||||
|
def test_cli_success_returns_cleanly(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
# rc == 0 means the CLI succeeded; the TUI must NOT start and main must
|
||||||
|
# NOT call sys.exit.
|
||||||
|
monkeypatch.setattr(main_module, "run_cli", lambda: 0)
|
||||||
|
_patch_tui_seeds(monkeypatch)
|
||||||
|
|
||||||
|
main_module.main() # must not raise SystemExit
|
||||||
|
|
||||||
|
assert _FakeMainMenu.instances == []
|
||||||
|
|
||||||
|
def test_cli_failure_exits_nonzero(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
# rc > 0 means the CLI reported a failure; main must propagate via sys.exit.
|
||||||
|
monkeypatch.setattr(main_module, "run_cli", lambda: 2)
|
||||||
|
_patch_tui_seeds(monkeypatch)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
main_module.main()
|
||||||
|
|
||||||
|
assert exc.value.code == 2
|
||||||
|
assert _FakeMainMenu.instances == []
|
||||||
|
|
||||||
|
def test_tui_keyboard_interrupt_is_absorbed(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
# A Ctrl-C while the TUI runs must be caught and printed friendly
|
||||||
|
# instead of crashing with a traceback.
|
||||||
|
class _InterruptingMenu(_FakeMainMenu):
|
||||||
|
def run(self) -> None:
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_module, "run_cli", lambda: -1)
|
||||||
|
monkeypatch.setattr(main_module, "KiauhSettings", lambda: None)
|
||||||
|
monkeypatch.setattr(main_module, "ensure_encoding", lambda: None)
|
||||||
|
monkeypatch.setattr(main_module, "MainMenu", _InterruptingMenu)
|
||||||
|
|
||||||
|
main_module.main() # must not raise; KeyboardInterrupt is absorbed
|
||||||
Reference in New Issue
Block a user