diff --git a/app/main.py b/app/main.py index 8b4d82c..29c8e3b 100644 --- a/app/main.py +++ b/app/main.py @@ -873,14 +873,17 @@ def save_pointage( p_by_date = {p["date"]: p for p in raw} # Les événements d'astreinte ne sont affichés (donc postés) que les jours # qualifiés (astreinte ou lendemain) ; ailleurs on conserve ceux déjà saisis - # au lieu de les écraser à vide. Les paires début/fin invalides sont ignorées. + # au lieu de les écraser à vide. Une ligne en cours de saisie (début sans + # fin, ou l'inverse) est conservée telle quelle : l'auto-enregistrement ne + # doit pas effacer une saisie à moitié tapée — les calculs (décompte, repos) + # ignorent les événements incomplets tant que la paire n'est pas complète. if jour_astreinte_qualifie(date, set(load_astreintes_effectives(user_id))): events = [] # Les descriptions sont optionnelles : sans elles (POST programmatique), # zip s'arrêterait à la liste la plus courte et perdrait les événements. for i, (deb, fin) in enumerate(zip(astreinte_debut, astreinte_fin)): deb, fin = clean(deb), clean(fin) - if deb and fin: + if deb or fin: desc = astreinte_desc[i] if i < len(astreinte_desc) else "" events.append({"debut": deb, "fin": fin, "desc": (desc or "").strip()[:200] or None}) diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 32c5e3a..bec5ba3 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -496,6 +496,11 @@ function asRestore() { } asRestore(); document.addEventListener('htmx:afterSwap', asRestore); +// Le settle HTMX (transition après swap) restaure les classes du snapshot pris +// à l'insertion : une classe ajoutée entre insertion et settle — comme « open » +// par asRestore sur afterSwap — est écrasée. On restaure donc aussi après le +// settle, dernier moment du cycle. +document.addEventListener('htmx:afterSettle', asRestore); // Ajoute une ligne d'intervention vierge dans le jour ciblé window.asAddEvent = function (date) { diff --git a/app/tests/test_autosave_astreinte.py b/app/tests/test_autosave_astreinte.py new file mode 100644 index 0000000..f1c79d8 --- /dev/null +++ b/app/tests/test_autosave_astreinte.py @@ -0,0 +1,83 @@ +"""Auto-enregistrement des interventions d'astreinte : une ligne en cours de +saisie (début sans fin, ou l'inverse) survit au cycle save/re-rendu — le calcul +l'ignore tant que la paire est incomplète, mais la valeur saisie reste stockée +et affichée, et le bloc dépliant ne se referme pas (restauration après le +settle HTMX, cf. semaine.html).""" +import sys + +import models # noqa: F401 (DATA_DIR temporaire posé par conftest) + +sys.path.insert(0, ".") +import main +from fastapi.testclient import TestClient + +client = TestClient(main.app) + + +def _login(uid: str) -> dict: + import bcrypt + models.save_auth(uid, email=f"{uid}@x.fr", + password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode()) + r = client.post("/login/password", + data={"email": f"{uid}@x.fr", "password": "x" * 12}, + follow_redirects=False) + assert r.status_code == 302, r.text + return {"user_id": r.cookies.get("user_id")} + + +def _events(uid: str, date: str): + from datetime import date as d_ + d = d_.fromisoformat(date) + iso = d.isocalendar() + row = next((p for p in models.load_week_pointages(iso.year, iso.week, uid) + if p["date"] == date), {}) + return row.get("astreinte_events") + + +def test_ligne_partielle_conservee_entre_deux_saves(): + uid = "u-as-partiel" + cookies = _login(uid) + models.toggle_astreinte("2026-07-14", uid) # mardi AS + + # 1er save auto : début saisi, fin pas encore — la ligne doit survivre + r = client.post("/pointage/2026-07-14", cookies=cookies, + data={"astreinte_debut": ["22:00"], "astreinte_fin": [""], + "astreinte_desc": [""]}, follow_redirects=False) + assert r.status_code == 200, r.text + assert _events(uid, "2026-07-14") == [{"debut": "22:00", "fin": None, "desc": None}] + # La valeur saisie est réaffichée dans le bloc re-rendu + assert 'value="22:00"' in r.text + + # 2e save : la fin complète la paire — l'événement devient calculable + r = client.post("/pointage/2026-07-14", cookies=cookies, + data={"astreinte_debut": ["22:00"], "astreinte_fin": ["23:30"], + "astreinte_desc": [""]}, follow_redirects=False) + assert r.status_code == 200, r.text + assert _events(uid, "2026-07-14") == [{"debut": "22:00", "fin": "23:30", "desc": None}] + assert "astreinte : 1h30" in r.text # décompte arrondi au quart d'heure + + +def test_event_partiel_ignore_par_les_calculs(): + """Un début sans fin ne déclenche ni décompte ni repos de 11h.""" + uid = "u-as-partiel-calc" + cookies = _login(uid) + models.toggle_astreinte("2026-07-14", uid) + + client.post("/pointage/2026-07-14", cookies=cookies, + data={"astreinte_debut": ["23:00"], "astreinte_fin": [""], + "astreinte_desc": [""]}, follow_redirects=False) + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert "astreinte :" not in r.text # rien de décompté + assert "retour ≥" not in r.text # pas de retour décalé + + +def test_ligne_entierement_vide_non_conservee(): + uid = "u-as-vide" + cookies = _login(uid) + models.toggle_astreinte("2026-07-14", uid) + + client.post("/pointage/2026-07-14", cookies=cookies, + data={"astreinte_debut": ["", "22:00"], "astreinte_fin": ["", "23:00"], + "astreinte_desc": ["", ""]}, follow_redirects=False) + # La ligne vide n'est pas stockée, la ligne complète l'est + assert _events(uid, "2026-07-14") == [{"debut": "22:00", "fin": "23:00", "desc": None}]