La dernière heure saisie qui clôt la semaine à +0h00 pile, sans
aucune demi-journée restante, déclenche une fête assumée : fond disco
en rotation de teintes, pluie d'une fourgonnée de confettis (emojis
et rectangles), trophée bondissant et titre doré défilant. Une seule
fois par semaine et par onglet, fermeture au clic ou après 8 s, et
animation réduite pour prefers-reduced-motion.
Le serveur pose un drapeau data-perfect (solde nul, plus rien à
remplir, heures dues non nulles) sur les soldes re-rendus à chaque
saisie et rafraîchissement ; le client le détecte et célèbre.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
181 lines
8.7 KiB
Python
181 lines
8.7 KiB
Python
"""Refonte de l'interface : auto-enregistrement (plus de bouton ✓), menu
|
||
d'état unique (congé / férié / astreinte) et panneau aujourd'hui.
|
||
|
||
Vérifie le rendu de la page semaine et les réponses HTMX (swaps OOB) qui
|
||
régénèrent ces composants.
|
||
"""
|
||
import datetime as dt_module
|
||
import sys
|
||
|
||
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
||
import models # noqa: F401
|
||
|
||
sys.path.insert(0, ".")
|
||
import main
|
||
from fastapi.testclient import TestClient
|
||
|
||
client = TestClient(main.app)
|
||
|
||
TODAY = "2026-07-17" # vendredi de la semaine 29 (cf. datetime gelé)
|
||
|
||
|
||
class _FrozenDatetime(dt_module.datetime):
|
||
@classmethod
|
||
def today(cls):
|
||
return cls(2026, 7, 17, 10, 30)
|
||
|
||
|
||
def _login(uid: str) -> dict:
|
||
import bcrypt
|
||
models.save_auth(uid, email=f"{uid}@x.fr",
|
||
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
||
r = client.post("/login/password",
|
||
data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
||
follow_redirects=False)
|
||
assert r.status_code == 302, r.text
|
||
return {"user_id": r.cookies.get("user_id")}
|
||
|
||
|
||
def test_page_semaine_rend_les_nouveaux_composants(monkeypatch):
|
||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||
cookies = _login("u-ui")
|
||
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
# Auto-enregistrement : un témoin par jour, plus de bouton ✓ ni sa colonne
|
||
assert "btn-valider" not in r.text
|
||
assert "col-save" not in r.text
|
||
assert r.text.count('class="autosave"') == 7
|
||
assert 'hx-trigger="save-day"' in r.text
|
||
# Menu d'état : un par jour, options des cinq bascules
|
||
assert r.text.count('class="cg-menu"') == 7
|
||
assert "Congé matin" in r.text
|
||
assert "Jour férié" in r.text
|
||
assert 'cg-opt-chip as">AS</span>' in r.text # option jour d'astreinte
|
||
# Panneau aujourd'hui (le jour gelé appartient à la semaine affichée)
|
||
assert "today-panel" in r.text
|
||
assert "Aujourd'hui ·" in r.text # Jinja laisse l'apostrophe telle quelle
|
||
|
||
|
||
def test_pointage_htmx_rafrachit_panneau_et_compteurs(monkeypatch):
|
||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||
cookies = _login("u-ui-pointage")
|
||
r = client.post(f"/pointage/{TODAY}", cookies=cookies,
|
||
data={"matin_entree": "08:00", "matin_sortie": "12:00"},
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
assert f'id="calc-{TODAY}-trav"' in r.text # compteurs OOB
|
||
assert "today-panel" in r.text # soldes + panneau re-rendus
|
||
|
||
|
||
def test_bascule_congen_regenere_le_menu_d_etat(monkeypatch):
|
||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||
cookies = _login("u-ui-conge")
|
||
r = client.post(f"/conge/{TODAY}/matin", cookies=cookies,
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
# Le swap OOB régénère le bouton avec la pastille MA active
|
||
assert 'cg-chip st-ma">MA<' in r.text
|
||
assert 'id="cg-btn-' + TODAY + '"' in r.text
|
||
|
||
|
||
def test_retour_decale_affiche_le_credit_de_presence(monkeypatch):
|
||
"""Retour mercredi 11:00 (event mardi 22:39→23:49) : la colonne Travaillé
|
||
crédite les 2h de présence couvertes, avec badge et détail explicites."""
|
||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||
cookies = _login("u-ui-credit")
|
||
client.post("/astreinte/2026-07-14/toggle", cookies=cookies, follow_redirects=False)
|
||
r = client.post("/pointage/2026-07-14", cookies=cookies,
|
||
data={"astreinte_debut": ["22:39"], "astreinte_fin": ["23:49"]},
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
|
||
# Le crédit s'affiche sur la ligne du lendemain (jour du retour décalé)
|
||
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
assert 'class="calc-couvert"' in r.text
|
||
assert "+2h00 repos" in r.text
|
||
assert "matin 09:00→11:00" in r.text # détail des plages couvertes
|
||
assert "de présence comptées" in r.text # rappel dans le bloc astreinte
|
||
|
||
|
||
def test_forfait_bonifie_journee_affiche_dans_le_recap(monkeypatch):
|
||
"""Forfait 1h00 réglé + AS le samedi 18/07 : le récapitulatif montre le
|
||
forfait pondéré par la bonification journée du samedi (×1,5 → 1h30)."""
|
||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||
cookies = _login("u-ui-forfait")
|
||
r = client.post("/settings/astreinte-forfait", cookies=cookies,
|
||
data={"astreinte_forfait": "01:00"}, follow_redirects=False)
|
||
assert r.status_code == 303, r.text
|
||
client.post("/astreinte/2026-07-18/toggle", cookies=cookies, follow_redirects=False)
|
||
|
||
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
||
assert r.status_code == 200, r.text
|
||
assert "forfait 1h00 → 1h30 pondérées" in r.text # ligne du jour (bloc AS)
|
||
assert "forfaits 1h00 → 1h30 pondérés" in r.text # totaux du récapitulatif
|
||
assert "considéré réalisé en journée" in r.text # explication au survol
|
||
|
||
def test_refresh_regenerere_les_cibles_en_fonction_de_lheure(monkeypatch):
|
||
"""Le poll /refresh (toutes les 30 s, et au retour sur l'onglet) régénère
|
||
les cibles OOB avec l'heure courante : à 14:12, l'après-midi non commencé
|
||
du vendredi ancre sa reprise à maintenant — jamais à l'heure désirée 13:30
|
||
passée — et la sortie s'ajuste au reste à faire de la semaine."""
|
||
class _Frozen14(dt_module.datetime):
|
||
@classmethod
|
||
def today(cls):
|
||
return cls(2026, 7, 17, 14, 12)
|
||
|
||
monkeypatch.setattr(main, "datetime", _Frozen14)
|
||
import calcul
|
||
monkeypatch.setattr(calcul, "datetime", _Frozen14)
|
||
cookies = _login("u-ui-refresh")
|
||
# Lundi-jeudi complets, vendredi matin seulement : reste 4h42,
|
||
# seul créneau ouvert = vendredi aprem, reprise ancrée à 14:12.
|
||
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"):
|
||
client.post(f"/pointage/{d}", cookies=cookies,
|
||
data={"matin_entree": "09:00", "matin_sortie": "12:24",
|
||
"aprem_entree": "13:30", "aprem_sortie": "17:54"},
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
client.post(f"/pointage/{TODAY}", cookies=cookies,
|
||
data={"matin_entree": "09:00", "matin_sortie": "12:00"},
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
|
||
r = client.get(f"/refresh/{TODAY}", cookies=cookies, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
# Cible du jour régénérée, ancrée à maintenant (14:12), pas à 13:30
|
||
assert f'id="calc-{TODAY}-cible"' in r.text
|
||
assert "A 14:12 → 19:00" in r.text
|
||
# Toutes les cibles de la semaine suivent (swaps OOB), pas seulement le jour
|
||
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"):
|
||
assert f'id="calc-{d}-cible"' in r.text
|
||
# Le panneau aujourd'hui est rafraîchi avec la même sortie cible
|
||
assert "today-panel" in r.text
|
||
assert "19:00" in r.text
|
||
|
||
def test_semaine_parfaite_marquee_pour_la_celebration(monkeypatch):
|
||
"""La dernière heure saisie qui boucle la semaine à +0h00 pile pose le
|
||
drapeau data-perfect (clé semaine) sur les soldes : le client déclenche la
|
||
célébration. Semaine incomplète ou solde non nul → pas de drapeau."""
|
||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||
cookies = _login("u-ui-perfect")
|
||
jours = {"matin_entree": "09:00", "matin_sortie": "12:24",
|
||
"aprem_entree": "13:30", "aprem_sortie": "17:54"} # 7h48 pile
|
||
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"):
|
||
client.post(f"/pointage/{d}", cookies=cookies, data=jours,
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
|
||
# Semaine incomplète (vendredi vide) : pas de drapeau
|
||
r = client.post(f"/pointage/{TODAY}", cookies=cookies,
|
||
data={"matin_entree": "09:00", "matin_sortie": "12:24",
|
||
"aprem_entree": "13:30", "aprem_sortie": "17:54"},
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
assert 'data-perfect="2026-W29"' in r.text # 5×7h48 = 39h00 pile : parfait !
|
||
|
||
# Solde non nul (vendredi écourté) : le drapeau disparaît
|
||
r = client.post(f"/pointage/{TODAY}", cookies=cookies,
|
||
data={"matin_entree": "09:00", "matin_sortie": "12:24",
|
||
"aprem_entree": "13:30", "aprem_sortie": "17:00"},
|
||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||
assert r.status_code == 200
|
||
assert "data-perfect" not in r.text
|