Ctrl K
ComunidadGuías…

test_tray_fil_principal.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Non-regression de l'issue #31 (2026-09-27, macOS 27.2) : « start »
lancait le serveur puis mourait cinq secondes plus tard (SIGTRAP, code
133). Le rapport de plantage de macOS designait un thread secondaire dans
-[NSStatusItem setMenu:] : le thread de rafraichissement du menu
(rafraichir(), tray.py) appelait icon.update_menu(), que pystray transmet
tel quel a AppKit, lequel n'admet les changements d'interface que depuis
le thread principal.

Sous macOS, l'appel passe desormais par PyObjCTools.AppHelper.callAfter,
qui le confie a la boucle principale ; ailleurs, il reste direct. Ces tests
simulent la plateforme et AppHelper, et tournent donc partout : la preuve
sur un vrai Mac reste celle de l'issue."""

from __future__ import annotations

import os
import sys
import tempfile
import threading
import types
import unittest
from unittest import mock

os.environ["BLINK_BOOTSTRAP"] = "none"
_TEST_HOME = tempfile.TemporaryDirectory(prefix="blink-tray-fil-")
os.environ["BLINK_HOME"] = _TEST_HOME.name

try:
    import pystray  # noqa: E402
except ImportError:
    pystray = None
try:
    # Menu, rafraîchissement et fil principal vivent dans la bibliothèque
    # commune depuis qu'elle est née de tray.py : c'est elle qu'on patche.
    from nico579_commons import tray as commun  # noqa: E402
except ImportError:
    commun = None
import tray  # noqa: E402


def _faux_pyobjctools(confies, signal=None):
    """Un paquet PyObjCTools dont AppHelper.callAfter note ce qu'on lui
    confie, sans l'executer : il n'y a pas de boucle principale AppKit ici."""
    def call_after(fonction, *args, **kwargs):
        confies.append(fonction)
        if signal is not None:
            signal.set()

    apphelper = types.ModuleType("PyObjCTools.AppHelper")
    apphelper.callAfter = call_after
    paquet = types.ModuleType("PyObjCTools")
    paquet.AppHelper = apphelper
    return {"PyObjCTools": paquet, "PyObjCTools.AppHelper": apphelper}


@unittest.skipIf(commun is None, "nico579_commons indisponible dans cet environnement")
class SurLeFilPrincipalTests(unittest.TestCase):
    def test_macos_confie_l_appel_a_la_boucle_principale(self) -> None:
        confies, faits = [], []

        def fonction():
            faits.append(threading.current_thread().name)

        with mock.patch.dict(sys.modules, _faux_pyobjctools(confies)):
            commun.sur_le_fil_principal(fonction, "darwin")()

        self.assertEqual(faits, [], "appel fait directement, hors de la boucle principale")
        self.assertEqual(confies, [fonction])

    def test_ailleurs_l_appel_reste_direct(self) -> None:
        def fonction():
            return None

        for plateforme in ("win32", "linux"):
            with self.subTest(plateforme=plateforme):
                self.assertIs(commun.sur_le_fil_principal(fonction, plateforme), fonction)


@unittest.skipUnless(tray.disponible(), "pystray indisponible dans cet environnement")
class RafraichissementDuMenuTests(unittest.TestCase):
    """rafraichir(), le thread d'executer(), face a une icone factice."""

    def _executer(self, plateforme: str):
        confies, icones = [], []
        agi = threading.Event()

        class FauxIcon:
            def __init__(self, name, icon_img, title, menu=None):
                self.directs = []
                icones.append(self)

            def stop(self):
                pass

            def update_menu(self):
                self.directs.append(threading.current_thread() is threading.main_thread())
                agi.set()

            def run(self):
                agi.wait(timeout=5)

        vrai = commun.sur_le_fil_principal
        arret = threading.Event()
        try:
            with mock.patch.object(pystray, "Icon", FauxIcon), \
                    mock.patch.object(commun, "CADENCE_MENU", 0.01), \
                    mock.patch.object(commun, "sur_le_fil_principal",
                                      lambda fonction: vrai(fonction, plateforme)), \
                    mock.patch.dict(sys.modules, _faux_pyobjctools(confies, agi)):
                tray.executer(8765, arret, lambda: None)
        finally:
            arret.set()
        self.assertTrue(agi.is_set(), "le thread de rafraichissement n'a jamais agi")
        return icones[0], confies

    def test_macos_ne_touche_jamais_le_menu_hors_du_thread_principal(self) -> None:
        icone, confies = self._executer("darwin")
        self.assertEqual(icone.directs, [])
        self.assertIn(icone.update_menu, confies)

    def test_windows_rafraichit_directement(self) -> None:
        icone, confies = self._executer("win32")
        self.assertEqual(confies, [])
        self.assertTrue(icone.directs)


if __name__ == "__main__":
    unittest.main()