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).
This commit is contained in:
@@ -10,8 +10,10 @@ Covers the five roadmap points:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
|
||||
</head><body>hi</body></html>"""
|
||||
|
||||
|
||||
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
|
||||
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
|
||||
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
|
||||
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
|
||||
PUBLIC_IP = "93.184.216.34"
|
||||
|
||||
|
||||
class TestOGParser:
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_dns(self, monkeypatch):
|
||||
real = socket.getaddrinfo
|
||||
|
||||
def fake_getaddrinfo(host, *args, **kwargs):
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
|
||||
return real(host, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
def test_parse_og_handles_attribute_order(self):
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_toolbar_markup_matches_spec():
|
||||
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
|
||||
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
|
||||
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
|
||||
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
|
||||
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
|
||||
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
|
||||
"Edit with AI", "Alt+⇧+E", # pied de page
|
||||
]
|
||||
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
|
||||
assert "data-tooltip-html=" in chunk
|
||||
# icônes réellement définies dans le jeu d'icônes
|
||||
assert 'fd_icon("message-square",15)' in chunk
|
||||
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
|
||||
assert 'fd_icon("link",15)' in chunk
|
||||
|
||||
|
||||
def test_toolbar_row4_matches_reference_layout():
|
||||
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
|
||||
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
|
||||
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
|
||||
assert 'ft-row-split' in chunk
|
||||
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
|
||||
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
|
||||
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
|
||||
assert '"fmtAskAI()"' not in chunk
|
||||
# entête Skills (≡ en CSS) + liste scrollable
|
||||
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
|
||||
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
|
||||
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
|
||||
|
||||
@@ -71,9 +71,9 @@ def test_toggle_reaction_api(client):
|
||||
def test_toolbar_has_react_button():
|
||||
assert 'fmtReact()' in TPL
|
||||
assert 'data-tooltip="React to selected text"' in TPL
|
||||
# position : 3ᵉ ligne, après le surlignage, avant le code
|
||||
# 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 = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
|
||||
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""7.67.0 — réactions emoji visibles dans le tiroir Comments + suppression."""
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def test_drawer_lists_reactions():
|
||||
# section « Reactions » du tiroir : une ligne par plage × émoji
|
||||
assert 'class="ct-reactions"' in TPL
|
||||
assert 'x-for="r in reactionList()"' in TPL
|
||||
assert '<span class="ct-re-emoji" x-text="r.emoji"></span>' in TPL
|
||||
assert '<span class="ct-re-text" x-text="r.text"></span>' in TPL
|
||||
# l'état vide ne s'affiche plus s'il reste des réactions
|
||||
assert "comments.length === 0 && !reactionList().length" in TPL
|
||||
# méthode : groupes déjà prêts côté API, texte source lu dans le DOM
|
||||
assert "reactionList(){" in SRC
|
||||
assert "_anchorText(bid,start,end)" in SRC
|
||||
# le texte source ignore les pastilles (offsets identiques à sync())
|
||||
assert "closest('.fd-react-chip'))continue;" in SRC
|
||||
|
||||
|
||||
def test_reaction_removable_from_the_drawer():
|
||||
assert 'title="Remove reaction"' in TPL
|
||||
# le ✕ n'apparaît que sur MA réaction (les autres comptes sont en lecture)
|
||||
assert 'x-show="r.mine" @click="removeReaction(r)"' in TPL
|
||||
# suppression = POST toggle (le backend DELETE si ma ligne existe déjà)
|
||||
assert "removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);}" in SRC
|
||||
for sel in (".ct-section-title {", ".ct-reaction {", ".ct-re-emoji {", ".ct-re-text {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
def test_reaction_removable_from_the_chip():
|
||||
# chaque émoji de la pastille porte son data-emoji, `is-mine` = j'ai réagi
|
||||
assert 'data-emoji="' in SRC
|
||||
assert "parseInt(i.mine,10)?' is-mine':''" in SRC
|
||||
assert ("d.removeReaction({bid:bid,start:start,end:end,"
|
||||
"emoji:emo.getAttribute('data-emoji')})") in SRC
|
||||
# la croix est un ::after (aucun nœud ajouté → offsets source inchangés)
|
||||
assert ".fd-react-emo.is-mine::after {" in CSS
|
||||
assert ".fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }" in CSS
|
||||
Reference in New Issue
Block a user