Compare commits
3 Commits
3e269dfb5e
...
96c1379621
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c1379621 | |||
| bfafc6a48c | |||
| aa63e94239 |
56
app.py
56
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
|
||||
|
||||
75
assemblage.py
Normal file
75
assemblage.py
Normal 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
227
lanceur_qt.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
|
||||
122
tests/test_assemblage.py
Normal file
122
tests/test_assemblage.py
Normal 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
124
tests/test_lanceur_qt.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user