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