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

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