diff --git a/tests/test_palier4.py b/tests/test_palier4.py
new file mode 100644
index 0000000..e98ed9a
--- /dev/null
+++ b/tests/test_palier4.py
@@ -0,0 +1,146 @@
+"""Palier 4 (0.6.0): QA column, label reconciliation, wallboard."""
+
+from fastapi.testclient import TestClient
+
+import app.main as main
+from app import models
+from tests.conftest import _issue, csrf_from
+
+
+def _column_block(html: str, column: str) -> str:
+ blocks = html.split('
", 1)[0]:
+ return block
+ raise AssertionError(f"column {column} not found")
+
+
+def _labelled(number: int, title: str, label_names: list[str]) -> dict:
+ issue = _issue(number, title)
+ issue["labels"] = [
+ {"id": 900 + i, "name": name, "color": "8b5cf6"}
+ for i, name in enumerate(label_names)
+ ]
+ return issue
+
+
+# ── QA column ─────────────────────────────────────────────────────
+
+
+def test_board_has_six_columns_including_qa(client):
+ r = client.get("/board/Projets/demo")
+ assert r.status_code == 200
+ assert "🧪 QA" in r.text
+ positions = [
+ r.text.index(f'data-column="{col}"')
+ for col in ("backlog", "todo", "in_progress", "qa", "review", "done")
+ ]
+ assert positions == sorted(positions) # QA between in_progress and review
+
+
+def test_move_to_qa_syncs_kanban_qa_label(client, fake_gitea):
+ token = csrf_from(client)
+ r = client.post(
+ "/api/move",
+ data={
+ "repo_full_name": "Projets/demo", "issue_number": 1,
+ "to_column": "qa", "from_column": "backlog",
+ "to_order": "1", "from_order": "5", "csrf_token": token,
+ },
+ )
+ assert r.status_code == 200
+ assert r.json()["label_sync"] == "ok"
+ assert ("add", 1, "kanban:qa") in fake_gitea.label_ops
+ state = models.get_board_state("Projets/demo")
+ assert 1 in [c["issue_number"] for c in state["qa"]]
+
+
+def test_qa_cards_appear_in_focus(client, fake_gitea):
+ models.move_issue("Projets/demo", 5, "qa")
+ r = client.get("/focus")
+ assert r.status_code == 200
+ assert "Tâche focus" in r.text
+ assert "🧪 QA" in r.text
+
+
+# ── Label reconciliation (adoption) ───────────────────────────────
+
+
+def test_board_adopts_diverging_kanban_label(client, fake_gitea):
+ fake_gitea.issues["Projets/demo"].append(
+ _labelled(9, "Déplacée via Gitea", ["kanban:review"])
+ )
+ r = client.get("/board/Projets/demo")
+ assert r.status_code == 200
+ assert "Déplacée via Gitea" in _column_block(r.text, "review")
+ state = models.get_board_state("Projets/demo")
+ assert 9 in [c["issue_number"] for c in state["review"]]
+ events = [
+ e for e in models.get_events("Projets/demo") if e["issue_number"] == 9
+ ]
+ assert any(
+ e["event_type"] == "moved" and e["to_column"] == "review"
+ and e["source"] == "gitea"
+ for e in events
+ )
+
+
+def test_board_ignores_conflicting_kanban_labels(client, fake_gitea):
+ fake_gitea.issues["Projets/demo"].append(
+ _labelled(9, "Étiquettes en conflit", ["kanban:todo", "kanban:review"])
+ )
+ r = client.get("/board/Projets/demo")
+ assert r.status_code == 200
+ assert "Étiquettes en conflit" in _column_block(r.text, "backlog")
+ state = models.get_board_state("Projets/demo")
+ elsewhere = [
+ c["issue_number"]
+ for col, cards in state.items() if col != "backlog"
+ for c in cards
+ ]
+ assert 9 not in elsewhere
+
+
+def test_adoption_is_idempotent(client, fake_gitea):
+ fake_gitea.issues["Projets/demo"].append(
+ _labelled(9, "Stable", ["kanban:qa"])
+ )
+ client.get("/board/Projets/demo")
+ client.get("/board/Projets/demo")
+ events = [
+ e for e in models.get_events("Projets/demo")
+ if e["issue_number"] == 9 and e["event_type"] == "moved"
+ ]
+ assert len(events) == 1 # adopted once, then label and state agree
+
+
+# ── Wallboard ─────────────────────────────────────────────────────
+
+
+def test_wall_renders_active_projects(client):
+ models.move_issue("Projets/demo", 5, "in_progress")
+ r = client.get("/wall")
+ assert r.status_code == 200
+ assert "Projets/demo" in r.text
+ assert "Tâche focus" in r.text # the in-progress card is on the wall
+ assert 'class="wall-slide active"' in r.text
+ assert "🧪 QA" in r.text # the six column counters include QA
+
+
+def test_wall_single_repo_mode(client):
+ r = client.get("/wall", params={"repo": "Projets/demo"})
+ assert r.status_code == 200
+ assert "Projets/demo" in r.text
+
+
+def test_wall_unknown_repo_shows_empty_state(client):
+ r = client.get("/wall", params={"repo": "Inconnu/xyz"})
+ assert r.status_code == 200
+ assert "Aucun projet actif" in r.text
+
+
+def test_wall_is_behind_auth(monkeypatch, fake_gitea):
+ monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
+ monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
+ with TestClient(main.app) as secured:
+ assert secured.get("/wall").status_code == 401