CI / test (push) Successful in 2m23s
test_column_entry_times_ignores_comments évaluait _days_ago(3) à la création puis à l'assertion : une bascule de seconde entre les deux faisait échouer le run CI 2333 alors que le run jumeau (tag v0.33.0) et la suite locale passaient.
188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""Palier 1 (0.3.0): event journal, card detail panel, aging + WIP limits."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app import models
|
|
from tests.conftest import csrf_from
|
|
|
|
|
|
def _days_ago(days: int) -> str:
|
|
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
|
"%Y-%m-%d %H:%M:%S"
|
|
)
|
|
|
|
|
|
# ── Journal ────────────────────────────────────────────────
|
|
|
|
def test_log_event_and_get_events():
|
|
models.log_event("a/b", 1, "created", to_column="backlog")
|
|
models.log_event("a/b", 1, "moved", from_column="backlog", to_column="todo")
|
|
events = models.get_events("a/b")
|
|
assert [e["event_type"] for e in events] == ["moved", "created"]
|
|
assert events[0]["from_column"] == "backlog"
|
|
|
|
|
|
def test_log_event_rejects_unknown_type():
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError):
|
|
models.log_event("a/b", 1, "exploded")
|
|
|
|
|
|
def test_move_via_api_writes_journal(client):
|
|
token = csrf_from(client)
|
|
client.post(
|
|
"/api/move",
|
|
data={
|
|
"repo_full_name": "Projets/demo", "issue_number": 1,
|
|
"to_column": "done", "from_column": "backlog",
|
|
"to_order": "1", "csrf_token": token,
|
|
},
|
|
)
|
|
types = [e["event_type"] for e in models.get_events("Projets/demo")]
|
|
assert types == ["closed", "moved"] # newest first
|
|
|
|
|
|
def test_same_column_reorder_does_not_log_move(client):
|
|
token = csrf_from(client)
|
|
client.post(
|
|
"/api/move",
|
|
data={
|
|
"repo_full_name": "Projets/demo", "issue_number": 5,
|
|
"to_column": "backlog", "from_column": "backlog",
|
|
"to_order": "5,1", "csrf_token": token,
|
|
},
|
|
)
|
|
assert models.get_events("Projets/demo") == []
|
|
|
|
|
|
def test_create_issue_logs_created_event(client):
|
|
token = csrf_from(client)
|
|
client.post(
|
|
"/api/issues",
|
|
data={
|
|
"repo_full_name": "Projets/demo", "title": "Nouvelle",
|
|
"column": "todo", "csrf_token": token,
|
|
},
|
|
)
|
|
events = models.get_events("Projets/demo")
|
|
assert events and events[0]["event_type"] == "created"
|
|
assert events[0]["to_column"] == "todo"
|
|
|
|
|
|
def test_events_api_and_activity_page(client):
|
|
models.log_event("Projets/demo", 1, "moved",
|
|
from_column="backlog", to_column="review")
|
|
r = client.get("/api/events?repo=Projets/demo")
|
|
assert r.status_code == 200
|
|
assert r.json()["events"][0]["event_type"] == "moved"
|
|
r = client.get("/activity")
|
|
assert r.status_code == 200
|
|
assert "Projets/demo" in r.text
|
|
|
|
|
|
def test_metrics_counts_and_cycle_time():
|
|
models.log_event("a/b", 1, "created", to_column="backlog",
|
|
created_at=_days_ago(10))
|
|
models.log_event("a/b", 1, "moved", from_column="backlog",
|
|
to_column="in_progress", created_at=_days_ago(9))
|
|
models.log_event("a/b", 1, "closed", to_column="done",
|
|
created_at=_days_ago(4))
|
|
metrics = models.get_metrics("a/b", window_days=30)
|
|
assert metrics["created"] == 1
|
|
assert metrics["closed"] == 1
|
|
assert metrics["avg_cycle_days"] == 5.0 # in_progress (J-9) -> closed (J-4)
|
|
r = models.get_metrics("a/b", window_days=30)
|
|
assert r["cycle_samples"] == 1
|
|
|
|
|
|
def test_column_entry_times_ignores_comments():
|
|
# Capturer l'horodatage UNE fois : _days_ago() réévalué à
|
|
# l'assertion basculait d'une seconde une fois sur ~N
|
|
# (échec CI 2333 du 2026-10-26, run jumeau vert).
|
|
entry = _days_ago(3)
|
|
models.log_event("a/b", 1, "moved", to_column="todo",
|
|
created_at=entry)
|
|
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
|
|
times = models.column_entry_times("a/b")
|
|
assert times[1] == entry
|
|
|
|
|
|
# ── Panneau détail ─────────────────────────────────────────
|
|
|
|
def test_issue_detail_returns_body_and_comments(client):
|
|
r = client.get("/api/issues/Projets/demo/1")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["issue"]["title"] == "Tâche un"
|
|
assert "markdown" in data["issue"]["body"]
|
|
assert data["comments"][0]["body"] == "Premier commentaire"
|
|
assert data["comments"][0]["user"]["login"] == "bruno"
|
|
|
|
|
|
def test_issue_detail_404(client):
|
|
r = client.get("/api/issues/Projets/demo/999")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_post_comment_goes_to_gitea_and_journals(client, fake_gitea):
|
|
token = csrf_from(client)
|
|
r = client.post(
|
|
"/api/issues/Projets/demo/1/comments",
|
|
data={"body": "Bien vu !", "csrf_token": token},
|
|
)
|
|
assert r.status_code == 201
|
|
assert fake_gitea.posted_comments == [("Projets", "demo", 1, "Bien vu !")]
|
|
assert models.get_events("Projets/demo")[0]["event_type"] == "commented"
|
|
|
|
|
|
def test_post_comment_requires_csrf_and_body(client):
|
|
r = client.post("/api/issues/Projets/demo/1/comments", data={"body": "x"})
|
|
assert r.status_code == 403
|
|
token = csrf_from(client)
|
|
r = client.post(
|
|
"/api/issues/Projets/demo/1/comments",
|
|
data={"body": " ", "csrf_token": token},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_board_contains_detail_drawer(client):
|
|
r = client.get("/board/Projets/demo")
|
|
assert 'id="card-detail"' in r.text
|
|
assert "marked.min.js" in r.text and "purify.min.js" in r.text
|
|
|
|
|
|
# ── Vieillissement + WIP ───────────────────────────────────
|
|
|
|
def test_aging_badge_after_threshold(client):
|
|
models.move_issue("Projets/demo", 1, "todo")
|
|
models.log_event("Projets/demo", 1, "moved", to_column="todo",
|
|
created_at=_days_ago(12))
|
|
r = client.get("/board/Projets/demo")
|
|
assert "age-badge" in r.text
|
|
assert "12 j" in r.text
|
|
|
|
|
|
def test_no_aging_badge_for_fresh_card(client):
|
|
# Every open card entered its column recently: no badge anywhere.
|
|
# (A card with no journal entry falls back to its Gitea creation
|
|
# date — the fake issues are old, so both cards get fresh entries.)
|
|
models.move_issue("Projets/demo", 1, "todo")
|
|
models.log_event("Projets/demo", 1, "moved", to_column="todo",
|
|
created_at=_days_ago(1))
|
|
models.log_event("Projets/demo", 5, "created", to_column="backlog",
|
|
created_at=_days_ago(1))
|
|
r = client.get("/board/Projets/demo")
|
|
assert "age-badge" not in r.text
|
|
|
|
|
|
def test_wip_limit_marks_column_over(client, monkeypatch):
|
|
monkeypatch.setenv("KANBANTE_WIP_LIMITS", "in_progress:1")
|
|
models.move_issue("Projets/demo", 1, "in_progress")
|
|
models.move_issue("Projets/demo", 5, "in_progress")
|
|
r = client.get("/board/Projets/demo")
|
|
assert 'data-limit="1"' in r.text
|
|
assert "2/1" in r.text
|
|
assert "over" in r.text
|