Un lanceur Qt pour Windows, et un assemblage partage

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralph Mayola 2026-08-23 20:00:03 +02:00
parent 3e269dfb5e
commit aa63e94239
5 changed files with 578 additions and 26 deletions

56
app.py
View File

@ -25,7 +25,6 @@ if "--diagnostic" in sys.argv:
print(mettre_en_forme(diagnostiquer())) print(mettre_en_forme(diagnostiquer()))
sys.exit(0) sys.exit(0)
import os
import threading import threading
from objc import super from objc import super
@ -41,11 +40,8 @@ from Foundation import NSObject, NSTimer
from PyObjCTools import AppHelper from PyObjCTools import AppHelper
from pynput import keyboard from pynput import keyboard
from controller import ZonzaController from assemblage import acquerir_verrou, construire
from core import CONFIG, modele_retenu, validate_config from core import CONFIG
from interface import classe_overlay_pour
from moteur import choisir_transcripteur, detecter_moteur
from systeme import choisir_systeme
_STATE_TITLES = { _STATE_TITLES = {
"idle": "🎙️", "idle": "🎙️",
@ -55,19 +51,21 @@ _STATE_TITLES = {
class ZonzaApp(NSObject): 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() self = super().init()
if self is None: if self is None:
return None return None
self._item = None self._item = None
self._sounds_item = None self._sounds_item = None
self.pulse = classe_overlay_pour().alloc().init() self.controller = controller
self.controller = ZonzaController(
CONFIG, self.pulse, transcripteur, systeme, on_state=self._set_state,
run_on_main=AppHelper.callAfter,
)
self._listener = None self._listener = None
return self return self
@ -144,23 +142,18 @@ class ZonzaApp(NSObject):
def main(): def main():
# une seule instance a la fois : sinon deux modeles en RAM et la meme # 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() systeme = choisir_systeme()
lock = systeme.acquerir_verrou_instance( lock = acquerir_verrou(systeme)
os.path.join(systeme.dossier_donnees(), "instance.lock")
)
if lock is None: if lock is None:
print("[garde] une autre instance de Zonza tourne deja — arret.") print("[garde] une autre instance de Zonza tourne deja — arret.")
return return
main._lock = lock # garder le verrou vivant tant que le process vit 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 # 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 — # (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages. # bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
@ -170,8 +163,19 @@ def main():
) )
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True}) trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
print(f"[diag] accessibilité accordée : {bool(trusted)}") print(f"[diag] accessibilité accordée : {bool(trusted)}")
app = NSApplication.sharedApplication() 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() delegate.setup()
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît) # référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
main._delegate = delegate main._delegate = delegate

75
assemblage.py Normal file
View File

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

227
lanceur_qt.py Normal file
View File

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

122
tests/test_assemblage.py Normal file
View File

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

124
tests/test_lanceur_qt.py Normal file
View File

@ -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 "<ctrl>+<alt>+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()