Files
flowdeck/tests/test_sidebar_tabs.py
T
bruno 1d1cdbd618
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped
fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
2026-10-05 22:47:36 -04:00

265 lines
10 KiB
Python

"""FlowDeck — onglets compressibles du sidebar (Home / Chat / Meeting / Inbox).
La barre latérale gauche devient un sélecteur d'onglet : le bouton actif
s'étend en pilule avec son libellé, les autres se réduisent à leur icône, et
chaque sélection remplace intégralement le contenu de la barre latérale.
* ordre exact des onglets : Home → Chat → Meeting → Inbox ;
* quatre panneaux Alpine dédiés (``sidebarTab === …``) ;
* contenu branché sur des données réelles : historique des conversations
agent (``/api/agent/conversations``), événements à venir
(``GET /api/v2/meetings/upcoming``, nouveau) et notifications
(``/api/notifications``).
"""
from __future__ import annotations
import json
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from conftest import login_test_client
TPL = Path("app/templates/base.html")
CSS = Path("static/css/app.css")
@pytest.fixture
def client():
"""Same isolated temp DB contract as tests/conftest.py::client."""
import os
import tempfile
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
data_dir = tempfile.mkdtemp(prefix="fd_data_")
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"
os.environ["FLOWDECK_DATA_DIR"] = data_dir
import app.config
s = app.config.settings
s.database_url = f"sqlite:///{db_path}"
s.app_secret_key = "test-secret-for-tests"
s.rate_limit_enabled = False
s.backup_enabled = False
s.backup_dir = backup_dir
s.project_sync_enabled = False
from app.db import init_db
from app.main import app
init_db()
from fastapi.testclient import TestClient
c = login_test_client(TestClient(app))
try:
yield c
finally:
try:
os.unlink(db_path)
except FileNotFoundError:
pass
def _render(client) -> str:
r = client.post("/api/local-workspace/items", json={"name": "TabsProbe"})
assert r.status_code == 200, r.text
page = client.get(f"/pages/{r.json()['id']}")
assert page.status_code == 200
return page.text
# ══════════════════ ordre et libellés des onglets ══════════════════
def test_tab_order_is_home_chat_meeting_inbox(client):
tpl = TPL.read_text(encoding="utf-8")
row = re.search(r'<div class="sidebar-nav-row".*?</div>', tpl, re.S)
assert row, "sidebar-nav-row introuvable"
tabs = re.findall(r'data-tab="([a-z]+)"', row.group(0))
assert tabs == ["home", "chat", "meetings", "inbox"], tabs
def test_rendered_sidebar_shows_the_four_tabs_in_order(client):
html = _render(client)
row = re.search(r'<div class="sidebar-nav-row".*?</div>\s*</div>', html, re.S)
assert row, "barre d'onglets absente du HTML rendu"
tabs = re.findall(r'data-tab="([a-z]+)"', row.group(0))
assert tabs == ["home", "chat", "meetings", "inbox"], tabs
for label in ("Home", "Chat", "Meeting", "Inbox"):
assert f">{label}</span>" in row.group(0), label
def test_tabs_are_accessible_tabs(client):
html = _render(client)
row = re.search(r'<div class="sidebar-nav-row".*?</div>\s*</div>', html, re.S).group(0)
assert row.count('role="tab"') == 4
assert ":aria-selected=\"sidebarTab === 'meetings'\"" in row
# ══════════════════ panneaux contextuels ══════════════════
def test_each_tab_has_its_own_panel(client):
tpl = TPL.read_text(encoding="utf-8")
for tab in ("home", "chat", "meetings", "inbox"):
assert f'x-show="sidebarTab === \'{tab}\'"' in tpl, tab
# Le contenu Home historique reste dans le panneau home.
assert tpl.index('sidebarTab === \'home\'') < tpl.index('sidebar-section" x-show="isSectionVisible(\'workspace\')')
def test_panels_use_real_data_sources(client):
tpl = TPL.read_text(encoding="utf-8")
assert "/api/agent/conversations" in tpl
assert "/api/v2/meetings/upcoming" in tpl
assert "/api/notifications?limit=50" in tpl
assert "/api/v2/meetings/transcribe" in tpl
def test_tab_state_is_persisted_and_validated(client):
tpl = TPL.read_text(encoding="utf-8")
assert "localStorage.setItem('fd_sidebar_tab', tab)" in tpl
assert "['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0" in tpl
def test_active_tab_is_a_pill_and_inactive_tabs_hide_their_label():
css = CSS.read_text(encoding="utf-8")
assert ".nav-tab-btn .nav-tab-label {" in css
assert "display: none;" in css.split(".nav-tab-btn .nav-tab-label {")[1].split("}")[0]
assert ".nav-tab-btn.active .nav-tab-label { display: inline-block; }" in css
assert "border-radius: 15px;" in css
def test_label_visibility_is_driven_by_the_template_not_only_by_css(client):
"""Régression : ``?v={{ asset_version }}`` vaut le contenu de ``VERSION``, donc
un navigateur garde l'ancien ``app.css`` en cache après une livraison. Si
l'affichage des libellés ne dépendait que du CSS, le clic sur Home
affichait le texte sur les quatre boutons. La décision est donc prise dans
le HTML (style inline Alpine), la CSS n'étant qu'un repli sans JS."""
tpl = TPL.read_text(encoding="utf-8")
for tab in ("home", "chat", "meetings", "inbox"):
needle = (f'<span class="nav-tab-label" :style="sidebarTab === \'{tab}\' '
f'? \'display:inline-block\' : \'display:none\'">')
assert needle in tpl, tab
# Idem pour les badges de compte (masqués sur l'onglet actif).
for tab in ("chat", "meetings", "inbox"):
assert (f':style="sidebarTab === \'{tab}\' ? \'display:none\' : \'\'"'
in tpl), tab
# ══════════════════ endpoint « Meeting » ══════════════════
def test_upcoming_requires_authentication():
from fastapi.testclient import TestClient
from app.main import app
r = TestClient(app).get("/api/v2/meetings/upcoming")
assert r.status_code == 401, r.text
def _seed_calendar_collection(client, days_ahead: int, title: str = "Sync weekly"):
"""Calendar link + dated rows (what the Google/CalDAV sync produces)."""
from app.db import get_conn
day = (datetime.now(UTC).date() + timedelta(days=days_ahead)).isoformat()
with get_conn() as conn:
cid = conn.execute(
"INSERT INTO collections (name) VALUES ('Agenda')"
).lastrowid
prop = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type) "
"VALUES (?, 'Date', 'date')",
(cid,),
).lastrowid
past = conn.execute(
"INSERT INTO collection_pages (collection_id, title, external_event_id) "
"VALUES (?, 'Last week sync', 'ext-old')",
(cid,),
).lastrowid
conn.execute(
"INSERT INTO collection_pages (collection_id, title, external_event_id) "
"VALUES (?, ?, 'ext-new')",
(cid, title),
)
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE collection_id=?",
(json.dumps({str(prop): day}), cid),
)
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps({str(prop): "2000-01-01"}), past),
)
conn.execute(
"INSERT INTO calendar_links (user_id, provider, tokens_enc, calendar_id, "
"collection_id, date_property) VALUES (1, 'google', '{}', 'primary', ?, ?)",
(cid, str(prop)),
)
conn.commit()
return cid
def test_upcoming_lists_only_current_and_future_events(client):
_seed_calendar_collection(client, 3, "Roadmap review")
r = client.get("/api/v2/meetings/upcoming?days=30")
assert r.status_code == 200, r.text
data = r.json()
titles = [e["title"] for e in data["events"]]
assert titles == ["Roadmap review"], titles
ev = data["events"][0]
assert ev["provider"] == "google"
assert ev["synced"] is True
assert ev["today"] is False
assert ev["url"] == f"/db/{ev['collection_id']}"
assert data["today"] == datetime.now(UTC).date().isoformat()
def test_upcoming_today_filter_flag(client):
_seed_calendar_collection(client, 0, "Standup")
events = client.get("/api/v2/meetings/upcoming").json()["events"]
assert [e["title"] for e in events] == ["Standup"]
assert events[0]["today"] is True
def test_upcoming_ignores_events_beyond_the_horizon(client):
_seed_calendar_collection(client, 200, "Far future")
r = client.get("/api/v2/meetings/upcoming?days=7")
assert r.json()["events"] == []
assert client.get("/api/v2/meetings/upcoming?days=365").json()["events"]
def test_upcoming_is_empty_without_a_calendar_link(client):
r = client.get("/api/v2/meetings/upcoming")
assert r.status_code == 200
assert r.json() == {"today": datetime.now(UTC).date().isoformat(),
"days": 30, "events": [], "count": 0}
def test_upcoming_validates_and_clamps_the_horizon(client):
r = client.get("/api/v2/meetings/upcoming?days=abc")
assert r.status_code == 422, r.text # FastAPI refuse un horizon non entier
assert client.get("/api/v2/meetings/upcoming?days=0").json()["days"] == 1
assert client.get("/api/v2/meetings/upcoming?days=9999").json()["days"] == 365
def test_upcoming_is_scoped_to_the_owner_of_the_link(client):
"""Un autre utilisateur ne voit pas les événements d'un lien qui n'est pas
le sien (le WHERE user_id= filtre sur calendar_links)."""
from app.db import get_conn
_seed_calendar_collection(client, 1, "Private sync")
with get_conn() as conn:
other = conn.execute(
"INSERT INTO users (login, password_hash) VALUES ('someone-else', 'x')"
).lastrowid
conn.execute("UPDATE calendar_links SET user_id=?", (other,))
conn.commit()
assert client.get("/api/v2/meetings/upcoming").json()["events"] == []