177 lines
6.8 KiB
Python
177 lines
6.8 KiB
Python
"""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
|