Files
flowdeck/tests/test_v764_comments_reactions.py
bruno d58c321c07
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
feat: réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection (v7.67.0)
Tiroir Comments
- Section « Reactions » au-dessus des fils : émoji, compte (si >1) et texte
  ancré lu dans le DOM (reactionList() / _anchorText(), offsets source hors
  pastilles) ; l'état vide ne s'affiche plus s'il reste des réactions.
- Suppression : ✕ sur la ligne du tiroir (mes réactions) ET croix ::after au
  survol de la pastille sur l'émoji que j'ai posé (aucun nœud ajouté → offsets
  intacts). Deux entrées → removeReaction() → POST toggle → loadReactions().

Menu de sélection (maquette ASCII)
- Ligne 4 = rangée scannée : 💬 Comment (libellé) à gauche, 🙂+ réaction à
  droite (.ft-row-split) ; la réaction sort de la ligne 3.
- Entête Skills avec ≡ à droite (::after), liste Skills scrollable.
- ✨ Ask AI retiré de la toolbar : doublon exact de « Edit with AI » du pied.
- Écart volontaire à la maquette : le barré (T) de la ligne 2 est conservé.

Fix racine (flake de gate)
- _is_public_host attrape OSError (le garde SSRF renvoie un booléen, il ne
  lève plus) : un socket.timeout DNS ne bascule plus fetch_og_metadata dans
  son except Exception → repli silencieux → AssertionError.
- fixture stub_dns sur TestOGParser (même motif que test_agent_web_tools) :
  zéro vrai DNS dans les tests OG.

Tests: pytest tests/ -q -n auto → 1390 passed / 0 failed (flake corrigé) ;
ruff check app tests → OK ; e2e v764 (réactions dans le tiroir + suppression
A/B), v762 (nouvelle ligne 4), v766, v7641, v763 → 6 passed sur :8081
(image flowdeck:v7.67.0 rebuild + redéployée).
2026-10-08 14:25:48 -04:00

140 lines
7.1 KiB
Python

"""7.64.0 — commentaires façon Notion (ancres jaunes + tiroir) & réactions sur texte."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
PICKER = (ROOT / "app" / "templates" / "_icon_picker.html").read_text(encoding="utf-8")
PSTORE = (ROOT / "static" / "js" / "_icon_picker_2.js").read_text(encoding="utf-8")
# ── Backend : migration + endpoints ────────────────────────────────────────
def test_text_reactions_table_exists(client): # le fixture client initialise le schéma
from app.db import get_conn
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(text_reactions)").fetchall()}
assert {"id", "page_id", "block_id", "anchor_start", "anchor_end", "emoji",
"user_id", "created_at"} <= cols, cols
def test_reactions_endpoints_registered():
from app.routers.collaboration import router
methods = {(r.path, m) for r in router.routes for m in r.methods}
assert ("/api/pages/{page_id}/reactions", "GET") in methods
assert ("/api/pages/{page_id}/reactions", "POST") in methods
def test_toggle_reaction_api(client):
"""Le POST bascule (added → removed → added) et le GET regroupe par plage."""
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)",
("v764u", "V764 User", "[email protected]"))
uid = conn.execute("SELECT id FROM users WHERE login='v764u'").fetchone()["id"]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)",
("u764", "React Page", "[]", "blocks"))
pid = cur.lastrowid
conn.commit()
cookies = {"flowdeck_session": SessionManager.create_session(
{"id": uid, "login": "v764u", "is_admin": 0})}
payload = {"block_id": "b1", "anchor_start": 0, "anchor_end": 5, "emoji": "👍"}
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "added"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "removed"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.json()["status"] == "added"
r = client.get(f"/api/pages/{pid}/reactions", cookies=cookies)
assert r.status_code == 200
rows = r.json()["reactions"]
assert len(rows) == 1
assert rows[0]["emoji"] == "👍" and rows[0]["count"] == 1 and rows[0]["mine"] == 1
# plage vide → 400
bad = dict(payload, anchor_start=4, anchor_end=4)
r = client.post(f"/api/pages/{pid}/reactions", json=bad, cookies=cookies)
assert r.status_code == 400
# ── Toolbar : bouton réaction ──────────────────────────────────────────────
def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
def test_fmt_react_opens_picker_with_selection():
js = SRC[SRC.index("fmtReact(){"):SRC.index("toggleComments(){")]
for token in ("FDIconPicker.openFor", "title:text", "onPick:",
"toggleReaction(bid,span.start,span.end,emoji)", "_selSpan(bel,this._sel.range)"):
assert token in js, token
# ── Ancres commentées + pastilles ──────────────────────────────────────────
def test_anchors_painting():
for token in ("function _wrapAnchored(", "_paintAnchors()", "fd-anchor",
"fd-react-chip", "data-react-bid", "data-react-start", "openThread(cid)",
"loadReactions()", "toggleReaction(bid,start,end,emoji)"):
assert token in SRC, token
# repeint après chaque reconstruction du bloc ET après le chargement
assert "this._paintAnchors();}catch(e){}" in SRC # fin de render()
assert "this._paintAnchors();\n" in SRC # fin de loadComments/loadReactions
# la pastille n'est pas du contenu lu/sauvé
assert "n.classList.contains('fd-react-chip')" in SRC # gtTok
assert "cl.contains('fd-react-chip')" in SRC # gtMd
# offsets source (les annotations ne décalent pas les ancres)
assert "function _srcOffset(" in SRC and "_srcNodeLen(" in SRC
# clic ancre → fil ; clic pastille → sélecteur
assert "t.closest('.fd-react-chip')" in SRC
assert "t.closest('.fd-anchor[data-cid]')" in SRC
# ── Tiroir de commentaires (image 1) ───────────────────────────────────────
def test_drawer_markup():
for token in ('class="ct-head"', 'class="ct-avatar"', 'class="ct-name"',
'class="ct-time"', 'class="ct-actions"', 'class="ct-menu"',
"copyCommentLink(c.id)", "deleteComment(c.id)", "resolveComment(c)",
':data-thread="c.id"', 'placeholder="Add a comment..."',
"commentAt()", "class=\"ct-send\"", "addPageComment()"):
assert token in TPL, token
# le bouton ✓ bascule resolve/reopen (l'objet, pas l'id seul)
assert "resolveComment(c)" in TPL and "async resolveComment(c)" in SRC
# hash #cmt- → centrage du fil
assert "#cmt-" in SRC and "scrollIntoView" in SRC
# styles du tiroir
for sel in (".comment-thread {", ".ct-head {", ".ct-avatar {", ".ct-menu {",
".ct-input-row {", ".ct-send {", ".fd-anchor {", ".fd-react-chip {"):
assert sel in CSS, sel
# ── Sélecteur d'émoji (image 3) : mode réaction ────────────────────────────
def test_picker_reaction_mode():
for token in ("fdip-title", "fdip-title-text", "fdip-title-grip",
"$store.fdIconPicker.title", "Filter...", "Recent"):
assert token in PICKER, token
for token in ("this.title = opts.title || ''", "this.category = 'people'",
"this.title = ''"):
assert token in PSTORE, token
# la ligne « Recent » au-dessus de la grille n'apparaît qu'en mode réaction
assert "$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent'" in PICKER
# onglets masqués en mode réaction (emoji only)
assert 'x-show="!$store.fdIconPicker.title"' in PICKER