Une avance que les départs du soir ne peuvent pas écouler (plancher de
présence) s'écoule désormais en cascade miroir du rattrapage : arrivées
du matin retardées, puis retours de midi, toujours bornés par les plages
de présence.
Une demi-journée en cours qui dépasse son nominal (ex. sortie de midi
oubliée) comptait son temps écoulé deux fois : suivre les cibles
sur-livrait jusqu'à 1h45 par semaine. Le créneau saturé propose
maintenant « sortir maintenant » et l'écart se reporte sur les autres
créneaux ; plus de retard fantôme affiché.
Le README liste désormais l'ensemble des règles de préconisation.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
674 lines
33 KiB
Python
674 lines
33 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"
|
||
|
||
|
||
# ── Avance : arrivées et retours de midi retardés en secours des départs ──────
|
||
|
||
def test_avance_ecoulee_sur_arrivee_puis_retour_de_midi(freeze_today):
|
||
"""Jeudi fini à +48 avec une sortie nominale déjà au plancher aprem_fin
|
||
(17:00) : l'avance ne tenait nulle part et les cibles restaient au rythme
|
||
nominal. Elle s'écoule désormais en retardant l'arrivée du matin (08:30 →
|
||
09:00, début de Présence matin) puis le retour de midi (13:30 → 13:48,
|
||
sous le début de Présence après-midi 14:00)."""
|
||
plages = {"arrivee_visee": "08:30"}
|
||
pointages = [
|
||
_pointage("2026-07-13", "08:30", "12:00", "13:30", "17:00"),
|
||
_pointage("2026-07-14", "08:30", "12:00", "13:30", "17:00"),
|
||
_pointage("2026-07-15", "08:30", "12:00", "13:30", "17:00"),
|
||
_pointage("2026-07-16", "08:30", "12:00", "13:30", "17:48"), # +48
|
||
_pointage("2026-07-17"),
|
||
_pointage("2026-07-20"),
|
||
]
|
||
conges = [{"date": "2026-07-17", "type": "jour"}]
|
||
result = compute_week(pointages, conges, 420, plages) # 7h00/jour : sortie nominale 17:00
|
||
lundi = result["jours"][5]
|
||
assert result["avance_min"] == 48
|
||
assert lundi["cible_matin_entree"] == "09:00" # arrivée retardée jusqu'à Présence matin
|
||
assert lundi["cible_matin"] == "12:00"
|
||
assert lundi["cible_aprem_entree"] == "13:48" # retour de midi retardé de 18 min
|
||
assert lundi["cible_aprem"] == "17:00" # déjà au plancher, rien à avancer
|
||
|
||
|
||
def test_avance_par_defaut_passe_par_le_retour_de_midi(freeze_today):
|
||
"""Plages par défaut (arrivée visée = début de Présence matin, rien à
|
||
retarder côté arrivée) : l'avance restante après les départs du soir
|
||
s'écoule sur le retour de midi, plafonné au début de Présence après-midi."""
|
||
pointages = [
|
||
_pointage("2026-07-13", "09:00", "12:00", "13:30", "17:30"),
|
||
_pointage("2026-07-14", "09:00", "12:00", "13:30", "17:30"),
|
||
_pointage("2026-07-15", "09:00", "12:00", "13:30", "17:30"),
|
||
_pointage("2026-07-16", "09:00", "12:00", "13:30", "18:10"), # +40
|
||
_pointage("2026-07-17"),
|
||
_pointage("2026-07-20"),
|
||
]
|
||
conges = [{"date": "2026-07-17", "type": "jour"}]
|
||
result = compute_week(pointages, conges, 420)
|
||
lundi = result["jours"][5]
|
||
assert result["avance_min"] == 40
|
||
assert lundi["cible_matin_entree"] == "09:00" # rien à retarder (visée = Présence matin)
|
||
assert lundi["cible_aprem_entree"] == "13:40" # retour de midi retardé de 10 min
|
||
assert lundi["cible_aprem"] == "17:00" # départ réduit de 30 min puis plafonné
|
||
|
||
|
||
# ── Demi-journée en cours saturée (temps écoulé ≥ nominal) ────────────────────
|
||
|
||
class _Freezer:
|
||
"""Fige calcul.datetime à une date/heure donnée (2026-07-17 = vendredi)."""
|
||
@staticmethod
|
||
def freeze(monkeypatch, heure, minute):
|
||
import calcul as calcul_module
|
||
|
||
class FrozenDatetime(calcul_module.datetime):
|
||
_frozen = calcul_module.datetime(2026, 7, 17, heure, minute)
|
||
|
||
@classmethod
|
||
def today(cls):
|
||
return cls._frozen
|
||
|
||
monkeypatch.setattr(calcul_module, "datetime", FrozenDatetime)
|
||
|
||
|
||
def test_en_cours_sature_matin_pas_de_double_comptage(monkeypatch):
|
||
"""Régression : vendredi matin en cours depuis 09:00 sans sortie, il est
|
||
13:45 (sortie de midi oubliée). Le temps écoulé (285 min) dépasse la
|
||
capacité du créneau matin (180 min) : il était compté deux fois — une fois
|
||
dans le travaillé, une fois réintégré au plan — si bien que suivre les
|
||
cibles sur-livrait de 105 min. Désormais le créneau saturé propose
|
||
« sortie maintenant », ne réintègre rien, et l'après-midi est allégé
|
||
d'autant : suivi des cibles → solde 0 (à ~12 min près bornées par les
|
||
plages : reprise ≥ maintenant, plancher 17:00)."""
|
||
_Freezer.freeze(monkeypatch, 13, 45)
|
||
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", matin_entree="09:00"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||
vendredi = result["jours"][4]
|
||
# Sortie du matin = maintenant (285 min déjà faites, créneau saturé)
|
||
assert vendredi["cible_matin"] == "13:45"
|
||
# L'après-midi absorbe l'excédent du matin : départ au plancher 17:00
|
||
# (nominal 288 − 78 de marge) et reprise retardée à maintenant.
|
||
assert vendredi["cible_aprem_entree"] == "13:45"
|
||
assert vendredi["cible_aprem"] == "17:00"
|
||
# Suivre les cibles ne sur-livre plus de 105 min : reste ≤ 12 min
|
||
# (le solde résiduel vient des bornes de plages, pas du calcul).
|
||
suivis = [dict(p) for p in pointages]
|
||
suivis[4] = _pointage("2026-07-17", "09:00", vendredi["cible_matin"],
|
||
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
|
||
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
|
||
assert 0 <= apres["solde_min"] <= 12
|
||
|
||
|
||
def test_en_cours_sature_aprem_propose_sortie_immediate(monkeypatch):
|
||
"""Régression : vendredi après-midi en cours depuis 13:30 sans sortie, il est
|
||
19:30 — le nominal aprem (234) est dépassé de 126 min. L'écart affichait un
|
||
retard fantôme de 54 min ; désormais l'avance réelle (72 min) est montrée et
|
||
la seule cible cohérente est « sortir maintenant »."""
|
||
_Freezer.freeze(monkeypatch, 19, 30)
|
||
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", aprem_entree="13:30"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||
vendredi = result["jours"][4]
|
||
assert result["extra_min"] == 0 # plus de retard fantôme
|
||
assert result["avance_min"] == 72 # l'excédent déjà travaillé, affiché tel quel
|
||
assert vendredi["cible_aprem"] == "19:30" # sortir maintenant
|
||
|
||
|
||
def test_en_cours_non_sature_reste_inchange(monkeypatch):
|
||
"""Un créneau en cours classique (écoulé < nominal) garde le comportement
|
||
historique : le temps écoulé est réintégré au plan, le rattrapage du
|
||
retard pousse la sortie du soir au-delà du nominal."""
|
||
_Freezer.freeze(monkeypatch, 14, 20)
|
||
pointages = [
|
||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:24"), # -90
|
||
_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", aprem_entree="13:30"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||
vendredi = result["jours"][4]
|
||
assert result["extra_min"] == 144
|
||
assert vendredi["cible_aprem"] == "19:48"
|
||
suivis = [dict(p) for p in pointages]
|
||
suivis[4] = _pointage("2026-07-17", "09:00", "12:00", "13:30", vendredi["cible_aprem"])
|
||
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
|
||
assert apres["solde_min"] == 0
|
||
|
||
|
||
def test_entree_apres_fin_matin_le_surplus_ne_double_compte_pas(monkeypatch):
|
||
"""Régression : entrée du matin pointée à 12:30 (après la fin de Présence
|
||
matin), sans sortie, il est 13:45. Aucun créneau matin n'existe (capacité
|
||
nulle) : le temps écoulé (75 min) ne doit pas être réintégré au plan, sinon
|
||
l'après-midi du même jour se retrouve allongé de 75 min en trop. Le surplus
|
||
matin (234) bascule sur l'après-midi, réduit de ce qui est déjà fait."""
|
||
_Freezer.freeze(monkeypatch, 13, 45)
|
||
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", matin_entree="12:30"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||
vendredi = result["jours"][4]
|
||
assert vendredi["cible_matin"] == "13:45" # sortie immédiate de l'entrée ouverte
|
||
suivis = [dict(p) for p in pointages]
|
||
suivis[4] = _pointage("2026-07-17", "12:30", vendredi["cible_matin"],
|
||
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
|
||
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
|
||
assert apres["solde_min"] == 0
|