"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9).""" from datetime import datetime, timedelta from app import code_links as cl from app import flow from app import models from tests.conftest import csrf_from # ── #3 Branche depuis la carte ──────────────────────────────────── def test_slugify_and_branch_name(): assert cl.slugify_title("Créer la branche depuis la carte") == \ "creer-la-branche-depuis-la-carte" assert cl.slugify_title("Fix: l'été indien (urgent!)") == \ "fix-l-ete-indien-urgent" assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3) assert len(cl.slugify_title("x" * 100)) == 40 assert cl.slugify_title("!!!") == "issue" assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair" # La convention est reconnue par l'appariement (#1/#2). assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12 def test_create_branch_endpoint(client, fake_gitea): token = csrf_from(client) r = client.post("/api/issues/Projets/demo/5/branch", data={"csrf_token": token}) assert r.status_code == 200 data = r.json() assert data["branch"] == "issue-5-tache-focus" assert data["from"] == "main" assert data["moved"] is True assert "issue-5-tache-focus" in fake_gitea.branches board = models.get_board_state("Projets/demo") assert 5 in [c["issue_number"] for c in board["in_progress"]] events = models.get_events(repo_full_name="Projets/demo") assert any( e["event_type"] == "moved" and e["source"] == "branch" for e in events ) # Deuxième appel : la branche existe déjà, pas de doublon. r2 = client.post("/api/issues/Projets/demo/5/branch", data={"csrf_token": token}) assert r2.json()["exists"] is True def test_create_branch_custom_name_and_validation(client, fake_gitea): token = csrf_from(client) r = client.post("/api/issues/Projets/demo/1/branch", data={"csrf_token": token, "name": "feature/super-truc"}) assert r.status_code == 200 assert r.json()["branch"] == "feature/super-truc" r = client.post("/api/issues/Projets/demo/1/branch", data={"csrf_token": token, "name": "mauvais nom.."}) assert r.status_code == 400 def test_code_endpoint_suggests_branch(client, fake_gitea): r = client.get("/api/issues/Projets/demo/3/code") assert r.json()["suggested_branch"] == "issue-3-ancienne-tache" # ── #9 Santé du flux : fonctions pures ──────────────────────────── def _ev(day_offset: float, kind: str, frm=None, to=None, number=1): at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime( "%Y-%m-%d %H:%M:%S" ) return { "repo_full_name": "Projets/demo", "issue_number": number, "event_type": kind, "from_column": frm, "to_column": to, "created_at": at, } def test_column_dwell_reconstruction(): events = [ _ev(0, "created", to="backlog"), _ev(4, "moved", frm="backlog", to="in_progress"), _ev(10, "closed", frm="in_progress", to="done"), _ev(1, "created", to="backlog", number=2), _ev(3, "moved", frm="backlog", to="review", number=2), ] dwell = flow.column_dwell(events) assert dwell["backlog"] == [4.0, 2.0] assert dwell["in_progress"] == [6.0] summary = flow.summarize_dwell(dwell) by_col = {row["column"]: row for row in summary} assert by_col["backlog"]["median_days"] == 3.0 assert by_col["backlog"]["count"] == 2 # backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot # nommable à ce stade (in_progress n'a qu'un séjour mesuré). slowest = flow.slowest_column(summary) assert slowest["column"] == "backlog" events.append(_ev(20, "moved", frm="review", to="done", number=2)) events += [ _ev(0, "created", to="backlog", number=3), _ev(2, "moved", frm="backlog", to="review", number=3), _ev(19, "moved", frm="review", to="done", number=3), ] dwell = flow.column_dwell(events) summary = flow.summarize_dwell(dwell) slowest = flow.slowest_column(summary) assert slowest["column"] == "review" assert slowest["median_days"] == 17.0 def test_last_activity(): events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")] latest = flow.last_activity(events) assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6) # ── #9 Pages santé ──────────────────────────────────────────────── def _seed_stay(number: int, days_ago_start: int, days_ago_end: int, frm="backlog", to="review"): models.log_event("Projets/demo", number, "created", to_column="backlog") models.log_event("Projets/demo", number, "moved", from_column=frm, to_column=to) now = datetime.now() with models.get_db() as conn: conn.execute( "UPDATE board_events SET created_at = ? " "WHERE issue_number = ? AND event_type = 'created'", ((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number), ) conn.execute( "UPDATE board_events SET created_at = ? " "WHERE issue_number = ? AND event_type = 'moved'", ((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number), ) def test_health_pages_and_bottleneck(client, fake_gitea): _seed_stay(1, 40, 10) # 30 j en backlog _seed_stay(5, 35, 5) # 30 j en backlog aussi page = client.get("/health/Projets/demo") assert page.status_code == 200 assert "backlog" in page.text.lower() index = client.get("/health") assert index.status_code == 200 assert "Projets/demo" in index.text def test_health_zombies_threshold(client, fake_gitea, monkeypatch): # Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité, # donc zombies au seuil par défaut de 30 j. page = client.get("/health/Projets/demo") assert "Tâche un" in page.text assert 'data-zombie="close"' in page.text monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000") page = client.get("/health/Projets/demo") assert "Aucune carte zombie" in page.text