"""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