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).
172 lines
8.0 KiB
Python
172 lines
8.0 KiB
Python
"""7.62.0 — menu contextuel au highlight de texte (toolbar façon Notion)."""
|
||
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
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")
|
||
APPJS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
|
||
|
||
|
||
def _slice(src: str, start: str, end: str) -> str:
|
||
i = src.index(start)
|
||
return src[i:src.index(end, i)]
|
||
|
||
|
||
# ── Markup : les 4 lignes + Skills + pied, dans l'ordre de la description ───
|
||
|
||
def test_toolbar_markup_matches_spec():
|
||
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
|
||
order = [
|
||
"fmtStyle($event)", # ligne 1 : Normal Text ›
|
||
"fmtApply('bold')", "fmtApply('italic')", "fmtApply('underline')",
|
||
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
|
||
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
|
||
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
|
||
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
|
||
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
|
||
"Edit with AI", "Alt+⇧+E", # pied de page
|
||
]
|
||
positions = [chunk.index(o) for o in order]
|
||
assert positions == sorted(positions), positions
|
||
# ligne 1 dynamique (type du bloc) + chevron de sous-menu
|
||
assert "fmtStyleName()" in chunk and "ft-caret" in chunk
|
||
# info-bulle HTML (exemple en italique + description)
|
||
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("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 ────────
|
||
|
||
def test_actions_are_wired_to_real_methods():
|
||
for m in ("fmtStyle(ev)", "fmtColor(ev)", "fmtApply(f)", "fmtCode()",
|
||
"fmtHighlight()", "fmtCodeBlock()", "fmtLink()", "fmtEquation()",
|
||
"fmtMore(ev)", "fmtComment()", "fmtAskAI()", "fmtSkill(tpl)",
|
||
"fmtEditAI()", "askAIBlock(idx)"):
|
||
assert m in SRC, m
|
||
# pas de doublon de menu : on rouvre les sous-menus/menus déjà existants
|
||
assert "_blockTurnMenuOpen(p.x,p.y,bid)" in SRC
|
||
assert "_blockColorMenuOpen(p.x,p.y,bid)" in SRC
|
||
assert "blockMenuOpen(" in SRC
|
||
# sous-menu ouvert depuis la toolbar : fermeture au survol sortant
|
||
assert "function _subHoverClose()" in SRC
|
||
# Ctrl+J et Alt+⇧+E ouvrent l'IA du BLOC (l'ancien askAI(idx) était écrasé
|
||
# par askAI() défini plus bas dans le même objet)
|
||
assert "E.askAIBlock(i1)" in SRC
|
||
assert "k === 'e'" in SRC
|
||
assert "askAI(idx)" not in SRC
|
||
|
||
|
||
# ── Persistance du formatage : sync() relit le DOM formaté en markdown ─────
|
||
|
||
def test_formatting_survives_sync():
|
||
assert "function gtMd(" in SRC
|
||
assert "gtMd(el)" in SRC and "gtMd(che)" in SRC # sync() + enfants toggle/columns
|
||
for tok in ("'**'+inner+'**'", "'*'+inner+'*'", "'__'+inner+'__'",
|
||
"'~~'+inner+'~~'", "'`'+inner+'`'", "'=='+inner+'=='"):
|
||
assert tok in SRC, tok
|
||
# mdEsc rend les nouveaux marqueurs (souligné, surlignage, équation inline)
|
||
for re_ in ("/__([^_]+)__/g", "/==([^=]+)==/g", "/\\$\\$([^$]+)\\$\\$/g"):
|
||
assert re_ in SRC, re_
|
||
# les chips wiki gardent leur token brut
|
||
assert "fd-wiki-chip" in SRC
|
||
|
||
|
||
# ── Placement & fermeture ──────────────────────────────────────────────────
|
||
|
||
def test_menu_position_and_close():
|
||
assert "r.bottom+8" in SRC # sous la sélection (bas de l'écran : au-dessus)
|
||
assert "d.fmt.open=false;d._sel=null" in SRC # clic ailleurs → fermeture
|
||
assert "closest('.format-toolbar')" in SRC # clic dans la toolbar ≠ clic ailleurs
|
||
assert "@mousedown.prevent" in TPL # la sélection survit au clic
|
||
|
||
|
||
# ── Styles ────────────────────────────────────────────────────────────────
|
||
|
||
def test_menu_styles():
|
||
for sel in (".format-toolbar .ft-row {", ".format-toolbar .ft-item {",
|
||
".format-toolbar .ft-style {", ".format-toolbar .ft-group {",
|
||
".format-toolbar .ft-sep {", ".format-toolbar .ft-foot {",
|
||
".tooltip.tooltip-light {"):
|
||
assert sel in CSS, sel
|
||
# l'info-bulle claire s'applique aux éléments du menu de sélection
|
||
assert "data-tooltip-html" in APPJS and "tooltip-light" in APPJS
|
||
|
||
|
||
# ── Comportement réel : gtMd (DOM → source markdown) + mdEsc, sous node ────
|
||
|
||
def test_gt_md_and_md_esc_roundtrip_for_real():
|
||
node = shutil.which("node")
|
||
if not node:
|
||
pytest.skip("node absent : test comportemental ignoré")
|
||
gtmd = _slice(SRC, "function gtMd(", " // Enveloppe/désenveloppe")
|
||
mdesc = _slice(SRC, "function mdEsc(", " // v5.11.0: labels of [[fdpage:ID]]")
|
||
script = f"""
|
||
var window = {{}}; // gtMd fait window.gtMd=…
|
||
function gtTok(e){{ return e.__raw || ''; }}
|
||
function fdDateLabel(iso){{ return iso; }}
|
||
{gtmd}
|
||
{mdesc}
|
||
// DOM minimal (gtMd ne lit que childNodes / tagName / classList / attributs)
|
||
function T(s){{ return {{nodeType:3, nodeValue:s}}; }}
|
||
function E(tag, attrs, kids){{
|
||
attrs = attrs || {{}};
|
||
const cls = String(attrs['class'] || '').split(/\\s+/);
|
||
return {{nodeType:1, tagName:tag, childNodes:kids || [],
|
||
classList:{{contains:c=>cls.indexOf(c) >= 0}},
|
||
getAttribute:k=>(k in attrs ? attrs[k] : null),
|
||
hasAttribute:k=>(k in attrs)}};
|
||
}}
|
||
// querySelector truthy → chemin « formaté » de gtMd (gtTok jamais appelé)
|
||
const root = E('DIV', {{'class':''}}, [
|
||
E('B', {{}}, [T('gras')]),
|
||
E('EM', {{}}, [T('italique')]),
|
||
E('U', {{}}, [T('souligne')]),
|
||
E('S', {{}}, [T('barre')]),
|
||
E('CODE', {{}}, [T('code')]),
|
||
E('MARK', {{}}, [T('surligne')]),
|
||
E('A', {{'href':'https://example.com/x'}}, [T('lien')]),
|
||
E('SPAN', {{'class':'fd-wiki-chip', 'data-token':'[[fdpage:42]]'}}),
|
||
E('SPAN', {{'class':'fd-math-inline', 'data-tex':'x^2'}}),
|
||
T(' fin')
|
||
]);
|
||
root.querySelector = () => ({{}}); // force le chemin formaté
|
||
const md = gtMd(root);
|
||
const html = mdEsc(md);
|
||
console.log(JSON.stringify({{md:md, html:html}}));
|
||
"""
|
||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||
timeout=30, encoding="utf-8")
|
||
assert out.returncode == 0, out.stderr
|
||
data = json.loads(out.stdout)
|
||
assert data["md"] == ("**gras***italique*__souligne__~~barre~~`code`==surligne=="
|
||
"[lien](https://example.com/x)[[fdpage:42]]$$x^2$$ fin"), data["md"]
|
||
for token in ("<strong>gras</strong>", "<u>souligne</u>", "<mark>surligne</mark>",
|
||
"<code>code</code>", 'href="https://example.com/x"',
|
||
'class="fd-wiki-chip"', 'data-tex="x^2"'):
|
||
assert token in data["html"], token
|