From 99376a8e12d77bcd2255d410c2e5350fc67f747e Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sun, 23 Aug 2026 19:16:41 +0200 Subject: [PATCH] Le test exerce enfin l'annulation de la garde, dans les deux interfaces Co-Authored-By: Claude Opus 5 --- interface/qt.py | 103 ++++++++++++++++++++-------------------- tests/test_interface.py | 71 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 51 deletions(-) diff --git a/interface/qt.py b/interface/qt.py index 18cf82e..57912e2 100644 --- a/interface/qt.py +++ b/interface/qt.py @@ -23,8 +23,8 @@ repère basculé, pour ne pas sortir à l'envers. import threading from PySide6.QtCore import QPointF, QRectF, Qt, QTimer -from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import QWidget +from PySide6.QtGui import QColor, QFontMetrics, QGuiApplication, QPainter, QPainterPath, QPen +from PySide6.QtWidgets import QApplication, QWidget from audio_level import bar_heights, push_level from core import CONFIG, bar_metrics @@ -184,51 +184,54 @@ class VueOverlay(QWidget): painter.drawPath(tail) def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + # `with` ferme le QPainter (.end()) meme si une exception surgit en + # cours de dessin : sans ca, la fermeture ne tenait qu'au comptage de + # references de PySide6 — suffisant aujourd'hui, fragile demain. + with QPainter(self) as painter: + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - w = CONFIG["bar_width"] - h = CONFIG["bar_height"] - x = (self.width() - w) / 2.0 - y = _BOTTOM_INSET - cy = y + h / 2.0 + w = CONFIG["bar_width"] + h = CONFIG["bar_height"] + x = (self.width() - w) / 2.0 + y = _BOTTOM_INSET + cy = y + h / 2.0 - m = bar_metrics(h) - pad, stop_d, mic_w, ecart = ( - m["marge_laterale"], m["stop_diametre"], m["mic_largeur"], m["ecart_interne"] - ) - bubble = self._bubble_box(x, y + h, w) - - # Bascule de repère : à partir d'ici, y croît vers le HAUT comme dans - # Cocoa (voir docstring du module). On la retire (`restore`) avant de - # dessiner le texte de la bulle plus bas, pour qu'il ne sorte pas à - # l'envers. - painter.save() - painter.translate(0, self.height()) - painter.scale(1, -1) - - self._draw_bar_background(painter, x, y, w, h) - self._draw_stop_button(painter, x + pad + stop_d / 2, cy, stop_d) - self._draw_mic(painter, x + w - pad - mic_w / 2, cy, m) - wave_x = x + pad + stop_d + ecart - wave_w = (x + w - pad - mic_w - ecart) - wave_x - self._draw_waveform(painter, wave_x, cy, wave_w) - if bubble is not None: - self._draw_bubble_shape(painter, bubble) - - painter.restore() - - if bubble is not None: - # Repère Qt normal (y vers le bas) : on convertit le coin bas-gauche - # « repère Cocoa » de la bulle en un rectangle Qt haut-gauche. - text_top_qt = self.height() - (bubble["y"] + _BUBBLE_PAD + bubble["th"]) - painter.setFont(bubble["font"]) - painter.setPen(_qcolor(CONFIG["bubble_text_rgb"])) - painter.drawText( - QRectF(bubble["x"] + _BUBBLE_PAD, text_top_qt, bubble["tw"], bubble["th"]), - int(Qt.TextFlag.TextWordWrap), - self._status, + m = bar_metrics(h) + pad, stop_d, mic_w, ecart = ( + m["marge_laterale"], m["stop_diametre"], m["mic_largeur"], m["ecart_interne"] ) + bubble = self._bubble_box(x, y + h, w) + + # Bascule de repère : à partir d'ici, y croît vers le HAUT comme dans + # Cocoa (voir docstring du module). On la retire (`restore`) avant de + # dessiner le texte de la bulle plus bas, pour qu'il ne sorte pas à + # l'envers. + painter.save() + painter.translate(0, self.height()) + painter.scale(1, -1) + + self._draw_bar_background(painter, x, y, w, h) + self._draw_stop_button(painter, x + pad + stop_d / 2, cy, stop_d) + self._draw_mic(painter, x + w - pad - mic_w / 2, cy, m) + wave_x = x + pad + stop_d + ecart + wave_w = (x + w - pad - mic_w - ecart) - wave_x + self._draw_waveform(painter, wave_x, cy, wave_w) + if bubble is not None: + self._draw_bubble_shape(painter, bubble) + + painter.restore() + + if bubble is not None: + # Repère Qt normal (y vers le bas) : on convertit le coin bas-gauche + # « repère Cocoa » de la bulle en un rectangle Qt haut-gauche. + text_top_qt = self.height() - (bubble["y"] + _BUBBLE_PAD + bubble["th"]) + painter.setFont(bubble["font"]) + painter.setPen(_qcolor(CONFIG["bubble_text_rgb"])) + painter.drawText( + QRectF(bubble["x"] + _BUBBLE_PAD, text_top_qt, bubble["tw"], bubble["th"]), + int(Qt.TextFlag.TextWordWrap), + self._status, + ) class OverlayQt: @@ -253,13 +256,11 @@ class OverlayQt: def _ensure_view(self): if self._view is not None: return - # Import local : PySide6 ne doit être exigé QUE quand cette classe est - # réellement instanciée (cf interface/__init__.py::classe_overlay_pour), - # jamais au chargement du module — sinon les tests réclameraient Qt sur - # toute machine, y compris ce Mac où Cocoa suffit. - from PySide6.QtGui import QGuiApplication - from PySide6.QtWidgets import QApplication - + # PySide6 est déjà exigé dès l'IMPORT de ce module (QWidget, QTimer... + # ci-dessus) : le report réel n'est pas ici, mais dans la fabrique + # (interface/__init__.py::classe_overlay_pour), qui n'importe + # `interface.qt` que pour la branche win32 — jamais sur ce Mac, où + # Cocoa suffit. if QApplication.instance() is None: QApplication([]) diff --git a/tests/test_interface.py b/tests/test_interface.py index 7b90929..407b42e 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -26,3 +26,74 @@ def test_l_overlay_qt_declare_tout_le_contrat(): classe = classe_overlay_pour("win32") for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"): assert callable(getattr(classe, methode, None)), f"{methode} manque" + + +# --- garde de duree de vie : annulation reelle a hide() --------------------- +# +# Exception assumee au principe "aucune fenetre" du haut de ce fichier : la +# seule facon honnete de prouver qu'une garde est REELLEMENT annulee (et pas +# seulement oubliee dans le champ `_garde`) est d'interroger l'etat vrai de la +# minuterie native — NSTimer.isValid() cote Cocoa, QTimer.isActive() cote Qt. +# Une doublure (fake) ne fournirait aucune garantie sur ce point : elle ne +# ferait que rejouer ce que le code de production affirme deja faire. +# +# Piege du 21/08 (halo fige) : une garde dereferencee (`_garde = None`) mais +# jamais invalidee continue de courir en coulisse, et peut refermer un overlay +# rouvert entre-temps, en pleine dictee. D'ou : on capture l'objet minuterie +# AVANT hide()/rearmer_garde(), pour verifier SON etat a lui, pas seulement le +# champ qui le reference. +from interface.cocoa import PulseWindow +from interface.qt import OverlayQt + + +def _garde_active_cocoa(garde): + return bool(garde.isValid()) + + +def _garde_active_qt(garde): + return garde.isActive() + + +_IMPLEMENTATIONS_REELLES = [ + (lambda: PulseWindow.alloc().init(), _garde_active_cocoa), + (OverlayQt, _garde_active_qt), +] +_IDS_IMPLEMENTATIONS = ["cocoa", "qt"] + + +@pytest.fixture(params=_IMPLEMENTATIONS_REELLES, ids=_IDS_IMPLEMENTATIONS) +def overlay_et_garde_active(request): + """Une vraie instance d'overlay, plus la fonction qui lit l'etat reel de sa + minuterie de garde. Ferme toujours la fenetre a la fin, meme si le test echoue.""" + construire, garde_active = request.param + ov = construire() + try: + yield ov, garde_active + finally: + ov.hide() + + +def test_show_arme_une_garde_valide(overlay_et_garde_active): + ov, garde_active = overlay_et_garde_active + ov.show() + assert ov._garde is not None + assert garde_active(ov._garde) + + +def test_hide_annule_reellement_la_garde(overlay_et_garde_active): + ov, garde_active = overlay_et_garde_active + ov.show() + garde = ov._garde # reference gardee AVANT hide() + ov.hide() + assert ov._garde is None + assert not garde_active(garde), "la minuterie doit etre invalidee, pas seulement oubliee" + + +def test_rearmer_garde_ne_laisse_qu_une_seule_garde_active(overlay_et_garde_active): + ov, garde_active = overlay_et_garde_active + ov.show() + ancienne_garde = ov._garde + ov.rearmer_garde(5.0) + assert ov._garde is not ancienne_garde + assert not garde_active(ancienne_garde), "l'ancienne garde doit etre invalidee au remplacement" + assert garde_active(ov._garde)