Ajoute les astreintes : saisie de la fin de dernière activité, retour au bureau décalé de 11h de repos
Un nouveau bouton AS marque un jour d'astreinte. Ce jour et son lendemain,
une saisie « Fin activité » enregistre l'heure de fin de la dernière
intervention hors horaires (elle peut être le matin si la nuit a dépassé
minuit). L'app en déduit l'heure de retour au bureau (fin + 11h de repos
consécutif) et retire du décompte de la semaine toute la présence avant
cette heure : heures dues réduites, présence pointée avant le retour non
comptée, préconisations Fin cible démarrées à l'heure de retour sans
avertissement de retard, demi-journée entièrement en repos considérée
comme remplie. Les exports .ods gagnent les colonnes Astreinte et Retour,
et les stats n'ont plus les retards liés au repos.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
235
app/tests/test_astreinte.py
Normal file
235
app/tests/test_astreinte.py
Normal file
@ -0,0 +1,235 @@
|
||||
"""Astreinte : fin de dernière activité, repos de 11h et heure de retour décalée."""
|
||||
from calcul import (
|
||||
compute_week, heures_dues, heures_travaillees, hhmm_to_minutes,
|
||||
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour,
|
||||
retour_apres_repos, DEFAULT_PLAGES,
|
||||
)
|
||||
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_fin=None):
|
||||
return {
|
||||
"date": date,
|
||||
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
|
||||
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
|
||||
"astreinte_fin": astreinte_fin,
|
||||
}
|
||||
|
||||
|
||||
# ── 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 ─────────────────────────────────────
|
||||
|
||||
def test_retour_fin_matinee_sur_le_lendemain():
|
||||
# fin 02:00 saisie sur le lendemain → retour 13:00 le même jour
|
||||
p_by_date = {"2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00")}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
|
||||
|
||||
|
||||
def test_retour_fin_matinale_sur_le_jour_d_astreinte():
|
||||
# fin 06:00 saisie sur le jour d'astreinte (la nuit a dépassé minuit) → retour 17:00 le lendemain
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="06:00")}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
|
||||
|
||||
|
||||
def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
|
||||
# fin 23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30
|
||||
|
||||
|
||||
def test_retour_la_plus_tardive_des_deux_fins_gagne():
|
||||
# fins saisies sur le jour d'astreinte (23:30) ET le lendemain (02:00) : la plus tardive gagne
|
||||
p_by_date = {
|
||||
"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30"),
|
||||
"2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00"),
|
||||
}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
|
||||
|
||||
|
||||
def test_retour_fin_tot_ne_contraint_rien():
|
||||
# fin 17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="17:30")}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||||
|
||||
|
||||
def test_retour_fin_sans_astreinte_ignoree():
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None
|
||||
|
||||
|
||||
# ── 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_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today):
|
||||
"""Mardi astreinte, activité terminée mercredi 02:00 (fin saisie sur le
|
||||
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_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
|
||||
assert mercredi["retour"] == "13:00"
|
||||
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_fin_matinale_sur_jour_d_astreinte(freeze_today):
|
||||
"""Fin 06:00 saisie directement 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_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"
|
||||
|
||||
|
||||
def test_compute_week_fin_ignoree_hors_jour_qualifie(freeze_today):
|
||||
"""Une fin d'activité saisie un jour non qualifié (pas d'astreinte la veille)
|
||||
ne change rien au décompte."""
|
||||
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_fin="02:00"),
|
||||
_pointage("2026-07-15"),
|
||||
_pointage("2026-07-16"),
|
||||
_pointage("2026-07-17"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, [])
|
||||
assert result["jours"][1]["du_min"] == HEURES_JOUR_MIN
|
||||
assert result["jours"][1]["retour_min"] is None
|
||||
|
||||
|
||||
def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today):
|
||||
"""Fin 23:30 mardi → 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_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), une fin à 02:00 (retour
|
||||
13:00) 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_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") == []
|
||||
Reference in New Issue
Block a user