"""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", "v764u@t.com")) 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