Files
pointeuse-optimisator/app/tests/test_calcul.py
Jacquin Antoine 45b450c1ca Préconisations d'horaire : cascade d'avance et correctif de double comptage
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
2026-09-11 14:04:37 +02:00

674 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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