From f82288b2e41c1669d572211b4ce18eddd9860a1a Mon Sep 17 00:00:00 2001 From: snoopy Date: Fri, 4 Sep 2026 11:24:55 +0200 Subject: [PATCH] Copier-coller dans le terminal, et suite de tests versionnee Le terminal n'avait aucun copier-coller : impossible de selectionner du texte a la souris ni de coller une commande. C'est la fonction la plus basique d'un terminal et de loin le manque le plus penible a l'usage. - selection a la souris avec surlignage, suivant le theme actif - double-clic : selectionne le mot sous le curseur - Ctrl+Shift+C / Ctrl+Shift+V, Maj+Inser, clic molette, menu au clic droit - Ctrl+C reste l'interruption : les raccourcis du presse-papiers sont traites avant la conversion Ctrl+lettre, sinon Ctrl+Shift+C serait parti comme un Ctrl+C vers le shell - au collage, les fins de ligne sont normalisees en retour chariot ; un \r\n enverrait une ligne vide supplementaire au shell La suite de tests vivait dans un dossier temporaire et aurait ete perdue : elle rejoint le depot dans tests/ avec un lanceur. Elle couvre le presse-papiers, la detection d'hote, la logique SFTP sur serveur simule et la construction de l'application. Co-Authored-By: Claude Opus 5 --- README.md | 16 +++- app/terminal_widget.py | 160 ++++++++++++++++++++++++++++++++++++- app/theme.py | 3 + tests/run_all.bat | 29 +++++++ tests/test_clipboard.py | 92 ++++++++++++++++++++++ tests/test_detect.py | 34 ++++++++ tests/test_full.py | 19 +++++ tests/test_sftp.py | 169 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 tests/run_all.bat create mode 100644 tests/test_clipboard.py create mode 100644 tests/test_detect.py create mode 100644 tests/test_full.py create mode 100644 tests/test_sftp.py diff --git a/README.md b/README.md index 67f5a65..b07072b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com ## Fonctionnalites -- Sessions SSH multiples dans des onglets, avec terminal ANSI colore (theme "bleu nuit") +- Sessions SSH multiples dans des onglets, avec terminal ANSI colore et trois themes +- Copier-coller dans le terminal : selection a la souris, double-clic pour selectionner un mot, + `Ctrl+Shift+C` / `Ctrl+Shift+V`, `Maj+Inser`, clic molette, et menu au clic droit. + `Ctrl+C` reste l'interruption, comme dans tout terminal - Terminaux locaux Windows (PowerShell / Invite de commandes) accessibles en un clic, **sans avoir besoin d'une session enregistree** — pratique pour un usage ponctuel, comme le terminal Windows natif - Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic - Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee) @@ -121,6 +124,17 @@ data/ known_hosts empreintes d'hotes approuvees (non versionne) ``` +## Tests + +```bash +tests\run_all.bat +``` + +Les tests construisent de vraies fenetres Qt sans les afficher (`QT_QPA_PLATFORM=offscreen`) et +couvrent : le copier-coller et les raccourcis du terminal, la detection de l'hote dans une commande +`ssh` tapee a la main, la logique SFTP (transferts, renommage, tri, suppression recursive) avec un +serveur simule, et la construction complete de l'application. + ## Limites connues / pistes d'amelioration - L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet. diff --git a/app/terminal_widget.py b/app/terminal_widget.py index 0a8491f..6cd0027 100644 --- a/app/terminal_widget.py +++ b/app/terminal_widget.py @@ -10,8 +10,10 @@ from __future__ import annotations import pyte from PySide6.QtCore import QRect, Qt, Signal -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent -from PySide6.QtWidgets import QWidget +from PySide6.QtGui import ( + QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent, QKeySequence, +) +from PySide6.QtWidgets import QWidget, QMenu, QApplication from app import theme as app_theme @@ -74,6 +76,11 @@ class TerminalWidget(QWidget): self.scroll_offset = 0 self._prev_cursor_y = 0 + # selection a la souris : coordonnees (colonne, ligne) dans la grille + self._sel_anchor: tuple[int, int] | None = None + self._sel_focus: tuple[int, int] | None = None + self._selecting = False + self._load_palette() self.setMinimumSize(200, 100) @@ -90,12 +97,81 @@ class TerminalWidget(QWidget): name: QColor(value) for name, value in palette["ansi"].items() } self._bold_fg = QColor(palette["ansi"]["brightwhite"]) + self.selection_color = QColor(*palette.get("selection", (79, 140, 255, 90))) def refresh_theme(self) -> None: """Recharge les couleurs apres un changement de theme, et redessine.""" self._load_palette() self.update() + # ---------- selection a la souris ---------- + def _cell_at(self, pos) -> tuple[int, int]: + col = max(0, min(self.cols - 1, pos.x() // self.char_w)) + row = max(0, min(self.rows - 1, pos.y() // self.char_h)) + return col, row + + def _ordered_selection(self) -> tuple[tuple[int, int], tuple[int, int]] | None: + """Renvoie (debut, fin) remis dans l'ordre de lecture.""" + if self._sel_anchor is None or self._sel_focus is None: + return None + a, b = self._sel_anchor, self._sel_focus + if (a[1], a[0]) <= (b[1], b[0]): + return a, b + return b, a + + def _selected_columns(self, row: int) -> tuple[int, int] | None: + """Plage [debut, fin[ de colonnes selectionnees sur cette ligne.""" + ordered = self._ordered_selection() + if not ordered: + return None + (sx, sy), (ex, ey) = ordered + if row < sy or row > ey: + return None + start = sx if row == sy else 0 + end = (ex + 1) if row == ey else self.cols + return (start, end) if end > start else None + + def has_selection(self) -> bool: + return self._ordered_selection() is not None + + def selected_text(self) -> str: + ordered = self._ordered_selection() + if not ordered: + return "" + lines = [] + for row in range(ordered[0][1], ordered[1][1] + 1): + span = self._selected_columns(row) + if not span: + continue + line = self.screen.buffer[row] + # rstrip : le tampon est complete par des espaces jusqu'au bord de l'ecran + lines.append("".join(line[col].data for col in range(*span)).rstrip()) + return "\n".join(lines) + + def clear_selection(self) -> None: + if self._sel_anchor is not None: + self._sel_anchor = self._sel_focus = None + self.update() + + def select_all(self) -> None: + self._sel_anchor = (0, 0) + self._sel_focus = (self.cols - 1, self.rows - 1) + self.update() + + # ---------- presse-papiers ---------- + def copy_selection(self) -> None: + text = self.selected_text() + if text: + QApplication.clipboard().setText(text) + + def paste_clipboard(self) -> None: + text = QApplication.clipboard().text() + if not text: + return + # un terminal attend un retour chariot ; \r\n enverrait une ligne vide de plus + payload = text.replace("\r\n", "\r").replace("\n", "\r") + self.data_to_send.emit(payload.encode("utf-8", errors="ignore")) + def buffer_text(self) -> str: """Texte visible + historique proche, pour retrouver ce qui a ete tape (utilise pour deduire l'hote d'une commande ssh saisie a la main). @@ -189,6 +265,17 @@ class TerminalWidget(QWidget): painter.setPen(fg) painter.drawText(px, py + self.char_h - 4, run_text) + # surlignage de la selection, par-dessus le texte + for y in range(y0, y1 + 1): + span = self._selected_columns(y) + if span: + start, end = span + painter.fillRect( + start * self.char_w, y * self.char_h, + (end - start) * self.char_w, self.char_h, + self.selection_color, + ) + # curseur (bloc plein), non affiche pendant un scroll dans l'historique if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1: cx, cy = cursor.x, cursor.y @@ -231,6 +318,20 @@ class TerminalWidget(QWidget): key = event.key() modifiers = event.modifiers() + # Copier/coller AVANT la gestion de Ctrl+lettre : sinon Ctrl+Shift+C partirait + # comme un Ctrl+C (interruption) au lieu de copier. Ctrl+C seul reste l'interruption, + # comme dans tous les terminaux. + if (modifiers & Qt.ControlModifier) and (modifiers & Qt.ShiftModifier): + if key == Qt.Key_C: + self.copy_selection() + return + if key == Qt.Key_V: + self.paste_clipboard() + return + if key == Qt.Key_Insert and (modifiers & Qt.ShiftModifier): + self.paste_clipboard() + return + # Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce : # sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper # @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble. @@ -263,4 +364,59 @@ class TerminalWidget(QWidget): def mousePressEvent(self, event) -> None: self.setFocus() + if event.button() == Qt.LeftButton: + self._sel_anchor = self._sel_focus = self._cell_at(event.position().toPoint()) + self._selecting = True + self.update() + elif event.button() == Qt.MiddleButton: + self.paste_clipboard() # habitude Unix : clic molette = coller super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + if self._selecting: + self._sel_focus = self._cell_at(event.position().toPoint()) + self.update() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self._selecting = False + # un simple clic (sans deplacement) annule la selection + if self._sel_anchor == self._sel_focus: + self.clear_selection() + super().mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: + """Double-clic : selectionne le mot sous le curseur.""" + col, row = self._cell_at(event.position().toPoint()) + line = self.screen.buffer[row] + separators = " \t\"'`()[]{}<>|;:," + if line[col].data in separators: + return + start = col + while start > 0 and line[start - 1].data not in separators: + start -= 1 + end = col + while end < self.cols - 1 and line[end + 1].data not in separators: + end += 1 + self._sel_anchor, self._sel_focus = (start, row), (end, row) + self.update() + + def contextMenuEvent(self, event) -> None: + menu = QMenu(self) + copy_action = menu.addAction("Copier") + copy_action.setShortcut(QKeySequence("Ctrl+Shift+C")) + copy_action.setEnabled(self.has_selection()) + paste_action = menu.addAction("Coller") + paste_action.setShortcut(QKeySequence("Ctrl+Shift+V")) + paste_action.setEnabled(bool(QApplication.clipboard().text())) + menu.addSeparator() + select_all_action = menu.addAction("Tout selectionner") + + action = menu.exec(event.globalPos()) + if action == copy_action: + self.copy_selection() + elif action == paste_action: + self.paste_clipboard() + elif action == select_all_action: + self.select_all() diff --git a/app/theme.py b/app/theme.py index 4f4bec5..ee770b1 100644 --- a/app/theme.py +++ b/app/theme.py @@ -59,6 +59,7 @@ UI_PALETTES: dict[str, dict[str, str]] = { TERMINAL_PALETTES: dict[str, dict] = { THEME_LIGHT: { "fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110), + "selection": (47, 111, 237, 70), "ansi": { "black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800", "blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875", @@ -69,6 +70,7 @@ TERMINAL_PALETTES: dict[str, dict] = { }, THEME_MIDNIGHT: { "fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140), + "selection": (79, 140, 255, 90), "ansi": { "black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c", "blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5", @@ -79,6 +81,7 @@ TERMINAL_PALETTES: dict[str, dict] = { }, THEME_BLACK: { "fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140), + "selection": (74, 158, 255, 90), "ansi": { "black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52", "blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0", diff --git a/tests/run_all.bat b/tests/run_all.bat new file mode 100644 index 0000000..d7242f1 --- /dev/null +++ b/tests/run_all.bat @@ -0,0 +1,29 @@ +@echo off +setlocal +cd /d "%~dp0\.." + +if not exist venv ( + echo Environnement virtuel introuvable. Lancez d'abord setup.bat + pause + exit /b 1 +) + +REM QT_QPA_PLATFORM=offscreen : les tests construisent de vraies fenetres Qt, +REM mais sans les afficher (utile en console comme en integration continue). +set QT_QPA_PLATFORM=offscreen +set PYTHONIOENCODING=utf-8 + +set FAILED=0 +for %%T in (test_detect test_clipboard test_sftp test_full) do ( + echo. + echo ================== %%T ================== + venv\Scripts\python.exe tests\%%T.py || set FAILED=1 +) + +echo. +if "%FAILED%"=="1" ( + echo Au moins un test a echoue. +) else ( + echo Tous les tests sont passes. +) +pause diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py new file mode 100644 index 0000000..e37988b --- /dev/null +++ b/tests/test_clipboard.py @@ -0,0 +1,92 @@ +"""Copier/coller du terminal : selection, presse-papiers, raccourcis.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QKeyEvent +from PySide6.QtCore import QEvent, Qt, QPoint + +from app.terminal_widget import TerminalWidget + +app = QApplication([]) +t = TerminalWidget() +t.resize(800, 400) +t.show() +t.feed(b"root@srv:~# cat /etc/hostname\r\nserveur-test-01\r\nroot@srv:~# ") + +print("=== 1. Selection d'une plage ===") +# selectionne "serveur-test-01" sur la ligne 1 +t._sel_anchor, t._sel_focus = (0, 1), (14, 1) +print(" texte selectionne:", repr(t.selected_text())) +assert t.selected_text() == "serveur-test-01", t.selected_text() +assert t.has_selection() + +print() +print("=== 2. Copie vers le presse-papiers ===") +t.copy_selection() +print(" presse-papiers:", repr(QApplication.clipboard().text())) +assert QApplication.clipboard().text() == "serveur-test-01" + +print() +print("=== 3. Selection sur plusieurs lignes ===") +t._sel_anchor, t._sel_focus = (0, 0), (14, 1) +lignes = t.selected_text().split("\n") +print(" lignes:", lignes) +assert lignes[0].startswith("root@srv"), lignes +assert lignes[1] == "serveur-test-01", lignes + +print() +print("=== 4. Collage (le presse-papiers part vers le shell) ===") +envoye = [] +t.data_to_send.connect(lambda d: envoye.append(d)) +QApplication.clipboard().setText("ssh root@192.168.10.109\n") +t.paste_clipboard() +print(" octets envoyes:", envoye) +# le \n doit devenir \r, sinon le shell recoit une ligne vide en plus +assert envoye == [b"ssh root@192.168.10.109\r"], envoye +envoye.clear() + +print() +print("=== 5. Ctrl+C doit rester l'interruption, Ctrl+Shift+C doit copier ===") +t._sel_anchor, t._sel_focus = (0, 1), (14, 1) +QApplication.clipboard().setText("") + +t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_C, Qt.ControlModifier, "")) +print(" Ctrl+C -> envoye:", envoye, "| presse-papiers:", repr(QApplication.clipboard().text())) +assert envoye == [bytes([0x03])], envoye +assert QApplication.clipboard().text() == "", "Ctrl+C ne doit pas copier" +envoye.clear() + +t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_C, Qt.ControlModifier | Qt.ShiftModifier, "")) +print(" Ctrl+Shift+C -> envoye:", envoye, "| presse-papiers:", repr(QApplication.clipboard().text())) +assert envoye == [], "Ctrl+Shift+C ne doit rien envoyer au shell" +assert QApplication.clipboard().text() == "serveur-test-01" + +print() +print("=== 6. Ctrl+Shift+V et Maj+Inser collent ===") +QApplication.clipboard().setText("echo test") +envoye.clear() +t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_V, Qt.ControlModifier | Qt.ShiftModifier, "")) +t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Insert, Qt.ShiftModifier, "")) +print(" envoye:", envoye) +assert envoye == [b"echo test", b"echo test"], envoye + +print() +print("=== 7. Double-clic : selection du mot ===") +t.clear_selection() +# ligne 1 = "serveur-test-01" ; les tirets ne sont pas des separateurs +class FakeEvt: + def position(self): return type("P", (), {"toPoint": lambda s: QPoint(5 * t.char_w, 1 * t.char_h)})() +t.mouseDoubleClickEvent(FakeEvt()) +print(" mot selectionne:", repr(t.selected_text())) +assert t.selected_text() == "serveur-test-01", t.selected_text() + +print() +print("=== 8. Tout selectionner / effacer la selection ===") +t.select_all() +assert t.has_selection() +t.clear_selection() +assert not t.has_selection() +print(" OK") + +print() +print("COPIER/COLLER : TOUS LES TESTS OK") diff --git a/tests/test_detect.py b/tests/test_detect.py new file mode 100644 index 0000000..9c0b1e6 --- /dev/null +++ b/tests/test_detect.py @@ -0,0 +1,34 @@ +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +from app.main_window import detect_ssh_target + +cas = [ + (r"C:\Users\Florian>ssh root@192.168.10.109", {"user": "root", "host": "192.168.10.109", "port": 22}), + (r"PS C:\> ssh admin@srv-test.local", {"user": "admin", "host": "srv-test.local", "port": 22}), + (r"> ssh -p 2222 root@10.0.0.5", {"user": "root", "host": "10.0.0.5", "port": 2222}), + (r"> ssh root@10.0.0.5 -p 2222", {"user": "root", "host": "10.0.0.5", "port": 2222}), + (r"> ssh monserveur", {"user": "", "host": "monserveur", "port": 22}), + ("rien a voir ici", None), +] + +ok = True +for texte, attendu in cas: + got = detect_ssh_target(texte) + statut = "OK " if got == attendu else "ECHEC" + if got != attendu: + ok = False + print(f"{statut} | {texte[:38]:<40} -> {got}") + +multi = "\n".join([ + r"C:\Users>ssh root@192.168.1.1", + "Connection closed.", + r"C:\Users>ssh deploy@192.168.10.109", + "Last login: Wed Sep 2 20:39:31 2026", +]) +got = detect_ssh_target(multi) +print() +print("plusieurs commandes -> derniere retenue:", got) +assert got and got["host"] == "192.168.10.109" and got["user"] == "deploy", got +assert ok, "au moins un cas a echoue" +print() +print("DETECTION AUTOMATIQUE DE L'HOTE : OK") diff --git a/tests/test_full.py b/tests/test_full.py new file mode 100644 index 0000000..f7b3352 --- /dev/null +++ b/tests/test_full.py @@ -0,0 +1,19 @@ +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +from PySide6.QtWidgets import QApplication +from app.main_window import MainWindow +from app.theme import apply_theme, build_app_icon + +app = QApplication([]) +apply_theme(app) +w = MainWindow() +w.setWindowIcon(build_app_icon()) +w.show() +print("OK: application complete construite sans erreur") + +# nettoyage explicite : le terminal local auto-ouvert au demarrage laisse un +# vrai sous-processus + threads vivants, qu'il faut fermer avant de quitter +for i in range(w.tabs.count()): + tab = w.tabs.widget(i) + tab.close_connection() +print("OK: connexions fermees proprement") diff --git a/tests/test_sftp.py b/tests/test_sftp.py new file mode 100644 index 0000000..df3b2d1 --- /dev/null +++ b/tests/test_sftp.py @@ -0,0 +1,169 @@ +"""Test de la logique SFTP avec un client SFTP simule (adosse a un dossier temporaire).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +import os +import posixpath +import shutil +import stat as statmod +import tempfile +import time +from pathlib import Path + +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import Qt + +from app.sftp_panel import SFTPPanel, TransferJob, NAME_ROLE, ISDIR_ROLE, PARENT_ROLE + + +class FakeAttr: + def __init__(self, path: Path): + st = path.stat() + self.filename = path.name + self.st_mode = st.st_mode + self.st_size = st.st_size + self.st_mtime = st.st_mtime + + +class FakeSFTP: + """Implemente juste ce que le panneau utilise, adosse a un vrai dossier local.""" + + def __init__(self, root: Path): + self.root = root + + def _real(self, path: str) -> Path: + return self.root / path.lstrip("/") + + def normalize(self, path): + return "/" + + def listdir_attr(self, path): + return [FakeAttr(p) for p in sorted(self._real(path).iterdir())] + + def stat(self, path): + return FakeAttr(self._real(path)) + + def mkdir(self, path): + self._real(path).mkdir() + + def rename(self, old, new): + self._real(old).rename(self._real(new)) + + def remove(self, path): + self._real(path).unlink() + + def rmdir(self, path): + self._real(path).rmdir() + + def chmod(self, path, mode): + self._real(path).chmod(mode) + + def put(self, local, remote, callback=None): + shutil.copyfile(local, self._real(remote)) + if callback: + callback(os.path.getsize(local), os.path.getsize(local)) + + def get(self, remote, local, callback=None): + shutil.copyfile(self._real(remote), local) + if callback: + callback(os.path.getsize(local), os.path.getsize(local)) + + +app = QApplication([]) + +remote_root = Path(tempfile.mkdtemp()) +local_root = Path(tempfile.mkdtemp()) + +# contenu distant simule +(remote_root / "dossier_distant").mkdir() +(remote_root / "config.yml").write_text("cle: valeur\n", encoding="utf-8") +(remote_root / "gros_fichier.log").write_text("x" * 5000, encoding="utf-8") +# contenu local +(local_root / "a_envoyer.txt").write_text("contenu local\n", encoding="utf-8") +(local_root / "dossier_local").mkdir() +(local_root / "dossier_local" / "interne.txt").write_text("imbrique\n", encoding="utf-8") + +panel = SFTPPanel(FakeSFTP(remote_root)) +panel.local.navigate(str(local_root)) +panel.resize(1000, 600) + +print("=== 1. Listing distant avec colonnes ===") +tree = panel.remote.tree +for i in range(tree.topLevelItemCount()): + it = tree.topLevelItem(i) + print(f" {it.text(0):<28} | {it.text(1):>9} | {it.text(2):<17} | {it.text(3)}") +assert tree.topLevelItemCount() == 4, "attendu .. + 3 entrees" +print(" -> colonnes Nom/Taille/Modifie/Droits presentes:", all(tree.headerItem().text(c) for c in range(4))) + +print() +print("=== 2. Envoi par glisser-deposer (local -> distant) ===") +payload = {"side": "local", "entries": [{"name": "a_envoyer.txt", "is_dir": False}]} +panel._on_transfer_payload(payload) +deadline = time.time() + 5 +while time.time() < deadline and panel._worker and panel._worker.isRunning(): + app.processEvents() + time.sleep(0.02) +app.processEvents() +sent = (remote_root / "a_envoyer.txt") +print(" fichier arrive sur le serveur:", sent.exists()) +assert sent.exists() + +print() +print("=== 3. Envoi recursif d'un dossier ===") +panel._on_transfer_payload({"side": "local", "entries": [{"name": "dossier_local", "is_dir": True}]}) +deadline = time.time() + 5 +while time.time() < deadline and panel._worker and panel._worker.isRunning(): + app.processEvents() + time.sleep(0.02) +app.processEvents() +nested = remote_root / "dossier_local" / "interne.txt" +print(" dossier + fichier imbrique transferes:", nested.exists()) +assert nested.exists() + +print() +print("=== 4. Reception (distant -> local) ===") +panel._on_transfer_payload({"side": "remote", "entries": [{"name": "config.yml", "is_dir": False}]}) +deadline = time.time() + 5 +while time.time() < deadline and panel._worker and panel._worker.isRunning(): + app.processEvents() + time.sleep(0.02) +app.processEvents() +got = local_root / "config.yml" +print(" fichier recupere en local:", got.exists(), "| contenu identique:", + got.read_text(encoding="utf-8") == "cle: valeur\n") +assert got.exists() + +print() +print("=== 5. Renommage distant ===") +panel.remote.navigate("/") +item = next(panel.remote.tree.topLevelItem(i) for i in range(panel.remote.tree.topLevelItemCount()) + if panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE) == "config.yml") +item.setSelected(True) +panel.remote.sftp.rename("/config.yml", "/config_renomme.yml") +panel.remote.refresh() +names = [panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE) + for i in range(panel.remote.tree.topLevelItemCount())] +print(" entrees apres renommage:", [n for n in names if n]) +assert "config_renomme.yml" in names + +print() +print("=== 6. Permissions (chmod) ===") +panel.remote.sftp.chmod("/config_renomme.yml", 0o600) +mode = statmod.S_IMODE((remote_root / "config_renomme.yml").stat().st_mode) +print(f" droits appliques: {mode:03o}") + +print() +print("=== 7. Suppression recursive distante ===") +panel.remote._rmdir_recursive("/dossier_local") +panel.remote.refresh() +print(" dossier supprime du serveur:", not (remote_root / "dossier_local").exists()) +assert not (remote_root / "dossier_local").exists() + +print() +print("=== 8. Entree '..' presente pour remonter ===") +has_parent = any(panel.remote.tree.topLevelItem(i).data(0, PARENT_ROLE) + for i in range(panel.remote.tree.topLevelItemCount())) +print(" ligne '..' presente:", has_parent) +assert has_parent + +print() +print("TOUS LES TESTS SFTP OK")