From 372df70c62dcfe99dbe6214a884b75ec26bc15c5 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sun, 23 Aug 2026 17:43:39 +0200 Subject: [PATCH] Plan : etape 1 du portage multiplateforme Sept taches, chacune en TDD. Point de vigilance principal : app.py garde son nom pour que le cdhash du stub reste identique et que l'autorisation d'Accessibilite survive au refactor. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-23-zonza-etape1-contrats.md | 819 ++++++++++++++++++ 1 file changed, 819 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md diff --git a/docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md b/docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md new file mode 100644 index 0000000..15bda13 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md @@ -0,0 +1,819 @@ +# Zonza multiplateforme — Étape 1 : extraire les contrats + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Nommer les trois frontières (moteur, interface, système) derrière des contrats explicites, sans changer un seul comportement. + +**Architecture:** `controller.py` reçoit ses collaborateurs par injection au lieu de les importer. Le code Cocoa est **déplacé sans être modifié**. Une fabrique par frontière choisit l'implémentation ; sur ce Mac, elle rend exactement ce qui tourne aujourd'hui. + +**Tech Stack:** Python 3.14, pytest, pyobjc (inchangé), sounddevice, pynput, mlx-whisper. + +**Critère de réussite :** les 80 tests restent verts, `./build_app.sh` produit un stub au **cdhash identique**, et Zonza dicte comme avant. + +--- + +## Contrainte non négociable + +`build_app.sh:43` grave `-DAPP_PY="$HERE/app.py"` dans le stub C. **Renommer ou déplacer `app.py` change le binaire, donc son cdhash, donc révoque l'autorisation d'Accessibilité de macOS** (vérifié le 2026-08-21). `app.py` reste donc à la racine sous ce nom — la spec parlait de `lanceur.py`, on s'en écarte volontairement. + +Vérification à faire à la fin de chaque tâche qui touche au build : + +```bash +codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash=' +``` + +## Structure de fichiers + +| Fichier | Responsabilité | +|---|---| +| `contrats.py` | **créer** — les trois protocoles, et rien d'autre | +| `systeme/__init__.py` | **créer** — `choisir_systeme()` | +| `systeme/macos.py` | **créer** — son, modificateurs, journal, verrou, raccourci | +| `moteur/__init__.py` | **créer** — `choisir_transcripteur()` | +| `moteur/mlx.py` | **créer** — `TranscripteurMLX`, extrait de `engine.Transcriber` | +| `interface/__init__.py` | **créer** — `choisir_interface()` | +| `interface/cocoa/overlay.py` | **créer** — `pulse_window.py` déplacé **tel quel** | +| `capture.py` | **créer** — `Recorder` et `inject_text`, déjà portables | +| `engine.py` | **supprimer** en fin de parcours | +| `pulse_window.py` | **supprimer** en fin de parcours | +| `controller.py` | **modifier** — injection au lieu d'imports | +| `app.py` | **modifier** — câblage, nom inchangé | +| `tests/test_contrats.py` | **créer** — suite rejouée contre chaque implémentation | + +--- + +## Task 1 : Les contrats + +**Files:** +- Create: `contrats.py` +- Test: `tests/test_contrats.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/test_contrats.py +"""Une suite unique, rejouée contre TOUTES les implémentations d'un contrat. + +Le jour où une implémentation oublie une méthode, c'est ici que ça rougit — y +compris pour les plateformes absentes de la machine de développement. +""" +import inspect + +import pytest + +from contrats import Overlay, Systeme, Transcripteur + + +@pytest.mark.parametrize("contrat, methodes", [ + (Transcripteur, ["warmup", "transcrire"]), + (Overlay, ["show", "hide", "set_level", "set_status", "rearmer_garde"]), + (Systeme, ["jouer_son", "modificateurs_enfonces", "raccourci_defaut", + "chemin_log", "acquerir_verrou_instance"]), +]) +def test_le_contrat_declare_exactement_ses_methodes(contrat, methodes): + declarees = [n for n, _ in inspect.getmembers(contrat, inspect.isfunction) + if not n.startswith("_")] + assert sorted(declarees) == sorted(methodes) + + +def test_les_contrats_sont_documentes(): + for contrat in (Transcripteur, Overlay, Systeme): + assert contrat.__doc__, f"{contrat.__name__} doit dire à quoi il sert" +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + +Run: `.venv/bin/python -m pytest tests/test_contrats.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'contrats'` + +- [ ] **Step 3 : Écrire l'implémentation minimale** + +```python +# contrats.py +"""Les trois frontières de Zonza. + +Chaque contrat est délibérément minuscule : ce sont les seuls points par lesquels +le noyau parle au monde extérieur. Tout ce qui dépend d'un système d'exploitation, +d'une bibliothèque d'affichage ou d'un moteur de transcription vit DERRIÈRE l'un +d'eux — jamais dans `core.py`, `controller.py` ni `audio_level.py`. +""" +from typing import Protocol + + +class Transcripteur(Protocol): + """Transforme un signal audio en texte.""" + + def warmup(self) -> None: + """Charge le modèle une fois, pour que la première dictée ne traîne pas.""" + + def transcrire(self, audio) -> str: + """Rend le texte d'un tableau numpy float32 mono.""" + + +class Overlay(Protocol): + """La fenêtre flottante affichée pendant la dictée.""" + + def show(self) -> None: + """Affiche la fenêtre et démarre l'animation. Thread principal.""" + + def hide(self) -> None: + """Masque la fenêtre et arrête l'animation. Thread principal.""" + + def set_level(self, level: float) -> None: + """Pousse un niveau audio dans [0, 1]. Appelable depuis un thread de fond.""" + + def set_status(self, text: str) -> None: + """Texte affiché dans la bulle. Appelable depuis un thread de fond.""" + + def rearmer_garde(self, duree_s: float) -> None: + """Remplace la durée de vie maximale de la fenêtre. Thread principal.""" + + +class Systeme(Protocol): + """Les appels qui diffèrent d'un système d'exploitation à l'autre.""" + + def jouer_son(self, nom: str) -> None: + """Joue « start », « done » ou « error » sans bloquer.""" + + def modificateurs_enfonces(self) -> bool: + """True si une touche modificatrice est physiquement enfoncée.""" + + def raccourci_defaut(self) -> str: + """Raccourci global au format pynput, propre à la plateforme.""" + + def chemin_log(self) -> str: + """Chemin absolu du journal.""" + + def acquerir_verrou_instance(self, chemin: str): + """Verrou exclusif non bloquant ; rend le fichier, ou None s'il est pris.""" +``` + +- [ ] **Step 4 : Lancer les tests** + +Run: `.venv/bin/python -m pytest tests/test_contrats.py -q` +Expected: PASS — 4 tests + +- [ ] **Step 5 : Commit** + +```bash +git add contrats.py tests/test_contrats.py +git commit -m "Declare les trois contrats de Zonza" +``` + +--- + +## Task 2 : La couche système macOS + +**Files:** +- Create: `systeme/__init__.py`, `systeme/macos.py` +- Modify: `core.py` (retirer `acquire_single_instance_lock` et `LOCK_PATH`) +- Test: `tests/test_systeme.py`, `tests/test_single_instance.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/test_systeme.py +"""La fabrique rend une implémentation conforme au contrat Systeme.""" +import os +import tempfile + +import pytest + +from contrats import Systeme +from systeme import choisir_systeme + + +def test_la_fabrique_rend_une_implementation_complete(): + s = choisir_systeme() + for methode in ("jouer_son", "modificateurs_enfonces", "raccourci_defaut", + "chemin_log", "acquerir_verrou_instance"): + assert callable(getattr(s, methode)), f"{methode} manque" + + +def test_une_plateforme_inconnue_est_refusee_clairement(): + with pytest.raises(NotImplementedError) as e: + choisir_systeme(plateforme="haiku-os") + assert "haiku-os" in str(e.value) + + +def test_le_raccourci_macos_est_celui_d_aujourd_hui(): + assert choisir_systeme(plateforme="darwin").raccourci_defaut() == "++d" + + +def test_le_verrou_refuse_une_seconde_prise(): + s = choisir_systeme() + chemin = os.path.join(tempfile.mkdtemp(), "sous-dossier", "z.lock") + assert s.acquerir_verrou_instance(chemin) is not None + assert s.acquerir_verrou_instance(chemin) is None + + +def test_aucun_modificateur_enfonce_pendant_les_tests(): + assert choisir_systeme().modificateurs_enfonces() is False +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + +Run: `.venv/bin/python -m pytest tests/test_systeme.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'systeme'` + +- [ ] **Step 3 : Écrire l'implémentation** + +```python +# systeme/macos.py +"""Appels propres à macOS. Rien ici ne doit être importé directement par le noyau.""" +import fcntl +import os +import subprocess + +_SONS = { + "start": "/System/Library/Sounds/Pop.aiff", + "done": "/System/Library/Sounds/Glass.aiff", + "error": "/System/Library/Sounds/Basso.aiff", +} + +# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz. +_MASQUE_MODIFICATEURS = 0x000F0000 + + +class SystemeMacOS: + """Implémentation du contrat Systeme pour macOS.""" + + def jouer_son(self, nom): + chemin = _SONS.get(nom) + if not chemin: + return + try: + subprocess.Popen(["afplay", chemin]) + except Exception: + pass # le son ne doit jamais casser la dictée + + def modificateurs_enfonces(self): + try: + from Quartz import CGEventSourceFlagsState + return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS) + except Exception: + return False # dans le doute, ne jamais bloquer le collage + + def raccourci_defaut(self): + return "++d" + + def chemin_log(self): + return os.path.expanduser("~/Library/Logs/Zonza.log") + + def dossier_donnees(self): + return os.path.expanduser("~/Library/Application Support/Zonza") + + def acquerir_verrou_instance(self, chemin): + parent = os.path.dirname(chemin) + if parent: + os.makedirs(parent, exist_ok=True) + fichier = open(chemin, "a") + try: + fcntl.flock(fichier, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + fichier.close() + return None + return fichier +``` + +```python +# systeme/__init__.py +"""Choisit la couche système selon la plateforme.""" +import sys + + +def choisir_systeme(plateforme=None): + """Rend l'implémentation du contrat Systeme pour cette machine. + + `plateforme` est injectable pour tester les branches absentes de la machine + de développement. + """ + plateforme = plateforme or sys.platform + if plateforme.startswith("darwin"): + from systeme.macos import SystemeMacOS + return SystemeMacOS() + raise NotImplementedError(f"Plateforme non prise en charge : {plateforme}") +``` + +- [ ] **Step 4 : Retirer le verrou de `core.py`** + +Supprimer de `core.py` : l'import `fcntl`, la constante `LOCK_PATH` et la fonction `acquire_single_instance_lock` entière. Garder `import os`, encore utilisé par le reste du fichier. + +- [ ] **Step 5 : Réécrire `tests/test_single_instance.py` pour viser la couche système** + +Remplacer la ligne d'import : + +```python +from systeme import choisir_systeme +``` + +et, dans chaque test, remplacer `acquire_single_instance_lock(...)` par `choisir_systeme().acquerir_verrou_instance(...)`. Les assertions ne changent pas. + +- [ ] **Step 6 : Lancer toute la suite** + +Run: `.venv/bin/python -m pytest -q` +Expected: PASS — 85 tests (80 + 5 nouveaux) + +- [ ] **Step 7 : Commit** + +```bash +git add systeme contrats.py core.py tests/test_systeme.py tests/test_single_instance.py +git commit -m "Extrait les appels macOS derriere le contrat Systeme" +``` + +--- + +## Task 3 : Le moteur de transcription + +**Files:** +- Create: `moteur/__init__.py`, `moteur/mlx.py` +- Test: `tests/test_moteur.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/test_moteur.py +"""La fabrique choisit le moteur d'après le matériel — décision pure, testable +depuis n'importe quelle machine.""" +import pytest + +from moteur import choisir_transcripteur, nom_moteur_pour + + +@pytest.mark.parametrize("plateforme, machine, cuda, attendu", [ + ("darwin", "arm64", False, "mlx"), + ("darwin", "x86_64", False, "faster-cpu"), + ("win32", "AMD64", True, "faster-gpu"), + ("win32", "AMD64", False, "faster-cpu"), +]) +def test_la_decision_suit_le_materiel(plateforme, machine, cuda, attendu): + assert nom_moteur_pour(plateforme, machine, cuda) == attendu + + +def test_apple_silicon_prend_mlx_meme_sans_cuda(): + assert nom_moteur_pour("darwin", "arm64", cuda_disponible=False) == "mlx" + + +def test_la_fabrique_rend_un_transcripteur_complet(): + t = choisir_transcripteur(nom="mlx", modele="mlx-community/whisper-medium", + langue="fr", amorce="") + assert callable(t.warmup) and callable(t.transcrire) + + +def test_un_moteur_inconnu_est_refuse_clairement(): + with pytest.raises(NotImplementedError) as e: + choisir_transcripteur(nom="vosk", modele="x", langue="fr", amorce="") + assert "vosk" in str(e.value) +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + +Run: `.venv/bin/python -m pytest tests/test_moteur.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'moteur'` + +- [ ] **Step 3 : Écrire l'implémentation** + +```python +# moteur/mlx.py +"""Transcription par MLX-Whisper — GPU Apple Silicon uniquement.""" +import numpy as np + +from core import clean_transcript + + +class TranscripteurMLX: + """Implémentation du contrat Transcripteur pour Apple Silicon.""" + + def __init__(self, modele, langue, amorce): + self.modele = modele + self.langue = langue + self.amorce = amorce + + def warmup(self): + """Charge le modèle une fois, sur une seconde de silence.""" + self.transcrire(np.zeros(16000, dtype="float32")) + + def transcrire(self, audio): + import mlx_whisper + resultat = mlx_whisper.transcribe( + audio, + path_or_hf_repo=self.modele, + language=self.langue, + initial_prompt=self.amorce, + ) + return clean_transcript(resultat.get("text", "")) +``` + +```python +# moteur/__init__.py +"""Choisit le moteur de transcription d'après le matériel.""" +import platform +import sys + + +def nom_moteur_pour(plateforme, machine, cuda_disponible): + """Décide du moteur. Fonction PURE : aucune interrogation de la machine ici, + ce qui rend les quatre configurations testables depuis un seul poste.""" + if plateforme.startswith("darwin") and machine in ("arm64", "aarch64"): + return "mlx" + return "faster-gpu" if cuda_disponible else "faster-cpu" + + +def detecter_moteur(): + """Interroge la machine, puis délègue la décision à `nom_moteur_pour`.""" + return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible()) + + +def _cuda_disponible(): + try: + import ctypes + ctypes.CDLL("libcudart.so") + return True + except Exception: + return False + + +def choisir_transcripteur(nom, modele, langue, amorce): + """Rend l'implémentation du contrat Transcripteur portant ce nom.""" + if nom == "mlx": + from moteur.mlx import TranscripteurMLX + return TranscripteurMLX(modele, langue, amorce) + raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}") +``` + +- [ ] **Step 4 : Lancer les tests** + +Run: `.venv/bin/python -m pytest tests/test_moteur.py -q` +Expected: PASS — 7 tests + +- [ ] **Step 5 : Commit** + +```bash +git add moteur tests/test_moteur.py +git commit -m "Extrait la transcription derriere le contrat Transcripteur" +``` + +--- + +## Task 4 : Déplacer l'interface Cocoa sans la modifier + +**Files:** +- Create: `interface/__init__.py`, `interface/cocoa/__init__.py`, `interface/cocoa/overlay.py` +- Delete: `pulse_window.py` +- Test: `tests/test_interface.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```python +# tests/test_interface.py +"""La fabrique rend un overlay conforme au contrat, sans ouvrir de fenêtre.""" +import pytest + +from interface import classe_overlay_pour + + +def test_l_overlay_macos_declare_tout_le_contrat(): + classe = classe_overlay_pour("darwin") + for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"): + assert callable(getattr(classe, methode, None)), f"{methode} manque" + + +def test_une_plateforme_inconnue_est_refusee_clairement(): + with pytest.raises(NotImplementedError) as e: + classe_overlay_pour("haiku-os") + assert "haiku-os" in str(e.value) +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + +Run: `.venv/bin/python -m pytest tests/test_interface.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'interface'` + +- [ ] **Step 3 : Déplacer le fichier SANS toucher à son contenu** + +```bash +mkdir -p interface/cocoa +git mv pulse_window.py interface/cocoa/overlay.py +touch interface/cocoa/__init__.py +``` + +Ne modifier **aucune ligne** du corps de `overlay.py`. Ses imports `from audio_level import ...` et `from core import ...` continuent de fonctionner : `conftest.py` place la racine du projet sur `sys.path`. + +- [ ] **Step 4 : Écrire la fabrique** + +```python +# interface/__init__.py +"""Choisit l'implémentation d'overlay selon la plateforme.""" +import sys + + +def classe_overlay_pour(plateforme=None): + """Rend la CLASSE d'overlay (pas une instance : Cocoa exige `alloc().init()`).""" + plateforme = plateforme or sys.platform + if plateforme.startswith("darwin"): + from interface.cocoa.overlay import PulseWindow + return PulseWindow + raise NotImplementedError(f"Aucune interface pour : {plateforme}") +``` + +- [ ] **Step 5 : Lancer toute la suite** + +Run: `.venv/bin/python -m pytest -q` +Expected: PASS — 94 tests + +- [ ] **Step 6 : Commit** + +```bash +git add interface tests/test_interface.py +git commit -m "Deplace l'overlay Cocoa derriere le contrat Overlay, sans le modifier" +``` + +--- + +## Task 5 : La capture audio, déjà portable + +**Files:** +- Create: `capture.py` +- Modify: `controller.py:15` +- Delete: `engine.py` +- Test: `tests/test_liberation_flux.py` + +- [ ] **Step 1 : Déplacer `Recorder` et `inject_text`** + +```bash +git mv engine.py capture.py +``` + +Puis, dans `capture.py` : +- supprimer la classe `Transcriber` (elle vit désormais dans `moteur/mlx.py`) ; +- supprimer `SOUNDS`, `play_sound` et `modificateurs_enfonces` (ils vivent dans `systeme/macos.py`) ; +- remplacer l'import `from core import CONFIG, attendre, clean_transcript` par `from core import CONFIG, attendre` ; +- donner à `inject_text` un paramètre `systeme` : + +```python +def inject_text(text, systeme): + """Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active. + + On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore + la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et + l'application cible ne colle rien — sans la moindre erreur de notre côté + (constaté le 2026-08-21). + """ + if not text: + return + pyperclip.copy(text) + libre = attendre( + lambda: not systeme.modificateurs_enfonces(), + CONFIG["attente_modificateurs_s"], + 0.03, + ) + if not libre: + print("[collage] modificateurs encore enfoncés — collage tenté quand même") + time.sleep(CONFIG["delai_presse_papier_s"]) + with _kbd.pressed(Key.cmd): + _kbd.press("v") + _kbd.release("v") + print("[collage] ok") +``` + +- [ ] **Step 2 : Mettre à jour l'import des tests** + +Dans `tests/test_liberation_flux.py`, remplacer : + +```python +from engine import Recorder +``` + +par : + +```python +from capture import Recorder +``` + +- [ ] **Step 3 : Lancer les tests de capture** + +Run: `.venv/bin/python -m pytest tests/test_liberation_flux.py -q` +Expected: PASS — 6 tests + +- [ ] **Step 4 : Commit** + +```bash +git add capture.py tests/test_liberation_flux.py +git commit -m "Isole la capture audio, deja portable, dans capture.py" +``` + +--- + +## Task 6 : Le contrôleur reçoit ses collaborateurs + +**Files:** +- Modify: `controller.py` +- Test: `tests/test_statut_bulle.py` + +- [ ] **Step 1 : Écrire le test qui échoue** + +Ajouter à `tests/test_statut_bulle.py` : + +```python +def test_le_controleur_n_importe_aucun_module_de_plateforme(): + """Le noyau ne doit connaître que des contrats. Si cette assertion tombe, une + dépendance de plateforme s'est glissée dans le chemin portable.""" + import controller + source = open(controller.__file__, encoding="utf-8").read() + for interdit in ("AppKit", "Foundation", "objc", "Quartz", "mlx_whisper", + "afplay", "from engine import"): + assert interdit not in source, f"{interdit} n'a rien à faire dans controller.py" +``` + +- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue** + +Run: `.venv/bin/python -m pytest tests/test_statut_bulle.py -q -k importe` +Expected: FAIL — `from engine import n'a rien à faire dans controller.py` + +- [ ] **Step 3 : Injecter les collaborateurs** + +Dans `controller.py`, remplacer la ligne 15 : + +```python +from engine import Recorder, Transcriber, inject_text, play_sound +``` + +par : + +```python +from capture import Recorder, inject_text +``` + +Puis modifier la signature et le corps de `__init__` : + +```python + def __init__(self, config, pulse_window, transcripteur, systeme, + on_state=None, run_on_main=None, lancer_tache=None): + self.config = config + self.pulse = pulse_window + self.transcriber = transcripteur + self.systeme = systeme +``` + +Le reste de `__init__` est inchangé, sauf la ligne qui construisait le transcripteur, à supprimer : + +```python + self.transcriber = Transcriber(config["model"], config["language"]) # SUPPRIMER +``` + +Enfin, remplacer partout dans le fichier `play_sound(` par `self.systeme.jouer_son(`, et `inject_text(text)` par `inject_text(text, self.systeme)`. + +- [ ] **Step 4 : Adapter les doublures des tests** + +Dans `tests/test_statut_bulle.py`, ajouter une doublure de système et la passer à la fabrique locale `_ctrl` : + +```python +class FauxSysteme: + def __init__(self): self.sons = [] + def jouer_son(self, nom): self.sons.append(nom) + def modificateurs_enfonces(self): return False + def raccourci_defaut(self): return "++d" + def chemin_log(self): return "/tmp/zonza-test.log" + def acquerir_verrou_instance(self, chemin): return object() + + +def _ctrl(recorder, taches=None): + pulse = FauxPulse() + faux_transcripteur = type("T", (), { + "transcrire": staticmethod(lambda a: "bonjour"), + "warmup": staticmethod(lambda: None), + })() + c = ZonzaController(CONFIG, pulse, faux_transcripteur, FauxSysteme(), + lancer_tache=taches) + c.recorder = recorder + return c, pulse +``` + +Le contrôleur appelle `transcrire`, non plus `transcribe` : renommer l'appel dans `_fermer_et_transcrire`. + +- [ ] **Step 5 : Lancer toute la suite** + +Run: `.venv/bin/python -m pytest -q` +Expected: PASS — 95 tests + +- [ ] **Step 6 : Commit** + +```bash +git add controller.py tests/test_statut_bulle.py +git commit -m "Le controleur recoit transcripteur et systeme par injection" +``` + +--- + +## Task 7 : Câbler le lanceur et prouver l'absence de régression + +**Files:** +- Modify: `app.py` +- Delete: `pulse_window.py` (déjà déplacé), `engine.py` (déjà déplacé) + +- [ ] **Step 1 : Relever le cdhash AVANT toute reconstruction** + +```bash +codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash=' +``` + +Noter la valeur. Elle devra être **identique** à la fin : `app.py` ne change pas de nom, donc le stub ne change pas, donc l'autorisation d'Accessibilité survit. + +- [ ] **Step 2 : Câbler les fabriques dans `app.py`** + +Remplacer les imports (lignes 30-32) : + +```python +from controller import ZonzaController +from core import CONFIG, validate_config +from interface import classe_overlay_pour +from moteur import choisir_transcripteur, detecter_moteur +from systeme import choisir_systeme +``` + +Dans `ZonzaApp.init`, remplacer la construction du contrôleur : + +```python + self.systeme = choisir_systeme() + self.pulse = classe_overlay_pour().alloc().init() + transcripteur = choisir_transcripteur( + nom=detecter_moteur(), + modele=CONFIG["model"], + langue=CONFIG["language"], + amorce=CONFIG["initial_prompt"], + ) + self.controller = ZonzaController( + CONFIG, self.pulse, transcripteur, self.systeme, + on_state=self._set_state, run_on_main=AppHelper.callAfter, + ) +``` + +Dans `main()`, remplacer la prise du verrou : + +```python + systeme = choisir_systeme() + import os + lock = systeme.acquerir_verrou_instance( + os.path.join(systeme.dossier_donnees(), "instance.lock") + ) + if lock is None: + print("[garde] une autre instance de Zonza tourne deja — arret.") + return + main._lock = lock +``` + +Et dans `_start_hotkey`, lire le raccourci depuis le système : + +```python + self._listener = keyboard.GlobalHotKeys( + {self.systeme.raccourci_defaut(): on_activate} + ) +``` + +- [ ] **Step 3 : Lancer toute la suite** + +Run: `.venv/bin/python -m pytest -q` +Expected: PASS — 95 tests + +- [ ] **Step 4 : Reconstruire et vérifier le cdhash** + +```bash +pkill -f '/Applications/Zonza.app/Contents/MacOS/zonza' +./build_app.sh +codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash=' +``` + +Expected: **la même valeur qu'à l'étape 1**. Si elle diffère, l'autorisation d'Accessibilité sera révoquée — s'arrêter et comprendre pourquoi avant de continuer. + +- [ ] **Step 5 : Vérifier que Zonza fonctionne réellement** + +```bash +open -a Zonza +sleep 10 +grep 'accessibilité accordée' ~/Library/Logs/Zonza.log | tail -1 +``` + +Expected: `[diag] accessibilité accordée : True` + +Puis dicter une phrase contenant « Playwright » et « Gitea », et vérifier dans le journal : +- une ligne `[texte]` avec les deux termes correctement orthographiés ; +- une ligne `[collage] ok` juste après. + +- [ ] **Step 6 : Commit** + +```bash +git add app.py +git commit -m "Cable le lanceur sur les trois fabriques" +git push origin main +``` + +--- + +## Vérification finale de l'étape + +- [ ] `.venv/bin/python -m pytest -q` → 95 tests verts +- [ ] `codesign -dvvv /Applications/Zonza.app | grep CDHash` → identique à avant l'étape +- [ ] `[diag] accessibilité accordée : True` dans le journal +- [ ] une dictée réelle produit `[texte]` puis `[collage] ok` +- [ ] `grep -rE 'AppKit|Quartz|mlx_whisper' core.py controller.py audio_level.py capture.py` → **aucun résultat** +- [ ] `git status --porcelain` → vide