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).
140 lines
7.1 KiB
Python
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
|