mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 18:41:50 +01:00
409 lines
20 KiB
Python
409 lines
20 KiB
Python
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
|
|
|
"""Unit tests for workspace/path resolution."""
|
|
|
|
import io
|
|
import re
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from kleinanzeigen_bot.utils import xdg_paths
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
class TestGetXdgBaseDir:
|
|
def test_returns_state_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
state_dir = tmp_path / "state"
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(state_dir / app_name))
|
|
|
|
resolved = xdg_paths.get_xdg_base_dir("state")
|
|
|
|
assert resolved == state_dir / "kleinanzeigen-bot"
|
|
|
|
def test_raises_for_unknown_category(self) -> None:
|
|
with pytest.raises(ValueError, match = "Unsupported XDG category"):
|
|
xdg_paths.get_xdg_base_dir("invalid") # type: ignore[arg-type]
|
|
|
|
def test_raises_when_base_dir_is_none(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: None)
|
|
|
|
with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory for category: state"):
|
|
xdg_paths.get_xdg_base_dir("state")
|
|
|
|
|
|
class TestDetectInstallationMode:
|
|
def test_detects_portable_mode_when_config_exists_in_cwd(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "config.yaml").touch()
|
|
|
|
assert xdg_paths.detect_installation_mode() == "portable"
|
|
|
|
def test_detects_xdg_mode_when_config_exists_in_xdg_location(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
xdg_config = tmp_path / "config" / "kleinanzeigen-bot"
|
|
xdg_config.mkdir(parents = True)
|
|
(xdg_config / "config.yaml").touch()
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "config" / app_name))
|
|
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
assert xdg_paths.detect_installation_mode() == "xdg"
|
|
|
|
def test_returns_none_when_no_config_found(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg" / app_name))
|
|
|
|
assert xdg_paths.detect_installation_mode() is None
|
|
|
|
|
|
class TestPromptInstallationMode:
|
|
@pytest.fixture(autouse = True)
|
|
def _force_identity_translation(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(xdg_paths, "_", lambda message: message)
|
|
|
|
def test_returns_portable_for_non_interactive_mode(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", None)
|
|
assert xdg_paths.prompt_installation_mode() == "portable"
|
|
|
|
def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
mock_stdin = io.StringIO()
|
|
mock_stdin.isatty = lambda: False # type: ignore[method-assign]
|
|
monkeypatch.setattr("sys.stdin", mock_stdin)
|
|
|
|
assert xdg_paths.prompt_installation_mode() == "portable"
|
|
|
|
def test_returns_portable_when_user_enters_1(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
mock_stdin = io.StringIO()
|
|
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
|
monkeypatch.setattr("sys.stdin", mock_stdin)
|
|
monkeypatch.setattr("builtins.input", lambda _: "1")
|
|
|
|
assert xdg_paths.prompt_installation_mode() == "portable"
|
|
|
|
def test_returns_xdg_when_user_enters_2(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
|
|
mock_stdin = io.StringIO()
|
|
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
|
monkeypatch.setattr("sys.stdin", mock_stdin)
|
|
monkeypatch.setattr("builtins.input", lambda _: "2")
|
|
|
|
mode = xdg_paths.prompt_installation_mode()
|
|
|
|
assert mode == "xdg"
|
|
captured = capsys.readouterr()
|
|
assert "Choose installation type:" in captured.out
|
|
assert "[2] User directories" in captured.out
|
|
|
|
def test_reprompts_on_invalid_input_then_accepts_valid(
|
|
self,
|
|
monkeypatch:pytest.MonkeyPatch,
|
|
capsys:pytest.CaptureFixture[str],
|
|
) -> None:
|
|
mock_stdin = io.StringIO()
|
|
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
|
monkeypatch.setattr("sys.stdin", mock_stdin)
|
|
inputs = iter(["invalid", "2"])
|
|
monkeypatch.setattr("builtins.input", lambda _: next(inputs))
|
|
|
|
mode = xdg_paths.prompt_installation_mode()
|
|
|
|
assert mode == "xdg"
|
|
captured = capsys.readouterr()
|
|
assert "Invalid choice. Please enter 1 or 2." in captured.out
|
|
|
|
def test_returns_portable_on_eof_error(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
mock_stdin = io.StringIO()
|
|
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
|
monkeypatch.setattr("sys.stdin", mock_stdin)
|
|
|
|
def raise_eof(_prompt:str) -> str:
|
|
raise EOFError
|
|
|
|
monkeypatch.setattr("builtins.input", raise_eof)
|
|
|
|
assert xdg_paths.prompt_installation_mode() == "portable"
|
|
|
|
def test_returns_portable_on_keyboard_interrupt(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
mock_stdin = io.StringIO()
|
|
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
|
monkeypatch.setattr("sys.stdin", mock_stdin)
|
|
|
|
def raise_keyboard_interrupt(_prompt:str) -> str:
|
|
raise KeyboardInterrupt
|
|
|
|
monkeypatch.setattr("builtins.input", raise_keyboard_interrupt)
|
|
|
|
assert xdg_paths.prompt_installation_mode() == "portable"
|
|
|
|
|
|
class TestWorkspace:
|
|
def test_ensure_directory_raises_when_target_is_not_directory(self, tmp_path:Path) -> None:
|
|
target = tmp_path / "created"
|
|
|
|
with patch.object(Path, "is_dir", return_value = False), pytest.raises(NotADirectoryError, match = re.escape(str(target))):
|
|
xdg_paths.ensure_directory(target, "test directory")
|
|
|
|
def test_for_config_derives_portable_layout(self, tmp_path:Path) -> None:
|
|
config_file = tmp_path / "custom" / "config.yaml"
|
|
ws = xdg_paths.Workspace.for_config(config_file, "mybot")
|
|
|
|
assert ws.config_file == config_file.resolve()
|
|
assert ws.config_dir == config_file.parent.resolve()
|
|
assert ws.log_file == config_file.parent.resolve() / "mybot.log"
|
|
assert ws.state_dir == config_file.parent.resolve() / ".temp"
|
|
assert ws.download_dir == config_file.parent.resolve() / "downloaded-ads"
|
|
assert ws.browser_profile_dir == config_file.parent.resolve() / ".temp" / "browser-profile"
|
|
assert ws.diagnostics_dir == config_file.parent.resolve() / ".temp" / "diagnostics"
|
|
|
|
def test_resolve_workspace_uses_config_arg(self, tmp_path:Path) -> None:
|
|
config_path = tmp_path / "cfg" / "config.yaml"
|
|
|
|
ws = xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = None,
|
|
workspace_mode = "portable",
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
|
|
assert ws.config_file == config_path.resolve()
|
|
assert ws.log_file == config_path.parent.resolve() / "kleinanzeigen-bot.log"
|
|
|
|
def test_resolve_workspace_uses_detected_xdg_layout(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(xdg_paths, "detect_installation_mode", lambda: "xdg")
|
|
monkeypatch.setattr(
|
|
xdg_paths,
|
|
"get_xdg_base_dir",
|
|
lambda category: {
|
|
"config": tmp_path / "xdg-config" / xdg_paths.APP_NAME,
|
|
"state": tmp_path / "xdg-state" / xdg_paths.APP_NAME,
|
|
"cache": tmp_path / "xdg-cache" / xdg_paths.APP_NAME,
|
|
}[category],
|
|
)
|
|
|
|
ws = xdg_paths.resolve_workspace(None, None, workspace_mode = None, logfile_explicitly_provided = False, log_basename = "kleinanzeigen-bot")
|
|
|
|
assert ws.config_file == (tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").resolve()
|
|
assert ws.log_file == (tmp_path / "xdg-state" / xdg_paths.APP_NAME / "kleinanzeigen-bot.log").resolve()
|
|
assert ws.state_dir == (tmp_path / "xdg-state" / xdg_paths.APP_NAME).resolve()
|
|
assert ws.browser_profile_dir == (tmp_path / "xdg-cache" / xdg_paths.APP_NAME / "browser-profile").resolve()
|
|
assert ws.diagnostics_dir == (tmp_path / "xdg-cache" / xdg_paths.APP_NAME / "diagnostics").resolve()
|
|
|
|
def test_resolve_workspace_first_run_uses_prompt_choice(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(xdg_paths, "detect_installation_mode", lambda: None)
|
|
monkeypatch.setattr(xdg_paths, "prompt_installation_mode", lambda: "portable")
|
|
|
|
ws = xdg_paths.resolve_workspace(None, None, workspace_mode = None, logfile_explicitly_provided = False, log_basename = "kleinanzeigen-bot")
|
|
|
|
assert ws.config_file == (Path.cwd() / "config.yaml").resolve()
|
|
|
|
def test_resolve_workspace_honors_logfile_override(self, tmp_path:Path) -> None:
|
|
config_path = tmp_path / "cfg" / "config.yaml"
|
|
explicit_log = tmp_path / "logs" / "my.log"
|
|
|
|
ws = xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = str(explicit_log),
|
|
workspace_mode = "portable",
|
|
logfile_explicitly_provided = True,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
|
|
assert ws.log_file == explicit_log.resolve()
|
|
|
|
def test_resolve_workspace_disables_logfile_when_empty_flag(self, tmp_path:Path) -> None:
|
|
ws = xdg_paths.resolve_workspace(
|
|
config_arg = str(tmp_path / "config.yaml"),
|
|
logfile_arg = "",
|
|
workspace_mode = "portable",
|
|
logfile_explicitly_provided = True,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
|
|
assert ws.log_file is None
|
|
|
|
def test_resolve_workspace_fails_when_config_mode_is_ambiguous(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
config_path = tmp_path / "cfg" / "config.yaml"
|
|
config_path.parent.mkdir(parents = True, exist_ok = True)
|
|
config_path.touch()
|
|
(config_path.parent / ".temp").mkdir(parents = True, exist_ok = True)
|
|
|
|
cwd_config = tmp_path / "cwd" / "config.yaml"
|
|
cwd_config.parent.mkdir(parents = True, exist_ok = True)
|
|
cwd_config.touch()
|
|
monkeypatch.chdir(cwd_config.parent)
|
|
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
|
|
(tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").parent.mkdir(parents = True, exist_ok = True)
|
|
(tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").touch()
|
|
|
|
with pytest.raises(ValueError, match = "Detected both portable and XDG footprints") as exc_info:
|
|
xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = None,
|
|
workspace_mode = None,
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
assert str((config_path.parent / ".temp").resolve()) in str(exc_info.value)
|
|
assert str((tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").resolve()) in str(exc_info.value)
|
|
|
|
def test_resolve_workspace_detects_portable_mode_from_custom_config_footprint(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
config_path = tmp_path / "cfg" / "config.yaml"
|
|
config_path.parent.mkdir(parents = True, exist_ok = True)
|
|
config_path.touch()
|
|
(config_path.parent / ".temp").mkdir(parents = True, exist_ok = True)
|
|
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
|
|
|
|
ws = xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = None,
|
|
workspace_mode = None,
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
|
|
assert ws.mode == "portable"
|
|
assert ws.config_file == config_path.resolve()
|
|
assert ws.state_dir == (config_path.parent / ".temp").resolve()
|
|
|
|
def test_resolve_workspace_detects_xdg_mode_from_xdg_footprint(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
|
|
xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME
|
|
xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME
|
|
xdg_config_dir.mkdir(parents = True, exist_ok = True)
|
|
xdg_cache_dir.mkdir(parents = True, exist_ok = True)
|
|
xdg_state_dir.mkdir(parents = True, exist_ok = True)
|
|
(xdg_cache_dir / "browser-profile").mkdir(parents = True, exist_ok = True)
|
|
(xdg_config_dir / "downloaded-ads").mkdir(parents = True, exist_ok = True)
|
|
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
|
|
|
|
config_path = xdg_config_dir / "config-alt.yaml"
|
|
config_path.touch()
|
|
|
|
ws = xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = None,
|
|
workspace_mode = None,
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
|
|
assert ws.mode == "xdg"
|
|
assert ws.config_file == config_path.resolve()
|
|
assert ws.browser_profile_dir == (xdg_cache_dir / "browser-profile").resolve()
|
|
|
|
def test_detect_mode_from_footprints_collects_portable_and_xdg_hit_paths(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.touch()
|
|
(tmp_path / "downloaded-ads").mkdir(parents = True, exist_ok = True)
|
|
|
|
xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
|
|
xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME
|
|
xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME
|
|
xdg_config_dir.mkdir(parents = True, exist_ok = True)
|
|
xdg_cache_dir.mkdir(parents = True, exist_ok = True)
|
|
xdg_state_dir.mkdir(parents = True, exist_ok = True)
|
|
(xdg_cache_dir / "diagnostics").mkdir(parents = True, exist_ok = True)
|
|
(xdg_state_dir / "update_check_state.json").touch()
|
|
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
|
|
|
|
detected_mode, portable_hits, xdg_hits = xdg_paths._detect_mode_from_footprints_with_hits(config_path) # noqa: SLF001
|
|
|
|
assert detected_mode == "ambiguous"
|
|
assert config_path.resolve() in portable_hits
|
|
assert (tmp_path / "downloaded-ads").resolve() in portable_hits
|
|
assert (xdg_cache_dir / "diagnostics").resolve() in xdg_hits
|
|
assert (xdg_state_dir / "update_check_state.json").resolve() in xdg_hits
|
|
|
|
def test_resolve_workspace_ignores_unrelated_cwd_config_when_config_is_elsewhere(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir(parents = True, exist_ok = True)
|
|
(cwd / "config.yaml").touch()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
|
|
xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME
|
|
xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME
|
|
xdg_config_dir.mkdir(parents = True, exist_ok = True)
|
|
xdg_cache_dir.mkdir(parents = True, exist_ok = True)
|
|
xdg_state_dir.mkdir(parents = True, exist_ok = True)
|
|
(xdg_config_dir / "config.yaml").touch()
|
|
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
|
|
|
|
custom_config = tmp_path / "external" / "config.yaml"
|
|
custom_config.parent.mkdir(parents = True, exist_ok = True)
|
|
custom_config.touch()
|
|
|
|
ws = xdg_paths.resolve_workspace(
|
|
config_arg = str(custom_config),
|
|
logfile_arg = None,
|
|
workspace_mode = None,
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
|
|
assert ws.mode == "xdg"
|
|
|
|
def test_resolve_workspace_fails_when_config_mode_is_unknown(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
|
config_path = tmp_path / "cfg" / "config.yaml"
|
|
config_path.parent.mkdir(parents = True, exist_ok = True)
|
|
config_path.touch()
|
|
(tmp_path / "cwd").mkdir(parents = True, exist_ok = True)
|
|
monkeypatch.chdir(tmp_path / "cwd")
|
|
|
|
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
|
|
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
|
|
|
|
with pytest.raises(ValueError, match = "Detected neither portable nor XDG footprints") as exc_info:
|
|
xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = None,
|
|
workspace_mode = None,
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|
|
assert "Portable footprint hits: none" in str(exc_info.value)
|
|
assert "XDG footprint hits: none" in str(exc_info.value)
|
|
|
|
def test_resolve_workspace_raises_when_config_path_is_unresolved(self, tmp_path:Path) -> None:
|
|
config_path = (tmp_path / "config.yaml").resolve()
|
|
original_resolve = Path.resolve
|
|
|
|
def patched_resolve(self:Path, strict:bool = False) -> object:
|
|
if self == config_path:
|
|
return None
|
|
return original_resolve(self, strict)
|
|
|
|
with patch.object(Path, "resolve", patched_resolve), pytest.raises(
|
|
RuntimeError, match = "Workspace mode and config path must be resolved"
|
|
):
|
|
xdg_paths.resolve_workspace(
|
|
config_arg = str(config_path),
|
|
logfile_arg = None,
|
|
workspace_mode = "portable",
|
|
logfile_explicitly_provided = False,
|
|
log_basename = "kleinanzeigen-bot",
|
|
)
|