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 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-09-04 11:24:55 +02:00
parent 81d5d8c75b
commit f82288b2e4
8 changed files with 519 additions and 3 deletions

View File

@@ -4,7 +4,10 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com
## Fonctionnalites ## 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 - 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 - 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) - 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) 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 ## Limites connues / pistes d'amelioration
- L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet. - L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet.

View File

@@ -10,8 +10,10 @@ from __future__ import annotations
import pyte import pyte
from PySide6.QtCore import QRect, Qt, Signal from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent from PySide6.QtGui import (
from PySide6.QtWidgets import QWidget QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent, QKeySequence,
)
from PySide6.QtWidgets import QWidget, QMenu, QApplication
from app import theme as app_theme from app import theme as app_theme
@@ -74,6 +76,11 @@ class TerminalWidget(QWidget):
self.scroll_offset = 0 self.scroll_offset = 0
self._prev_cursor_y = 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._load_palette()
self.setMinimumSize(200, 100) self.setMinimumSize(200, 100)
@@ -90,12 +97,81 @@ class TerminalWidget(QWidget):
name: QColor(value) for name, value in palette["ansi"].items() name: QColor(value) for name, value in palette["ansi"].items()
} }
self._bold_fg = QColor(palette["ansi"]["brightwhite"]) self._bold_fg = QColor(palette["ansi"]["brightwhite"])
self.selection_color = QColor(*palette.get("selection", (79, 140, 255, 90)))
def refresh_theme(self) -> None: def refresh_theme(self) -> None:
"""Recharge les couleurs apres un changement de theme, et redessine.""" """Recharge les couleurs apres un changement de theme, et redessine."""
self._load_palette() self._load_palette()
self.update() 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: def buffer_text(self) -> str:
"""Texte visible + historique proche, pour retrouver ce qui a ete tape """Texte visible + historique proche, pour retrouver ce qui a ete tape
(utilise pour deduire l'hote d'une commande ssh saisie a la main). (utilise pour deduire l'hote d'une commande ssh saisie a la main).
@@ -189,6 +265,17 @@ class TerminalWidget(QWidget):
painter.setPen(fg) painter.setPen(fg)
painter.drawText(px, py + self.char_h - 4, run_text) 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 # 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: if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1:
cx, cy = cursor.x, cursor.y cx, cy = cursor.x, cursor.y
@@ -231,6 +318,20 @@ class TerminalWidget(QWidget):
key = event.key() key = event.key()
modifiers = event.modifiers() 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 : # Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce :
# sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper # sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper
# @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble. # @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble.
@@ -263,4 +364,59 @@ class TerminalWidget(QWidget):
def mousePressEvent(self, event) -> None: def mousePressEvent(self, event) -> None:
self.setFocus() 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) 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()

View File

@@ -59,6 +59,7 @@ UI_PALETTES: dict[str, dict[str, str]] = {
TERMINAL_PALETTES: dict[str, dict] = { TERMINAL_PALETTES: dict[str, dict] = {
THEME_LIGHT: { THEME_LIGHT: {
"fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110), "fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110),
"selection": (47, 111, 237, 70),
"ansi": { "ansi": {
"black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800", "black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800",
"blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875", "blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875",
@@ -69,6 +70,7 @@ TERMINAL_PALETTES: dict[str, dict] = {
}, },
THEME_MIDNIGHT: { THEME_MIDNIGHT: {
"fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140), "fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140),
"selection": (79, 140, 255, 90),
"ansi": { "ansi": {
"black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c", "black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c",
"blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5", "blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5",
@@ -79,6 +81,7 @@ TERMINAL_PALETTES: dict[str, dict] = {
}, },
THEME_BLACK: { THEME_BLACK: {
"fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140), "fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140),
"selection": (74, 158, 255, 90),
"ansi": { "ansi": {
"black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52", "black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52",
"blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0", "blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0",

29
tests/run_all.bat Normal file
View File

@@ -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

92
tests/test_clipboard.py Normal file
View File

@@ -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")

34
tests/test_detect.py Normal file
View File

@@ -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")

19
tests/test_full.py Normal file
View File

@@ -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")

169
tests/test_sftp.py Normal file
View File

@@ -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")