"""Palier 9 (0.12.0): Focus du jour (#5), Prévisions (#7), Récit (#4).""" from datetime import date, timedelta from app import forecast as fc from app import models from tests.conftest import csrf_from def _ev(day: date, kind: str = "closed") -> dict: return {"event_type": kind, "created_at": day.strftime("%Y-%m-%d 10:00:00")} # ── Prévisions : fonctions pures ────────────────────────────────── def test_weekly_closures_buckets(): today = date(2026, 10, 14) # a Wednesday monday = today - timedelta(days=2) events = [ _ev(monday), _ev(monday + timedelta(days=1)), # this week: 2 _ev(monday - timedelta(days=7)), # last week: 1 _ev(monday - timedelta(days=30), "moved"), # not a closure _ev(monday - timedelta(days=90)), # too old ] weekly = fc.weekly_closures(events, weeks=8, today=today) assert len(weekly) == 8 assert weekly[-1]["count"] == 2 assert weekly[-2]["count"] == 1 assert sum(b["count"] for b in weekly) == 3 def test_throughput_excludes_current_week_and_needs_history(): today = date(2026, 10, 14) monday = today - timedelta(days=2) events = [] # 4 complete weeks: 2, 4, 0, 6 closures; current week: 99 (ignored) for weeks_ago, n in ((1, 2), (2, 4), (3, 0), (4, 6)): for k in range(n): events.append(_ev(monday - timedelta(weeks=weeks_ago) + timedelta(days=k % 5))) for k in range(9): events.append(_ev(monday + timedelta(days=k % 2))) weekly = fc.weekly_closures(events, weeks=8, today=today) stats = fc.throughput_stats(weekly) assert stats == {"avg": 3.0, "min": 0, "max": 6, "weeks": 4} assert fc.throughput_stats(fc.weekly_closures([], today=today)) is None def test_estimate_dates_and_labels(): today = date(2026, 10, 14) monday = today - timedelta(days=2) events = [_ev(monday - timedelta(weeks=w)) for w in (1, 2, 3, 4) for _ in range(2)] weekly = fc.weekly_closures(events, weeks=8, today=today) est = fc.estimate(6, weekly, today=today) assert est["done"] is False assert est["avg_per_week"] == 2.0 assert est["central"] == today + timedelta(days=21) # min == max == 2/week -> no spread: latest==earliest==central assert est["earliest"] == est["central"] == est["latest"] label = fc.eta_label(est) assert "à ce rythme ≈" in label assert fc.estimate(0, weekly, today=today) == {"done": True} assert fc.estimate(5, [], today=today) is None assert fc.french_date(date(2026, 11, 3)) == "3 novembre" def test_metrics_include_weekly_closures(client, fake_gitea): models.log_event("Projets/demo", 3, "closed", to_column="done") r = client.get("/api/metrics", params={"repo": "Projets/demo"}) assert r.status_code == 200 weekly = r.json()["metrics"]["weekly_closures"] assert len(weekly) == 8 assert weekly[-1] == 1 # the closure we just logged is this week def test_forecast_page_and_milestone_banner(client, fake_gitea): r = client.get("/forecast") assert r.status_code == 200 assert "Prévisions" in r.text assert "Projets/demo" in r.text assert "Sprint 1" in r.text board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"}) assert board.status_code == 200 assert "🔮" in board.text assert "historique pour prévoir" in board.text # empty journal assert "/sprint/Projets/demo" in board.text # ── Focus du jour ───────────────────────────────────────────────── def _pin(client, token, number, repo="Projets/demo"): return client.post("/api/today/pin", data={ "repo_full_name": repo, "issue_number": number, "csrf_token": token, }) def test_today_pin_unpin_flow(client, fake_gitea): token = csrf_from(client) r = _pin(client, token, 1) assert r.status_code == 200 and r.json()["count"] == 1 r = _pin(client, token, 1) # idempotent assert r.json().get("already") is True page = client.get("/today") assert page.status_code == 200 assert "Tâche un" in page.text r = client.post("/api/today/unpin", data={ "repo_full_name": "Projets/demo", "issue_number": 1, "csrf_token": token, }) assert r.status_code == 200 assert models.get_picks(models_now()) == [] def models_now() -> str: from datetime import datetime return datetime.now().strftime("%Y-%m-%d") def test_today_pin_cap_three(client, fake_gitea): token = csrf_from(client) today = models_now() for n in (11, 12, 13): models.add_pick(today, "Projets/demo", n) r = _pin(client, token, 1) assert r.status_code == 400 assert "3 cartes max" in r.json()["error"] def test_briefing_includes_today_picks(client, fake_gitea): models.add_pick(models_now(), "Projets/demo", 5) r = client.get("/api/briefing") assert r.status_code == 200 picks = r.json()["briefing"]["today_picks"] assert [p["number"] for p in picks] == [5] assert picks[0]["title"] == "Tâche focus" def test_today_page_shows_candidates(client, fake_gitea): token = csrf_from(client) r = client.post("/api/move", data={ "repo_full_name": "Projets/demo", "issue_number": 1, "from_column": "backlog", "to_column": "in_progress", "to_order": "1", "csrf_token": token, }) assert r.status_code == 200 page = client.get("/today") assert page.status_code == 200 assert "Candidates" in page.text assert "Tâche un" in page.text # ── Récit de sprint ─────────────────────────────────────────────── def test_sprint_markdown_from_fake_milestone(client, fake_gitea): models.log_event("Projets/demo", 3, "moved", to_column="in_progress") models.log_event("Projets/demo", 3, "closed", to_column="done") r = client.get("/api/sprint/Projets/demo", params={"milestone": "Sprint 1"}) assert r.status_code == 200 md = r.json()["markdown"] assert "# 📖 Récit de sprint — Projets/demo · Sprint 1" in md assert "## ✅ Livré (1)" in md assert "Ancienne tâche" in md assert "## ↩️ Reporté (1)" in md assert "Tâche un" in md page = client.get("/sprint/Projets/demo", params={"milestone": "Sprint 1"}) assert page.status_code == 200 assert "Copier le markdown" in page.text def test_sprint_unknown_milestone_404(client, fake_gitea): r = client.get("/api/sprint/Projets/demo", params={"milestone": "Nope"}) assert r.status_code == 404 page = client.get("/sprint/Projets/demo") assert page.status_code == 200 assert "Sprint 1" in page.text # picker lists the milestones