From aa63e9423983dd0b0d024962bc70c3f5725d71a9 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sun, 23 Aug 2026 20:00:03 +0200 Subject: [PATCH 1/2] Un lanceur Qt pour Windows, et un assemblage partage Co-Authored-By: Claude Opus 5 --- app.py | 56 +++++----- assemblage.py | 75 +++++++++++++ lanceur_qt.py | 227 +++++++++++++++++++++++++++++++++++++++ tests/test_assemblage.py | 122 +++++++++++++++++++++ tests/test_lanceur_qt.py | 124 +++++++++++++++++++++ 5 files changed, 578 insertions(+), 26 deletions(-) create mode 100644 assemblage.py create mode 100644 lanceur_qt.py create mode 100644 tests/test_assemblage.py create mode 100644 tests/test_lanceur_qt.py diff --git a/app.py b/app.py index 7a07477..0fc9878 100644 --- a/app.py +++ b/app.py @@ -25,7 +25,6 @@ if "--diagnostic" in sys.argv: print(mettre_en_forme(diagnostiquer())) sys.exit(0) -import os import threading from objc import super @@ -41,11 +40,8 @@ from Foundation import NSObject, NSTimer from PyObjCTools import AppHelper from pynput import keyboard -from controller import ZonzaController -from core import CONFIG, modele_retenu, validate_config -from interface import classe_overlay_pour -from moteur import choisir_transcripteur, detecter_moteur -from systeme import choisir_systeme +from assemblage import acquerir_verrou, construire +from core import CONFIG _STATE_TITLES = { "idle": "đŸŽ™ïž", @@ -55,19 +51,21 @@ _STATE_TITLES = { class ZonzaApp(NSObject): - """DĂ©lĂ©guĂ© applicatif : status item + menu + hotkey + contrĂŽleur.""" + """DĂ©lĂ©guĂ© applicatif : status item + menu + hotkey. - def initWithTranscripteur_systeme_(self, transcripteur, systeme): + Le contrĂŽleur (donc le transcripteur, l'overlay et le systĂšme) vient dĂ©jĂ  + assemblĂ© d'`assemblage.construire()` — voir `main()`. Ce dĂ©lĂ©guĂ© ne fait + plus QUE ce qui est propre Ă  Cocoa : le NSStatusItem, le menu, et le + marshalage `AppHelper.callAfter` vers le thread principal. + """ + + def initWithController_(self, controller): self = super().init() if self is None: return None self._item = None self._sounds_item = None - self.pulse = classe_overlay_pour().alloc().init() - self.controller = ZonzaController( - CONFIG, self.pulse, transcripteur, systeme, on_state=self._set_state, - run_on_main=AppHelper.callAfter, - ) + self.controller = controller self._listener = None return self @@ -144,23 +142,18 @@ class ZonzaApp(NSObject): def main(): # une seule instance a la fois : sinon deux modeles en RAM et la meme - # hotkey captee deux fois (cf systeme.acquerir_verrou_instance). + # hotkey captee deux fois (cf systeme.acquerir_verrou_instance). La prise + # du verrou reste ICI (pas dans assemblage.construire) : c'est au lanceur + # de dĂ©cider quoi faire d'un refus, avant mĂȘme de construire quoi que ce + # soit d'autre. + from systeme import choisir_systeme systeme = choisir_systeme() - lock = systeme.acquerir_verrou_instance( - os.path.join(systeme.dossier_donnees(), "instance.lock") - ) + lock = acquerir_verrou(systeme) if lock is None: print("[garde] une autre instance de Zonza tourne deja — arret.") return main._lock = lock # garder le verrou vivant tant que le process vit - validate_config(CONFIG) - moteur = detecter_moteur() - transcripteur = choisir_transcripteur( - nom=moteur, - modele=modele_retenu(moteur, CONFIG["model"]), - langue=CONFIG["language"], - amorce=CONFIG["initial_prompt"], - ) + # si l'AccessibilitĂ© n'est pas accordĂ©e, macOS affiche sa popup officielle # (« Zonza souhaite contrĂŽler cet ordinateur ») et ajoute l'app Ă  la liste — # bien plus fiable qu'un ajout manuel via « + » dans les RĂ©glages. @@ -170,8 +163,19 @@ def main(): ) trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True}) print(f"[diag] accessibilitĂ© accordĂ©e : {bool(trusted)}") + app = NSApplication.sharedApplication() - delegate = ZonzaApp.alloc().initWithTranscripteur_systeme_(transcripteur, systeme) + # DĂ©pendance circulaire : `on_state` doit marshaler vers `delegate._apply_title`, + # mais `delegate` a besoin du contrĂŽleur dĂ©jĂ  construit pour naĂźtre. CassĂ©e + # avec une case remplie APRÈS coup, comme dans lanceur_qt.py — la fermeture + # ne lit `case["delegate"]` qu'au moment de l'appel, jamais Ă  la construction. + case = {} + assemblage = construire( + run_on_main=AppHelper.callAfter, + on_state=lambda s: case["delegate"]._set_state(s), + ) + delegate = ZonzaApp.alloc().initWithController_(assemblage["controller"]) + case["delegate"] = delegate delegate.setup() # rĂ©fĂ©rence gardĂ©e pour toute la vie du processus (sinon GC → icĂŽne disparaĂźt) main._delegate = delegate diff --git a/assemblage.py b/assemblage.py new file mode 100644 index 0000000..6be9429 --- /dev/null +++ b/assemblage.py @@ -0,0 +1,75 @@ +"""Assemblage commun aux deux lanceurs (`app.py` Cocoa, `lanceur_qt.py` Qt/Windows). + +Avant ce module, `app.py` Ă©tait le SEUL endroit qui savait choisir le systĂšme, +prendre le verrou d'instance, construire le transcripteur et l'overlay, puis +cĂąbler le tout dans un `ZonzaController`. Un second lanceur qui recopierait +cette sĂ©quence crĂ©erait deux copies vouĂ©es Ă  diverger en silence — exactement +le dĂ©faut dĂ©jĂ  rĂ©sorbĂ© entre `engine.py` et `systeme/macos.py` (cf journal du +projet). Les deux lanceurs appellent donc les deux fonctions d'ici ; ce qui +reste propre Ă  chacun (boucle d'Ă©vĂ©nements, icĂŽne de barre, menu) reste dans +`app.py` et `lanceur_qt.py`. + +Tout est injectable (`plateforme`, `run_on_main`...) pour rester testable sans +jamais ouvrir de fenĂȘtre ni de micro : la construction du transcripteur charge +un vrai modĂšle, donc les tests injectent un `moteur.detecter_moteur`/ +`moteur.choisir_transcripteur` doublĂ©s plutĂŽt que de laisser tourner le vrai. +""" +import os + +import moteur +from controller import ZonzaController +from core import CONFIG, modele_retenu, validate_config +from interface import classe_overlay_pour +from systeme import choisir_systeme + + +def acquerir_verrou(systeme, chemin_verrou=None): + """Prend le verrou d'instance unique ; rend le handle, ou None si pris. + + Une seule instance Ă  la fois : sinon deux modĂšles en RAM et la mĂȘme + hotkey captĂ©e deux fois (cf systeme.acquerir_verrou_instance). Le chemin + est injectable : les tests et les essais manuels d'un lanceur doivent + pouvoir viser un fichier qui n'est PAS celui de l'instance dĂ©jĂ  en cours + de l'utilisateur. + """ + chemin = chemin_verrou or os.path.join(systeme.dossier_donnees(), "instance.lock") + return systeme.acquerir_verrou_instance(chemin) + + +def construire(plateforme=None, run_on_main=None, on_state=None, + detecter_moteur=None, choisir_transcripteur=None): + """Construit le systĂšme, le transcripteur, l'overlay et le contrĂŽleur. + + Ne prend PAS le verrou (c'est le rĂŽle d'`acquerir_verrou`, appelĂ©e Ă  part + par le lanceur, qui dĂ©cide seul de ce qu'il fait d'un refus). Ne valide + la config qu'une fois, ici, pour que les deux lanceurs en bĂ©nĂ©ficient + sans avoir Ă  y penser. + + `detecter_moteur`/`choisir_transcripteur` sont injectables : ils + permettent aux tests de construire l'assemblage entier sans charger un + vrai modĂšle de transcription (opĂ©ration lourde, et qui Ă©chouerait sans + matĂ©riel/poids tĂ©lĂ©chargĂ©s). + """ + detecter_moteur = detecter_moteur or moteur.detecter_moteur + choisir_transcripteur = choisir_transcripteur or moteur.choisir_transcripteur + + validate_config(CONFIG) + systeme = choisir_systeme(plateforme) + moteur_nom = detecter_moteur() + transcripteur = choisir_transcripteur( + nom=moteur_nom, + modele=modele_retenu(moteur_nom, CONFIG["model"]), + langue=CONFIG["language"], + amorce=CONFIG["initial_prompt"], + ) + overlay = classe_overlay_pour(plateforme)() + controller = ZonzaController( + CONFIG, overlay, transcripteur, systeme, on_state=on_state, + run_on_main=run_on_main, + ) + return { + "systeme": systeme, + "transcripteur": transcripteur, + "overlay": overlay, + "controller": controller, + } diff --git a/lanceur_qt.py b/lanceur_qt.py new file mode 100644 index 0000000..1234a35 --- /dev/null +++ b/lanceur_qt.py @@ -0,0 +1,227 @@ +"""Point d'entrĂ©e : application Qt de barre de tĂąches (PySide6, Windows). + +Pendant Qt de `app.py` (Cocoa/macOS) : mĂȘme icĂŽne de barre (đŸŽ™ïž/🔮/⏳), mĂȘme +menu Ă  deux entrĂ©es (Sons, Quitter), mĂȘme hotkey globale via pynput. Ce qui +diffĂšre de Cocoa est ISOLÉ ici — boucle d'Ă©vĂ©nements Qt, `QSystemTrayIcon`, +marshalage par signal Qt — le reste (choix du systĂšme, verrou d'instance, +transcripteur, overlay, contrĂŽleur) vient d'`assemblage.py`, partagĂ© avec +`app.py`. Dupliquer cet assemblage crĂ©erait deux copies vouĂ©es Ă  diverger en +silence (cf docstring d'`assemblage.py`). + +Marshalage thread de fond → thread Qt : `AppHelper.callAfter` (Cocoa) n'a pas +d'Ă©quivalent Qt. La voie sĂ»re est un signal Qt Ă©mis depuis le thread de fond +et connectĂ© en `QueuedConnection` — Qt place alors l'exĂ©cution du slot dans +la boucle d'Ă©vĂ©nements du thread PROPRIÉTAIRE de l'objet rĂ©cepteur (le thread +principal, puisque `_Pont` y est instanciĂ©), quel que soit le thread Ă©metteur. +`QTimer.singleShot` depuis un thread non-Qt n'offre pas cette garantie : un +QTimer dĂ©marrĂ© hors du thread principal s'exĂ©cute (ou pas) sur CE thread, pas +sur le thread principal — comportement non documentĂ© comme sĂ»r, Ă  l'inverse +des connexions en file, documentĂ©es explicitement thread-safe. +""" +import sys + +# --diagnostic doit s'Ă©valuer AVANT tout import PySide6/pynput : sur une +# machine qui ne les a pas, c'est le seul moyen d'Ă©prouver la chaĂźne (mĂȘme +# raison que dans app.py — voir sa docstring). +if "--diagnostic" in sys.argv: + from diagnostic import diagnostiquer, mettre_en_forme + print(mettre_en_forme(diagnostiquer())) + sys.exit(0) + +import os +import threading + +from PySide6.QtCore import QObject, Qt, Signal +from PySide6.QtGui import QAction, QIcon, QPixmap +from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon +from pynput import keyboard + +from assemblage import acquerir_verrou, construire +from core import CONFIG +from systeme import choisir_systeme + +_STATE_TITLES = { + "idle": "đŸŽ™ïž", + "recording": "🔮", + "transcribing": "⏳", +} + + +def _icone_pour(emoji): + """Rend un QIcon dessinĂ© Ă  partir d'un emoji — pas de fichier .ico Ă  livrer.""" + pixmap = QPixmap(64, 64) + pixmap.fill(Qt.GlobalColor.transparent) + from PySide6.QtGui import QFont, QPainter + with QPainter(pixmap) as painter: + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + font = QFont() + font.setPointSize(40) + painter.setFont(font) + painter.drawText(pixmap.rect(), int(Qt.AlignmentFlag.AlignCenter), emoji) + return QIcon(pixmap) + + +class _Pont(QObject): + """Fait passer un appel du thread de fond (hotkey, contrĂŽleur) au thread + Qt principal. `etat_change` porte le nom d'Ă©tat ("idle"/"recording"/ + "transcribing") ; `appel_demande` porte une fonction Ă  exĂ©cuter sans + argument (utilisĂ©e pour `controller.toggle`). + + Émettre un signal Qt depuis un thread quelconque est thread-safe par + construction ; c'est la connexion en Qt.QueuedConnection (posĂ©e Ă  la + construction de `ZonzaTray`) qui garantit que le SLOT, lui, s'exĂ©cute + sur le thread principal. + """ + etat_change = Signal(str) + appel_demande = Signal(object) + + +class ZonzaTray: + """IcĂŽne de barre de tĂąches + menu + hotkey + contrĂŽleur, cĂŽtĂ© Qt.""" + + def __init__(self, controller, systeme): + self.controller = controller + self.systeme = systeme + self._listener = None + self._pont = _Pont() + self._pont.etat_change.connect( + self._apply_title, Qt.ConnectionType.QueuedConnection + ) + self._pont.appel_demande.connect( + lambda fn: fn(), Qt.ConnectionType.QueuedConnection + ) + + self._current_state = "idle" + self.tray = QSystemTrayIcon(_icone_pour(_STATE_TITLES["idle"])) + self.tray.setToolTip("Zonza") + + self.menu = QMenu() + self.sounds_action = QAction("Sons", self.menu) + self.sounds_action.setCheckable(True) + self.sounds_action.setChecked(bool(CONFIG["sounds"])) + self.sounds_action.triggered.connect(self.toggle_sounds) + self.menu.addAction(self.sounds_action) + self.menu.addSeparator() + self.quit_action = QAction("Quitter", self.menu) + self.quit_action.triggered.connect(self.quit_app) + self.menu.addAction(self.quit_action) + self.tray.setContextMenu(self.menu) + + def run_on_main(self, fn): + """PassĂ© au contrĂŽleur comme `run_on_main` : marshale `fn` sur le thread Qt.""" + self._pont.appel_demande.emit(fn) + + def on_state(self, state): + """PassĂ© au contrĂŽleur comme `on_state` : peut arriver d'un thread de fond.""" + self._pont.etat_change.emit(state) + + def _apply_title(self, state): + # `_current_state` n'est pas utilisĂ© pour dessiner (l'icĂŽne vient de + # `_STATE_TITLES` juste en dessous) : il existe pour que les tests + # puissent vĂ©rifier la correspondance Ă©tat → icĂŽne sans avoir Ă  + # comparer deux QIcon pixel Ă  pixel. + self._current_state = state + self.tray.setIcon(_icone_pour(_STATE_TITLES.get(state, "đŸŽ™ïž"))) + + def show(self): + self.tray.show() + self._start_hotkey() + self._warmup_async() + + # --- actions du menu ------------------------------------------------ + def toggle_sounds(self, checked): + CONFIG["sounds"] = bool(checked) + self.sounds_action.setChecked(CONFIG["sounds"]) + + def quit_app(self): + if self._listener is not None: + self._listener.stop() + QApplication.instance().quit() + + # --- hotkey globale --------------------------------------------------- + def _start_hotkey(self): + # pynput exĂ©cute ce callback sur SON thread d'Ă©coute, pas le thread + # Qt principal. toggle() touche l'overlay (Qt) → marshalage requis, + # mĂȘme raison que app.py avec AppHelper.callAfter. + def on_activate(): + self.run_on_main(self.controller.toggle) + self._listener = keyboard.GlobalHotKeys( + {self.systeme.raccourci_defaut(): on_activate} + ) + self._listener.start() + + # --- warmup modĂšle ---------------------------------------------------- + def _warmup_async(self): + def run(): + try: + self.controller.transcripteur.warmup() + except Exception as e: + print(f"[erreur] warmup modĂšle : {e}") + threading.Thread(target=run, daemon=True).start() + + +def _rediriger_sortie_vers_le_journal(chemin): + """Renvoie stdout/stderr vers `chemin`. + + Sur macOS, c'est `launcher/stub.c` (freopen) qui s'en charge AVANT mĂȘme + de lancer Python, parce que le bundle n'a pas de terminal. Ce lanceur n'a + pas de stub Ă©quivalent sur Windows (lancĂ© directement, pas via un + exĂ©cutable C) : c'est donc lui qui doit faire cette redirection, le plus + tĂŽt possible dans main(), avant le premier print. + """ + parent = os.path.dirname(chemin) + if parent: + os.makedirs(parent, exist_ok=True) + journal = open(chemin, "a", buffering=1) # ligne par ligne, comme setvbuf(_IOLBF) cĂŽtĂ© stub + sys.stdout = journal + sys.stderr = journal + + +# Variable d'environnement permettant de faire tourner ce lanceur SANS toucher +# aux chemins rĂ©els de l'utilisateur (verrou d'instance, journal) : c'est ce +# qui rend possible de le lancer manuellement Ă  cĂŽtĂ© d'une instance Zonza +# dĂ©jĂ  installĂ©e, pour une vĂ©rification, sans lui disputer son verrou ni +# mĂ©langer les journaux. Absente (cas normal) : les chemins rĂ©els du systĂšme +# sont utilisĂ©s, comme app.py. +_ENV_DOSSIER_DONNEES = "ZONZA_DOSSIER_DONNEES_TEST" + + +def main(): + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) # la fenĂȘtre, c'est l'icĂŽne de la barre + + systeme = choisir_systeme() + dossier_test = os.environ.get(_ENV_DOSSIER_DONNEES) + if dossier_test: + chemin_verrou = os.path.join(dossier_test, "instance.lock") + chemin_log = os.path.join(dossier_test, "Zonza.log") + else: + chemin_verrou = None # None -> systeme.dossier_donnees(), le chemin rĂ©el + chemin_log = systeme.chemin_log() + _rediriger_sortie_vers_le_journal(chemin_log) + lock = acquerir_verrou(systeme, chemin_verrou) + if lock is None: + print("[garde] une autre instance de Zonza tourne deja — arret.") + return + main._lock = lock # garder le verrou vivant tant que le process vit + + # DĂ©pendance circulaire : le contrĂŽleur a besoin de run_on_main/on_state + # du tray, mais le tray a besoin du contrĂŽleur pour ĂȘtre construit. On la + # casse avec une case remplie APRÈS coup : les deux fermetures ci-dessous + # ne lisent `case["tray"]` qu'au moment de l'appel, jamais Ă  la + # construction — ce qui laisse le temps de la remplir. + case = {} + assemblage = construire( + run_on_main=lambda fn: case["tray"].run_on_main(fn), + on_state=lambda s: case["tray"].on_state(s), + ) + tray = ZonzaTray(assemblage["controller"], assemblage["systeme"]) + case["tray"] = tray + tray.show() + main._tray = tray # rĂ©fĂ©rence gardĂ©e pour toute la vie du processus + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/tests/test_assemblage.py b/tests/test_assemblage.py new file mode 100644 index 0000000..a336b62 --- /dev/null +++ b/tests/test_assemblage.py @@ -0,0 +1,122 @@ +"""L'assemblage commun aux deux lanceurs — sans jamais ouvrir de fenĂȘtre ni de micro. + +Le transcripteur rĂ©el charge un modĂšle (opĂ©ration lourde, parfois absente sur +la machine de test) : on injecte donc toujours un `choisir_transcripteur` et +un `detecter_moteur` doublĂ©s. L'overlay rĂ©el (Cocoa ou Qt), lui, est fait pour +ĂȘtre construit sans effet visible tant que `show()` n'est pas appelĂ© — les +tests le laissent donc rĂ©el, et vĂ©rifient justement qu'aucune fenĂȘtre ne +s'affiche. +""" +import os +import tempfile + +from assemblage import acquerir_verrou, construire +from controller import ZonzaController +from systeme import choisir_systeme + + +class _TranscripteurFactice: + def __init__(self, modele, langue, amorce): + self.modele = modele + self.langue = langue + self.amorce = amorce + + def warmup(self): + pass + + def transcrire(self, audio): + return "texte factice" + + +def _choisir_transcripteur_factice(nom, modele, langue, amorce): + return _TranscripteurFactice(modele, langue, amorce) + + +def _tmp_verrou(): + return os.path.join(tempfile.mkdtemp(), "instance.lock") + + +# --- construire() ------------------------------------------------------------ + +def test_construire_rend_un_controleur_cable_sur_les_bons_objets(): + resultat = construire( + plateforme="darwin", + detecter_moteur=lambda: "mlx", + choisir_transcripteur=_choisir_transcripteur_factice, + ) + assert isinstance(resultat["controller"], ZonzaController) + # le contrĂŽleur pointe vers CE systĂšme, CE transcripteur, CET overlay — + # pas une copie recréée Ă  cĂŽtĂ©. + assert resultat["controller"].systeme is resultat["systeme"] + assert resultat["controller"].transcripteur is resultat["transcripteur"] + assert resultat["controller"].pulse is resultat["overlay"] + assert isinstance(resultat["transcripteur"], _TranscripteurFactice) + + +def test_construire_fonctionne_aussi_pour_win32(): + resultat = construire( + plateforme="win32", + detecter_moteur=lambda: "faster-cpu", + choisir_transcripteur=_choisir_transcripteur_factice, + ) + from interface.qt import OverlayQt + assert isinstance(resultat["overlay"], OverlayQt) + + +def test_construire_transmet_run_on_main_et_on_state(): + appels = [] + resultat = construire( + plateforme="darwin", + run_on_main=lambda fn: appels.append(fn) or fn(), + on_state=lambda s: appels.append(s), + detecter_moteur=lambda: "mlx", + choisir_transcripteur=_choisir_transcripteur_factice, + ) + resultat["controller"].on_state("recording") + assert "recording" in appels + + +def test_construire_ne_leve_aucune_erreur_de_config(): + # validate_config(CONFIG) est appelĂ©e Ă  l'intĂ©rieur : si elle levait, le + # test Ă©chouerait ici plutĂŽt qu'au premier lancement rĂ©el. + construire( + plateforme="darwin", + detecter_moteur=lambda: "mlx", + choisir_transcripteur=_choisir_transcripteur_factice, + ) + + +# --- acquerir_verrou() -------------------------------------------------------- + +def test_acquerir_verrou_rend_un_handle_pour_la_premiere_instance(): + systeme = choisir_systeme() + handle = acquerir_verrou(systeme, _tmp_verrou()) + assert handle is not None + + +def test_acquerir_verrou_refuse_une_seconde_instance(): + systeme = choisir_systeme() + chemin = _tmp_verrou() + premier = acquerir_verrou(systeme, chemin) + assert premier is not None + assert acquerir_verrou(systeme, chemin) is None + + +def test_acquerir_verrou_sans_chemin_utilise_le_dossier_de_donnees_du_systeme(): + # Pas de deuxiĂšme prise ici (on toucherait le verrou rĂ©el de l'utilisateur) : + # on vĂ©rifie seulement que le chemin par dĂ©faut est bien dĂ©rivĂ© du systĂšme. + class _SystemeSonde: + def __init__(self): + self.chemin_demande = None + + def dossier_donnees(self): + return "/tmp/zonza-sonde" + + def acquerir_verrou_instance(self, chemin): + self.chemin_demande = chemin + return "handle-factice" + + sonde = _SystemeSonde() + resultat = acquerir_verrou(sonde) + assert resultat == "handle-factice" + assert sonde.chemin_demande == "/tmp/zonza-sonde/instance.lock" diff --git a/tests/test_lanceur_qt.py b/tests/test_lanceur_qt.py new file mode 100644 index 0000000..18df5a1 --- /dev/null +++ b/tests/test_lanceur_qt.py @@ -0,0 +1,124 @@ +"""Ce qui est testable dans le lanceur Qt SANS boucle d'Ă©vĂ©nements. + +`ZonzaTray.__init__` construit le menu et l'icĂŽne de façon synchrone (aucun +`.show()`, aucune boucle) : c'est dĂ©jĂ  Ă©prouvable. Le marshalage par signal +Qt.QueuedConnection, lui, N'EST PAS testĂ© ici — sa livraison dĂ©pend d'une +boucle d'Ă©vĂ©nements qui tourne, ce qu'aucun test de ce fichier ne fait +tourner. On teste donc directement les mĂ©thodes qu'un slot appellerait +(`_apply_title`, `toggle_sounds`), pas le trajet signal → file → slot. +""" +import pytest + +PySide6 = pytest.importorskip("PySide6") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from core import CONFIG # noqa: E402 +from lanceur_qt import _STATE_TITLES, ZonzaTray # noqa: E402 + + +@pytest.fixture(scope="module", autouse=True) +def _app_qt(): + # QIcon/QPixmap/QSystemTrayIcon exigent une QApplication dĂ©jĂ  construite — + # sans .exec() : on ne fait jamais tourner la boucle d'Ă©vĂ©nements. + app = QApplication.instance() or QApplication([]) + yield app + + +class _ControleurFactice: + """Ni toggle() ni transcripteur ne sont appelĂ©s tant que .show() ne l'est + pas — ce que ces tests Ă©vitent justement.""" + def toggle(self): + raise AssertionError("ne doit pas ĂȘtre appelĂ© sans .show()") + + +class _SystemeFactice: + def raccourci_defaut(self): + return "++d" + + +@pytest.fixture +def tray(): + t = ZonzaTray(_ControleurFactice(), _SystemeFactice()) + yield t + # Ă©vite qu'un menu/tray fantĂŽme traĂźne d'un test Ă  l'autre (pas de .show() + # appelĂ© ici, donc rien n'a jamais Ă©tĂ© rendu visible). + t.menu.deleteLater() + + +# --- construction du menu ----------------------------------------------- + +def test_le_menu_contient_exactement_sons_et_quitter(): + t = ZonzaTray(_ControleurFactice(), _SystemeFactice()) + titres = [a.text() for a in t.menu.actions() if not a.isSeparator()] + assert titres == ["Sons", "Quitter"] + + +def test_sons_est_une_case_a_cocher(): + t = ZonzaTray(_ControleurFactice(), _SystemeFactice()) + assert t.sounds_action.isCheckable() + + +def test_l_etat_initial_de_la_case_suit_la_config(): + CONFIG["sounds"] = True + t = ZonzaTray(_ControleurFactice(), _SystemeFactice()) + assert t.sounds_action.isChecked() is True + + +def test_le_menu_est_bien_celui_du_tray(): + t = ZonzaTray(_ControleurFactice(), _SystemeFactice()) + assert t.tray.contextMenu() is t.menu + + +# --- bascule des sons ----------------------------------------------------- + +def test_toggle_sounds_coupe_le_son(tray): + CONFIG["sounds"] = True + tray.toggle_sounds(False) + assert CONFIG["sounds"] is False + assert tray.sounds_action.isChecked() is False + + +def test_toggle_sounds_reactive_le_son(tray): + CONFIG["sounds"] = False + tray.toggle_sounds(True) + assert CONFIG["sounds"] is True + assert tray.sounds_action.isChecked() is True + + +def test_toggle_sounds_ne_touche_a_rien_d_autre_dans_la_config(tray): + avant = dict(CONFIG) + tray.toggle_sounds(True) + apres = dict(CONFIG) + del avant["sounds"] + del apres["sounds"] + assert avant == apres + + +# --- correspondance Ă©tat → icĂŽne ------------------------------------------ + +def test_trois_etats_sont_couverts(): + assert set(_STATE_TITLES) == {"idle", "recording", "transcribing"} + + +def test_etat_idle_par_defaut(tray): + assert tray._current_state == "idle" + + +@pytest.mark.parametrize("etat", ["idle", "recording", "transcribing"]) +def test_apply_title_memorise_l_etat_recu(tray, etat): + tray._apply_title(etat) + assert tray._current_state == etat + + +def test_apply_title_pose_une_icone_non_nulle_pour_chaque_etat(tray): + for etat in _STATE_TITLES: + tray._apply_title(etat) + assert not tray.tray.icon().isNull() + + +def test_un_etat_inconnu_retombe_sur_l_icone_de_repos(tray): + # _apply_title ne doit jamais planter sur un Ă©tat imprĂ©vu — repli sur + # "idle" (mĂȘme garde que app.py : `_STATE_TITLES.get(state, "đŸŽ™ïž")`). + tray._apply_title("etat-invente") + assert not tray.tray.icon().isNull() From bfafc6a48c5af0c54a310c5c2ef732f868297458 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sun, 23 Aug 2026 20:04:52 +0200 Subject: [PATCH 2/2] Le masque des modificateurs couvre enfin Command, et jamais Caps Lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux defauts trouves en rendant la sonde injectable. 1. Le masque etait ecrit 0x000F0000 A LA MAIN. Il couvrait Shift, Control et Alt mais PAS Command (0x00100000) — pourtant la touche du raccourci de Zonza. Maintenir Cmd seul passait donc inapercu. Le masque est desormais COMPOSE depuis les constantes nommees de Quartz : un masque calcule de tete se trompe en silence, compose il ne peut plus. 2. Le test lisait le CLAVIER PHYSIQUE (`modificateurs_enfonces() is False`). Son resultat dependait des mains de l'utilisateur pendant la suite — un echec inreproductible a ete observe ce jour, puis 17 executions vertes. La sonde est rendue injectable, comme SystemeWindows l'avait deja, et les quatre modificateurs sont eprouves un par un. Un test verifie que la sonde reelle rend un booleen sans lever, SANS affirmer sa valeur. Le repli hors Quartz excluait aussi le verrouillage majuscule : actif, il aurait fait attendre 2 s avant CHAQUE collage, en permanence. 195 tests. Co-Authored-By: Claude Opus 5 (1M context) --- systeme/macos.py | 35 +++++++++++++++++++++++++++++++---- tests/test_systeme.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/systeme/macos.py b/systeme/macos.py index f9ac6e1..48b8c59 100644 --- a/systeme/macos.py +++ b/systeme/macos.py @@ -9,8 +9,24 @@ _SONS = { "error": "/System/Library/Sounds/Basso.aiff", } -# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz. -_MASQUE_MODIFICATEURS = 0x000F0000 +def _masque_modificateurs(): + """Masque des modificateurs, COMPOSE depuis les constantes nommees de Quartz. + + Il etait ecrit 0x000F0000 a la main : ce masque couvrait Shift, Control et Alt + mais PAS Command (0x00100000), pourtant la touche du raccourci de Zonza. Un + masque calcule de tete se trompe en silence ; compose depuis les constantes, + il ne peut plus. Repli sur la valeur complete si Quartz est indisponible. + """ + try: + from Quartz import (kCGEventFlagMaskAlternate, kCGEventFlagMaskCommand, + kCGEventFlagMaskControl, kCGEventFlagMaskShift) + return (kCGEventFlagMaskShift | kCGEventFlagMaskControl + | kCGEventFlagMaskAlternate | kCGEventFlagMaskCommand) + except Exception: + return 0x001E0000 # sans le verrouillage majuscule : il resterait actif en permanence + + +_MASQUE_MODIFICATEURS = _masque_modificateurs() class SystemeMacOS: @@ -25,10 +41,21 @@ class SystemeMacOS: except Exception: pass # le son ne doit jamais casser la dictĂ©e + def __init__(self, sonde_modificateurs=None): + """`sonde_modificateurs` est injectable : sans cela, tout test de cette + methode lirait le CLAVIER PHYSIQUE, et son resultat dependrait des mains + de l'utilisateur au moment ou la suite tourne. Meme forme que + SystemeWindows, qui recoit deja son clavier.""" + self._sonde_modificateurs = sonde_modificateurs or self._drapeaux_reels + + @staticmethod + def _drapeaux_reels(): + from Quartz import CGEventSourceFlagsState + return CGEventSourceFlagsState(1) + def modificateurs_enfonces(self): try: - from Quartz import CGEventSourceFlagsState - return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS) + return bool(self._sonde_modificateurs() & _MASQUE_MODIFICATEURS) except Exception: return False # dans le doute, ne jamais bloquer le collage diff --git a/tests/test_systeme.py b/tests/test_systeme.py index 3595234..254f1bc 100644 --- a/tests/test_systeme.py +++ b/tests/test_systeme.py @@ -35,5 +35,37 @@ def test_le_verrou_refuse_une_seconde_prise(): assert s.acquerir_verrou_instance(chemin) is None -def test_aucun_modificateur_enfonce_pendant_les_tests(): - assert choisir_systeme().modificateurs_enfonces() is False +def test_les_modificateurs_sont_lus_par_le_masque(): + """La sonde est injectee : sans cela ce test lirait le CLAVIER PHYSIQUE et + echouerait au hasard, selon qu'une touche est enfoncee ou non pendant la + suite. C'est arrive le 2026-08-23.""" + from systeme.macos import SystemeMacOS + assert SystemeMacOS(sonde_modificateurs=lambda: 0x00000000).modificateurs_enfonces() is False + # chaque modificateur, un par un, depuis les constantes nommees + from Quartz import (kCGEventFlagMaskAlternate, kCGEventFlagMaskCommand, + kCGEventFlagMaskControl, kCGEventFlagMaskShift) + for drapeau in (kCGEventFlagMaskShift, kCGEventFlagMaskControl, + kCGEventFlagMaskAlternate, kCGEventFlagMaskCommand): + assert SystemeMacOS(sonde_modificateurs=lambda d=drapeau: d).modificateurs_enfonces() is True + + +def test_une_sonde_qui_leve_ne_bloque_jamais_le_collage(): + from systeme.macos import SystemeMacOS + + def sonde_cassee(): + raise RuntimeError("Quartz indisponible") + + assert SystemeMacOS(sonde_modificateurs=sonde_cassee).modificateurs_enfonces() is False + + +def test_le_masque_couvre_command(): + """Command etait ABSENT du masque ecrit a la main : Zonza ne voyait jamais + cette touche, pourtant celle de son propre raccourci.""" + from Quartz import kCGEventFlagMaskCommand + from systeme.macos import _MASQUE_MODIFICATEURS + assert kCGEventFlagMaskCommand & _MASQUE_MODIFICATEURS + + +def test_la_sonde_reelle_rend_un_booleen_sans_lever(): + """On n'affirme PAS sa valeur : elle depend du clavier de l'utilisateur.""" + assert isinstance(choisir_systeme().modificateurs_enfonces(), bool)