Files
pointeuse-optimisator/app/tests/test_calcul.py
Antoine 3162968bfb Empêche la cible de proposer un départ déjà passé quand la semaine est en avance
La répartition de l'avance sur les départs du soir ne vérifiait que le
plancher aprem_fin, pas l'heure actuelle : sur une demi-journée en cours,
elle pouvait recommander de partir à une heure déjà écoulée.

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

441 lines
21 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"