From 54479d9af96e7b5914b5f48fd64c7c35bf74b897 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sun, 23 Aug 2026 17:47:02 +0200 Subject: [PATCH] Declare les trois contrats de Zonza Co-Authored-By: Claude Opus 5 --- contrats.py | 59 ++++++++++++++++++++++++++++++++++++++++++ tests/test_contrats.py | 27 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 contrats.py create mode 100644 tests/test_contrats.py diff --git a/contrats.py b/contrats.py new file mode 100644 index 0000000..579d54f --- /dev/null +++ b/contrats.py @@ -0,0 +1,59 @@ +"""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 dossier_donnees(self) -> str: + """Dossier des données de l'application (verrou d'instance, réglages).""" + + def acquerir_verrou_instance(self, chemin: str): + """Verrou exclusif non bloquant ; rend le fichier, ou None s'il est pris.""" diff --git a/tests/test_contrats.py b/tests/test_contrats.py new file mode 100644 index 0000000..6f4cb61 --- /dev/null +++ b/tests/test_contrats.py @@ -0,0 +1,27 @@ +"""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", "dossier_donnees", "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"