Pour tenir les heures de la semaine, les préconisations n'ajustaient que
les départs du soir : un retard trop gros était déclaré rattrapage
impossible dès minuit atteint. Deux leviers s'enchaînent désormais :
avancer les arrivées du matin (jamais avant le début de la présence
matin, le retour de repos, ni l'heure actuelle), puis les retours de midi
(jamais avant la fin de la présence matin, ou la sortie réelle du matin).
Les entrées déjà pointées restent intactes ; l'aide des Réglages
documente la cascade.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
508 lines
24 KiB
Python
508 lines
24 KiB
Python
from calcul import (
|
|
compute_week, heures_dues, heures_travaillees, hhmm_to_minutes, minutes_to_hhmm,
|
|
)
|
|
|
|
HEURES_JOUR_MIN = 468 # 7h48 = 7.8 * 60
|
|
|
|
|
|
def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None):
|
|
return {
|
|
"date": date,
|
|
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
|
|
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
|
|
}
|
|
|
|
|
|
def test_hhmm_to_minutes():
|
|
assert hhmm_to_minutes("09:00") == 540
|
|
assert hhmm_to_minutes("00:00") == 0
|
|
assert hhmm_to_minutes(None) == 0
|
|
|
|
|
|
def test_minutes_to_hhmm():
|
|
assert minutes_to_hhmm(468) == "7h48"
|
|
assert minutes_to_hhmm(0) == "0h00"
|
|
assert minutes_to_hhmm(-90) == "-1h30"
|
|
|
|
|
|
def test_heures_dues_weekend():
|
|
assert heures_dues("2026-07-18", [], HEURES_JOUR_MIN) == 0 # samedi
|
|
|
|
|
|
def test_heures_dues_conge_jour():
|
|
conges = [{"date": "2026-07-13", "type": "jour"}]
|
|
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == 0
|
|
|
|
|
|
def test_heures_dues_conge_demi_journee():
|
|
conges = [{"date": "2026-07-13", "type": "matin"}]
|
|
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == HEURES_JOUR_MIN // 2
|
|
|
|
|
|
def test_heures_dues_matin_et_aprem_congés_egale_jour_complet():
|
|
conges = [{"date": "2026-07-13", "type": "matin"}, {"date": "2026-07-13", "type": "aprem"}]
|
|
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == 0
|
|
|
|
|
|
def test_heures_dues_jour_normal():
|
|
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN) == HEURES_JOUR_MIN
|
|
|
|
|
|
def test_heures_travaillees_journee_complete():
|
|
p = _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54")
|
|
assert heures_travaillees(p) == 204 + 264 # 468
|
|
|
|
|
|
def test_heures_travaillees_pas_pointe():
|
|
assert heures_travaillees(_pointage("2026-07-13")) == 0
|
|
|
|
|
|
def test_heures_travaillees_en_cours_aujourdhui(freeze_today):
|
|
# freeze_today = 2026-07-17 10:30 ; entrée à 09:00 -> 90 min écoulées
|
|
p = _pointage("2026-07-17", matin_entree="09:00")
|
|
assert heures_travaillees(p) == 90
|
|
|
|
|
|
def _semaine_complete(dates_travaillees):
|
|
"""4 jours complets à exactement HEURES_JOUR_MIN, pas de congé."""
|
|
return [
|
|
_pointage(d, "09:00", "12:24", "13:30", "17:54")
|
|
for d in dates_travaillees
|
|
]
|
|
|
|
|
|
def test_compute_week_semaine_entierement_complete_solde_zero():
|
|
dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
|
pointages = _semaine_complete(dates)
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
assert result["total_travaille"] == "39h00"
|
|
assert result["total_du"] == "39h00"
|
|
assert result["solde_min"] == 0
|
|
assert all(j["is_complete"] for j in result["jours"])
|
|
|
|
|
|
def test_compute_week_cumul_visible_seulement_si_chaine_ininterrompue():
|
|
dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
|
pointages = _semaine_complete(dates)
|
|
# mercredi incomplet (pas d'aprem)
|
|
pointages[2] = _pointage("2026-07-15", "09:00", "12:24")
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
jours = result["jours"]
|
|
assert jours[0]["cumul_visible"] is True
|
|
assert jours[1]["cumul_visible"] is True
|
|
assert jours[2]["cumul_visible"] is False # jour incomplet
|
|
assert jours[3]["cumul_visible"] is False # chaîne cassée après
|
|
assert jours[4]["cumul_visible"] is False
|
|
|
|
|
|
def test_compute_week_cible_repartie_sur_seule_demi_journee_restante(freeze_today):
|
|
"""Régression: capture exactement les seuils 9h/12h/13h30/17h codés en dur.
|
|
|
|
Lundi-jeudi complets à 468 min. Vendredi (aujourd'hui, frozen) pointé
|
|
seulement le matin 09:00-12:00 (180 min, sous les 234 min nominales de la
|
|
demi-journée). Comme il ne reste qu'une seule demi-journée non traitée
|
|
(l'après-midi de vendredi), tout le manque de la semaine s'y retrouve —
|
|
même résultat que l'ancien algorithme "cliff-edge" dans ce cas précis.
|
|
"""
|
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
|
pointages = _semaine_complete(dates_completes)
|
|
pointages.append(_pointage("2026-07-17", matin_entree="09:00", matin_sortie="12:00"))
|
|
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
|
|
assert vendredi["cible_matin"] is None # matin déjà pointé (entrée+sortie), plus rien à y faire
|
|
assert vendredi["cible_aprem"] == "18:18"
|
|
# La préconisation aprem inclut aussi l'heure d'arrivée (reprise par défaut 13:30)
|
|
assert vendredi["cible_aprem_entree"] == "13:30"
|
|
|
|
|
|
def test_compute_week_semaine_totalement_vide_compte_les_10_demi_journees(freeze_today):
|
|
"""Régression : une semaine entièrement vide (rien saisi, y compris les jours déjà
|
|
passés) doit compter 10 demi-journées restantes, pas seulement celles à partir
|
|
d'aujourd'hui. Cette appli permet de saisir ses heures après coup — un jour passé
|
|
non renseigné reste "à faire", il n'est pas un manque définitivement perdu."""
|
|
dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
|
pointages = [_pointage(d) for d in dates]
|
|
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
assert result["slots_restants"] == 10
|
|
assert result["reste_min"] == HEURES_JOUR_MIN * 5
|
|
# Pas de retard (rien n'a encore été renseigné) : le rythme normal suffit, extra = 0.
|
|
# Mais chaque demi-journée reste propose son horaire visé (matin 09:00→12:00,
|
|
# aprem 13:30→18:18 avec le surplus nominal basculé depuis le matin saturé).
|
|
assert result["extra_min"] == 0
|
|
assert all(j["cible_matin"] == "12:00" and j["cible_aprem"] == "18:18" for j in result["jours"])
|
|
|
|
|
|
def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_today):
|
|
"""Un jour futur non entamé fait maintenant partie de la répartition (spread sur la semaine)."""
|
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
|
pointages = _semaine_complete(dates_completes)
|
|
pointages.append(_pointage("2026-07-17", matin_entree="09:00", matin_sortie="12:00"))
|
|
pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé
|
|
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
# Le manque (288 min) est maintenant réparti entre l'après-midi de vendredi et
|
|
# les deux demi-journées de lundi, au lieu d'être entièrement dumpé sur vendredi soir.
|
|
vendredi = result["jours"][4]
|
|
lundi_suivant = result["jours"][5]
|
|
assert vendredi["cible_aprem"] is not None
|
|
assert lundi_suivant["cible_matin"] is not None or lundi_suivant["cible_aprem"] is not None
|
|
|
|
|
|
def test_compute_week_jour_futur_a_egalite_propose_le_rythme_normal(freeze_today):
|
|
"""Quand le rythme normal des demi-journées restantes suffit (pas de rattrapage),
|
|
les horaires visés du matin et de l'après-midi sont quand même proposés."""
|
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
|
pointages = _semaine_complete(dates_completes)
|
|
pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé — mais la semaine est déjà à l'équilibre
|
|
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
lundi_suivant = result["jours"][5]
|
|
assert result["extra_min"] == 0
|
|
assert lundi_suivant["cible_matin"] == "12:00"
|
|
assert lundi_suivant["cible_aprem"] == "18:18"
|
|
|
|
|
|
def test_compute_week_jour_courant_non_entame_propose_le_rythme_normal(freeze_today):
|
|
""""Aujourd'hui" sans rien pointé n'est pas traité comme un jour futur figé : ses deux
|
|
demi-journées entrent dans la répartition. Ici le manque (468 min) correspond exactement
|
|
au nominal des deux demi-journées restantes : le rythme normal suffit, mais les horaires
|
|
visés matin et aprem restent proposés."""
|
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
|
pointages = _semaine_complete(dates_completes)
|
|
pointages.append(_pointage("2026-07-17"))
|
|
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
assert result["extra_min"] == 0
|
|
assert vendredi["cible_matin"] == "12:00"
|
|
assert vendredi["cible_aprem"] == "18:18"
|
|
|
|
|
|
def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today):
|
|
"""Régression : une demi-journée en cours (entrée pointée sans sortie, aujourd'hui)
|
|
était comptée deux fois — son temps écoulé dans le travaillé ET son nominal complet
|
|
comme capacité restante — ce qui masquait le rattrapage pendant la journée.
|
|
|
|
Lundi en retard de 60 min, mardi-jeudi complets, vendredi (aujourd'hui, gelé à
|
|
10:30) matin en cours depuis 09:00. Le matin s'arrête à la pause de midi
|
|
(nominal plafonné à 12:00, l'excédent bascule sur l'après-midi) : les 60 min
|
|
de rattrapage vont entièrement sur l'après-midi.
|
|
"""
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 min
|
|
_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", matin_entree="09:00"), # en cours, 90 min écoulées
|
|
]
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
assert result["extra_min"] == 60
|
|
# matin en cours : sa cible correspond au planning nominal (09:00→12:00), pas de rattrapage
|
|
# possible (saturé au plafond pause) ; aprem : 13:30 + 288 + 60 = 19:18
|
|
assert vendredi["cible_matin"] == "12:00"
|
|
assert vendredi["cible_aprem"] == "19:18"
|
|
|
|
|
|
def test_compute_week_cible_matin_ne_mange_jamais_la_pause_de_midi(freeze_today):
|
|
"""La cible matin ne dépasse jamais matin_fin : la pause de midi (12:00→13:30)
|
|
reste libre, même quand le retard ne peut pas être rattrapé ailleurs.
|
|
|
|
Vendredi en congé après-midi : seule sa matinée reste, plafonnée à 09:00→12:00
|
|
(180 min, sous les 234 nominales). Avec 120 min de retard sur lundi, aucune
|
|
cible n'est proposée (le créneau est déjà saturé) plutôt qu'une sortie à 13:30
|
|
qui supprimerait la pause ; le manque non plaçable reste visible dans extra.
|
|
"""
|
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "15:54"), # -120 min
|
|
_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"),
|
|
]
|
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
# 120 min de retard + 54 min de nominal matin qui ne tiennent pas avant la pause
|
|
# (et ne peuvent pas basculer sur l'après-midi, en congé)
|
|
assert result["extra_min"] == 174
|
|
# Le matin (seul créneau dispo, aprem en congé) est saturé à son plafond 09:00→12:00 :
|
|
# cible proposée au rythme normal, mais le rattrapage (174 min) ne tient pas.
|
|
assert vendredi["cible_matin"] == "12:00"
|
|
assert vendredi["cible_aprem"] is None # congé
|
|
|
|
|
|
def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today):
|
|
"""Quand le créneau matin sature (plafond pause de midi), le rattrapage doit se
|
|
reporter sur les créneaux encore ouverts au lieu d'être perdu.
|
|
|
|
80 min de retard, vendredi entièrement vide : le matin est déjà saturé à son
|
|
plafond 09:00→12:00 (nominal 180, excédent 54 basculé sur l'après-midi qui
|
|
passe à 288), donc tout le rattrapage va sur l'après-midi.
|
|
"""
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 min
|
|
_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"),
|
|
]
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
assert result["extra_min"] == 80
|
|
# matin saturé au nominal (cible au rythme normal 09:00→12:00) ; aprem : 13:30 + 288 + 80 = 19:38
|
|
assert vendredi["cible_matin"] == "12:00"
|
|
assert vendredi["cible_aprem"] == "19:38"
|
|
|
|
|
|
def test_compute_week_avance_reduit_les_departs_du_soir_restants(freeze_today):
|
|
"""Symétrique du rattrapage : une avance (travaillé plus que dû) doit réduire
|
|
les heures de départ du soir des jours restants, pas seulement rester
|
|
invisible — pour permettre de planifier un solde à 0 en fin de semaine.
|
|
|
|
Plages personnalisées sans report matin→aprem (capacité matin 240 = nominal).
|
|
Lundi +20 min d'avance, reste de la semaine vide : les 4 départs restants
|
|
sont chacun avancés de 5 min (18:00 nominal → 17:55).
|
|
"""
|
|
plages = {
|
|
"matin_debut": "09:00", "matin_fin": "13:00",
|
|
"aprem_debut": "14:00", "aprem_fin": "17:00",
|
|
"pause_dejeuner_fin": "14:00",
|
|
}
|
|
heures_jour = 480 # 240 matin + 240 aprem
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "13:00", "14:00", "18:20"), # +20 min
|
|
_pointage("2026-07-14"),
|
|
_pointage("2026-07-15"),
|
|
_pointage("2026-07-16"),
|
|
_pointage("2026-07-17"),
|
|
]
|
|
result = compute_week(pointages, [], heures_jour, plages)
|
|
assert result["extra_min"] == 0 # extra_min ne compte que le retard, pas l'avance
|
|
for jour in result["jours"][1:]:
|
|
assert jour["cible_matin"] == "13:00" # rythme normal, jamais ajusté par l'avance
|
|
assert jour["cible_aprem"] == "17:55"
|
|
|
|
|
|
def test_compute_week_avance_ne_descend_jamais_sous_aprem_fin(freeze_today):
|
|
"""L'avance ne peut pas faire descendre un départ sous le plancher aprem_fin
|
|
(présence minimale obligatoire) : au-delà de la marge disponible, l'avance
|
|
restante n'est simplement pas placée (elle reste visible dans le solde).
|
|
|
|
234 min nominal aprem (13:30 + 234 = 17:24), plancher 17:00 : seulement 24 min
|
|
de marge par jour. Avec 200 min d'avance sur un seul jour ouvert, la cible
|
|
plafonne à 17:00 au lieu de descendre à 17:24 - 200.
|
|
"""
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "20:34"), # +200 min
|
|
_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"),
|
|
]
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
assert vendredi["cible_aprem"] == "17:00"
|
|
|
|
|
|
def test_compute_week_cible_matin_jamais_ajustee_meme_avec_de_la_marge(freeze_today):
|
|
"""Le rattrapage hebdomadaire ne porte jamais sur le matin, même quand la
|
|
matinée obligatoire est plus longue que le nominal et aurait de la marge.
|
|
Le matin garde son horaire nominal (rythme normal), non ajusté.
|
|
|
|
Matin 08:00→13:00 (capacité 300 > nominal 270). Vendredi en congé après-midi,
|
|
120 min de retard : le seul créneau ouvert (le matin) n'est pas ajustable, le
|
|
retard n'est pas plaçable ailleurs (l'après-midi est en congé).
|
|
"""
|
|
plages = {
|
|
"matin_debut": "08:00", "matin_fin": "13:00",
|
|
"aprem_debut": "14:00", "aprem_fin": "18:00",
|
|
"pause_dejeuner_fin": "14:00",
|
|
}
|
|
heures_jour = 540
|
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
|
pointages = [
|
|
_pointage("2026-07-13", "08:00", "13:00", "14:00", "16:00"), # -120 min
|
|
_pointage("2026-07-14", "08:00", "13:00", "14:00", "18:00"),
|
|
_pointage("2026-07-15", "08:00", "13:00", "14:00", "18:00"),
|
|
_pointage("2026-07-16", "08:00", "13:00", "14:00", "18:00"),
|
|
_pointage("2026-07-17"),
|
|
]
|
|
result = compute_week(pointages, conges, heures_jour, plages)
|
|
vendredi = result["jours"][4]
|
|
assert result["extra_min"] == 120
|
|
assert vendredi["cible_matin"] == "12:30" # rythme normal (08:00 + 270 nominal), pas de rattrapage
|
|
assert vendredi["cible_aprem"] is None # congé
|
|
|
|
|
|
def test_compute_week_arrivee_visee_n_etend_pas_le_rattrapage_au_matin(freeze_today):
|
|
"""L'arrivée visée sert de début projeté du matin (pour le rythme normal affiché
|
|
et le calcul du surplus basculant vers l'après-midi), mais n'ouvre jamais le
|
|
matin au rattrapage hebdomadaire — celui-ci reste réservé aux départs du soir.
|
|
"""
|
|
plages = {"arrivee_visee": "08:00"}
|
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "15:54"), # -120 min
|
|
_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"),
|
|
]
|
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
|
|
vendredi = result["jours"][4]
|
|
assert result["extra_min"] == 120
|
|
assert vendredi["cible_matin"] == "11:54" # rythme normal (08:00 + 234 nominal), pas de rattrapage
|
|
|
|
|
|
def test_compute_week_depart_vise_plafond_souple(freeze_today):
|
|
"""Le départ visé concentre d'abord le rattrapage sur les créneaux qui tiennent
|
|
avant cette heure ; il n'est dépassé que si ça ne tient nulle part (plafond souple).
|
|
|
|
Vendredi matin pointé 09:00→12:00, son après-midi reste (nominal 234) ; lundi
|
|
suivant vide (matin 180 saturé, aprem 288). Rattrapage : 54 min. Sans départ
|
|
visé, réparti 27/27. Avec départ visé 18:00 : l'aprem de vendredi (qui a de la
|
|
marge avant 18:00) absorbe d'abord 36, puis la 2e passe répartit le reste 9/9.
|
|
"""
|
|
plages = {"depart_vise": "18: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:00"),
|
|
_pointage("2026-07-20"), # lundi suivant, vide
|
|
]
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages)
|
|
vendredi = result["jours"][4]
|
|
lundi = result["jours"][5]
|
|
assert result["extra_min"] == 54
|
|
# vendredi aprem : 13:30 + 234 + (36 + 9) = 18:09 ; lundi aprem : 13:30 + 288 + 9 = 18:27
|
|
# (sans départ visé, lundi finirait à 18:45)
|
|
assert vendredi["cible_aprem"] == "18:09"
|
|
assert lundi["cible_matin"] == "12:00" # matin au rythme normal (pas de rattrapage pour ce créneau)
|
|
assert lundi["cible_aprem"] == "18:27"
|
|
|
|
|
|
def test_compute_week_plages_personnalisees(freeze_today):
|
|
"""Les seuils de plage sont bien paramétrables, pas juste figés sur les défauts."""
|
|
plages = {
|
|
"matin_debut": "08:00", "matin_fin": "13:00",
|
|
"aprem_debut": "14:00", "aprem_fin": "18:00",
|
|
"pause_dejeuner_fin": "14:00",
|
|
}
|
|
heures_jour = (13 * 60 - 8 * 60) + (18 * 60 - 14 * 60) # 540 = journée complète sous ce schéma
|
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
|
pointages = [_pointage(d, "08:00", "13:00", "14:00", "18:00") for d in dates_completes]
|
|
# Matin écourté (240 min au lieu des 300 nominales) : le manque doit être
|
|
# rattrapé sur l'unique demi-journée restante, avec les seuils personnalisés.
|
|
pointages.append(_pointage("2026-07-17", "08:00", "12:00"))
|
|
|
|
result = compute_week(pointages, [], heures_jour, plages)
|
|
vendredi = result["jours"][4]
|
|
assert vendredi["cible_matin"] is None
|
|
assert vendredi["cible_aprem"] == "19:00"
|
|
|
|
|
|
def test_compute_week_cible_en_cours_ne_recommande_jamais_un_depart_deja_passe(monkeypatch):
|
|
"""Régression : quand l'après-midi est en cours et que la semaine a de l'avance,
|
|
la répartition de l'avance ne doit jamais faire retomber la cible du jour même
|
|
avant l'heure actuelle — on ne peut pas recommander un départ déjà passé.
|
|
|
|
Lundi-mercredi en léger surplus, jeudi (aujourd'hui, gelé à 17:19) après-midi en
|
|
cours depuis 13:37 : le calcul nominal donnerait 17:17 (avant maintenant), la
|
|
cible doit être plafonnée à 17:19.
|
|
"""
|
|
import calcul as calcul_module
|
|
|
|
class FrozenDatetime(calcul_module.datetime):
|
|
_frozen = calcul_module.datetime(2026, 8, 6, 17, 19)
|
|
|
|
@classmethod
|
|
def today(cls):
|
|
return cls._frozen
|
|
|
|
monkeypatch.setattr(calcul_module, "datetime", FrozenDatetime)
|
|
|
|
plages = {"arrivee_visee": "08:30"}
|
|
pointages = [
|
|
_pointage("2026-08-03", "08:12", "12:02", "13:23", "18:00"), # +39
|
|
_pointage("2026-08-04", "08:19", "12:02", "13:01", "17:01"), # -5
|
|
_pointage("2026-08-05", "08:15", "12:03", "12:55", "17:02"), # +7
|
|
_pointage("2026-08-06", "08:25", "12:05", aprem_entree="13:37"), # en cours
|
|
_pointage("2026-08-07"),
|
|
]
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages)
|
|
jeudi = result["jours"][3]
|
|
assert jeudi["cible_aprem"] == "17:19"
|
|
|
|
|
|
# ── Rattrapage extrême : arrivées et retours de midi ──────────────────────────
|
|
|
|
def test_rattrapage_sature_departs_arrivees_puis_retour_de_midi(freeze_today):
|
|
"""Retard qui dépasse la capacité des départs du soir (minuit) : l'arrivée
|
|
du matin est avancée (jamais avant le début de « Présence matin »), puis le
|
|
retour de midi (jamais avant la fin de « Présence matin »). Arrivée visée
|
|
10:00, lun-jeu complets courts de 105 min/jour, vendredi congé, lundi vide :
|
|
420 min de retard — 281 dans le départ du soir (→ 23:59), 60 en arrivant à
|
|
09:00 (début Présence matin), 79 en reprenant à 12:11."""
|
|
plages = {"arrivee_visee": "10:00"}
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "12:00", "14:00", "17:03"),
|
|
_pointage("2026-07-14", "09:00", "12:00", "14:00", "17:03"),
|
|
_pointage("2026-07-15", "09:00", "12:00", "14:00", "17:03"),
|
|
_pointage("2026-07-16", "09:00", "12:00", "14:00", "17:03"),
|
|
_pointage("2026-07-17"),
|
|
_pointage("2026-07-20"),
|
|
]
|
|
conges = [{"date": "2026-07-17", "type": "jour"}]
|
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
|
|
lundi = result["jours"][5]
|
|
assert result["extra_min"] == 420
|
|
assert lundi["cible_matin_entree"] == "09:00" # arrivée avancée jusqu'au début de Présence matin
|
|
assert lundi["cible_matin"] == "12:00"
|
|
assert lundi["cible_aprem_entree"] == "12:11" # retour de midi avancé de 79 min
|
|
assert lundi["cible_aprem"] == "23:59" # départ saturé à minuit
|
|
|
|
|
|
def test_rattrapage_par_defaut_n_avance_pas_larrivee_avant_presence_matin(freeze_today):
|
|
"""Plages par défaut : l'arrivée visée (09:00) est déjà le début de
|
|
« Présence matin », rien à avancer — le levier restant est le retour de
|
|
midi, raccourci jusqu'à la fin de « Présence matin » (12:00)."""
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-14", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-15", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-16", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-17"),
|
|
_pointage("2026-07-20"),
|
|
]
|
|
conges = [{"date": "2026-07-17", "type": "jour"}]
|
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN)
|
|
lundi = result["jours"][5]
|
|
assert lundi["cible_matin_entree"] == "09:00" # déjà au début de Présence matin
|
|
assert lundi["cible_matin"] == "12:00"
|
|
assert lundi["cible_aprem_entree"] == "12:00" # pause réduite jusqu'à la fin de Présence matin
|
|
assert lundi["cible_aprem"] == "23:59"
|
|
|
|
|
|
def test_jour_en_cours_ne_vise_pas_une_heure_passee(freeze_today):
|
|
"""Le jour même (gelé à 10:30), ni l'arrivée du matin ni le retour de midi
|
|
ne peuvent être proposés avant maintenant : l'arrivée reste à l'heure
|
|
visée, le retour de midi est borné par la fin de « Présence matin »."""
|
|
pointages = [
|
|
_pointage("2026-07-13", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-14", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-15", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-16", "09:00", "09:30", "14:00", "14:30"),
|
|
_pointage("2026-07-17"), # aujourd'hui 10:30, rien pointé
|
|
]
|
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
|
vendredi = result["jours"][4]
|
|
assert vendredi["cible_matin_entree"] == "09:00" # pas d'arrivée rétroactive
|
|
assert vendredi["cible_aprem_entree"] == "12:00" # borné par Présence matin, pas avant 10:30
|
|
assert vendredi["cible_aprem"] == "23:59"
|