[ADD] script todo : Starship depuis le menu Git, renommé Git et Shell
Poser le binaire ne change pas le prompt : c'est la ligne d'initialisation dans le fichier du shell qui le fait, et les deux étapes échouent séparément. Le paquet vient de la distribution quand elle le connaît, de l'installateur amont sinon — starship n'est pas empaqueté partout. Le fichier du shell n'est demandé que devant plusieurs candidats, et la ligne ne s'écrit qu'une fois. L'entrée porte sa destination dans « method » : son rang suit le nombre d'entrées de todo.json, qu'un numéro codé en dur ignorerait. Vérifié : 18 tests. --- EN --- Laying down the binary does not change the prompt: the init line in the shell file does, and the two steps fail separately. The package comes from the distribution when it knows it, from the upstream installer otherwise — starship is not packaged everywhere. The shell file is only asked for when several candidates exist, and the line is written only once. The entry carries its destination in « method »: its rank follows the number of todo.json entries, which a hard-coded number would ignore. Checked: 18 tests. Assisted-by: Claude Opus 5
This commit is contained in:
parent
d31ca9eab9
commit
b321052888
4 changed files with 465 additions and 14 deletions
|
|
@ -26,7 +26,7 @@ sys.path.append(new_path)
|
|||
|
||||
from script.config import config_file
|
||||
from script.execute import execute
|
||||
from script.todo import todo_prefs
|
||||
from script.todo import todo_install, todo_prefs
|
||||
from script.todo.database_manager import DatabaseManager
|
||||
from script.todo.longtest_menu import LongTestMenuMixin
|
||||
from script.todo.proxmox_menu import ProxmoxMenuMixin
|
||||
|
|
@ -256,7 +256,7 @@ class TODO(
|
|||
[7] {t("Analyse - Odoo database analysis")}
|
||||
|
||||
── {t("Sources & documentation")} ──
|
||||
[8] {t("Git - Git tools")}
|
||||
[8] {t("Git - Git and shell tools")}
|
||||
[9] {t("Doc - Documentation search")}
|
||||
|
||||
── {t("AI & automation")} ──
|
||||
|
|
@ -2530,7 +2530,7 @@ class TODO(
|
|||
_GIT_HOOKS_PATH = os.path.join("script", "git", "hooks")
|
||||
|
||||
def prompt_execute_git(self):
|
||||
print(f"🤖 {t('Git management tools!')}")
|
||||
print(f"🤖 {t('Git and shell management tools!')}")
|
||||
choices = [
|
||||
{"prompt_description": t("Local git server")},
|
||||
{"prompt_description": t("Add a remote to a local repository")},
|
||||
|
|
@ -2551,6 +2551,17 @@ class TODO(
|
|||
if config_entries:
|
||||
choices.extend(config_entries)
|
||||
|
||||
# Starship ferme la liste : c'est un outil de shell, pas de git. Son
|
||||
# rang dépend du nombre d'entrées venues de todo.json, donc « method »
|
||||
# porte la destination dans l'entrée elle-même — un numéro codé en dur
|
||||
# mènerait ailleurs dès qu'une entrée de configuration s'ajoute.
|
||||
choices.append(
|
||||
{
|
||||
"prompt_description": t("Install Starship on Shell"),
|
||||
"method": "_shell_install_starship",
|
||||
}
|
||||
)
|
||||
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
while True:
|
||||
|
|
@ -2573,7 +2584,11 @@ class TODO(
|
|||
if 0 < int_cmd <= len(choices):
|
||||
cmd_no_found = False
|
||||
instance = choices[int_cmd - 1]
|
||||
self.execute_from_configuration(instance)
|
||||
method = instance.get("method")
|
||||
if method:
|
||||
getattr(self, method)()
|
||||
else:
|
||||
self.execute_from_configuration(instance)
|
||||
except ValueError:
|
||||
pass
|
||||
if cmd_no_found:
|
||||
|
|
@ -2675,6 +2690,137 @@ class TODO(
|
|||
)
|
||||
print(f"✅ merge.conflictStyle = {value}")
|
||||
|
||||
# Le shell -> (fichier de configuration, ligne d'initialisation). La ligne
|
||||
# va en FIN de fichier : starship compose le prompt et doit passer après
|
||||
# tout ce qui y touche.
|
||||
_STARSHIP_SHELLS = {
|
||||
"bash": ("~/.bashrc", 'eval "$(starship init bash)"'),
|
||||
"zsh": ("~/.zshrc", 'eval "$(starship init zsh)"'),
|
||||
"fish": ("~/.config/fish/config.fish", "starship init fish | source"),
|
||||
}
|
||||
|
||||
# L'installateur amont pose un binaire statique. Il sert de recours parce
|
||||
# que le paquet manque d'une partie des dépôts des plateformes supportées.
|
||||
_STARSHIP_UPSTREAM = "curl -sS https://starship.rs/install/install.sh | sh"
|
||||
|
||||
@staticmethod
|
||||
def _shell_name():
|
||||
"""Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide."""
|
||||
return os.path.basename(os.environ.get("SHELL", "")).strip()
|
||||
|
||||
def _shell_install_starship(self):
|
||||
"""Poser starship, puis l'accrocher au shell de l'utilisateur.
|
||||
|
||||
Deux étapes qui échouent séparément : le binaire, que le gestionnaire
|
||||
de paquets de la distribution fournit quand il le connaît, et la ligne
|
||||
d'initialisation dans le fichier de configuration du shell. Sans la
|
||||
seconde, starship est installé et le prompt ne change pas.
|
||||
"""
|
||||
if shutil.which("starship") is None:
|
||||
self._shell_install_starship_binary()
|
||||
if shutil.which("starship") is None:
|
||||
print(
|
||||
f"❌ {t('starship is not installed, shell left untouched.')}"
|
||||
)
|
||||
return
|
||||
self._shell_hook_starship()
|
||||
|
||||
def _shell_install_starship_binary(self):
|
||||
"""Poser le binaire : le paquet de la distribution, sinon l'amont.
|
||||
|
||||
Un refus de l'opérateur arrête là. Un paquet inconnu ou une
|
||||
installation en échec passent au recours amont, qui couvre les dépôts
|
||||
où starship n'est pas empaqueté.
|
||||
"""
|
||||
cmd = todo_install.install_command(["starship"])
|
||||
if cmd:
|
||||
status = todo_install.ask_and_install(
|
||||
self.execute,
|
||||
cmd,
|
||||
t("Install starship? (y/N): "),
|
||||
self._is_yes,
|
||||
)
|
||||
if status is None:
|
||||
return
|
||||
if status == 0 and shutil.which("starship"):
|
||||
return
|
||||
print(f" {t('No starship package here, falling back upstream.')}")
|
||||
todo_install.ask_and_install(
|
||||
self.execute,
|
||||
self._STARSHIP_UPSTREAM,
|
||||
t("Run the upstream installer? (y/N): "),
|
||||
self._is_yes,
|
||||
)
|
||||
|
||||
def _shell_starship_present(self):
|
||||
"""Les shells dont le fichier de configuration existe déjà."""
|
||||
return [
|
||||
nom
|
||||
for nom, (fichier, _ligne) in self._STARSHIP_SHELLS.items()
|
||||
if os.path.exists(os.path.expanduser(fichier))
|
||||
]
|
||||
|
||||
def _shell_starship_target(self):
|
||||
"""Le shell à accrocher. Ne demande que devant un vrai choix.
|
||||
|
||||
Aucun fichier de configuration présent : bash, sans question — la pose
|
||||
le créera. Un seul présent : celui-là, il n'y a rien à choisir. Deux
|
||||
ou trois : à l'opérateur de trancher, le sien proposé par défaut.
|
||||
"""
|
||||
presents = self._shell_starship_present()
|
||||
if not presents:
|
||||
return "bash"
|
||||
if len(presents) == 1:
|
||||
return presents[0]
|
||||
courant = self._shell_name()
|
||||
defaut = courant if courant in presents else presents[0]
|
||||
print(f"\n{t('Which shell configuration?')}")
|
||||
for i, nom in enumerate(presents, 1):
|
||||
print(f" [{i}] {nom:<5} {self._STARSHIP_SHELLS[nom][0]}")
|
||||
sel = input(
|
||||
f"{t('Choice (number or name, default:')} {defaut}) : "
|
||||
).strip()
|
||||
if not sel:
|
||||
return defaut
|
||||
if sel in presents:
|
||||
return sel
|
||||
try:
|
||||
idx = int(sel) - 1
|
||||
if 0 <= idx < len(presents):
|
||||
return presents[idx]
|
||||
except ValueError:
|
||||
pass
|
||||
return defaut
|
||||
|
||||
def _shell_hook_starship(self):
|
||||
"""Ajouter la ligne d'initialisation au fichier du shell choisi.
|
||||
|
||||
La ligne n'est écrite qu'une fois : « starship init » cherché dans le
|
||||
fichier couvre les trois shells, dont les lignes diffèrent. L'écriture
|
||||
ne demande pas de confirmation — le choix du fichier, quand il y en a
|
||||
un à faire, l'a déjà donnée.
|
||||
"""
|
||||
shell = self._shell_starship_target()
|
||||
chemin, ligne = self._STARSHIP_SHELLS[shell]
|
||||
chemin = os.path.expanduser(chemin)
|
||||
contenu = ""
|
||||
if os.path.exists(chemin):
|
||||
with open(chemin, encoding="utf-8") as fh:
|
||||
contenu = fh.read()
|
||||
if "starship init" in contenu:
|
||||
print(f"✅ {t('starship is already hooked into: ')}{chemin}")
|
||||
return
|
||||
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
||||
with open(chemin, "a", encoding="utf-8") as fh:
|
||||
# Un fichier qui ne finit pas par un saut de ligne collerait la
|
||||
# ligne d'init à la dernière commande.
|
||||
if contenu and not contenu.endswith("\n"):
|
||||
fh.write("\n")
|
||||
fh.write(f"{ligne}\n")
|
||||
print(f"✅ {t('starship hooked into: ')}{chemin}")
|
||||
print(f" {ligne}")
|
||||
print(f" {t('Open a new shell to see it.')}")
|
||||
|
||||
def prompt_execute_git_local_server(self):
|
||||
print(f"🤖 {t('Manage local git repository server!')}")
|
||||
choices = [
|
||||
|
|
|
|||
|
|
@ -821,13 +821,13 @@ TRANSLATIONS = {
|
|||
"en": "Module name is required!",
|
||||
},
|
||||
# Git section
|
||||
"Git - Git tools": {
|
||||
"fr": "🌿 Git - Outils Git",
|
||||
"en": "🌿 Git - Git tools",
|
||||
"Git - Git and shell tools": {
|
||||
"fr": "🌿 Git - Outils Git et Shell",
|
||||
"en": "🌿 Git - Git and shell tools",
|
||||
},
|
||||
"Git management tools!": {
|
||||
"fr": "Outils de gestion Git!",
|
||||
"en": "Git management tools!",
|
||||
"Git and shell management tools!": {
|
||||
"fr": "Outils de gestion Git et Shell!",
|
||||
"en": "Git and shell management tools!",
|
||||
},
|
||||
"Local git server": {
|
||||
"fr": "Serveur git local",
|
||||
|
|
@ -903,6 +903,43 @@ TRANSLATIONS = {
|
|||
"en": "Error adding remote: ",
|
||||
},
|
||||
# Git hooks
|
||||
# Starship
|
||||
"Install Starship on Shell": {
|
||||
"fr": "🚀 Installer Starship sur le shell",
|
||||
"en": "🚀 Install Starship on Shell",
|
||||
},
|
||||
"Install starship? (y/N): ": {
|
||||
"fr": "Installer starship ? (o/N) : ",
|
||||
"en": "Install starship? (y/N): ",
|
||||
},
|
||||
"No starship package here, falling back upstream.": {
|
||||
"fr": "Aucun paquet starship ici, recours à l'installateur amont.",
|
||||
"en": "No starship package here, falling back upstream.",
|
||||
},
|
||||
"Run the upstream installer? (y/N): ": {
|
||||
"fr": "Lancer l'installateur amont ? (o/N) : ",
|
||||
"en": "Run the upstream installer? (y/N): ",
|
||||
},
|
||||
"starship is not installed, shell left untouched.": {
|
||||
"fr": "starship n'est pas installé, le shell reste intact.",
|
||||
"en": "starship is not installed, shell left untouched.",
|
||||
},
|
||||
"Which shell configuration?": {
|
||||
"fr": "Quel fichier de configuration de shell ?",
|
||||
"en": "Which shell configuration?",
|
||||
},
|
||||
"starship is already hooked into: ": {
|
||||
"fr": "starship est déjà accroché à : ",
|
||||
"en": "starship is already hooked into: ",
|
||||
},
|
||||
"starship hooked into: ": {
|
||||
"fr": "starship accroché à : ",
|
||||
"en": "starship hooked into: ",
|
||||
},
|
||||
"Open a new shell to see it.": {
|
||||
"fr": "Ouvrir un nouveau shell pour le voir.",
|
||||
"en": "Open a new shell to see it.",
|
||||
},
|
||||
"Set merge.conflictStyle to zdiff3 (global)": {
|
||||
"fr": "Configurer merge.conflictStyle en zdiff3 (global)",
|
||||
"en": "Set merge.conflictStyle to zdiff3 (global)",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ class MenuCoherence:
|
|||
todo.py), ce socle sert DEUX menus : QEMU/KVM et Proxmox. Un troisième
|
||||
n'aura qu'à déclarer ses quatre attributs.
|
||||
|
||||
Une entrée peut aussi porter sa destination dans « method » plutôt que
|
||||
dans un « elif status » numéroté. Elle échappe alors à la renumérotation
|
||||
par construction, et EXPECTED la vérifie contre cette clé.
|
||||
|
||||
À déclarer par la sous-classe : SOURCE (le fichier), ENTRY (la ligne
|
||||
« def prompt_execute_… »), END (le membre suivant, qui borne la lecture) et
|
||||
EXPECTED (où mène chaque entrée, par le début de son libellé).
|
||||
|
|
@ -170,18 +174,33 @@ class MenuCoherence:
|
|||
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
|
||||
r"\s*(?:status = )?self\.(\w+)\("
|
||||
)
|
||||
# Une entrée qui porte sa destination dans « method » se dispatche seule,
|
||||
# par le repli générique. Elle n'a pas de numéro dans le code, donc aucune
|
||||
# renumérotation ne peut la désaligner : c'est le seul moyen de placer une
|
||||
# entrée codée en dur APRÈS des entrées venues de la configuration, dont
|
||||
# le nombre n'est pas connu à la lecture du source.
|
||||
RE_SELF_DISPATCH = re.compile(
|
||||
r'"prompt_description": t\(\s*\n?\s*"([^"]+)"\s*\)?,?\s*\n'
|
||||
r'\s*"method": "(\w+)"'
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
source = self.SOURCE.read_text(encoding="utf-8")
|
||||
start = source.index(self.ENTRY)
|
||||
end = source.index(self.END, start)
|
||||
self.body = source[start:end]
|
||||
self.self_dispatch = dict(self.RE_SELF_DISPATCH.findall(self.body))
|
||||
num = 0
|
||||
self.shown = []
|
||||
for kind, label in self.RE_ENTRY.findall(self.body):
|
||||
if kind == "prompt_description":
|
||||
num += 1
|
||||
self.shown.append((num, label))
|
||||
self.numbered = [
|
||||
(n, label)
|
||||
for n, label in self.shown
|
||||
if label not in self.self_dispatch
|
||||
]
|
||||
self.dispatch = [
|
||||
(int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body)
|
||||
]
|
||||
|
|
@ -189,7 +208,7 @@ class MenuCoherence:
|
|||
def test_the_menu_was_actually_parsed(self):
|
||||
"""Sur une liste vide, tout test passe : mieux vaut tomber ici."""
|
||||
self.assertGreater(len(self.shown), self.MINIMUM)
|
||||
self.assertEqual(len(self.shown), len(self.dispatch))
|
||||
self.assertEqual(len(self.numbered), len(self.dispatch))
|
||||
|
||||
def test_numbering_is_contiguous_from_one(self):
|
||||
self.assertEqual(
|
||||
|
|
@ -199,7 +218,7 @@ class MenuCoherence:
|
|||
|
||||
def test_every_shown_entry_has_the_matching_dispatch(self):
|
||||
self.assertEqual(
|
||||
[n for n, _ in self.shown], [n for n, _ in self.dispatch]
|
||||
[n for n, _ in self.numbered], [n for n, _ in self.dispatch]
|
||||
)
|
||||
|
||||
def _key(self, label):
|
||||
|
|
@ -218,10 +237,11 @@ class MenuCoherence:
|
|||
f"entrée [{num}] « {label} » absente d'EXPECTED :"
|
||||
" déclarez où elle mène",
|
||||
)
|
||||
atteint = self.self_dispatch.get(label, dct.get(num))
|
||||
self.assertEqual(
|
||||
dct.get(num),
|
||||
atteint,
|
||||
self.EXPECTED[key],
|
||||
f"[{num}] « {label} » mène à {dct.get(num)}"
|
||||
f"[{num}] « {label} » mène à {atteint}"
|
||||
f" au lieu de {self.EXPECTED[key]}",
|
||||
)
|
||||
|
||||
|
|
@ -229,6 +249,16 @@ class MenuCoherence:
|
|||
keys = {self._key(label) for _, label in self.shown}
|
||||
self.assertEqual(set(self.EXPECTED) - keys, set())
|
||||
|
||||
def test_self_dispatched_entries_name_a_real_method(self):
|
||||
"""« method » est une chaîne : rien ne la relie au code sans ceci."""
|
||||
from script.todo.todo import TODO
|
||||
|
||||
for label, method in self.self_dispatch.items():
|
||||
self.assertTrue(
|
||||
hasattr(TODO, method),
|
||||
f"« {label} » mène à {method}, qui n'existe pas",
|
||||
)
|
||||
|
||||
|
||||
class TestLaParitéProxmox(unittest.TestCase):
|
||||
"""Deux manques signalés par l'audit du découpage, comblés.
|
||||
|
|
@ -468,6 +498,7 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase):
|
|||
"Add a remote to a local repository": "_git_add_remote",
|
||||
"Install git hooks": "_git_install_hooks",
|
||||
"Set merge.conflictStyle": "_git_set_conflict_style",
|
||||
"Install Starship on Shell": "_shell_install_starship",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
237
test/test_todo_starship.py
Normal file
237
test/test_todo_starship.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Installer starship : deux étapes qui échouent séparément.
|
||||
|
||||
Poser le binaire ne change pas le prompt ; c'est la ligne d'initialisation
|
||||
dans le fichier du shell qui le fait. Un menu qui confond les deux annonce un
|
||||
succès devant un prompt inchangé, ou réécrit la ligne à chaque passage.
|
||||
|
||||
Le fichier à modifier ne se demande que devant un vrai choix : plusieurs
|
||||
fichiers de configuration présents. Aucun, ou un seul, ne laisse rien à
|
||||
trancher — une question posée là n'attend qu'une frappe pour rien.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from script.todo.todo import TODO
|
||||
|
||||
LIGNE_BASH = 'eval "$(starship init bash)"'
|
||||
LIGNE_ZSH = 'eval "$(starship init zsh)"'
|
||||
|
||||
|
||||
def refuse_input(*args, **kwargs):
|
||||
raise AssertionError("aucune question ne devait être posée")
|
||||
|
||||
|
||||
class TestShellName(unittest.TestCase):
|
||||
"""Le shell se lit dans $SHELL, dont seul le nom de base compte."""
|
||||
|
||||
def test_basename_of_shell(self):
|
||||
with patch.dict(os.environ, {"SHELL": "/usr/bin/zsh"}):
|
||||
self.assertEqual(TODO._shell_name(), "zsh")
|
||||
|
||||
def test_empty_when_unset(self):
|
||||
with patch.dict(os.environ, {"SHELL": ""}):
|
||||
self.assertEqual(TODO._shell_name(), "")
|
||||
|
||||
|
||||
class ShellFixture(unittest.TestCase):
|
||||
"""Un faux HOME où l'on pose les fichiers de configuration voulus."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
racine = Path(self.tmp.name)
|
||||
self.fichiers = {
|
||||
"bash": racine / "bashrc",
|
||||
"zsh": racine / "zshrc",
|
||||
"fish": racine / "fish" / "config.fish",
|
||||
}
|
||||
table = {
|
||||
"bash": (str(self.fichiers["bash"]), LIGNE_BASH),
|
||||
"zsh": (str(self.fichiers["zsh"]), LIGNE_ZSH),
|
||||
"fish": (
|
||||
str(self.fichiers["fish"]),
|
||||
"starship init fish | source",
|
||||
),
|
||||
}
|
||||
patcher = patch.dict(TODO._STARSHIP_SHELLS, table, clear=True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.todo = TODO()
|
||||
|
||||
def cree(self, *shells, contenu=""):
|
||||
for shell in shells:
|
||||
chemin = self.fichiers[shell]
|
||||
chemin.parent.mkdir(parents=True, exist_ok=True)
|
||||
chemin.write_text(contenu, encoding="utf-8")
|
||||
|
||||
|
||||
class TestStarshipTarget(ShellFixture):
|
||||
"""Quel fichier, et quand la question se pose."""
|
||||
|
||||
def choisit(self, shell_env="/bin/bash", reponse=None):
|
||||
entree = refuse_input if reponse is None else (lambda *a: reponse)
|
||||
with patch.dict(os.environ, {"SHELL": shell_env}), patch(
|
||||
"builtins.input", entree
|
||||
), redirect_stdout(io.StringIO()):
|
||||
return self.todo._shell_starship_target()
|
||||
|
||||
def test_no_file_means_bash_without_asking(self):
|
||||
self.assertEqual(self.choisit(), "bash")
|
||||
|
||||
def test_a_single_file_is_taken_without_asking(self):
|
||||
self.cree("zsh")
|
||||
self.assertEqual(self.choisit(), "zsh")
|
||||
|
||||
def test_a_single_file_wins_over_the_current_shell(self):
|
||||
"""Le fichier présent l'emporte : $SHELL n'en a pas, lui."""
|
||||
self.cree("fish")
|
||||
self.assertEqual(self.choisit(shell_env="/bin/bash"), "fish")
|
||||
|
||||
def test_several_files_ask_and_default_to_the_current_shell(self):
|
||||
self.cree("bash", "zsh")
|
||||
self.assertEqual(
|
||||
self.choisit(shell_env="/usr/bin/zsh", reponse=""), "zsh"
|
||||
)
|
||||
|
||||
def test_several_files_answered_by_number(self):
|
||||
self.cree("bash", "zsh")
|
||||
self.assertEqual(self.choisit(reponse="2"), "zsh")
|
||||
|
||||
def test_several_files_answered_by_name(self):
|
||||
self.cree("bash", "zsh")
|
||||
self.assertEqual(self.choisit(reponse="zsh"), "zsh")
|
||||
|
||||
def test_a_nonsense_answer_falls_back_on_the_default(self):
|
||||
self.cree("bash", "zsh")
|
||||
self.assertEqual(self.choisit(reponse="42"), "bash")
|
||||
|
||||
|
||||
class TestHookStarship(ShellFixture):
|
||||
"""L'écriture de la ligne, qui ne demande rien."""
|
||||
|
||||
def hook(self, shell_env="/bin/bash"):
|
||||
out = io.StringIO()
|
||||
with patch.dict(os.environ, {"SHELL": shell_env}), patch(
|
||||
"builtins.input", refuse_input
|
||||
), redirect_stdout(out):
|
||||
self.todo._shell_hook_starship()
|
||||
return out.getvalue()
|
||||
|
||||
def test_it_appends_the_line(self):
|
||||
self.cree("bash", contenu="export EDITOR=vim\n")
|
||||
self.hook()
|
||||
self.assertEqual(
|
||||
self.fichiers["bash"].read_text(encoding="utf-8"),
|
||||
f"export EDITOR=vim\n{LIGNE_BASH}\n",
|
||||
)
|
||||
|
||||
def test_a_file_without_a_final_newline_keeps_its_last_command(self):
|
||||
self.cree("bash", contenu="export EDITOR=vim")
|
||||
self.hook()
|
||||
self.assertEqual(
|
||||
self.fichiers["bash"].read_text(encoding="utf-8"),
|
||||
f"export EDITOR=vim\n{LIGNE_BASH}\n",
|
||||
)
|
||||
|
||||
def test_it_writes_only_once(self):
|
||||
self.cree("bash", contenu=f"{LIGNE_BASH}\n")
|
||||
sortie = self.hook()
|
||||
self.assertEqual(
|
||||
self.fichiers["bash"].read_text(encoding="utf-8"),
|
||||
f"{LIGNE_BASH}\n",
|
||||
)
|
||||
self.assertIn("✅", sortie)
|
||||
|
||||
def test_no_file_at_all_creates_the_bash_one(self):
|
||||
self.hook()
|
||||
self.assertEqual(
|
||||
self.fichiers["bash"].read_text(encoding="utf-8"),
|
||||
f"{LIGNE_BASH}\n",
|
||||
)
|
||||
|
||||
def test_a_missing_parent_directory_is_created(self):
|
||||
"""config.fish vit sous ~/.config/fish, que rien ne garantit."""
|
||||
self.todo._STARSHIP_SHELLS["bash"] = (
|
||||
str(self.fichiers["fish"]),
|
||||
LIGNE_BASH,
|
||||
)
|
||||
self.hook()
|
||||
self.assertEqual(
|
||||
self.fichiers["fish"].read_text(encoding="utf-8"),
|
||||
f"{LIGNE_BASH}\n",
|
||||
)
|
||||
|
||||
|
||||
class TestInstallStarship(unittest.TestCase):
|
||||
"""L'enchaînement des deux étapes, et ce qui l'arrête."""
|
||||
|
||||
def test_an_installed_binary_goes_straight_to_the_shell(self):
|
||||
todo = TODO()
|
||||
todo.execute = MagicMock()
|
||||
with patch(
|
||||
"script.todo.todo.shutil.which", return_value="/usr/bin/starship"
|
||||
), patch.object(TODO, "_shell_hook_starship") as hook, patch.object(
|
||||
TODO, "_shell_install_starship_binary"
|
||||
) as poser:
|
||||
todo._shell_install_starship()
|
||||
poser.assert_not_called()
|
||||
hook.assert_called_once()
|
||||
|
||||
def test_a_missing_binary_leaves_the_shell_alone(self):
|
||||
todo = TODO()
|
||||
todo.execute = MagicMock()
|
||||
out = io.StringIO()
|
||||
with patch(
|
||||
"script.todo.todo.shutil.which", return_value=None
|
||||
), patch.object(TODO, "_shell_hook_starship") as hook, patch.object(
|
||||
TODO, "_shell_install_starship_binary"
|
||||
), redirect_stdout(
|
||||
out
|
||||
):
|
||||
todo._shell_install_starship()
|
||||
hook.assert_not_called()
|
||||
self.assertIn("❌", out.getvalue())
|
||||
|
||||
def test_a_refused_package_does_not_chain_to_upstream(self):
|
||||
"""Un refus est une décision : il n'appelle pas une seconde offre."""
|
||||
todo = TODO()
|
||||
todo.execute = MagicMock()
|
||||
appels = []
|
||||
with patch(
|
||||
"script.todo.todo.todo_install.install_command",
|
||||
return_value=["sudo", "pacman", "-S", "starship"],
|
||||
), patch(
|
||||
"script.todo.todo.todo_install.ask_and_install",
|
||||
side_effect=lambda *a, **k: appels.append(a[1]) or None,
|
||||
):
|
||||
todo._shell_install_starship_binary()
|
||||
self.assertEqual(len(appels), 1)
|
||||
|
||||
def test_no_package_falls_back_upstream(self):
|
||||
todo = TODO()
|
||||
todo.execute = MagicMock()
|
||||
appels = []
|
||||
with patch(
|
||||
"script.todo.todo.todo_install.install_command", return_value=None
|
||||
), patch(
|
||||
"script.todo.todo.todo_install.ask_and_install",
|
||||
side_effect=lambda *a, **k: appels.append(a[1]) or 0,
|
||||
), redirect_stdout(
|
||||
io.StringIO()
|
||||
):
|
||||
todo._shell_install_starship_binary()
|
||||
self.assertEqual(appels, [TODO._STARSHIP_UPSTREAM])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue