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
235 lines
11 KiB
Python
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
|