Files
pointeuse-optimisator/app/tests/test_repos_hebdo.py
Jacquin Antoine f6fef650a7 Astreinte : repos hebdomadaire de 35h en option (7 jours glissants)
Un week-end d'astreinte fragmenté peut ne contenir aucun trou de 35h
consécutives sans activité : le retour est alors repoussé à dernière
activité + 35h, et toute nouvelle intervention redémarre le décompte.
Désactivé par défaut, activable par compte dans Réglages ; la journée
couverte est créditée comme pour les 11h quotidiennes.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-09-09 21:49:19 +02:00

235 lines
11 KiB
Python

"""Repos hebdomadaire de 35h (24h + 11h consécutives, une fois par 7 jours
glissants) : préférence personnelle désactivée par défaut. Un week-end
d'astreinte fragmenté par des interventions peut ne contenir aucun trou de
35h — le retour est alors repoussé à dernière activité + 35h, et toute nouvelle
perturbation avant cette échéance redémarre le décompte."""
import sys
import models # noqa: F401 (DATA_DIR temporaire posé par conftest)
sys.path.insert(0, ".")
from calcul import (
DEFAULT_PLAGES, retour_repos, retour_repos_hebdo, retour_txt, compute_week,
)
import main
from fastapi.testclient import TestClient
client = TestClient(main.app)
HEURES_JOUR_MIN = 468 # 7h48
PLAGES_OFF = dict(DEFAULT_PLAGES)
PLAGES_ON = {**DEFAULT_PLAGES, "repos_hebdo_active": True}
def _p(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None,
astreinte_events=None):
return {
"date": date,
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
"astreinte_events": astreinte_events,
}
def _semaine_ouvree(premier_lundi: str) -> list[dict]:
"""Lundi→vendredi d'une semaine sans astreinte, journées complètes."""
from datetime import date, timedelta
d0 = date.fromisoformat(premier_lundi)
return [
_p((d0 + timedelta(days=i)).isoformat(), "09:00", "12:24", "13:30", "17:54")
for i in range(5)
]
# Semaine de référence : lundi 2026-07-13 (semaine ISO 29), week-end précédent
# sam 11 / dim 12 juillet, aujourd'hui figé au vendredi 17/07 10:30.
WE_ALEATOIRE = ["2026-07-11", "2026-07-12"]
def _we_fragmente(event_sam, event_dim, extra=None):
p = {r["date"]: r for r in _semaine_ouvree("2026-07-06")}
p["2026-07-11"] = _p("2026-07-11", astreinte_events=[event_sam])
p["2026-07-12"] = _p("2026-07-12", astreinte_events=[event_dim])
p.update(extra or {})
return p
# ── retour_repos_hebdo : fenêtre glissante de 7 jours ─────────────────────────
def test_fenetre_vide_sans_contrainte(freeze_today):
assert retour_repos_hebdo("2026-07-13", {}, PLAGES_ON, WE_ALEATOIRE) is None
def test_weekend_astreinte_fragmente_repousse_le_lundi(freeze_today):
"""Sam 10:00→11:00 et dim 10:00→11:00 : trous de 16h, 23h puis final — aucun
35h dans la fenêtre → retour = dim 11:00 + 35h = lundi 22:00."""
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 22 * 60
assert retour_repos("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 22 * 60
def test_desactivee_le_weekend_fragmente_ne_contraint_rien(freeze_today):
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_OFF, WE_ALEATOIRE) is None
assert retour_repos("2026-07-13", p, PLAGES_OFF, WE_ALEATOIRE) is None
def test_trou_de_35h_entre_interventions_valide_le_repos(freeze_today):
"""Sam 08:00→09:00, dim 20:00→21:00 : le trou sam 09:00 → dim 20:00 fait
exactement 35h — repos pris, aucune contrainte (alors que la dernière
activité + 35h tomberait mardi 08:00)."""
p = _we_fragmente({"debut": "08:00", "fin": "09:00"}, {"debut": "20:00", "fin": "21:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) is None
def test_repos_fini_avant_ouverture_sans_contrainte(freeze_today):
"""Sam 06:00→07:00 seulement : trou insuffisant (12h depuis vendredi), mais
sam 07:00 + 35h = dimanche 18:00 — le repos s'achève avant l'ouverture du
lundi → aucune contrainte."""
p = _we_fragmente({"debut": "06:00", "fin": "07:00"}, None)
p["2026-07-12"] = _p("2026-07-12")
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) is None
def test_repos_qui_deborde_sur_le_lendemain(freeze_today):
"""Sam 08:00→09:00, dim 17:00→18:00 : trou de 32h, retour = dim 18:00 + 35h
= mardi 05:00 → lundi entièrement couvert (retour ≥ 1440), mardi libre dès
l'ouverture (05:00 < 09:00)."""
p = _we_fragmente({"debut": "08:00", "fin": "09:00"}, {"debut": "17:00", "fin": "18:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 5 * 60
assert retour_repos_hebdo("2026-07-14", p, PLAGES_ON, WE_ALEATOIRE) is None
def test_nouvelle_intervention_pendant_le_repos_redemarre_35h(freeze_today):
"""Week-end fragmenté (retour lundi 22:00) + intervention lundi matin
07:00→08:00 sur le jour qualifié (veille dim AS) : le décompte repart de
lundi 08:00 → retour mardi 19:00. Le retour combine 11h (19:00 le lundi)
et 35h (mardi 19:00) — le plus tardif gagne."""
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"},
extra={"2026-07-13": _p("2026-07-13", astreinte_events=[{"debut": "07:00", "fin": "08:00"}])})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 19 * 60
assert retour_repos("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 19 * 60
def test_semaine_normale_avec_option_active_sans_contrainte(freeze_today):
"""Lundi→vendredi pointés, week-ends sans activité : chaque fenêtre de 7
jours contient le week-end libre (trou ≥ 35h) — aucun jour ouvré
contraint."""
p = {r["date"]: r for r in _semaine_ouvree("2026-07-13")}
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"):
assert retour_repos_hebdo(d, p, PLAGES_ON, []) is None, d
def test_repos_txt_jour_suivant():
assert retour_txt(13 * 60, "2026-07-13") == "13:00"
assert retour_txt(24 * 60 + 5 * 60, "2026-07-13") == "Mar 05:00"
assert retour_txt(24 * 60 + 19 * 60, "2026-07-13") == "Mar 19:00"
# ── compute_week : contexte de la semaine précédente ──────────────────────────
def test_compute_week_sans_contexte_le_trou_initial_valide_le_repos(freeze_today):
"""Sans les jours précédents (ancien comportement, ex. données absentes),
le trou initial de la fenêtre couvre tout : aucune contrainte."""
pointages = [_p("2026-07-13"), _p("2026-07-14"), _p("2026-07-15")]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_ON, WE_ALEATOIRE)
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
assert lundi["retour_min"] is None
def test_compute_week_contexte_reprise_le_weekend_fragmente(freeze_today):
"""Le week-end d'astreinte appartient à la semaine précédente : passé via
`contexte`, le lundi est couvert par le repos jusqu'à mardi 05:00."""
contexte = _semaine_ouvree("2026-07-06") + [
_p("2026-07-11", astreinte_events=[{"debut": "08:00", "fin": "09:00"}]),
_p("2026-07-12", astreinte_events=[{"debut": "17:00", "fin": "18:00"}]),
]
pointages = [
_p("2026-07-13"),
_p("2026-07-14"),
_p("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_p("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_ON, WE_ALEATOIRE,
contexte=contexte)
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
assert lundi["retour_min"] == 24 * 60 + 5 * 60
assert lundi["retour"] == "Mar 05:00"
assert lundi["couvert_min"] == 6 * 60 # plages 09-12 et 14-17 couvertes
assert lundi["travaille_min"] == 6 * 60 # créditées comme effectuées
assert lundi["delta_min"] == 6 * 60 - HEURES_JOUR_MIN
assert lundi["cible_matin"] is None and lundi["cible_aprem"] is None # rien à planifier
# Mardi (14 juillet, férié) : repos fini à 05:00, avant l'ouverture
mardi = next(j for j in result["jours"] if j["date"] == "2026-07-14")
assert mardi["retour_min"] is None
def test_compute_week_contexte_ignore_si_option_desactivee(freeze_today):
contexte = _semaine_ouvree("2026-07-06") + [
_p("2026-07-11", astreinte_events=[{"debut": "08:00", "fin": "09:00"}]),
_p("2026-07-12", astreinte_events=[{"debut": "17:00", "fin": "18:00"}]),
]
pointages = [_p("2026-07-13")]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_OFF, WE_ALEATOIRE,
contexte=contexte)
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
assert lundi["retour_min"] is None
# ── Préférence Réglages ───────────────────────────────────────────────────────
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_bascule_de_la_preference():
uid = "u-rh-pref"
cookies = _login(uid)
assert models.load_plages(uid).get("repos_hebdo_active") is False # désactivée par défaut
r = client.post("/settings/repos-hebdo", cookies=cookies,
data={"repos_hebdo_active": "1"}, follow_redirects=False)
assert r.status_code == 303
assert models.load_plages(uid)["repos_hebdo_active"] is True
r = client.post("/settings/repos-hebdo", cookies=cookies, follow_redirects=False)
assert r.status_code == 303
assert models.load_plages(uid)["repos_hebdo_active"] is False
def test_carte_visible_et_cochee_dans_reglages():
uid = "u-rh-ui"
cookies = _login(uid)
plages = models.load_plages(uid)
plages["repos_hebdo_active"] = True
models.save_plages(uid, plages)
r = client.get("/settings", cookies=cookies, follow_redirects=False)
assert "Repos hebdomadaire de 35h" in r.text
assert 'name="repos_hebdo_active" value="1" checked' in r.text
plages = models.load_plages(uid)
plages["astreinte_active"] = False
models.save_plages(uid, plages)
r = client.get("/settings", cookies=cookies, follow_redirects=False)
assert "Repos hebdomadaire de 35h" not in r.text
def test_sauvegarde_plages_conserve_la_preference():
uid = "u-rh-merge"
cookies = _login(uid)
plages = models.load_plages(uid)
plages["repos_hebdo_active"] = True
models.save_plages(uid, plages)
r = client.post("/settings/plages", cookies=cookies, data={
"matin_debut": "09:00", "matin_fin": "12:00",
"aprem_debut": "14:00", "aprem_fin": "17:00",
"pause_dejeuner_fin": "13:30", "arrivee_visee": "", "depart_vise": "",
}, follow_redirects=False)
assert r.status_code == 303, r.text
assert models.load_plages(uid)["repos_hebdo_active"] is True