Files
pointeuse-optimisator/app/tests/test_calcul.py
Antoine b9a75fffea Répartit avance/retard sur les départs du soir, plus jamais sur le matin
Le rattrapage hebdomadaire ne portait que sur le retard et pouvait
s'étaler sur le matin quand il avait de la marge. Objectif demandé :
pouvoir planifier les horaires à l'avance pour arriver à 0 en fin de
semaine, en ajustant uniquement les heures de départ du soir - dans
un sens (retard) comme dans l'autre (avance, plafonnée par aprem_fin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 17:12:33 +02:00

397 lines
18 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"
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
# Rien n'est encore en retard puisque rien n'a encore été renseigné : le rythme
# normal (à répartir librement sur les 10 demi-journées) suffit, pas de cible imposée.
assert result["extra_min"] == 0
assert all(j["cible_matin"] is None and j["cible_aprem"] is None 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_pas_de_cible(freeze_today):
"""Quand le rythme normal des demi-journées restantes suffit déjà, aucune cible n'est affichée."""
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 lundi_suivant["cible_matin"] is None
assert lundi_suivant["cible_aprem"] is None
def test_compute_week_jour_courant_non_entame_rythme_normal_suffit(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, pas de cible."""
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 vendredi["cible_matin"] is None
assert vendredi["cible_aprem"] is None
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 saturé au nominal (09:00→12:00), pas de cible ; aprem : 13:30 + 288 + 60 = 19:18
assert vendredi["cible_matin"] is None
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
assert vendredi["cible_matin"] is None
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, pas de cible ; aprem : 13:30 + 288 + 80 = 19:38
assert vendredi["cible_matin"] is None
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"] is None
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.
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"] is None
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 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"] is None
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"] is None
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"