Compare commits
No commits in common. "99376a8e12d77bcd2255d410c2e5350fc67f747e" and "d7680227dba4fb9161f00fd547bda837a75ca2dc" have entirely different histories.
99376a8e12
...
d7680227db
@ -15,7 +15,4 @@ def classe_overlay_pour(plateforme=None):
|
|||||||
if plateforme.startswith("darwin"):
|
if plateforme.startswith("darwin"):
|
||||||
from interface.cocoa import PulseWindow
|
from interface.cocoa import PulseWindow
|
||||||
return PulseWindow
|
return PulseWindow
|
||||||
if plateforme.startswith("win32"):
|
|
||||||
from interface.qt import OverlayQt
|
|
||||||
return OverlayQt
|
|
||||||
raise NotImplementedError(f"Aucune interface pour : {plateforme}")
|
raise NotImplementedError(f"Aucune interface pour : {plateforme}")
|
||||||
|
|||||||
343
interface/qt.py
343
interface/qt.py
@ -1,343 +0,0 @@
|
|||||||
"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran (Qt).
|
|
||||||
|
|
||||||
Copie conforme de `interface/cocoa.py`, pour les plateformes sans Cocoa
|
|
||||||
(Windows aujourd'hui). Mêmes trois zones, de gauche à droite : un bouton stop
|
|
||||||
rond rouge-orange, une forme d'onde en barres verticales, une icône de micro.
|
|
||||||
Au-dessus, une bulle sombre affiche l'état en cours.
|
|
||||||
|
|
||||||
Qt natif (PySide6). Tout le dessin a lieu sur le thread principal via un
|
|
||||||
QTimer ; les niveaux audio arrivent d'un thread de fond par set_level(), et
|
|
||||||
l'historique qui alimente la forme d'onde est protégé par un verrou — même
|
|
||||||
disposition que Cocoa.
|
|
||||||
|
|
||||||
Repère : Qt place l'origine en HAUT à gauche (y croît vers le bas), alors que
|
|
||||||
Cocoa la place en BAS à gauche (y croît vers le haut). C'est la source
|
|
||||||
d'erreur la plus probable d'un portage Cocoa → Qt : décaler un signe à un
|
|
||||||
seul endroit du dessin fait sortir une forme du mauvais côté sans qu'aucun
|
|
||||||
test ne le révèle (cf. les 42 tests verts du 21/08 pendant qu'une icône
|
|
||||||
sortait en tache blanche — seule une capture d'écran l'a montré). On bascule
|
|
||||||
donc le repère une seule fois, ici, avant de dessiner les formes qui
|
|
||||||
raisonnent comme Cocoa ; le texte de la bulle est dessiné à part, hors de ce
|
|
||||||
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, QGuiApplication, QPainter, QPainterPath, QPen
|
|
||||||
from PySide6.QtWidgets import QApplication, QWidget
|
|
||||||
|
|
||||||
from audio_level import bar_heights, push_level
|
|
||||||
from core import CONFIG, bar_metrics
|
|
||||||
|
|
||||||
_BOTTOM_MARGIN = 80.0 # au-dessus de la barre des tâches
|
|
||||||
_BUBBLE_GAP = 12.0 # espace entre la bulle et la barre
|
|
||||||
_BUBBLE_PAD = 12.0 # marge intérieure de la bulle
|
|
||||||
_TAIL = 7.0 # demi-largeur de la pointe de la bulle
|
|
||||||
_BOTTOM_INSET = 8.0 # évite que l'arrondi bas de la barre touche le bord
|
|
||||||
|
|
||||||
|
|
||||||
def _qcolor(triplet, alpha=1.0):
|
|
||||||
r, g, b = triplet[:3]
|
|
||||||
c = QColor()
|
|
||||||
c.setRgbF(r, g, b, alpha)
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
class VueOverlay(QWidget):
|
|
||||||
"""Dessine la barre, la forme d'onde et la bulle d'état.
|
|
||||||
|
|
||||||
Widget Qt top-level SANS PARENT : Qt n'exige pas, contrairement à Cocoa,
|
|
||||||
une fenêtre (NSWindow) séparée de sa vue de contenu (NSView) — un QWidget
|
|
||||||
sans parent EST déjà une fenêtre. Cette classe joue donc les deux rôles :
|
|
||||||
« vue qui dessine » ET « fenêtre affichée », la façade `OverlayQt`
|
|
||||||
n'ayant qu'à la montrer/masquer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._bars = []
|
|
||||||
self._status = ""
|
|
||||||
# Fenêtre sans cadre, toujours au-dessus, à fond transparent, et
|
|
||||||
# transparente aux clics : la souris doit traverser l'overlay jusqu'à
|
|
||||||
# l'application en dessous (Qt.WindowTransparentForInput).
|
|
||||||
self.setWindowFlags(
|
|
||||||
Qt.WindowType.FramelessWindowHint
|
|
||||||
| Qt.WindowType.WindowStaysOnTopHint
|
|
||||||
| Qt.WindowType.Tool
|
|
||||||
| Qt.WindowType.WindowTransparentForInput
|
|
||||||
)
|
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
|
|
||||||
|
|
||||||
def set_bars(self, bars):
|
|
||||||
self._bars = bars
|
|
||||||
|
|
||||||
def set_status(self, text):
|
|
||||||
self._status = text or ""
|
|
||||||
|
|
||||||
# --- morceaux de dessin (repère Cocoa : y croît vers le HAUT) ---------
|
|
||||||
def _draw_bar_background(self, painter, x, y, w, h):
|
|
||||||
painter.setBrush(_qcolor(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]))
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
painter.drawRoundedRect(
|
|
||||||
QRectF(x, y, w, h), CONFIG["bar_radius"], CONFIG["bar_radius"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def _draw_stop_button(self, painter, cx, cy, diametre):
|
|
||||||
painter.setBrush(_qcolor(CONFIG["stop_button_rgb"]))
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
painter.drawEllipse(QPointF(cx, cy), diametre / 2, diametre / 2)
|
|
||||||
# carré arrondi blanc au centre = « arrêter »
|
|
||||||
side = diametre * 0.34
|
|
||||||
painter.setBrush(_qcolor(CONFIG["stop_glyph_rgb"]))
|
|
||||||
painter.drawRoundedRect(QRectF(cx - side / 2, cy - side / 2, side, side), 2.0, 2.0)
|
|
||||||
|
|
||||||
def _draw_waveform(self, painter, x0, cy, width):
|
|
||||||
bars = self._bars
|
|
||||||
if not bars:
|
|
||||||
return
|
|
||||||
n = len(bars)
|
|
||||||
step = width / float(n)
|
|
||||||
bw = max(2.0, step * 0.38)
|
|
||||||
half_max = CONFIG["bar_height"] * 0.30 # amplitude verticale max
|
|
||||||
painter.setBrush(_qcolor(CONFIG["waveform_rgb"]))
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
for i, level in enumerate(bars):
|
|
||||||
cxi = x0 + step * (i + 0.5)
|
|
||||||
half = max(bw / 2.0, level * half_max) # jamais moins qu'un point rond
|
|
||||||
painter.drawRoundedRect(
|
|
||||||
QRectF(cxi - bw / 2, cy - half, bw, half * 2), bw / 2, bw / 2
|
|
||||||
)
|
|
||||||
|
|
||||||
def _draw_mic(self, painter, cx, cy, m):
|
|
||||||
painter.setBrush(_qcolor(CONFIG["mic_rgb"]))
|
|
||||||
largeur = m["mic_largeur"]
|
|
||||||
body_h = m["mic_corps_hauteur"]
|
|
||||||
body_y = cy - body_h / 2 + body_h * 0.23
|
|
||||||
# capsule (le micro lui-même)
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
painter.drawRoundedRect(
|
|
||||||
QRectF(cx - largeur / 2, body_y, largeur, body_h), largeur / 2, largeur / 2
|
|
||||||
)
|
|
||||||
# arceau en U qui passe SOUS la capsule. PIÈGE vérifié par capture
|
|
||||||
# d'écran (cf tâche) : `drawArc` mesure ses angles dans le repère
|
|
||||||
# ÉCRAN natif de Qt (0° = 3 h, sens visuellement anti-horaire), et ne
|
|
||||||
# tient PAS compte du basculement `scale(1, -1)` appliqué plus haut —
|
|
||||||
# contrairement aux formes remplies (rect, ellipse), qui suivent la
|
|
||||||
# transformation courante. Un premier essai avec les bornes de Cocoa
|
|
||||||
# telles quelles (195°→345°) donnait un arc reflété verticalement
|
|
||||||
# (ouvert vers le BAS, au-dessus de la capsule au lieu de dessous).
|
|
||||||
# On compense en reflétant l'angle : depart' = -(depart+etendue),
|
|
||||||
# etendue' = etendue.
|
|
||||||
rayon = m["mic_arceau_rayon"]
|
|
||||||
arc_cx, arc_cy = cx, body_y + body_h * 0.23
|
|
||||||
depart_cocoa, etendue_cocoa = 195, 345 - 195
|
|
||||||
depart = -(depart_cocoa + etendue_cocoa) % 360
|
|
||||||
# QPen neuf : `painter.pen()` aurait hérité le style NoPen laissé par
|
|
||||||
# le dessin de la capsule juste au-dessus — un pen NoPen ignore
|
|
||||||
# silencieusement couleur et épaisseur, l'arc ne se serait jamais vu.
|
|
||||||
pen = QPen(_qcolor(CONFIG["mic_rgb"]))
|
|
||||||
pen.setWidthF(max(1.0, body_h * 0.12))
|
|
||||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
||||||
painter.setPen(pen)
|
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
||||||
rect_arc = QRectF(arc_cx - rayon, arc_cy - rayon, rayon * 2, rayon * 2)
|
|
||||||
painter.drawArc(rect_arc, depart * 16, etendue_cocoa * 16)
|
|
||||||
# pied vertical sous l'arceau
|
|
||||||
pied = max(1.2, largeur * 0.18)
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
painter.setBrush(_qcolor(CONFIG["mic_rgb"]))
|
|
||||||
painter.drawRoundedRect(
|
|
||||||
QRectF(cx - pied / 2, body_y - body_h * 0.62, pied, body_h * 0.31),
|
|
||||||
pied / 2, pied / 2,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _bubble_box(self, bar_x, bar_top, bar_w):
|
|
||||||
"""Géométrie de la bulle (repère Cocoa), ou None si aucun texte."""
|
|
||||||
if not self._status:
|
|
||||||
return None
|
|
||||||
font = self.font()
|
|
||||||
font.setPointSizeF(CONFIG["bubble_font_size"])
|
|
||||||
metrics = QFontMetrics(font)
|
|
||||||
bounds = metrics.boundingRect(
|
|
||||||
QRectF(0, 0, CONFIG["bubble_max_width"], 400.0).toRect(),
|
|
||||||
int(Qt.TextFlag.TextWordWrap),
|
|
||||||
self._status,
|
|
||||||
)
|
|
||||||
tw, th = float(bounds.width()), float(bounds.height())
|
|
||||||
w, h = tw + 2 * _BUBBLE_PAD, th + 2 * _BUBBLE_PAD
|
|
||||||
x = bar_x + CONFIG["bar_height"] * 0.46 # alignée à gauche, comme la référence
|
|
||||||
y = bar_top + _BUBBLE_GAP
|
|
||||||
return {"x": x, "y": y, "w": w, "h": h, "tw": tw, "th": th, "font": font}
|
|
||||||
|
|
||||||
def _draw_bubble_shape(self, painter, box):
|
|
||||||
painter.setBrush(_qcolor(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]))
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
x, y, w, h = box["x"], box["y"], box["w"], box["h"]
|
|
||||||
painter.drawRoundedRect(QRectF(x, y, w, h), 10.0, 10.0)
|
|
||||||
# petite pointe vers la barre, en bas à gauche
|
|
||||||
tail = QPainterPath()
|
|
||||||
tail.moveTo(x + 20.0 - _TAIL, y + 1.0)
|
|
||||||
tail.lineTo(x + 20.0 + _TAIL, y + 1.0)
|
|
||||||
tail.lineTo(x + 20.0, y - 7.0)
|
|
||||||
tail.closeSubpath()
|
|
||||||
painter.drawPath(tail)
|
|
||||||
|
|
||||||
def paintEvent(self, event):
|
|
||||||
# `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
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""Fenêtre flottante Qt : show(), hide(), set_level(), set_status(). Thread-safe.
|
|
||||||
|
|
||||||
Contrairement à `PulseWindow` (NSObject, exige `alloc().init()`), c'est une
|
|
||||||
classe Python ordinaire : `classe_overlay_pour("win32")()` suffit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._history = []
|
|
||||||
self._status = ""
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._view = None
|
|
||||||
self._timer = None
|
|
||||||
self._garde = None # ferme l'overlay quoi qu'il arrive (cf show)
|
|
||||||
|
|
||||||
def _window_size(self):
|
|
||||||
return (CONFIG["bar_width"] + 80.0,
|
|
||||||
CONFIG["bar_height"] + _BOTTOM_INSET + 140.0)
|
|
||||||
|
|
||||||
def _ensure_view(self):
|
|
||||||
if self._view is not None:
|
|
||||||
return
|
|
||||||
# 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([])
|
|
||||||
|
|
||||||
ww, wh = self._window_size()
|
|
||||||
self._view = VueOverlay()
|
|
||||||
self._view.resize(int(ww), int(wh))
|
|
||||||
ecran = QGuiApplication.primaryScreen().geometry()
|
|
||||||
px = ecran.left() + (ecran.width() - ww) / 2.0
|
|
||||||
py = ecran.top() + ecran.height() - _BOTTOM_MARGIN - wh
|
|
||||||
self._view.move(int(px), int(py))
|
|
||||||
|
|
||||||
def set_level(self, level):
|
|
||||||
"""Pousse le dernier niveau audio (appelable depuis un thread de fond)."""
|
|
||||||
with self._lock:
|
|
||||||
self._history = push_level(self._history, level, CONFIG["waveform_bars"])
|
|
||||||
|
|
||||||
def set_status(self, text):
|
|
||||||
"""Texte affiché dans la bulle (thread de fond autorisé)."""
|
|
||||||
with self._lock:
|
|
||||||
self._status = text or ""
|
|
||||||
|
|
||||||
def _tick(self):
|
|
||||||
with self._lock:
|
|
||||||
history, status = list(self._history), self._status
|
|
||||||
if self._view is not None:
|
|
||||||
self._view.set_bars(
|
|
||||||
bar_heights(history, CONFIG["waveform_bars"], CONFIG["waveform_floor"])
|
|
||||||
)
|
|
||||||
self._view.set_status(status)
|
|
||||||
self._view.update()
|
|
||||||
|
|
||||||
def show(self):
|
|
||||||
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
|
|
||||||
self._ensure_view()
|
|
||||||
with self._lock:
|
|
||||||
self._history = []
|
|
||||||
self._view.show()
|
|
||||||
self._timer = QTimer()
|
|
||||||
self._timer.timeout.connect(self._tick)
|
|
||||||
self._timer.start(int(1000.0 / float(CONFIG["fps"])))
|
|
||||||
# Garde-fou : la fenêtre porte sa propre durée de vie. Si le contrôleur
|
|
||||||
# échoue à la refermer — transcription bloquée, thread mort, bug en
|
|
||||||
# amont — elle se ferme seule plutôt que de rester figée à l'écran
|
|
||||||
# (même correctif que Cocoa, 2026-08-21).
|
|
||||||
self._garde = QTimer()
|
|
||||||
self._garde.setSingleShot(True)
|
|
||||||
self._garde.timeout.connect(self._expiration)
|
|
||||||
self._garde.start(int(float(CONFIG["duree_max_overlay_s"]) * 1000.0))
|
|
||||||
|
|
||||||
def rearmer_garde(self, duree_s):
|
|
||||||
"""Remplace la garde en cours par une plus courte. Thread principal.
|
|
||||||
|
|
||||||
Appelée au passage en transcription : la garde longue couvre un
|
|
||||||
enregistrement de 2 min, mais une fois l'enregistrement fini il ne
|
|
||||||
reste que quelques secondes de calcul — inutile de laisser un halo
|
|
||||||
trois minutes.
|
|
||||||
"""
|
|
||||||
if self._garde is not None:
|
|
||||||
self._garde.stop()
|
|
||||||
self._garde = QTimer()
|
|
||||||
self._garde.setSingleShot(True)
|
|
||||||
self._garde.timeout.connect(self._expiration)
|
|
||||||
self._garde.start(int(float(duree_s) * 1000.0))
|
|
||||||
|
|
||||||
def _expiration(self):
|
|
||||||
print("[garde] overlay ouvert trop longtemps — fermeture forcée")
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def hide(self):
|
|
||||||
"""Arrête l'animation et masque la fenêtre. Thread principal."""
|
|
||||||
if self._timer is not None:
|
|
||||||
self._timer.stop()
|
|
||||||
self._timer = None
|
|
||||||
if self._garde is not None:
|
|
||||||
self._garde.stop()
|
|
||||||
self._garde = None
|
|
||||||
if self._view is not None:
|
|
||||||
self._view.hide()
|
|
||||||
with self._lock:
|
|
||||||
self._status = ""
|
|
||||||
@ -10,5 +10,3 @@ pynput>=1.8.2
|
|||||||
pyperclip>=1.11
|
pyperclip>=1.11
|
||||||
pillow>=11
|
pillow>=11
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
# PySide6 : uniquement hors macOS (Cocoa/pyobjc y sert deja l'overlay) — cf interface/qt.py.
|
|
||||||
PySide6>=6.11
|
|
||||||
|
|||||||
@ -14,86 +14,3 @@ def test_une_plateforme_inconnue_est_refusee_clairement():
|
|||||||
with pytest.raises(NotImplementedError) as e:
|
with pytest.raises(NotImplementedError) as e:
|
||||||
classe_overlay_pour("haiku-os")
|
classe_overlay_pour("haiku-os")
|
||||||
assert "haiku-os" in str(e.value)
|
assert "haiku-os" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
def test_la_fabrique_rend_overlayqt_pour_win32():
|
|
||||||
classe = classe_overlay_pour("win32")
|
|
||||||
from interface.qt import OverlayQt
|
|
||||||
assert classe is OverlayQt
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user