Files
pointeuse-optimisator/app/tests/test_astreinte.py
Jacquin Antoine 5506f1ba39 Le forfait d'astreinte ne compte plus dans les heures travaillées
Le temps travaillé, l'écart du jour et le solde de la semaine ne
restituent plus que les heures pointées. Le forfait des jours AS reste
suivi à part : ligne du jour et récapitulatif d'astreinte, libellés et
documentation ajustés en conséquence.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-09-09 07:26:28 +02:00

595 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé."""
from calcul import (
compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes,
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour,
retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ASTREINTE_DEBUT_MIN,
)
import models
HEURES_JOUR_MIN = 468 # 7h48
PLAGES = dict(DEFAULT_PLAGES)
def _pointage(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,
}
# ── Qualification ─────────────────────────────────────────────────────────────
def test_jour_astreinte_qualifie():
astreintes = ["2026-07-14"]
assert jour_astreinte_qualifie("2026-07-14", astreintes) # jour d'astreinte
assert jour_astreinte_qualifie("2026-07-15", astreintes) # lendemain
assert not jour_astreinte_qualifie("2026-07-13", astreintes)
assert not jour_astreinte_qualifie("2026-07-15", [])
# ── Heures dues : exclusion avant l'heure de retour ───────────────────────────
def test_heures_dues_retour_avant_ouverture_sans_effet():
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("08:30")) == HEURES_JOUR_MIN
def test_heures_dues_retour_midi_retire_la_presence_du_matin():
# 13:00 → plage matin 09:00-12:00 (180 min) retirée du décompte
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == HEURES_JOUR_MIN - 180
def test_heures_dues_retour_apres_midi_retire_matin_et_partie_aprem():
# 15:00 → matin (180) + 14:00→15:00 (60)
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == HEURES_JOUR_MIN - 240
def test_heures_dues_conge_matin_pas_de_double_exclusion():
conges = [{"date": "2026-07-13", "type": "matin"}]
# base = demi-journée aprem (234) ; le matin déjà en congé n'est pas décompté deux fois
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == 234
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == 234 - 60
def test_exclusion_retour_sans_retour():
assert exclusion_retour(set(), None, PLAGES) == (0, 0)
# ── Heures travaillées : la présence avant le retour ne compte pas ────────────
def test_heures_travaillees_presence_avant_retour_non_comptee():
p = _pointage("2026-07-13", "09:00", "12:00", "14:00", "18:00")
# matin 09:00→12:00 entièrement avant le retour 13:00 → 0 ; aprem 14:00→18:00 → 240
assert heures_travaillees(p, hhmm_to_minutes("13:00")) == 240
assert heures_travaillees(p) == 180 + 240
def test_heures_travaillees_entree_chevauchant_le_retour():
p = _pointage("2026-07-13", "08:00", "12:00")
# entrée décalée à 10:30 → 90 min comptées
assert heures_travaillees(p, hhmm_to_minutes("10:30")) == 90
# ── Retour au plus tôt après 11h de repos (depuis le dernier événement) ───────
def test_retour_fin_nocturne_sur_le_lendemain():
"""Event 01:00→02:00 saisi sur le lendemain de l'astreinte → retour 13:00."""
p_by_date = {"2026-07-15": _pointage("2026-07-15",
astreinte_events=[{"debut": "01:00", "fin": "02:00"}])}
# 02:00 + 11h = 13:00
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
def test_retour_activite_matinale_sur_le_lendemain_qualifie():
"""Event 05:00→06:00 saisi sur le lendemain de l'astreinte : le matin du
lendemain est dans la fenêtre 18:00→18:00, l'activité s'est terminée à 06:00
ce jour-là → repos jusqu'à 17:00 le même jour."""
p_by_date = {"2026-07-15": _pointage("2026-07-15",
astreinte_events=[{"debut": "05:00", "fin": "06:00"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
def test_retour_intervention_avant_18h_sur_le_jour_as_ignoree():
"""L'astreinte couvre 18:00 → 18:00 : une intervention du matin du jour
déclaré (avant 18:00) n'est pas de l'astreinte et ne déclenche pas de repos."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "10:00", "fin": "12:00"}])}
assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) is None
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
def test_retour_intervention_finissant_avant_18h_la_veille_ignoree():
"""Event 16:00→17:30 la veille déclarée AS : fin 17:30 arrondie 17:30, avant
le début de la fenêtre (18:00) → pas de contrainte de retour."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "16:00", "fin": "17:30"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
"""Event 21:00→23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "21:00", "fin": "23:30"}])}
# 23:30 + 11h = 10:30 le lendemain
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30
def test_retour_fin_hors_quart_arrondie_au_quart_entame():
"""Event 21:00→23:37 : le quart d'heure entamé est travaillé, fin comptée
23:45 → repos terminé 10:45 le lendemain."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "21:00", "fin": "23:37"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 45
def test_retour_fin_nocturne_hors_quart_arrondie():
"""Event 23:00→01:07 (à cheval sur minuit) : fin comptée 01:15 → retour 12:15."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "23:00", "fin": "01:07"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15
def test_retour_event_a_cheval_sur_minuit_saisi_la_veille():
"""Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour
même → 01:00 + 11h = 12:00."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "23:00", "fin": "01:00"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60
def test_retour_nuit_d_astreinte_finissant_au_matin():
"""Event 23:00→06:00 (à cheval sur minuit) sur le jour d'astreinte : fin réelle
06:00 le lendemain → retour 17:00 le lendemain."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "23:00", "fin": "06:00"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
def test_retour_la_plus_tardive_des_deux_fins_gagne():
"""Fins veille 23:30 (retour 10:30) ET jour 02:00 (retour 13:00) : la plus tardive gagne."""
p_by_date = {
"2026-07-14": _pointage("2026-07-14", astreinte_events=[{"debut": "21:00", "fin": "23:30"}]),
"2026-07-15": _pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
def test_retour_event_sans_astreinte_ignore():
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "21:00", "fin": "23:30"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None
def test_retour_fin_tot_ne_contraint_rien():
"""Event 17:15→17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "17:15", "fin": "17:30"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
# ── Décompte astreinte : tranches de 15 min entamées, par règle ───────────────
def test_decompte_tranche_entamee_comptee_entiere():
"""22:07→22:20 touche les tranches alignées 22:00 et 22:15 : comptées entières."""
d = decompte_astreinte("2026-07-13", [{"debut": "22:07", "fin": "22:20"}])
assert d["tranches"] == 2 # tranches 22:00 + 22:15 entamées
assert d["total_min"] == 2 * PAS_ASTREINTE_MIN # 30
assert d["total_h"] == "0h30"
assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
assert d["total_pondere_min"] == 45 # 30 × 1,5
def test_decompte_arrondi_des_bornes():
"""09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches en Journée ×1."""
d = decompte_astreinte("2026-07-13", [{"debut": "09:50", "fin": "10:05"}])
assert d["tranches"] == 2 # tranches 09:45 + 10:00
assert d["total_min"] == 30
assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1
assert d["total_pondere_min"] == 30
def test_decompte_evenement_a_cheval_sur_minuit():
"""Mardi 14/07 (férié), 23:00→01:00 : 8 tranches, tout en Nuit ×1,5."""
d = decompte_astreinte("2026-07-14", [{"debut": "23:00", "fin": "01:00"}])
assert d["tranches"] == 8 # 23:00→01:00 = 120 min = 8 × 15
assert d["total_min"] == 120
assert d["categories"][0]["nom"] == "Nuit"
assert d["total_pondere_min"] == 180 # 120 × 1,5
def test_decompte_jour_ferie_en_journee():
"""14 juillet : la journée compte en « Dimanche / férié » ×2."""
d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}])
assert d["total_min"] == 120 # 2h = 8 tranches
assert d["categories"][0]["nom"] == "Dimanche / férié"
assert d["total_pondere_min"] == 240 # 120 × 2
assert d["total_pondere_h"] == "4h00"
def test_decompte_vendredi_vers_samedi_reste_en_nuit():
"""23:00→02:00 le vendredi : 12 tranches, toutes en Nuit (même après minuit)."""
d = decompte_astreinte("2026-07-17", [{"debut": "23:00", "fin": "02:00"}])
assert d["tranches"] == 12 # 180 min
assert d["total_min"] == 180
assert [c["nom"] for c in d["categories"]] == ["Nuit"]
assert d["total_pondere_min"] == 270 # 180 × 1,5
def test_decompte_chevauchement_dedouble():
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
qu'une fois, attribuées au premier événement ; les lignes somment au total."""
d = decompte_astreinte("2026-07-13", [
{"debut": "10:00", "fin": "10:20"},
{"debut": "10:10", "fin": "10:30"},
])
assert len(d["events"]) == 2
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15
assert d["events"][0]["min"] == 30
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées
assert d["events"][1]["min"] == 0
assert d["tranches"] == 2 # union dédoublonnée
assert d["total_min"] == 30
assert sum(e["min"] for e in d["events"]) == d["total_min"]
assert d["categories"][0]["nom"] == "Journée"
def test_decompte_quart_partage_entre_interventions():
"""Deux interventions qui se suivent et finissent/débutent dans le même quart :
le quart n'est compté qu'une fois (attribué à la première), les lignes
d'événements somment au total du jour."""
d = decompte_astreinte("2026-07-13", [
{"debut": "22:39", "fin": "23:05"},
{"debut": "23:02", "fin": "23:49"},
])
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté)
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6
assert d["total_min"] == 90
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
def test_decompte_event_incomplet_ignore():
"""Un événement sans debut ou sans fin est ignoré."""
d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}])
assert d["tranches"] == 0
assert d["total_min"] == 0
assert d["events"] == []
assert d["categories"] == []
def test_decompte_regles_personnalisees():
"""Une config custom remplace les règles par défaut (fenêtre à cheval sur minuit)."""
facteurs = [{"nom": "Garde", "jours": ["semaine"], "debut": "18:00", "fin": "09:00", "facteur": 2}]
d = decompte_astreinte("2026-07-13", [{"debut": "20:00", "fin": "21:00"}], facteurs)
assert d["total_min"] == 60
assert d["categories"][0]["nom"] == "Garde"
assert d["total_pondere_min"] == 120
# ── Nominaux ──────────────────────────────────────────────────────────────────
def test_nominaux_exclusion_totale_du_matin():
m, a = nominaux_du_jour(set(), 360, 180, 0)
assert (m, a) == (0, 180)
def test_nominaux_sans_exclusion_reste_la_moitié():
m, a = nominaux_du_jour(set(), 468, 0, 0)
assert (m, a) == (234, 234)
# ── compute_week ──────────────────────────────────────────────────────────────
def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today):
"""Lundi et jeudi complets, mardi astreinte avec event 23:00→01:00, mercredi
vide : les heures d'astreinte n'entrent jamais dans total_travaille/total_du,
seulement dans result["astreinte"]."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", astreinte_events=[{"debut": "23:00", "fin": "01:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
# seul le pointé compte : 2 × 468 = 15h36, astreinte exclue
assert result["total_travaille"] == "15h36"
ast = result["astreinte"]
assert ast["actif"] is True
assert ast["total_min"] == 120 # 23:00→01:00 = 8 tranches
assert ast["total_h"] == "2h00"
assert ast["total_pondere_min"] == 180 # Nuit ×1,5
assert ast["total_pondere_h"] == "3h00"
assert ast["categories"][0]["nom"] == "Nuit"
assert any(j["date"] == "2026-07-14" for j in ast["jours"])
mardi = result["jours"][1]
assert mardi["astreinte_min"] == 120
assert mardi["astreinte_h"] == "2h00"
assert mardi["astreinte_pondere_min"] == 180
def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today):
"""Mardi astreinte, event 01:00→02:00 saisi sur le mercredi (lendemain) :
retour mercredi 13:00, présence du matin retirée de la semaine, aucune cible
matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
assert mercredi["du_min"] == 468 - 180 # matin 09:00→12:00 exclu par le retour 13:00
assert mercredi["retour"] == "13:00" # 02:00 + 11h
assert mercredi["cible_matin"] is None
assert mercredi["cible_aprem"] == "18:18"
assert result["total_du"] == "36h00" # 39h - 3h de présence retirées
def test_compute_week_nuit_finissant_au_matin(freeze_today):
"""Event 23:00→06:00 (à cheval sur minuit) saisi sur le mardi astreinte : le
retour 17:00 s'applique au mercredi (matin et après-midi exclus jusqu'à 17:00)."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
astreinte_events=[{"debut": "23:00", "fin": "06:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
assert mercredi["retour"] == "17:00"
# matin (180) + 14:00→17:00 (180) exclus → dû = 108
assert mercredi["du_min"] == 468 - 360
assert mercredi["cible_matin"] is None
# aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48
assert mercredi["cible_aprem_entree"] == "17:00"
assert mercredi["cible_aprem"] == "18:48"
# décompte : 23:00→06:00 = 28 tranches = 7h en Nuit ×1,5
assert result["jours"][1]["astreinte_min"] == 420
assert result["astreinte"]["total_pondere_min"] == 630
def test_compute_week_event_ignore_hors_jour_qualifie(freeze_today):
"""Un event saisi un jour non qualifié (pas d'astreinte) ne change rien au
décompte ni au décompte d'astreinte."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, [])
mardi = result["jours"][1]
assert mardi["du_min"] == HEURES_JOUR_MIN
assert mardi["retour_min"] is None
assert mardi["astreinte_min"] == 0
assert result["astreinte"]["actif"] is False
def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today):
"""Event mardi 21:00→23:30 → retour mercredi 10:30 : arrivée cible du matin
10:30, sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54
manquantes) bascule sur l'après-midi du même jour."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
astreinte_events=[{"debut": "21:00", "fin": "23:30"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
assert mercredi["retour"] == "10:30"
assert mercredi["cible_matin_entree"] == "10:30"
assert mercredi["cible_matin"] == "12:00"
# aprem : nominal 234 + 54 reportées = 288 depuis 13:30 → 18:18
assert mercredi["cible_aprem"] == "18:18"
def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_today):
"""Avec un quota de 6h/jour (180 par demi-journée), un retour 13:00 (event
01:00→02:00 sur le mercredi, mardi astreinte) couvre toute la plage du matin :
plus rien à y remplir, la demi-journée est complète d'office et l'absence de
pointage matin ne casse pas le cumul."""
heures_jour = 360
pointages = [
_pointage("2026-07-13", "09:00", "12:00", "13:30", "16:30"),
_pointage("2026-07-14", "09:00", "12:00", "13:30", "16:30"),
_pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30",
astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
_pointage("2026-07-16", "09:00", "12:00", "13:30", "16:30"),
_pointage("2026-07-17", "09:00", "12:00", "13:30", "16:30"),
]
result = compute_week(pointages, [], heures_jour, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
assert mercredi["matin_nominal_min"] == 0
assert mercredi["is_complete"] is True
assert mercredi["cumul_visible"] is True
assert mercredi["du_min"] == 180 # 6h dues - 3h de présence matin retirées
# ── Stockage des astreintes ───────────────────────────────────────────────────
def test_toggle_astreinte_persiste():
models.toggle_astreinte("2026-07-14", "u-astreinte")
assert models.load_astreintes("u-astreinte") == ["2026-07-14"]
models.toggle_astreinte("2026-07-14", "u-astreinte")
assert models.load_astreintes("u-astreinte") == []
def test_load_facteurs_astreinte_defaut_si_absent():
facteurs = models.load_facteurs_astreinte()
assert any(f["nom"] == "Nuit" for f in facteurs)
# ── Fenêtre d'astreinte 18:00 → 18:00 ───────────────────────────────────────
def test_fenetre_astreinte_jour():
"""Le jour déclaré couvre 18:00 → 18:00 le lendemain ; le lendemain n'est
couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent.
Samedi, dimanche et férié : journée entière 0h00 → 0h00."""
# jours ouvrés non fériés (mercredi 15 → vendredi 17 juillet 2026)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-15"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15"]) == (0, ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15", "2026-07-16"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-17", ["2026-07-15"]) is None
# samedi et dimanche : journée entière dès 0h00, déclarés ou lendemain
# d'astreinte (le jour AS déclaré garde sa borne du lendemain 18:00)
assert fenetre_astreinte_jour("2026-07-18", ["2026-07-18"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-19", ["2026-07-18"]) == (0, 24 * 60)
# férié légal (14 juillet) : journée entière même en milieu de semaine
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-13"]) == (0, 24 * 60)
# férié marqué à la main : idem
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"], {"2026-07-15"}) == (0, 24 * 60)
def test_decompte_hors_fenetre_18h_18h_ignore():
# Matin du jour AS déclaré : avant 18:00, hors astreinte
d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}],
None, (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN))
assert d["total_min"] == 0
# Lendemain après 18:00 : hors astreinte
d = decompte_astreinte("2026-07-15", [{"debut": "19:00", "fin": "20:00"}],
None, (0, ASTREINTE_DEBUT_MIN))
assert d["total_min"] == 0
# Lendemain matin : dans la fenêtre
d = decompte_astreinte("2026-07-15", [{"debut": "01:00", "fin": "02:00"}],
None, (0, ASTREINTE_DEBUT_MIN))
assert d["total_min"] == 60
# Événement à cheval sur la borne 18:00 du lendemain : seul le fragment
# intérieur compte (17:00→18:00 = 4 tranches)
d = decompte_astreinte("2026-07-15", [{"debut": "17:00", "fin": "19:00"}],
None, (0, ASTREINTE_DEBUT_MIN))
assert d["total_min"] == 60
def test_compute_week_intervention_avant_18h_hors_astreinte(freeze_today):
"""Mercredi ouvré AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le
même créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1)."""
pointages = [
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-16"),
_pointage("2026-07-17"),
_pointage("2026-07-15", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
assert result["astreinte"]["total_min"] == 0
assert result["jours"][2]["astreinte_min"] == 0
# le même créneau le soir, dans la fenêtre 18:00 → 18:00, compte (Journée ×1)
pointages[4] = _pointage("2026-07-15", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
assert result["astreinte"]["total_min"] == 60
assert result["astreinte"]["total_pondere_min"] == 60
def test_compute_week_astreinte_weekend_journee_entiere(freeze_today):
"""Vendredi AS : le samedi compte de 0h00 à 0h00 — l'intervention 20:00→21:00
du samedi (après la borne 18:00 des jours ouvrés) est de l'astreinte (Nuit ×1,5)."""
pointages = [
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17", astreinte_events=[{"debut": "20:00", "fin": "21:00"}]),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-17"])
# l'événement saisi sur le vendredi est dans sa fenêtre 18:00 → 18:00
vendredi = result["jours"][4]
assert vendredi["astreinte_min"] == 60
def test_decompte_fenetre_samedi_soir_apres_18h():
"""Samedi lendemain d'astreinte : 20:00→21:00 compte (journée entière),
4 tranches en Nuit ×1,5 = 1h30 pondérées."""
fen = fenetre_astreinte_jour("2026-07-18", ["2026-07-17"])
d = decompte_astreinte("2026-07-18", [{"debut": "20:00", "fin": "21:00"}], None, fen)
assert d["total_min"] == 60
assert d["categories"][0]["nom"] == "Nuit"
assert d["total_pondere_min"] == 90
def test_compute_week_ferie_journee_entiere_ratio_x2(freeze_today):
"""Lundi AS, intervention le mardi 14 juillet (férié légal) : journée entière
0h00 → 0h00, ratio Dimanche / férié ×2 (10:00→12:00 = 2h → 4h pondérées)."""
pointages = [
_pointage("2026-07-13"),
_pointage("2026-07-14", astreinte_events=[{"debut": "10:00", "fin": "12:00"}]),
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-13"])
assert result["astreinte"]["total_min"] == 120
assert result["astreinte"]["total_pondere_min"] == 240 # férié ×2
def test_compute_week_astreinte_samedi_dimanche(freeze_today):
"""Samedi AS avec intervention nocturne : dû 0 (week-end), décompte en
compteur astreinte (Nuit vendredi ×1,5 puis Samedi ×1,5 sur 08:00→20:00)."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-18", astreinte_events=[{"debut": "23:00", "fin": "09:00"}]),
_pointage("2026-07-19"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-18"])
samedi = result["jours"][5]
assert samedi["du_min"] == 0
assert samedi["astreinte_min"] == 10 * 60 # 23:00→09:00 = 10h
assert result["total_du"] == "39h00" # week-end hors dues
# ── Forfait par jour d'astreinte déclaré ───────────────────────────────────────
def test_compute_week_forfait_hors_heures_travaillees(freeze_today):
"""Forfait 1h00 réglé : attribué au jour AS déclaré uniquement (pas du
lendemain), mais jamais compté dans les heures travaillées — seulement
suivi dans le récapitulatif d'astreinte."""
plages = {**PLAGES, "astreinte_forfait": "01:00"}
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages, ["2026-07-14"])
mardi = result["jours"][1]
assert mardi["forfait_min"] == 60
assert mardi["travaille_min"] == HEURES_JOUR_MIN # pointé seul, forfait exclu
assert result["jours"][2]["forfait_min"] == 0 # lendemain : rien
assert result["jours"][0]["forfait_min"] == 0 # jour sans astreinte
assert result["total_travaille"] == "15h36" # 2 × 7h48, forfait exclu
# récapitulatif d'astreinte : le jour AS apparaît avec son forfait même sans
# intervention ; le forfait est rappelé mais hors compteurs pondérés
ast = result["astreinte"]
assert ast["actif"] is True
assert ast["total_min"] == 0
assert ast["total_forfait_min"] == 60
assert ast["total_forfait_h"] == "1h00"
assert [(j["date"], j["forfait_min"], j["events"]) for j in ast["jours"]] == [("2026-07-14", 60, [])]
def test_compute_week_sans_forfait_comportement_inchange(freeze_today):
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
astreinte_events=[{"debut": "21:00", "fin": "22:00"}]),
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
assert result["jours"][1]["forfait_min"] == 0
assert result["total_travaille"] == "15h36"