- 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).
265 lines
10 KiB
Python
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"] == []
|