diff --git a/tests/test_palier6.py b/tests/test_palier6.py
new file mode 100644
index 0000000..51d0405
--- /dev/null
+++ b/tests/test_palier6.py
@@ -0,0 +1,112 @@
+"""Palier 6 (0.9.0): board dynamique sans refresh (issue #15)."""
+
+import re
+
+import app.main as main
+from tests.conftest import csrf_from
+
+
+def _page_counts(html: str) -> dict[str, int]:
+ counts = {}
+ for col, count in re.findall(
+ r'data-column="(\w+)">\s*
]*>(\d+)',
+ html, re.S,
+ ):
+ counts[col] = int(count)
+ return counts
+
+
+def test_api_board_matches_page(client, fake_gitea):
+ page = client.get("/board/Projets/demo")
+ assert page.status_code == 200
+ api = client.get("/api/board/Projets/demo")
+ assert api.status_code == 200
+ data = api.json()
+ assert data["repo"] == "Projets/demo"
+ page_counts = _page_counts(page.text)
+ for col in ("backlog", "todo", "in_progress", "qa", "review", "done"):
+ assert data["columns"][col]["count"] == page_counts[col], col
+ assert "Tâche un" in data["columns"]["backlog"]["html"]
+ assert 'data-issue="1"' in data["columns"]["backlog"]["html"]
+ # Empty column carries the same hint as the server-rendered page.
+ assert "Glissez une carte ici" in data["columns"]["qa"]["html"]
+
+
+def test_api_board_reflects_move(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 and r.json()["ok"]
+ data = client.get("/api/board/Projets/demo").json()
+ assert 'data-issue="1"' in data["columns"]["in_progress"]["html"]
+ # #5 ("Tâche focus", also unplaced) stays in the backlog.
+ assert data["columns"]["backlog"]["count"] == 1
+ assert 'data-issue="5"' in data["columns"]["backlog"]["html"]
+
+
+def test_board_page_uses_board_live_not_live(client, fake_gitea):
+ page = client.get("/board/Projets/demo")
+ assert "/static/js/board-live.js" in page.text
+ assert "/static/js/live.js" not in page.text
+ focus = client.get("/focus")
+ assert "/static/js/live.js" in focus.text # unchanged elsewhere
+
+
+def test_move_broadcasts_board_changed(client, fake_gitea, monkeypatch):
+ sent = []
+
+ async def record(payload):
+ sent.append(payload)
+
+ monkeypatch.setattr(main.hub, "broadcast", record)
+ token = csrf_from(client)
+ r = client.post("/api/move", data={
+ "repo_full_name": "Projets/demo", "issue_number": 1,
+ "from_column": "backlog", "to_column": "todo",
+ "to_order": "1", "csrf_token": token,
+ })
+ assert r.status_code == 200
+ assert any(
+ p.get("type") == "board-changed" and p.get("repo") == "Projets/demo"
+ for p in sent
+ ), sent
+
+
+def test_create_issue_broadcasts(client, fake_gitea, monkeypatch):
+ sent = []
+
+ async def record(payload):
+ sent.append(payload)
+
+ monkeypatch.setattr(main.hub, "broadcast", record)
+ token = csrf_from(client)
+ r = client.post("/api/issues", data={
+ "repo_full_name": "Projets/demo", "title": "Nouvelle carte live",
+ "column": "backlog", "csrf_token": token,
+ })
+ assert r.status_code == 201
+ assert any(p.get("type") == "board-changed" for p in sent), sent
+
+
+def test_webhook_label_event_broadcasts(client, fake_gitea, monkeypatch):
+ sent = []
+
+ async def record(payload):
+ sent.append(payload)
+
+ monkeypatch.setattr(main.hub, "broadcast", record)
+ r = client.post(
+ "/webhooks/gitea",
+ json={
+ "action": "labeled",
+ "repository": {"full_name": "Projets/demo"},
+ "issue": {"number": 1},
+ },
+ headers={"x-gitea-event": "issues"},
+ )
+ assert r.status_code == 200
+ assert r.json()["ok"] is True
+ assert sent and sent[0]["action"] == "labeled"