From 463dd7b752cfe30a7ddf8ec31cd093523a6c3e01 Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 16:57:05 +0000 Subject: [PATCH] =?UTF-8?q?Flowdeck=207.85.3=20=E2=80=94=20HUB-21=20:=20co?= =?UTF-8?q?nsommation=20des=20r=C3=A9glages=20g=C3=A9n=C3=A9raux=20du=20Hu?= =?UTF-8?q?b=20(banni=C3=A8re=20+=20drapeau=20recherche=5Fpartout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++ VERSION | 2 +- app/routers/search.py | 10 +- app/services/hub_reglages.py | 150 ++++++++++++++++++++++++++ app/templates/base.html | 28 +++++ app/templates/recherche.html | 1 + app/templating.py | 12 +++ static/css/app.css | 18 ++++ static/js/recherche.js | 76 +++++++++++++- tests/recherche.test.js | 13 +++ tests/test_hub_reglages.py | 198 +++++++++++++++++++++++++++++++++++ 11 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 app/services/hub_reglages.py create mode 100644 tests/test_hub_reglages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db36622..32e0ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## v7.85.3 (2026-11-06) — HUB-21 : consommation des réglages généraux du Hub + +- **Client des réglages généraux** (`app/services/hub_reglages.py`, nouveau) : propagation du pilote TikTak 0.61.22. Consultation côté serveur de `GET {hub}/api/reglages/effectifs` — endpoint public, aucun cookie, `Accept: application/json`, délai 2,0 s. URL du Hub résolue comme le reste de la suite (`draco_urls.hub_url()` : hôte de la requête, puis dérivation par l'hôte de l'agent — `xxx-dev` → `hub-dev` — jamais d'URL dev figée ni de variable explicite qui figerait la dérivation). Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Forme validée strictement (liste fermée D6(a)) ; toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels (`recherche_partout: true`, `banniere: ""`, `domaine_defaut: null`), jamais d'exception. +- **Bannière d'annonce** : global Jinja `banniere_hub` (fonction, jamais un instantané) consommé par `app/templates/base.html` — bandeau sous la barre commune, texte rendu par l'autoescape Jinja (jamais de HTML injecté), bouton « fermer » qui mémorise le texte fermé en `sessionStorage` (clé `flowdeck_banniere_masquee`) : une nouvelle annonce réapparaît. Bannière vide = aucun fragment rendu, zéro changement visuel. Styles `.banniere-hub*` dans `static/css/app.css` (fond `var(--accent)`). Hors mode embarqué (`embed_mode`), jamais sur la page publique. +- **Drapeau `recherche_partout`** : la route `/recherche` pose `data-recherche-partout` sur la coquille ; le JS vendangé `static/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console quand la valeur est `false` — leurs sections affichent « Recherche fédérée désactivée… », la recherche locale Flowdeck continue ; attribut absent ou illisible = fédération active (repli). +- **`domaine_defaut` non consommé** : Flowdeck n'a pas de notion de domaine (Pro/Famille/Perso) propre — le sélecteur de la barre commune pilote le panneau Spark, pas les vues Flowdeck. La valeur est lue et validée par le client, sans effet local ; seul l'hôte console (agent) l'applique. +- Tests : `tests/test_hub_reglages.py` (nouveau, 13 tests — validation de la forme, contrat exact de l'appel, cache 60 s, ancien cache après échec, repli Hub muet, bannière présente et échappée dans la page /recherche, absente si vide ou Hub muet, attribut du drapeau rendu selon la valeur) ; `tests/recherche.test.js` étendu (fonction pure `federationAutorisee`, 92 assertions). + ## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a)) - **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement. diff --git a/VERSION b/VERSION index f1ff083..020a223 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.85.2 +7.85.3 diff --git a/app/routers/search.py b/app/routers/search.py index 3a3f9e5..87455a1 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -56,8 +56,15 @@ def recherche_page(request: Request, q: str = Query(default="")): static/js/recherche.js (API de recherche de chaque app, en parallèle, états par application). La palette Ctrl+K native et ``GET /api/search`` restent inchangés par ailleurs. + + HUB-21 : le drapeau ``recherche_partout`` des réglages + généraux du Hub est rendu dans l'attribut + ``data-recherche-partout`` de la coquille (cache serveur de + 60 s de ``app.services.hub_reglages`` ; Hub muet → « true », + comportement actuel) — quand il est faux, recherche.js + n'interroge que la section locale Flowdeck. """ - from app.services import draco_urls + from app.services import draco_urls, hub_reglages from app.templating import ENV user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) @@ -69,6 +76,7 @@ def recherche_page(request: Request, q: str = Query(default="")): # l'onglet Console Spark (GET /api/v1/recherche, alignement #36). return HTMLResponse(template.render( request=request, q=q.strip(), user=user, + recherche_partout=hub_reglages.recherche_partout(), bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit", "agent")}, )) diff --git a/app/services/hub_reglages.py b/app/services/hub_reglages.py new file mode 100644 index 0000000..7112691 --- /dev/null +++ b/app/services/hub_reglages.py @@ -0,0 +1,150 @@ +"""Réglages généraux de la suite — consommation du Hub (HUB-21). + +Propagation du pilote TikTak (0.61.22, PR ticktick-clone #244) à +Flowdeck, sur le contrat figé du Hub 0.16.0 : ``GET +{hub}/api/reglages/effectifs`` → 200 JSON +``{"recherche_partout": bool, "banniere": string ("" = aucune), +"domaine_defaut": "pro"|"famille"|"perso"|null, +"ttl_secondes": 60}``, ``Cache-Control: public, max-age=60`` — +endpoint public, aucun cookie relayé. + +Petit client serveur sur le patron du pilote et du client des +rôles (``app.services.hub_roles``) : cache mémoire 60 s, +horodatage posé **avant** l'appel — un échec ne déclenche pas de +rafale et l'ancien cache reste en vigueur. L'URL du Hub est +résolue comme la barre commune (``app.services.draco_urls.hub_url()`` +: variable explicite ``HUB_URL`` / réglage, dérivation par l'hôte +de la requête, puis par l'hôte sœur de l'agent, puis défaut +officiel) — jamais d'URL dev figée. Consultation côté serveur, +délai court de 2 s, jamais au démarrage (premier appel paresseux, +au premier rendu de gabarit). + +**Repli obligatoire** : Hub muet, en erreur ou réponse de forme +inattendue → valeurs par défaut actuelles (pas de bannière, +recherche partout active, aucun domaine par défaut) — +comportement d'aujourd'hui à l'identique. Ce module ne lève +jamais. + +**Périmètre Flowdeck (cette PR)** : +- ``banniere`` : bandeau d'annonce dans le gabarit principal + (``app/templates/base.html``, sous la barre commune), exposé + aux gabarits par ``app.templating`` (fonction, pas un + instantané : chaque rendu lit le cache de 60 s) ; texte échappé + par l'autoescape Jinja, masquage de session navigateur. +- ``recherche_partout`` : la route ``GET /recherche`` rend + l'attribut ``data-recherche-partout`` de la coquille ; quand le + drapeau est faux, ``recherche.js`` (copie vendangée, même + modification dans chaque hôte) n'interroge plus les + applications sœurs ni la console — la recherche locale + Flowdeck (sa propre section, même origine) continue. +- ``domaine_defaut`` : **non consommé** — Flowdeck n'a pas de + notion de domaine (Pro/Famille/Perso) propre : ses vues ne sont + filtrées par aucun domaine ; le sélecteur de la barre commune + pilote le panneau Spark, pas Flowdeck. Le réglage est lu et + validé par le client, mais pas appliqué ici. +""" + +from __future__ import annotations + +import logging +import threading +import time + +logger = logging.getLogger(__name__) + +#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub. +CACHE_TTL = 60.0 # secondes + +#: Délai de consultation du Hub : court par contrat (2 s). +DELAI = 2.0 # secondes + +#: Valeurs par défaut actuelles (repli quand le Hub ne répond pas). +DEFAUTS: dict = { + "recherche_partout": True, + "banniere": "", + "domaine_defaut": None, +} + +_DOMAINES = ("pro", "famille", "perso") + +_verrou = threading.Lock() +_cache: dict | None = None +_cache_at: float = 0.0 + + +def reinitialiser_cache() -> None: + """Vide le cache (tests uniquement).""" + global _cache, _cache_at + with _verrou: + _cache = None + _cache_at = 0.0 + + +def _valider(data) -> dict | None: + """Valide la charge utile du Hub ; dict normalisé ou None.""" + if not isinstance(data, dict): + return None + recherche = data.get("recherche_partout") + banniere = data.get("banniere") + domaine = data.get("domaine_defaut") + if not isinstance(recherche, bool): + return None + if not isinstance(banniere, str): + return None + if domaine is not None and domaine not in _DOMAINES: + return None + return { + "recherche_partout": recherche, + "banniere": banniere, + "domaine_defaut": domaine, + } + + +def _consulter_hub() -> dict | None: + """Un appel au Hub ; retourne les réglages validés ou None.""" + from app.services import draco_urls + + url = f"{draco_urls.hub_url().rstrip('/')}/api/reglages/effectifs" + try: + import httpx + + r = httpx.get(url, headers={"Accept": "application/json"}, + timeout=DELAI) + if r.status_code != 200: + return None + return _valider(r.json()) + except Exception as exc: # Hub injoignable : repli, jamais d'exception + logger.debug("Réglages du Hub indisponibles (%s): %s", url, exc) + return None + + +def reglages_effectifs() -> dict: + """Réglages généraux en vigueur : réponse du Hub (cache 60 s), + sinon ancien cache, sinon les valeurs par défaut actuelles.""" + global _cache, _cache_at + with _verrou: + if _cache is not None and time.monotonic() - _cache_at < CACHE_TTL: + return dict(_cache) + # Horodatage posé avant l'appel (patron révocations) : + # l'ancien cache reste en vigueur si l'appel échoue. + _cache_at = time.monotonic() + ancien = dict(_cache) if _cache is not None else None + resultat = _consulter_hub() + if resultat is not None: + with _verrou: + _cache = resultat + _cache_at = time.monotonic() + return dict(resultat) + if ancien is not None: + return ancien + return dict(DEFAUTS) + + +def banniere() -> str: + """Texte de la bannière d'annonce en vigueur ("" = aucune).""" + return str(reglages_effectifs()["banniere"] or "") + + +def recherche_partout() -> bool: + """Drapeau « Rechercher partout » en vigueur (True = actif).""" + return bool(reglages_effectifs()["recherche_partout"]) diff --git a/app/templates/base.html b/app/templates/base.html index 360d1d2..204e98a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -150,6 +150,34 @@ {% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %} +{% if not embed_mode %}{% set _banniere_hub = banniere_hub() %}{% if _banniere_hub %} + + +{% endif %}{% endif %}
diff --git a/app/templates/recherche.html b/app/templates/recherche.html index 3f68efa..85cd8ab 100644 --- a/app/templates/recherche.html +++ b/app/templates/recherche.html @@ -12,6 +12,7 @@ son contexte `bases` (defaut 6). recherche.js garde ses replis (derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
str: @@ -105,6 +111,12 @@ def _draco_urls() -> dict: return draco_urls.toutes() +def _banniere_hub() -> str: + from app.services import hub_reglages + + return hub_reglages.banniere() + + def _plugin_enabled(slug: str) -> bool: from app.services.plugins import is_enabled diff --git a/static/css/app.css b/static/css/app.css index cd559fb..a492d84 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -5342,3 +5342,21 @@ body.has-draco-bar .app-layout { height: calc(100vh - 60px); } background: transparent; cursor: pointer; font: inherit; font-size: 13.5px; } .draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; } + +/* Bannière d'annonce des réglages généraux du Hub (HUB-21) : + bandeau rendu par base.html sous la barre commune, patron du + pilote TikTak — accent Flowdeck, texte centré. */ +.banniere-hub { + display: flex; align-items: center; gap: 10px; + padding: 8px 14px; + background: var(--accent, #2f6fed); color: #fff; + font-size: 14px; line-height: 1.35; +} +.banniere-hub[hidden] { display: none; } +.banniere-hub-texte { flex: 1 1 auto; text-align: center; overflow-wrap: anywhere; } +.banniere-hub-fermer { + flex: 0 0 auto; border: 0; background: transparent; color: inherit; + font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 6px; + border-radius: 6px; +} +.banniere-hub-fermer:hover { background: rgba(255, 255, 255, .18); } diff --git a/static/js/recherche.js b/static/js/recherche.js index 301f490..58ab135 100644 --- a/static/js/recherche.js +++ b/static/js/recherche.js @@ -246,6 +246,21 @@ return [tab]; } + /* HUB-21 (propagation des réglages généraux du Hub) : le drapeau + * « Rechercher partout », rendu par l'hôte dans l'attribut + * data-recherche-partout de #recherche (« true »/« false », lu + * côté serveur depuis GET {hub}/api/reglages/effectifs, cache + * 60 s, patron du pilote TikTak 0.61.22). Seules les valeurs + * explicitement négatives désactivent la fédération ; attribut + * absent ou illisible = fédération active — le comportement + * actuel, qui est aussi le repli quand le Hub est muet. + * Fonction pure, exposée aux tests. */ + function federationAutorisee(valeur) { + if (valeur == null) return true; + var v = String(valeur).trim().toLowerCase(); + return v !== "false" && v !== "0" && v !== "non"; + } + /* Troncature d'affichage : liste complète dans l'onglet propre à * l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */ function pageItems(items, full) { @@ -514,6 +529,7 @@ visibleKeys: visibleKeys, pageItems: pageItems, buildSearchUrl: buildSearchUrl, + federationAutorisee: federationAutorisee, tabCounts: tabCounts, searchPath: searchPath, normalize: normalize, @@ -609,6 +625,14 @@ ".

"; return; } + if (state.status === "desactivee") { + count.textContent = ""; + body.innerHTML = + '

Recherche fédérée désactivée dans les ' + + "réglages généraux de la suite (Hub) : cette application " + + "n'est pas interrogée.

"; + return; + } if (state.status === "idle") { count.textContent = ""; body.innerHTML = @@ -651,6 +675,8 @@ var input = document.getElementById("rp-q"); if (!input) return; var hostApp = root.getAttribute("data-host") || ""; + var federation = federationAutorisee( + root.getAttribute("data-recherche-partout")); var explicit = {}; APPS.forEach(function (app) { var v = root.getAttribute("data-base-" + app.key); @@ -772,7 +798,23 @@ applyTab(tab, opts); /* La console n'est interrogée qu'une fois affichée. */ if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) { - fetchConsole(currentQ, runId); + if (!federation) { + var secConsOnglet = sections.console; + if (secConsOnglet) { + states.console = { + status: "desactivee", + items: [], + message: "", + q: currentQ, + base: consoleBase, + app: "console", + }; + renderSection(secConsOnglet, states.console, true); + renderTabs(); + } + } else { + fetchConsole(currentQ, runId); + } } } @@ -865,6 +907,22 @@ APPS.forEach(function (app) { var sec = sections[app.key]; if (!sec) return; + if (!federation && app.key !== hostApp) { + /* HUB-21 : fédération désactivée par le Hub — la section + * sœur n'est pas interrogée, la recherche locale + * (app.key === hostApp, même origine) continue. */ + states[app.key] = { + status: "desactivee", + items: [], + message: "", + q: q, + base: bases[app.key], + app: app.key, + }; + renderSection(sec, states[app.key], activeTab === app.key); + renderTabs(); + return; + } states[app.key] = { status: "loading", items: [], @@ -915,7 +973,21 @@ }); /* Console : seulement si son onglet est affiché ; sinon son * compteur reste vide (pas de faux 0) jusqu'à son activation. */ - if (activeTab === "console") { + if (activeTab === "console" && !federation) { + var secConsRecherche = sections.console; + if (secConsRecherche) { + states.console = { + status: "desactivee", + items: [], + message: "", + q: q, + base: consoleBase, + app: "console", + }; + renderSection(secConsRecherche, states.console, true); + renderTabs(); + } + } else if (activeTab === "console") { fetchConsole(q, myRun); } else { states.console = { diff --git a/tests/recherche.test.js b/tests/recherche.test.js index e2720a7..ca921c9 100644 --- a/tests/recherche.test.js +++ b/tests/recherche.test.js @@ -197,4 +197,17 @@ ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide re ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion"); ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte"); +/* federationAutorisee (HUB-21) : seules les valeurs explicitement + négatives désactivent la fédération ; absent/illisible = actif + (repli = comportement actuel, Hub muet compris). */ +ok(rp.federationAutorisee("false") === false, "fédération : false désactive"); +ok(rp.federationAutorisee("0") === false, "fédération : 0 désactive"); +ok(rp.federationAutorisee(" FALSE ") === false, "fédération : casse/espaces ignorés"); +ok(rp.federationAutorisee("true") === true, "fédération : true active"); +ok(rp.federationAutorisee("1") === true, "fédération : 1 active"); +ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif"); +ok(rp.federationAutorisee(undefined) === true, "fédération : undefined = actif"); +ok(rp.federationAutorisee("") === true, "fédération : vide = actif"); +ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif"); + console.log("recherche.test.js : " + n + " assertions OK"); diff --git a/tests/test_hub_reglages.py b/tests/test_hub_reglages.py new file mode 100644 index 0000000..08b8005 --- /dev/null +++ b/tests/test_hub_reglages.py @@ -0,0 +1,198 @@ +"""HUB-21 — réglages généraux du Hub consommés par Flowdeck. + +Propagation du pilote TikTak 0.61.22 : client serveur +``app.services.hub_reglages`` (cache 60 s, horodatage posé avant +l'appel, repli intégral sur les défauts actuels), bannière +d'annonce dans le gabarit principal (texte échappé par +l'autoescape Jinja) et drapeau ``recherche_partout`` rendu en +attribut ``data-recherche-partout`` de la page /recherche (la +logique JS associée est couverte par tests/recherche.test.js). +``domaine_defaut`` n'est pas appliqué : Flowdeck n'a pas de +notion de domaine propre (voir le docstring du module). +""" + +import time + +import pytest + +from app.services import hub_reglages + + +@pytest.fixture(autouse=True) +def _cache_propre(): + hub_reglages.reinitialiser_cache() + yield + hub_reglages.reinitialiser_cache() + + +def _pose_reglages(monkeypatch, reglages, compteur=None): + """Simule la consultation du Hub (appel réseau évité).""" + def faux_consulter(): + if compteur is not None: + compteur.append(1) + return reglages + monkeypatch.setattr(hub_reglages, "_consulter_hub", faux_consulter) + + +# ── Client : validation, cache, repli ─────────────────────────────── + +def test_validation_forme_du_contrat(): + assert hub_reglages._valider({ + "recherche_partout": False, "banniere": "Maintenance dimanche", + "domaine_defaut": "famille", "ttl_secondes": 60}) == { + "recherche_partout": False, "banniere": "Maintenance dimanche", + "domaine_defaut": "famille"} + for charge in ( + None, "texte", ["x"], {}, + {"recherche_partout": "oui", "banniere": "", "domaine_defaut": None}, + {"recherche_partout": True, "banniere": 42, "domaine_defaut": None}, + {"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"}, + ): + assert hub_reglages._valider(charge) is None, charge + + +class _Reponse: + def __init__(self, status_code, payload=None): + self.status_code = status_code + self._payload = payload + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +def _pose_http(monkeypatch, reponse_ou_exception, appels=None): + import httpx + + from app.services import draco_urls + monkeypatch.setattr(draco_urls, "hub_url", + lambda: "https://hub.example") + + def faux_get(url, headers=None, timeout=None): + if appels is not None: + appels.append((url, headers, timeout)) + if isinstance(reponse_ou_exception, Exception): + raise reponse_ou_exception + return reponse_ou_exception + + monkeypatch.setattr(httpx, "get", faux_get) + + +def test_consultation_contrat_exact(monkeypatch): + appels = [] + _pose_http(monkeypatch, _Reponse(200, { + "recherche_partout": True, "banniere": "Annonce", + "domaine_defaut": None, "ttl_secondes": 60}), appels) + assert hub_reglages._consulter_hub() == { + "recherche_partout": True, "banniere": "Annonce", + "domaine_defaut": None} + url, headers, timeout = appels[0] + assert url == "https://hub.example/api/reglages/effectifs" + assert headers["Accept"] == "application/json" + assert "Cookie" not in headers # endpoint public + assert timeout == 2.0 + + +@pytest.mark.parametrize("reponse", [ + _Reponse(500, {}), + _Reponse(200, {"recherche_partout": "oui", "banniere": "", + "domaine_defaut": None}), + _Reponse(200, ValueError("json illisible")), + ConnectionError("hub injoignable"), +]) +def test_consultation_erreurs_repli_none(monkeypatch, reponse): + _pose_http(monkeypatch, reponse) + assert hub_reglages._consulter_hub() is None + + +def test_hub_muet_repli_defauts(monkeypatch): + _pose_reglages(monkeypatch, None) + assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS + assert hub_reglages.banniere() == "" + assert hub_reglages.recherche_partout() is True + + +def test_cache_60_s(monkeypatch): + appels = [] + _pose_reglages(monkeypatch, { + "recherche_partout": False, "banniere": "Annonce A", + "domaine_defaut": None}, appels) + assert hub_reglages.banniere() == "Annonce A" + assert hub_reglages.recherche_partout() is False + assert len(appels) == 1 # cache 60 s + hub_reglages._cache_at = time.monotonic() - 61 + assert hub_reglages.banniere() == "Annonce A" + assert len(appels) == 2 # expiration → rappel + + +def test_echec_ancien_cache_en_vigueur(monkeypatch): + appels = [] + _pose_reglages(monkeypatch, { + "recherche_partout": True, "banniere": "Annonce A", + "domaine_defaut": None}, appels) + assert hub_reglages.banniere() == "Annonce A" + hub_reglages._cache_at = time.monotonic() - 61 + _pose_reglages(monkeypatch, None, appels) # Hub tombe + assert hub_reglages.banniere() == "Annonce A" # ancien cache + assert len(appels) == 2 # pas de rafale + + +# ── Intégration : bannière et drapeau dans les pages ──────────────── + +@pytest.fixture() +def hub_banniere(monkeypatch): + etat = {"reglages": None} + monkeypatch.setattr( + hub_reglages, "_consulter_hub", lambda: etat["reglages"]) + hub_reglages.reinitialiser_cache() + return etat + + +def test_banniere_presente_et_echappee(client, hub_banniere): + hub_banniere["reglages"] = { + "recherche_partout": True, + "banniere": "Maintenance dimanche gras & cie", + "domaine_defaut": None} + r = client.get("/recherche") + assert r.status_code == 200 + assert 'id="banniere-hub"' in r.text + assert "Maintenance dimanche <b>gras</b> & cie" in r.text + assert "sessionStorage" in r.text + assert "flowdeck_banniere_masquee" in r.text + + +def test_banniere_absente_si_vide_ou_hub_muet(client, hub_banniere): + hub_banniere["reglages"] = { + "recherche_partout": True, "banniere": "", "domaine_defaut": "pro"} + r = client.get("/recherche") + assert r.status_code == 200 + assert "banniere-hub" not in r.text + hub_banniere["reglages"] = None + hub_reglages.reinitialiser_cache() + r = client.get("/recherche") + assert r.status_code == 200 + assert "banniere-hub" not in r.text + + +def test_recherche_drapeau_rendu_dans_la_coquille(client, hub_banniere): + hub_banniere["reglages"] = { + "recherche_partout": False, "banniere": "", "domaine_defaut": None} + r = client.get("/recherche") + assert r.status_code == 200 + assert 'data-recherche-partout="false"' in r.text + hub_banniere["reglages"] = { + "recherche_partout": True, "banniere": "", "domaine_defaut": None} + hub_reglages.reinitialiser_cache() + r = client.get("/recherche") + assert 'data-recherche-partout="true"' in r.text + hub_banniere["reglages"] = None # Hub muet → défaut actif + hub_reglages.reinitialiser_cache() + r = client.get("/recherche") + assert 'data-recherche-partout="true"' in r.text + + +def test_version_7_85_3(): + from pathlib import Path + version = (Path(__file__).parent.parent / "VERSION").read_text().strip() + assert version == "7.85.3"