Files
flowdeck/tests/test_my_tasks_workspace_coherence.py
bruno 81746d9440
FlowDeck CI / test (push) Failing after 13m34s
FlowDeck CI / lint (push) Successful in 2m32s
FlowDeck CI / docker (push) Skipped
fix: peek de Library nu comme local-workspace - iframe pleine (v7.50.2)
.peek-body gardait padding:16px 20px + overflow-y:auto (heritage de l'ancien
peek a rendu HTML) : l'iframe etait amochee (519x816 mesures contre 559x848
en local-workspace) avec bande grise autour du document + double scrollbar.
Corps nu (padding:0, overflow:hidden) : l'editeur remplit le panneau,
exactement comme le peek de /local-workspace.

Mesure Playwright (cookie de session forge, les 14 boutons Open de Library) :
embed OK partout, bases hydratees, aucun jeton « Loading database... ».
Test: test_library_peek_body_has_no_inset_padding. Suite 1266/0 (-n auto)
+ ruff 0. Bump VERSION/main.py 7.50.2, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:53:04 -04:00

1588 lines
61 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FlowDeck — My Tasks : contrat « façon Notion » + cohérence workspace.
Bug d'origine : après suppression de tous les documents d'un workspace, la vue
My Tasks continuait d'afficher les tâches de ce workspace (les bases et leurs
lignes survivaient, la vue ne vérifiait rien).
Évolution : My Tasks devient un poste de travail **transverse** (tous les
workspaces, comme l'onglet *Home* de Notion) alimenté par des **sources
opt-in** : une base n'émet ses tâches qu'après « conversion en base de tâches »
et le mapping explicite de ses 3 colonnes requises.
Ce fichier verrouille :
* le périmètre transverse (plus de scoping par `flowdeck_workspace`) ;
* l'opt-in : une base non convertie n'émet rien ; une conversion incomplète
n'émet rien non plus ;
* le mapping **nommé** (Assigné à / Statut / Échéance) et sa validation ;
* la limite de 10 bases connectées ;
* les 3 vues, les filtres globaux et le tri ;
* l'édition en direct (statut / échéance / titre) via le mapping ;
* l'ajout rapide multi-destinations (auto-assigné) ;
* les cascades de suppression (corbeille / purge / workspace / base).
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import tempfile
from datetime import UTC, date, timedelta
import pytest
from conftest import login_test_client
@pytest.fixture
def client():
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
os.environ["LLM_PROVIDER"] = "offline"
from app.config import settings
from app.db import init_db
from app.main import app
settings.database_url = f"sqlite:///{db_path}"
settings.llm_provider = "offline"
settings.rate_limit_enabled = False
init_db()
from fastapi.testclient import TestClient
client = login_test_client(TestClient(app))
try:
yield client
finally:
for suffix in ("", "-wal", "-shm"):
try:
os.unlink(db_path + suffix)
except FileNotFoundError:
pass
@contextlib.contextmanager
def _conn():
from app.db import get_conn
with get_conn() as conn:
yield conn
def _one(sql: str, params=()):
"""Première ligne, via une connexion qui se ferme toute seule."""
with _conn() as conn:
row = conn.execute(sql, params).fetchone()
return dict(row) if row else None
def _make_workspace(client, name: str) -> int:
r = client.post("/api/workspaces", json={"name": name})
assert r.status_code == 200, r.text
return r.json()["id"]
def _host_page(ws_id: int, title: str) -> int:
with _conn() as conn:
page_id = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section) VALUES ('', ?, ?, '', 'blocks', 'Private')",
(ws_id, title),
).lastrowid
conn.commit()
return page_id
def _prop(cid: int, name: str, prop_type: str, options=None) -> int:
with _conn() as conn:
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
"WHERE collection_id=?", (cid,),
).fetchone()[0]
pid = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, "
"options_json, position, required, visible_in_views) VALUES (?,?,?,?,?,0,1)",
(cid, name, prop_type, json.dumps(options or []), pos),
).lastrowid
conn.commit()
return pid
def _database(client, ws_id: int, name: str, *, host_page: bool = True,
rows: int = 0) -> int:
"""Base pleine page si `host_page`, sinon base rattachée au workspace seule."""
page = _host_page(ws_id, name) if host_page else None
with _conn() as conn:
cid = conn.execute(
"INSERT INTO collections (name, workspace_id, parent_page_id) VALUES (?, ?, ?)",
(name, ws_id, page),
).lastrowid
if page:
conn.execute(
"UPDATE pages SET content_format='collection', collection_id=? WHERE id=?",
(cid, page),
)
conn.commit()
if rows:
with _conn() as conn:
for i in range(rows):
conn.execute(
"INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
(cid, f"{name} #{i + 1}"),
)
conn.commit()
return cid
def _host_page_id(cid: int) -> int:
with _conn() as conn:
return conn.execute(
"SELECT parent_page_id FROM collections WHERE id=?", (cid,)
).fetchone()[0]
def _other_user() -> dict:
"""Un second utilisateur (la base de test n'en contient qu'un)."""
with _conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE id<>? ORDER BY id LIMIT 1", (_user()["id"],)
).fetchone()
if row is not None:
return dict(row)
uid = conn.execute(
"INSERT INTO users (login, full_name, is_admin) VALUES ('other','Other',0)"
).lastrowid
conn.commit()
return _one("SELECT * FROM users WHERE id=?", (uid,))
def _today() -> date:
"""« Aujourd'hui » selon le service (fuseau de l'utilisateur, sinon UTC)."""
from app.services.task_databases import user_today as service_today
with _conn() as conn:
return service_today(conn, _user()["id"])
def _user() -> dict:
return _one("SELECT * FROM users ORDER BY id LIMIT 1")
def _connect(client, cid: int, *, assignee: str = "Assigné à",
status: str = "Statut", due: str = "Échéance") -> dict:
"""Convertit la base via l'API (mapping explicite, colonnes créées)."""
r = client.post(f"/db/{cid}/task-db/api", json={
"create_missing": {
"assignee": {"name": assignee, "prop_type": "person"},
"status": {"name": status, "prop_type": "status",
"options": [{"name": "À faire"}, {"name": "En cours"},
{"name": "Terminée"}]},
"due": {"name": due, "prop_type": "date"},
}
})
assert r.status_code == 200, r.text
return r.json()
def _row(cid: int, title: str, *, user=None, status: str = "À faire",
due: str = "") -> int:
"""Ligne dont les valeurs sont **indexées par ID de colonne**."""
with _conn() as conn:
props = {p["id"]: p for p in conn.execute(
"SELECT id, prop_type FROM collection_properties WHERE collection_id=?", (cid,)
).fetchall()}
a = props[[i for i, p in props.items() if p["prop_type"] == "person"][0]]["id"]
s = props[[i for i, p in props.items() if p["prop_type"] == "status"][0]]["id"]
d = props[[i for i, p in props.items() if p["prop_type"] == "date"][0]]["id"]
pv = {str(a): [{"id": user["id"], "login": user["login"],
"full_name": user["full_name"] or user["login"]}],
str(s): status}
if due:
pv[str(d)] = due
rid = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, "
"property_values_json) VALUES (?, ?, 0, ?)",
(cid, title, json.dumps(pv)),
).lastrowid
conn.commit()
return rid
def _api(client, **params):
r = client.get("/my-tasks/api", params=params)
assert r.status_code == 200, r.text
return r.json()
# ── Périmètre transverse + opt-in ────────────────────────────────────────
def test_my_tasks_spans_all_workspaces(client):
"""Toutes les bases connectées, quel que soit le workspace actif."""
a = _make_workspace(client, "Alpha")
b = _make_workspace(client, "Beta")
for ws, name, n in ((a, "Tâches A", 2), (b, "Tâches B", 3)):
cid = _database(client, ws, name)
_connect(client, cid)
u = _user()
for i in range(n):
_row(cid, f"{name} #{i + 1}", user=u)
body = _api(client)
assert body["total"] == 5, body
assert {s["name"] for s in body["sources"]} == {"Tâches A", "Tâches B"}
# Le cookie de workspace ne cadre plus la vue.
assert _api(client, **{})["total"] == 5
def test_unconverted_database_feeds_nothing(client):
"""Opt-in : une base jamais convertie n'apparaît pas."""
ws = _make_workspace(client, "OptIn")
cid = _database(client, ws, "Backlog", rows=2)
body = _api(client)
assert body["total"] == 0
assert body["sources_total"] == 0
assert body["tasks"] == []
_connect(client, cid)
# La conversion crée les colonnes : les 2 lignes existantes n'ont pas de
# statut, mais elles sont listées (assigné vide = implicite « mienne »).
assert _api(client)["sources_total"] == 1
def test_mapping_incomplete_emits_nothing_but_is_listed(client):
"""Base connectée au mapping incomplet : visible, mais silencieuse."""
ws = _make_workspace(client, "Partial")
cid = _database(client, ws, "Partielle")
status_prop = _prop(cid, "Statut", "status", [{"name": "À faire"},
{"name": "Terminée"}])
_prop(cid, "Échéance", "date")
# Connectée mais mapping partiel : seul le statut est lié.
with _conn() as conn:
conn.execute(
"UPDATE collections SET is_task=1, task_status_prop=? WHERE id=?",
(status_prop, cid),
)
conn.commit()
body = _api(client)
assert body["sources_total"] == 1
assert body["sources"][0]["configured"] is False
assert set(body["sources"][0]["missing_roles"]) == {"assignee", "due"}
assert body["total"] == 0
def test_only_tasks_assigned_to_me_are_listed(client):
ws = _make_workspace(client, "Assignees")
cid = _database(client, ws, "Sprint")
_connect(client, cid)
u = _user()
other = _other_user()
_row(cid, "Pour moi", user=u)
_row(cid, "Pour quelqu'un d'autre", user=other)
body = _api(client)
assert body["total"] == 1
assert body["tasks"][0]["title"] == "Pour moi"
assert body["tasks"][0]["assignees"] == [u["login"]]
# ── Mapping nommé + validation ───────────────────────────────────────────
def test_conversion_links_existing_columns_by_name(client):
"""Des colonnes déjà présentes sont **liées**, pas dupliquées."""
ws = _make_workspace(client, "Linking")
cid = _database(client, ws, "Projets")
a = _prop(cid, "Resp.", "person")
s = _prop(cid, "Avancement", "select", [{"name": "Fait"}])
d = _prop(cid, "Livraison", "date")
state = client.get(f"/db/{cid}/task-db/api").json()
assert {p["id"] for p in state["candidates"]["assignee"]} == {a}
assert {p["id"] for p in state["candidates"]["status"]} == {s}
assert {p["id"] for p in state["candidates"]["due"]} == {d}
out = _connect(client, cid, assignee="Resp.", status="Avancement", due="Livraison")
assert out["mapping"] == {"assignee": a, "status": s, "due": d}
with _conn() as conn:
names = {r["name"] for r in conn.execute(
"SELECT name FROM collection_properties WHERE collection_id=?", (cid,)
)}
assert names == {"Resp.", "Avancement", "Livraison"} # aucune colonne créée
def test_conversion_rejects_incompatible_column(client):
"""Une colonne Date ne peut pas servir de colonne « Assigné à »."""
ws = _make_workspace(client, "Types")
cid = _database(client, ws, "Typée")
d = _prop(cid, "Date", "date")
r = client.post(f"/db/{cid}/task-db/api", json={"mapping": {"assignee": d}})
assert r.status_code == 400
assert "incompatible" in r.json()["detail"].lower()
def test_conversion_rejects_a_column_used_twice(client):
"""Une même colonne ne peut pas couvrir deux rôles (Notion l'interdit)."""
ws = _make_workspace(client, "Double")
cid = _database(client, ws, "Double emploi")
# Type `select` : compatible à la fois de « Statut »… mais pas de « Date ».
sel = _prop(cid, "Etat", "select", [{"name": "A"}])
other = _prop(cid, "Autre Etat", "select", [{"name": "B"}])
# Deux rôles de même famille de types compatibles → double emploi détecté.
r = client.post(f"/db/{cid}/task-db/api", json={"mapping": {
"assignee": None, "status": sel, "due": other,
}})
assert r.status_code == 400
# Et le cas typique : la même colonne pour deux rôles compatibles.
r = client.post(f"/db/{cid}/task-db/api", json={
"mapping": {"status": sel},
"create_missing": {"assignee": {"prop_type": "person"}},
})
assert r.status_code == 200, r.text
r = client.post(f"/db/{cid}/task-db/api", json={
"mapping": {"status": sel, "due": sel},
})
assert r.status_code == 400
assert "incompatible" in r.json()["detail"].lower()
def test_disconnect_keeps_the_tasks(client):
ws = _make_workspace(client, "Disconnect")
cid = _database(client, ws, "Tasks")
_connect(client, cid)
u = _user()
_row(cid, "Gardée", user=u)
assert _api(client)["total"] == 1
r = client.delete(f"/db/{cid}/task-db/api")
assert r.status_code == 200, r.text
assert _api(client)["total"] == 0
with _conn() as conn:
assert conn.execute(
"SELECT COUNT(*) FROM collection_pages WHERE id IS NOT NULL AND collection_id=?",
(cid,),
).fetchone()[0] == 1
def test_sources_are_capped_at_ten(client):
ws = _make_workspace(client, "Dix")
for i in range(10):
_connect(client, _database(client, ws, f"Base {i}"))
listed = client.get("/db/task-dbs/api")
assert listed.status_code == 200, listed.text
assert listed.json()["limit_reached"] is True
r = client.post(f"/db/{_database(client, ws, 'Base 11')}/task-db/api", json={})
assert r.status_code == 409
assert "10" in r.json()["detail"]
# ── Filtres, tri, vues ───────────────────────────────────────────────────
def _seed(client, ws: int) -> int:
cid = _database(client, ws, "Backlog")
_connect(client, cid)
u = _user()
today = _today()
_row(cid, "En retard", user=u, status="À faire",
due=(today - timedelta(days=3)).isoformat())
_row(cid, "Aujourd'hui", user=u, status="En cours", due=today.isoformat())
_row(cid, "Future", user=u, status="À faire",
due=(today + timedelta(days=10)).isoformat())
_row(cid, "Terminée", user=u, status="Terminée",
due=(today - timedelta(days=1)).isoformat())
_row(cid, "Sans date", user=u, status="À faire")
return cid
def test_due_filters_and_hide_done(client):
ws = _make_workspace(client, "Filters")
_seed(client, ws)
def titles(**kw):
return [t["title"] for t in _api(client, **kw)["tasks"]]
assert len(titles(due="all")) == 5
assert titles(due="today") == ["Aujourd'hui"]
assert set(titles(due="overdue")) == {"En retard", "Aujourd'hui", "Terminée"}
assert set(titles(due="week")) == {"En retard", "Aujourd'hui", "Terminée"} # borné à J+7
assert "Terminée" not in titles(hide_done="true")
# Tri par statut : les tâches terminées passent en fin de liste.
by_status = titles(due="all", sort="status")
assert by_status[-1] == "Terminée"
assert "Terminée" not in titles(due="all", sort="status", hide_done="true")
def test_unknown_view_and_sort_fall_back(client):
"""Un paramètre d'URL hors enum est ramené à une valeur réelle."""
ws = _make_workspace(client, "Guards")
_seed(client, ws)
body = _api(client, view="n'importe quoi", sort="; DROP TABLE pages;")
assert body["filters"]["sort"] == "due"
assert body["total"] == 5
def test_due_state_flags_drive_the_table_styling(client):
ws = _make_workspace(client, "States")
_seed(client, ws)
states = {t["title"]: t["due_state"] for t in _api(client)["tasks"]}
assert states["En retard"] == "overdue"
assert states["Aujourd'hui"] == "today"
assert states["Future"] == "upcoming"
assert states["Sans date"] == ""
def test_task_payload_carries_its_source_and_columns(client):
"""Chaque tâche porte sa source et les colonnes à écrire (pas de devinette)."""
ws = _make_workspace(client, "Payload")
cid = _seed(client, ws)
task = _api(client)["tasks"][0]
assert task["collection_id"] == cid
assert task["collection_name"] == "Backlog"
assert task["workspace_name"] == "Payload"
assert task["status_prop"] and task["due_prop"] and task["assignee_prop"]
assert task["status_options"][0] == "À faire"
# ── Modification en direct ───────────────────────────────────────────────
def test_editing_status_writes_the_mapped_column(client):
ws = _make_workspace(client, "Edit")
_seed(client, ws)
task = next(t for t in _api(client)["tasks"] if t["title"] == "Sans date")
r = client.put(f"/my-tasks/api/task/{task['id']}",
json={"field": "status", "value": "En cours"})
assert r.status_code == 200, r.text
assert r.json()["task"]["status"] == "En cours"
with _conn() as conn:
pv = json.loads(conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(task["id"],),
).fetchone()[0])
assert pv[str(task["status_prop"])] == "En cours"
def test_editing_due_and_title(client):
ws = _make_workspace(client, "Edit2")
_seed(client, ws)
task = next(t for t in _api(client)["tasks"] if t["title"] == "Future")
iso = (_today() + timedelta(days=2)).isoformat()
client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "due", "value": iso})
client.put(f"/my-tasks/api/task/{task['id']}",
json={"field": "title", "value": "Future renommée"})
body = _api(client)
updated = next(t for t in body["tasks"] if t["id"] == task["id"])
assert updated["due"] == iso
assert updated["title"] == "Future renommée"
def test_unassigning_removes_the_task_from_my_tasks(client):
ws = _make_workspace(client, "Unassign")
_seed(client, ws)
task = next(t for t in _api(client)["tasks"] if t["title"] == "Sans date")
other = _other_user()
r = client.put(f"/my-tasks/api/task/{task['id']}", json={
"field": "assignee",
"value": [{"id": other["id"], "login": other["login"]}],
})
assert r.status_code == 200, r.text
assert r.json()["left_mytasks"] is True
assert task["id"] not in [t["id"] for t in _api(client)["tasks"]]
def test_update_rejects_unknown_field_and_foreign_source(client):
ws = _make_workspace(client, "Guards2")
cid = _seed(client, ws)
task = _api(client)["tasks"][0]
r = client.put(f"/my-tasks/api/task/{task['id']}",
json={"field": "collection_id", "value": 1})
assert r.status_code == 400
with _conn() as conn:
conn.execute("UPDATE collections SET is_task=0 WHERE id=?", (cid,))
conn.commit()
r = client.put(f"/my-tasks/api/task/{task['id']}",
json={"field": "status", "value": "À faire"})
assert r.status_code == 400
assert "no longer feeds" in r.json()["detail"]
# ── Ajout rapide multi-destinations ──────────────────────────────────────
def test_quick_add_creates_a_self_assigned_task_in_the_chosen_base(client):
ws = _make_workspace(client, "QuickAdd")
a = _database(client, ws, "Clients")
b = _database(client, ws, "Editorial")
for cid in (a, b):
_connect(client, cid)
r = client.post("/my-tasks/api/task",
json={"collection_id": b, "title": "Nouvelle vidéo", "due": "2030-01-02"})
assert r.status_code == 200, r.text
assert r.json()["collection_id"] == b
body = _api(client)
assert body["total"] == 1
task = body["tasks"][0]
assert task["title"] == "Nouvelle vidéo"
assert task["collection_name"] == "Editorial"
assert task["assignees"] == [_user()["login"]] # auto-assignée
assert task["due"] == "2030-01-02"
def test_quick_add_refuses_a_database_that_does_not_feed_my_tasks(client):
ws = _make_workspace(client, "QuickAdd2")
cid = _database(client, ws, "Non connectée")
r = client.post("/my-tasks/api/task", json={"collection_id": cid, "title": "X"})
assert r.status_code == 400
assert "does not feed" in r.json()["detail"]
def test_quick_add_validates_its_input(client):
ws = _make_workspace(client, "QuickAdd3")
cid = _database(client, ws, "Tasks")
_connect(client, cid)
assert client.post("/my-tasks/api/task",
json={"title": "Sans base"}).status_code == 400
assert client.post("/my-tasks/api/task",
json={"collection_id": cid}).status_code == 400
assert client.post("/my-tasks/api/task",
json={"collection_id": cid, "title": " "}).status_code == 400
def test_quick_add_requires_a_session(client):
cid = _database(client, _make_workspace(client, "Anon"), "Tasks")
r = client.post("/my-tasks/api/task", json={"collection_id": cid, "title": "X"},
cookies={"flowdeck_session": ""})
assert r.status_code in (401, 403)
# ── Rendu de la page ─────────────────────────────────────────────────────
def test_dashboard_renders_the_three_views(client):
ws = _make_workspace(client, "Render")
_seed(client, ws)
r = client.get("/my-tasks")
assert r.status_code == 200, r.text
assert 'id="my-tasks-app"' in r.text
assert "/static/js/my_tasks.js" in r.text
# Le cookie de workspace n'est plus réécrit par cette vue.
assert "flowdeck_workspace" not in r.headers.get("set-cookie", "")
for view in ("table", "board", "calendar"):
r = client.get("/my-tasks", params={"view": view})
assert r.status_code == 200, r.text
# Le JSON est échappé pour l'attribut HTML (`&quot;`).
assert "my-tasks-app" in r.text
assert "&quot;" in r.text and view in r.text
def test_dashboard_without_source_explains_the_conversion(client):
ws = _make_workspace(client, "NoSource")
_database(client, ws, "Backlog", rows=1)
r = client.get("/my-tasks")
assert r.status_code == 200, r.text
assert "Convertir en base de tâches" in r.text
assert "Jusqu'à 10 bases" in r.text
# Rien à afficher côté client → le script n'est pas chargé, mais la
# feuille de style l'est (sinon l'invite retombe à gauche).
assert "/static/js/my_tasks.js" not in r.text
assert "/static/css/my_tasks.css" in r.text
def test_dashboard_without_any_workspace(client):
r = client.get("/my-tasks")
assert r.status_code in (200, 302), r.text
# ── Cohérence workspace : corbeille, purge, suppression ──────────────────
def test_trashed_database_host_page_hides_its_tasks(client):
"""Base à la corbeille → plus de tâches, la base reste restaurable."""
ws = _make_workspace(client, "Coherence A")
cid = _database(client, ws, "Backlog")
_connect(client, cid)
u = _user()
for i in range(3):
_row(cid, f"Backlog #{i + 1}", user=u)
assert _api(client)["total"] == 3
assert _api(client)["total"] == 3
page = _host_page_id(cid)
client.post(f"/api/pages/{page}/trash")
body = _api(client)
assert body["total"] == 0
assert body["tasks"] == []
with _conn() as conn:
assert conn.execute(
"SELECT COUNT(*) FROM collections WHERE name='Backlog'").fetchone()[0] == 1
client.post(f"/board/api/trash/{page}/restore")
assert _api(client)["total"] == 3
def test_trashed_row_content_page_hides_the_row(client):
"""La page contenu d'une ligne à la corbeille → la ligne sort de My Tasks."""
ws = _make_workspace(client, "Coherence B")
cid = _database(client, ws, "Tasks")
_connect(client, cid)
rid = _row(cid, "Avec page", user=_user())
with _conn() as conn:
page_id = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, collection_id, collection_row_id) "
"VALUES ('', ?, 'Row doc', '', 'blocks', 'DbRow', ?, ?)",
(ws, cid, rid),
).lastrowid
conn.commit()
assert _api(client)["total"] == 1
client.post(f"/api/pages/{page_id}/trash")
assert _api(client)["total"] == 0
def test_permanent_delete_of_host_page_deletes_the_database(client):
ws = _make_workspace(client, "Coherence E")
cid = _database(client, ws, "Backlog", rows=2)
page = _host_page_id(cid)
client.post(f"/api/pages/{page}/trash")
r = client.delete(f"/board/api/trash/{page}")
assert r.status_code == 200, r.text
assert r.json()["collections"] == 1
with _conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?",
(cid,)).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?",
(cid,)).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?",
(page,)).fetchone()[0] == 0
def test_trash_purge_deletes_hosted_databases(client):
from app.services.trash import purge_expired
ws = _make_workspace(client, "Coherence F")
cid = _database(client, ws, "Backlog", rows=1)
page = _host_page_id(cid)
with _conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at='2000-01-01 00:00:00' WHERE id=?", (page,))
conn.commit()
summary = purge_expired(days=30)
assert page in summary["purged"]
with _conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?",
(cid,)).fetchone()[0] == 0
def test_empty_trash_deletes_hosted_databases(client):
ws = _make_workspace(client, "Coherence G")
cid = _database(client, ws, "Backlog", rows=1)
page = _host_page_id(cid)
client.post(f"/api/pages/{page}/trash")
r = client.post("/board/api/trash/empty")
assert r.status_code == 200, r.text
assert r.json()["collections"] == 1
with _conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?",
(cid,)).fetchone()[0] == 0
def test_delete_workspace_removes_its_databases(client):
ws = _make_workspace(client, "Coherence H")
cid = _database(client, ws, "Backlog", rows=1)
r = client.delete(f"/api/workspaces/{ws}")
assert r.status_code == 200, r.text
assert r.json()["collections"] == 1
with _conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?",
(cid,)).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM pages WHERE workspace_id=?",
(ws,)).fetchone()[0] == 0
def test_delete_collection_detaches_host_page(client):
"""Supprimer une base laisse la page hôte vivante (plus de FK bloquante)."""
ws = _make_workspace(client, "Coherence I")
cid = _database(client, ws, "Backlog", rows=1)
page = _host_page_id(cid)
r = client.delete(f"/db/api/{cid}")
assert r.status_code == 200, r.text
assert r.json()["status"] == "deleted"
with _conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page,)).fetchone()
assert row["collection_id"] is None
assert row["content_format"] == "blocks"
def test_today_follows_the_user_timezone(client):
"""« Aujourd'hui » suit ``users.timezone`` — le fuseau que le projet
utilise déjà pour le calendrier des bases.
Sans cela, une tâche due aujourd'hui bascule dans « demain » (ou
l'inverse) dès que l'utilisateur est à plus de quelques heures d'UTC.
"""
from datetime import datetime
from app.services.task_databases import user_today as service_today
ws = _make_workspace(client, "TZ")
cid = _database(client, ws, "Tasks")
_connect(client, cid)
uid = _user()["id"]
def set_tz(tz):
with _conn() as conn:
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, uid))
conn.commit()
def service_day():
with _conn() as conn:
return service_today(conn, uid)
try:
# Sans fuseau : UTC (le comportement historique du service).
set_tz("")
assert service_day() == datetime.now(UTC).date()
# Kiritimati est à UTC+14 : le jour du service peut avancer d'un cran.
set_tz("Pacific/Kiritimati")
ahead = service_day()
assert ahead in (datetime.now(UTC).date(),
(datetime.now(UTC) + timedelta(hours=14)).date())
# Une tâche datée du jour du service est « aujourd'hui » pour la vue.
_row(cid, "Du jour", user=_user(), due=ahead.isoformat())
assert [t["title"] for t in _api(client, due="today")["tasks"]] == ["Du jour"]
# Si le jour du service diffère de celui d'UTC, une tâche datée du
# jour UTC ne doit PAS être considérée comme « aujourd'hui ».
# On repart d'un jeu de lignes vide : « Du jour » était daté de
# `ahead` (Kiritimati) et le tri des tâches n'est pas garanti entre
# les deux fuseaux.
set_tz("Pacific/Midway") # UTC-7
behind = service_day()
with _conn() as conn:
conn.execute(
"DELETE FROM collection_pages WHERE collection_id=? "
"AND title IN ('Du jour', 'Jour UTC')",
(cid,),
)
conn.commit()
# Le jour « aujourd'hui » suit le fuseau de l'utilisateur.
_row(cid, "Jour du service", user=_user(), due=behind.isoformat())
got = [t["title"] for t in _api(client, due="today")["tasks"]]
assert got == ["Jour du service"], got
# Le jour UTC n'en fait partie que s'il coïncide avec celui du
# service — ce qui dépend de l'heure : on ne l'affirme que dans ce cas.
utc_day = datetime.now(UTC).date()
if utc_day != behind:
_row(cid, "Jour UTC", user=_user(), due=utc_day.isoformat())
got = [t["title"] for t in _api(client, due="today")["tasks"]]
assert "Jour UTC" not in got
finally:
with _conn() as conn:
conn.execute("UPDATE users SET timezone='' WHERE id=?", (uid,))
conn.commit()
# ── Page « /db » : destination du bouton « Ouvrir mes bases » ───────────
def test_db_page_lists_the_workspace_databases(client):
"""`/db` était un dump JSON de debug sans layout : il sert de destination
au bouton « Ouvrir mes bases » de My Tasks."""
ws = _make_workspace(client, "Index")
_database(client, ws, "Clients")
_database(client, ws, "Editorial")
r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
assert r.status_code == 200, r.text
assert "Bases de données" in r.text
assert "Clients" in r.text and "Editorial" in r.text
assert "<pre>" not in r.text # plus de dump JSON
assert "base de tâches" in r.text.lower()
def test_db_page_shows_the_task_database_state(client):
"""Chaque base annonce son état : connectée, à configurer, ou à convertir."""
ws = _make_workspace(client, "Index2")
_database(client, ws, "Non configurée")
linked = _database(client, ws, "Connectée")
_connect(client, linked)
r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
assert r.status_code == 200, r.text
assert "Base de tâches" in r.text
assert "Convertir en base de tâches" in r.text
assert "Alimente My Tasks" in r.text
def test_db_page_skips_databases_whose_host_page_is_trashed(client):
"""Une base dont la page hôte est à la corbeille n'est pas proposée."""
ws = _make_workspace(client, "Index3")
_database(client, ws, "Vivante")
gone = _database(client, ws, "Supprimée")
client.post(f"/api/pages/{_host_page_id(gone)}/trash")
r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
assert r.status_code == 200, r.text
assert "Vivante" in r.text
assert "Supprimée" not in r.text
def test_db_page_lists_other_workspaces_too(client):
"""Le workspace actif d'abord, les autres dans une section séparée : sans
cela, un workspace sans base affichait une page vide alors que l'utilisateur
en possédait ailleurs."""
a = _make_workspace(client, "WS-A")
b = _make_workspace(client, "WS-B")
_database(client, a, "Base A")
_database(client, b, "Base B")
body = client.get("/db", cookies={"flowdeck_workspace": str(b)}).text
assert "Base B" in body
# Base A reste atteignable, mais signalée comme appartenant à un autre
# workspace — elle alimente My Tasks comme les autres.
assert "Base A" in body
assert "Autres workspaces" in body
def test_db_page_shows_workspace_name_and_mine_first(client):
a = _make_workspace(client, "Mon espace")
b = _make_workspace(client, "Autre espace")
_database(client, a, "La mienne")
_database(client, b, "La sienne")
body = client.get("/db", cookies={"flowdeck_workspace": str(a)}).text
assert "Mon espace" in body
assert body.index("La mienne") < body.index("La sienne")
def test_db_page_requires_a_session(client):
"""Sans session, `/db` redirige vers la page de connexion (et non pas une
liste de bases)."""
from conftest import anon
resp = anon(client).get("/db", follow_redirects=False)
assert resp.status_code in (302, 303), resp.status_code
assert "/auth/login" in resp.headers.get("location", "")
def test_empty_my_tasks_points_at_the_right_button(client):
"""L'invite doit désigner le bouton qui existe réellement."""
ws = _make_workspace(client, "Invite")
_database(client, ws, "Backlog", rows=1)
r = client.get("/my-tasks")
assert r.status_code == 200, r.text
assert "Convertir en base de tâches" in r.text
# L'ancien texte renvoyait vers un bouton « … » qui n'existe pas.
assert "bouton « … »" not in r.text
assert "à gauche du bouton" in r.text
def test_database_view_bar_exposes_the_conversion_button(client):
"""Le bouton est dans la barre de la base, avec un libellé lisible."""
r = client.get("/static/js/database_table.js")
assert r.status_code == 200, r.text
assert "db-task-db-btn" in r.text
assert "Convertir en base de tâches" in r.text
assert "data-task-db=" in r.text
# ── Bases portées par une page (`collections.workspace_id` NULL) ──────────
#
# Une base née d'un document (page convertie en base, base inline, base liée
# ou modèle importé) a `workspace_id` NULL et n'appartient qu'à travers sa page
# hôte. Filtrer sur `workspace_id`, ou joindre `workspaces` sans repli, la
# faisait disparaître de `/db` **et** de My Tasks : l'utilisateur voyait sa base
# dans la sidebar mais n'arrivait plus à la convertir ni à la retrouver.
def _page_hosted_database(ws_id: int, name: str, *, rows: int = 0,
host_page: bool = True) -> int:
"""Base portée par une page du workspace, `workspace_id` laissé NULL."""
page = _host_page(ws_id, name) if host_page else None
with _conn() as conn:
cid = conn.execute(
"INSERT INTO collections (name, workspace_id, parent_page_id) VALUES (?, NULL, ?)",
(name, page),
).lastrowid
if page:
conn.execute(
"UPDATE pages SET content_format='collection', collection_id=? WHERE id=?",
(cid, page),
)
conn.commit()
if rows:
with _conn() as conn:
for i in range(rows):
conn.execute(
"INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
(cid, f"{name} #{i + 1}"),
)
conn.commit()
return cid
def test_db_page_lists_page_hosted_database(client):
"""Une base à `workspace_id` NULL est bien listée dans son workspace."""
ws = _make_workspace(client, "Portée par page")
_page_hosted_database(ws, "Base portée", rows=2)
body = client.get("/db", cookies={"flowdeck_workspace": str(ws)}).text
assert "Base portée" in body
assert "Aucune base dans ce workspace" not in body
def test_db_page_shows_page_hosted_base_of_another_workspace(client):
"""Workspace actif vide + base portée par une page ailleurs : la section
« Autres workspaces » la remonte, avec le nom du workspace d'origine."""
mine = _make_workspace(client, "Actif")
other = _make_workspace(client, "Elsewhere")
_page_hosted_database(other, "Base ailleurs")
body = client.get("/db", cookies={"flowdeck_workspace": str(mine)}).text
assert "Base ailleurs" in body
assert "Autres workspaces" in body
# Le workspace d'appartenance est indiqué (l'en-tête ne montre que l'actif).
assert "Elsewhere" in body
def test_page_hosted_task_database_feeds_my_tasks(client):
"""Le bout en bout du signalement : une base portée par une page, convertie
en base de tâches, doit alimenter My Tasks."""
ws = _make_workspace(client, "Émission")
cid = _page_hosted_database(ws, "Backlog page")
_connect(client, cid)
_row(cid, "Écrire le rapport", user=_user())
payload = client.get("/my-tasks/api").json()
assert payload["total"] == 1, payload
assert payload["tasks"][0]["title"] == "Écrire le rapport"
assert payload["sources_total"] == 1
# La source est attribuée à son workspace effectif, pas à `NULL`.
assert payload["sources"][0]["workspace_name"] == "Émission"
def test_task_database_of_shared_workspace_is_listed(client):
"""Un workspace dont l'utilisateur est **membre** (pas propriétaire) doit
alimenter ses bases : le périmètre est `owner_id` ∪ `workspace_members`."""
mine = _make_workspace(client, "Mien")
shared = _make_workspace(client, "Partagé")
with _conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) "
"VALUES (?, ?, 'editor')",
(shared, _user()["id"]),
)
conn.commit()
cid = _page_hosted_database(shared, "Base partagée")
_connect(client, cid)
sources = client.get("/db/task-dbs/api").json()
assert [s["name"] for s in sources["sources"]] == ["Base partagée"]
assert "Base partagée" in client.get(
"/db", cookies={"flowdeck_workspace": str(mine)}
).text
def _other_workspace(owner_id: int, name: str) -> int:
with _conn() as conn:
ws_id = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(name, owner_id),
).lastrowid
conn.commit()
return ws_id
def test_db_page_hides_bases_of_another_user(client):
"""Isolation : la liste « Autres workspaces » ne doit exposer que les
workspaces de l'appelant."""
from conftest import login_test_client
from fastapi.testclient import TestClient
from app.main import app
# La session (et donc la ligne `users`) doit exister avant d'attribuer un
# workspace à cet utilisateur : `workspaces.owner_id` est une clé étrangère.
stranger = login_test_client(
TestClient(app), user_id=4242, login="stranger", is_admin=0
)
their_ws = _other_workspace(4242, "Espace inconnu")
their_cid = _page_hosted_database(their_ws, "Base inconnue")
mine = _make_workspace(client, "Mien")
_page_hosted_database(mine, "Ma base")
assert "Ma base" in client.get("/db").text
body = stranger.get("/db").text
assert "Base inconnue" in body # la sienne, bien
assert "Ma base" not in body # pas celle d'autrui
assert "Mien" not in body
assert stranger.get("/db/task-dbs/api").json()["total"] == 0
# Même verdict côté My Tasks : aucune source étrangère.
assert stranger.get("/db/task-dbs/api").json()["sources"] == []
assert their_cid != mine
def test_db_page_skips_database_without_any_workspace(client):
"""Base sans `workspace_id` **et** sans page hôte : elle n'appartient à
personne, donc à personne ne doit la voir (une liste « toutes les bases »
de l'instance l'afficherait à tout le monde)."""
ws = _make_workspace(client, "Seul")
with _conn() as conn:
conn.execute(
"INSERT INTO collections (name, workspace_id, parent_page_id) "
"VALUES ('Base orpheline', NULL, NULL)"
)
conn.commit()
body = client.get("/db", cookies={"flowdeck_workspace": str(ws)}).text
assert "Base orpheline" not in body
def test_page_hosted_database_of_deleted_page_is_hidden(client):
"""Le repli sur la page hôte ne doit pas ressusciter une base dont le
document a été supprimé."""
ws = _make_workspace(client, "Coroutine")
cid = _page_hosted_database(ws, "Base morte")
with _conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id="
"(SELECT parent_page_id FROM collections WHERE id=?)",
(cid,),
)
conn.commit()
assert "Base morte" not in client.get(
"/db", cookies={"flowdeck_workspace": str(ws)}
).text
# ══════════════════════════════════════════════════════════════════════════
# v7.47.1 — Rendu « non connectée », icône de ligne et navigation interne
# ══════════════════════════════════════════════════════════════════════════
def _my_tasks_js() -> str:
from pathlib import Path
return Path("static/js/my_tasks.js").read_text(encoding="utf-8")
def _my_tasks_css() -> str:
from pathlib import Path
return Path("static/css/my_tasks.css").read_text(encoding="utf-8")
def test_disconnected_state_still_links_the_stylesheet(client):
"""Aucune base connectée : l'invite doit être **centrée**.
Régression : la feuille de style était liée par le même mini-template que
le script, et ce dernier était conditionné à ``app_js``. Sans base, la
page perdait donc sa feuille de style et tout retombait à gauche.
"""
body = client.get("/my-tasks").text
assert "my_tasks.css" in body, "la feuille de style doit TOUJOURS être liée"
# Gabarit centré par la feuille de style, pas par du style inline.
assert "my-tasks-empty" in body
assert 'style="text-align:center"' not in body
# Aucun tableau de bord client à monter : le script reste inutile.
assert "my_tasks.js" not in body
def test_no_workspace_state_still_links_the_stylesheet(client):
"""Même exigence pour l'état « aucun workspace » (même gabarit)."""
body = client.get("/my-tasks").text
assert "Aucun workspace" in body
assert "my_tasks.css" in body
assert "my_tasks.js" not in body
def test_connected_state_links_stylesheet_and_script(client):
"""Dès qu'une base alimente la vue, CSS + JS sont tous deux liés."""
ws = _make_workspace(client, "Connectée")
cid = _page_hosted_database(ws, "Base")
_connect(client, cid)
body = client.get("/my-tasks").text
assert 'id="my-tasks-app"' in body
assert "my_tasks.css" in body
assert "my_tasks.js" in body
def test_my_tasks_js_does_not_use_a_one_shot_window_guard():
"""La navigation interne rejoue le <script> sans recharger le document.
Un garde global (`window.__fdMyTasksLoaded`) faisait sortir le IIFE au
retour sur la page : `#my-tasks-app` restait vide jusqu'au hard refresh.
Le montage doit être rejouable et porté par l'élément.
"""
src = _my_tasks_js()
assert "__fdMyTasksLoaded" not in src
assert "htmx:afterSwap" in src, "le remontage doit suivre les swaps htmx"
assert "popstate" in src, "le bouton « retour » du navigateur doit remonter"
def test_my_tasks_js_hides_the_legacy_file_icon_sentinel():
"""`collection_pages.icon = 'file'` = « pas d'icône » (cf. database_table.js).
Affiché tel quel, le mot « file » apparaissait devant chaque titre.
"""
src = _my_tasks_js()
assert "function rowIcon(" in src
assert "=== 'file'" in src
assert "esc(t.icon)" not in src, "l'icône brute ne doit plus être échappée"
def test_my_tasks_js_opens_the_row_content_page():
"""Chaque tâche du tableau doit ouvrir sa page de contenu.
Un id de ligne n'est pas un id de page : naviguer vers
``/pages/{rowId}`` ouvrirait une page sans rapport (cf.
``database_table.js``). Le repli doit donc rester sur le tableau de bord.
"""
src = _my_tasks_js()
assert "data-open-row" in src
assert "/open/api" in src
assert "'/pages/' + rowId" not in src, "ne jamais ouvrir un id de ligne"
def test_task_title_cell_is_padded():
"""La cellule « Tâche » a une marge interne : le titre ne colle pas au bord."""
css = _my_tasks_css()
cell = css.split(".mt-cell {")[1].split("}")[0]
assert "padding" in cell
# ══════════════════════════════════════════════════════════════════════════
# v7.48.0 — Parité Library (side peek, survol, layout) + robustesse navigation
# ══════════════════════════════════════════════════════════════════════════
def test_task_rows_use_library_hover_only_open_button():
"""Chaque tâche porte le bouton « Open » de Library, révélé au survol.
Bibliothèque de référence : ``.btn-open`` / ``.hover-only`` dans
``app/templates/library.html``.
"""
js = _my_tasks_js()
css = _my_tasks_css()
assert "mt-open-btn" in js and "mt-hover-only" in js
# Le libellé et l'icone sont ceux de Library.
assert 'title="Open in side peek"' in js
assert "> Open</button>" in js
# Masque tant que la ligne n'est pas survolee.
hover = css.split(".mt-row:hover .mt-hover-only")[1]
assert "opacity: 1" in hover
def test_task_rows_are_divs_not_a_table():
"""Lignes en `div`, comme Library : c'est ce qui permet le survol."""
js = _my_tasks_js()
assert "<table" not in js, "les lignes doivent être des div (parité Library)"
assert 'class="mt-row"' in js
assert 'class="mt-cell mt-cell-name"' in js
def test_task_content_opens_in_a_side_peek_panel():
"""Le contenu s'ouvre dans un panneau latéral (pas une navigation)."""
js = _my_tasks_js()
css = _my_tasks_css()
assert "mt-peek" in js and "openRowContent" in js
assert "?embed=1" in js, "le panneau charge l'éditeur comme le peek de Library"
# Le panneau est cree a la demande et vit hors de #my-tasks-app, sinon
# render() (filtres) le detruirait.
assert "document.body.appendChild" in js
# Fermeture par Echap, comme dans Library.
assert "Escape" in js
assert ".mt-peek" in css
def test_stale_overlay_and_peek_are_closed_on_mount():
"""Modale / panneau restés ouverts masqueraient la page suivante."""
js = _my_tasks_js()
assert "mt-modal-overlay" in js
assert "closePeek()" in js
def test_my_tasks_mounts_again_if_left_empty():
"""Coquille montée mais vide = swap interrompu : on remonte."""
js = _my_tasks_js()
assert "app.__fdMounted && app.innerHTML.trim()" in js
# Les delegations d'evenements ne sont posees qu'une fois par noeud.
assert "app.__fdWired" in js
assert js.count("if (wire) app.addEventListener") == 2
def test_my_tasks_stylesheet_is_linked_by_the_shell():
"""`my_tasks.css` doit etre lie par base.html, pas par la zone swappee.
Un ``<link>`` a l'interieur de ``.main-wrapper`` peut etre retire par htmx
lors d'une navigation partielle : la page revenait sans feuille de style
(composants blancs en theme sombre).
"""
from pathlib import Path
shell = Path("app/templates/base.html").read_text(encoding="utf-8")
head = shell.split("</head>")[0]
assert "/static/css/my_tasks.css" in head
def test_spa_navigation_is_never_silently_dropped():
"""`fdLoad` ne doit plus ignorer un clic quand une navigation est en vol.
Le clic etait perdu sans changer ni l'URL ni le contenu ; et si la
promesse htmx ne se resolvait pas, ``loading`` restait a ``true`` et plus
aucune navigation ne repartait (page figee).
"""
from pathlib import Path
src = Path("static/js/app.js").read_text(encoding="utf-8")
fd_load = src.split("function fdLoad(")[1].split("\n }")[0]
assert "if (loading) return;" not in fd_load
assert "if (loading) { window.location.href = url; return; }" in fd_load
assert "LOAD_WATCHDOG_MS" in fd_load, "loading ne doit pas pouvoir rester bloque"
def test_swap_waits_only_for_its_own_scripts():
"""Le compteur de scripts ne doit reacting qu'aux scripts de la zone swappee.
``load`` en capture sur ``document`` voit TOUS les <script> de la page :
compter les autres faisait monter l'arbre Alpine avant que les scripts de
la page n'aient tourne.
"""
from pathlib import Path
src = Path("static/js/app.js").read_text(encoding="utf-8")
on_script = src.split("var onScript = function (e) {")[1].split("};")[0]
assert "scripts.indexOf(node) === -1" in on_script
def test_editor_has_ctrl_s_save_shortcut():
"""Ctrl/Cmd + S sauvegarde le document en cours d'edition."""
from pathlib import Path
src = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
block = src.split("Ctrl/Cmd + S")[1]
assert "e.ctrlKey || e.metaKey" in block
assert "E.save(notify)" in block
assert "e.preventDefault()" in block, "sinon le navigateur ouvre « Enregistrer la page »"
# `window.E` survit aux navigations partielles : on exige que l'editeur
# soit reellement monte, sinon Ctrl+S sauvegarderait un editeur detache.
assert "document.getElementById('_blocksCt')" in block
assert "E.canEdit === false" in block, "une page verrouillee ne doit pas ecrire"
# ══════════════════════════════════════════════════════════════════════════
# v7.49.0 — Side peek : même éditeur que le plein écran, calendrier à 5 vues
# ══════════════════════════════════════════════════════════════════════════
def _library_html() -> str:
from pathlib import Path
return Path("app/templates/library.html").read_text(encoding="utf-8")
def _library_js() -> str:
from pathlib import Path
return Path("static/js/library.js").read_text(encoding="utf-8")
def _strip_js_comments(src: str) -> str:
r"""Retire commentaires de bloc, de ligne et HTML.
Les trois contiennent des occurrences de `:src` / `?embed=1` dans des
phrases explicatives : on ne teste donc que le code réellement exécuté.
"""
src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S)
src = re.sub(r"(?m)^\s*//.*$", " ", src)
return re.sub(r"<!--.*?-->", " ", src, flags=re.S)
def _js_func(src: str, header: str) -> str:
"""Corps d'une fonction JavaScript, délimité par comptage d'accolades.
Évite les tranches par `split`, qui tombaient sur le *site d'appel*
(`this.openPeek(item)`) au lieu de la définition.
"""
i = src.index(header)
j = src.index("{", i)
depth, k = 0, j
while True:
if src[k] == "{":
depth += 1
elif src[k] == "}":
depth -= 1
if depth == 0:
return src[j:k + 1]
k += 1
# ══════════════════════════════════════════════════════════════════════════
# v7.49.0 — Side peek : même éditeur que le plein écran, calendrier à 5 vues
# ══════════════════════════════════════════════════════════════════════════
def test_library_peek_iframe_is_not_inside_the_x_if_template():
"""L'iframe doit survivre au changement de document et être devoid de
toute expression Alpine : dans `<template x-if>` comme avec `:src` sous
le build CSP, elle restait vide et aucun document n'apparaissait."""
html = _strip_js_comments(_library_html())
assert ":src" not in html, "plus de binding :src (incompatible build CSP)"
assert 'id="lib-peek-iframe"' in html
at = html.index('id="lib-peek-iframe"')
assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
def test_library_peek_loads_the_embed_editor_only():
"""Le peek charge `/pages/{id}?embed=1` : le document en mode édition
SEUL — le cadre du panneau suffit, ni sidebar ni barre de navigation."""
body = _js_func(_library_js(), "openPeek(item) {")
assert "frame.src = '/pages/' + item.id + '?embed=1'" in body
def test_library_peek_body_has_no_inset_padding():
"""Le corps du peek de Library est nu (padding 0, overflow hidden) :
l'iframe doit remplir le panneau comme dans local-workspace, sans double
scrollbar ni bande grise autour du document."""
from pathlib import Path
html = Path("app/templates/library.html").read_text(encoding="utf-8")
rule = html.split(".peek-body{")[1].split("}")[0]
assert "padding:0" in rule
assert "overflow-y:auto" not in rule
def test_every_side_peek_loads_the_embed_editor_only():
"""Library, My Tasks, bases et workspace local : le peek ne montre QUE le
document éditable (`?embed=1`), jamais l'habillage pleine page."""
from pathlib import Path
for name in ("library.js", "my_tasks.js", "database_table.js",
"local_workspace.js"):
src = _strip_js_comments((Path("static/js") / name).read_text(encoding="utf-8"))
assert "?embed=1" in src, name
def test_library_peek_reloads_when_switching_documents():
"""Ouvrir un autre document ferme l'iframe avant de recharger.
Sans le passage par `about:blank`, la même URL ne se recharge pas et le
document précédent reste affiché.
"""
body = _js_func(_library_js(), "openPeek(item) {")
assert body.count("frame.src =") == 2, "fermeture puis rechargement"
assert "frame.src = 'about:blank'" in body
assert body.index("about:blank") < body.index("'/pages/' + item.id")
def test_close_peek_unloads_the_editor_iframe():
"""Fermer le peek décharge l'éditeur : sinon il reste en mémoire et son
autosave continue de tourner alors qu'il est invisible."""
body = _js_func(_library_js(), "closePeek() {")
assert "frame.src = 'about:blank'" in body
assert "frame.onload = null" in body
def test_calendar_offers_five_views():
"""Mois / Année / Semaine / Jour / Planning."""
js = _my_tasks_js()
for view in ("month", "year", "week", "day", "planning"):
assert f"['{view}'," in js, f"vue calendrier manquante : {view}"
for fn in ("calMonth", "calYear", "calWeek", "calDay", "calPlanning"):
assert f"function {fn}(" in js, fn
assert "function calShift(" in js, "navigation propre à chaque granularité"
def test_calendar_events_have_a_side_peek_icon():
"""Chaque tâche du calendrier porte son icône d'ouverture, avec le même
attribut de ligne que le tableau : un seul gestionnaire de clic."""
body = _js_func(_my_tasks_js(), "function calEvHtml(")
assert "mt-cal-open" in body
assert 'title="Open in side peek"' in body
assert "data-open-row=" in body
assert "data-open-title=" in body
def test_open_buttons_resolve_with_closest_not_target():
"""Le gestionnaire de clic lit `data-open-row` via `closest` : les boutons
du calendrier et du Kanban ne contiennent que leur SVG — un clic sur
l'icone (e.target = <svg>, sans dataset) doit ouvrir le peek aussi."""
js = _my_tasks_js()
i = js.index("app.addEventListener('click'")
block = js[i:i + 900]
assert "closest('[data-open-row]')" in block
def test_kanban_cards_have_the_open_button():
"""Les tuiles du Kanban portent le même bouton « Open » que les lignes
du tableau (parité avec la page d'origine : même peek latéral)."""
body = _js_func(_my_tasks_js(), "function viewBoard(")
assert "openButton(t)" in body
# Le bouton est révélé au survol de la tuile (overlay, la carte est une
# colonne flex — pas de place en flux).
css = _my_tasks_css()
assert ".mt-card:hover .mt-hover-only" in css
def test_base_page_collection_renders_the_table_when_embedded():
"""`/pages/{id}?embed=1` sur une page de base garde le TABLEAU (template
collection) — le peek d'une base ouvre le tableau sans sidebar ni barre,
pas l'éditeur de blocs vide."""
from pathlib import Path
src = Path("app/routers/dashboard/pages_html.py").read_text(encoding="utf-8")
i = src.index('content_format") == "collection"')
branch = src[i:i + 220]
assert "not embed" not in branch.split("template")[0]
def test_embed_editor_can_render_inline_databases():
"""Le peek (`page_editor_embed.html`) doit charger database_table.js :
sans lui, une base embarquée restait bloquée à « Loading database… » et
l'icône Open de fin de ligne ne faisait rien. Le panneau #db-side-peek
est créé à la demande par database_table.js (markup unique)."""
from pathlib import Path
html = Path("app/templates/page_editor_embed.html").read_text(encoding="utf-8")
assert "_database_table_scripts.html" in html
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
assert "ensureDbPeek" in js
# Pas de second markup statique du panneau (source unique).
tbl = Path("app/templates/_database_table.html").read_text(encoding="utf-8")
assert 'id="db-side-peek"' not in tbl
def test_open_button_carries_its_title_as_attribute():
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le
calendrier et la vue Jour, où il n'y a pas de ligne `.mt-row`."""
js = _my_tasks_js()
assert 'data-open-title="' in _js_func(js, "function openButton(")
assert "dataset.openTitle" in js
def test_db_side_peek_never_falls_back_to_the_row_id():
"""Un id de ligne de base n'est PAS un id de page.
Le repli `return rowId` ouvrait `/pages/{rowId}`, c'est-à-dire une page
sans rapport portant le même identifiant — exactement le piège documenté
quelques lignes plus haut dans ce fichier. En cas d'échec on remonte
l'erreur au lieu d'ouvrir le mauvais document.
"""
from pathlib import Path
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
body = _js_func(js, "function resolveRowContentPage(")
assert "return rowId" not in body, "repli interdit sur l'id de ligne"
assert "throw new Error" in body, "un échec doit être signalé"
caller = js[js.index("resolveRowContentPage(pageId)"):][:1400]
assert "'/pages/'+pid" in caller, "seule une page résolue est chargée"
assert ".catch(" in caller, "l'appelant doit traiter l'échec"
assert "ouvrir le contenu de cette ligne" in caller, "erreur remontée"
def test_peek_iframes_never_start_empty():
"""Une iframe `src=""` se résout en l'URL courante.
Le navigateur chargeait donc une copie complète de la page dans l'iframe
cachée, et `frame.src = ''` à la fermeture rechargeait la page entière
dans le panneau. Les deux doivent valoir `about:blank`.
"""
from pathlib import Path
for name in ("_database_table.html", "library.html", "base.html"):
html = (Path("app/templates") / name).read_text(encoding="utf-8")
for tag in re.findall(r"<iframe[^>]*>", html):
assert 'src=""' not in tag, f"{name} : {tag[:80]}"
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
assert "iframe').src=''" not in js, "fermeture : src='' recharge la page"
assert "db-side-peek-iframe').src='about:blank'" in js
def test_db_side_peek_reports_resolution_failure_in_the_panel():
"""L'échec doit être VISIBLE, pas seulement journalisé.
Sous le build CSP d'Alpine aucun composant n'est initialisé
(`Alpine.$data(el)` ne renvoie aucune clé), donc `window.showToast` — qui
lit `el.__x`, propriété du build de développement — reste sans effet. Le
message est donc rendu dans le panneau via `srcdoc`, sans dépendance.
"""
from pathlib import Path
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
# On isole le bloc `.catch(...)` qui suit l'appel : le panneau doit y être
# affiché APRÈS que l'erreur y soit injectée, sinon l'utilisateur voit un
# cadre vide. On part de l'appel pour ne pas attraper un autre `.catch`.
tail = js[js.index("resolveRowContentPage(pageId)"):]
block = _js_func(tail, ".catch(function(){")
assert "srcdoc" in block, "erreur rendue dans le panneau"
assert "Impossible d’ouvrir le contenu" in block
# Le panneau (révélé par `classList.add('open')` depuis la réécriture
# vanilla JS de v7.49.0) doit s'afficher APRÈS l'injection du message.
assert block.index("srcdoc") < block.index("classList.add('open')")