- Barre flottante défilante sous le curseur (mobile ≤ 768 px) au lieu de la popup desktop, 15 boutons dans l'ordre de la description : +▾, Turn into▾, @, 💬, 🖼, 🗑, ←, →, ↑, ↓, ↩, ↪, Color▾, More▾, ⌨ - +▾ → feuille plein écran « Insert block » (titre centré, Cancel bleu, groupes Basic blocks / AI writing / Media / Data / Synced / Actions, raccourcis markdown à droite) ; items partagés avec la popup desktop (SM._slashItemHtml) - Turn into ▾ / Color ▾ → feuilles réutilisant les générateurs extraits _blockTurnItems / _blockColorItems (extraits de _blockTurnMenuOpen / _blockColorMenuOpen, comportement desktop inchangé) - More ▾ → menu contextuel de bloc existant ; @ insère + ouvre les mentions ; 💬 commentaire ; 🖼 bloc image ; 🗑 suppression ; ⌨ masque le clavier ; ↩ ↪ annuler/rétablir - moveBlock(bid, dir) : ↑↓ réordonne (racine + enfants toggle/columns), → indent dans le frère précédent, ← outdent, garde-fous avec toast - Fermeture auto de la barre quand le « / » n'est plus en tête de bloc ; taps dans la barre/les feuilles exclus du handler « clic hors-zone » ; Cancel ne referme que la feuille (la barre reste tant que le / est tapé) - Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---), groupe SYNCED libellé « Synced » - Tests : tests/test_v763_mobile_menus.py (ordre, câblage, structure, styles + moveBlock exécuté sous node), e2e/v763_mobile_menus.spec.js (390×844, 1 passed) ; test_v761 adapté aux extractions ; suite complète 1375 passed / 0 failed, ruff OK, OpenAPI 7.63.0 (525 chemins)
137 lines
6.2 KiB
Python
137 lines
6.2 KiB
Python
"""7.63.0 — menus mobiles façon Notion : barre horizontale au « / » + feuille plein écran."""
|
|
|
|
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")
|
|
CSS = (ROOT / "static" / "css" / "app.css").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)]
|
|
|
|
|
|
# ── Barre horizontale : les 15 boutons, dans l'ordre de la description ─────
|
|
|
|
def test_bar_items_in_order():
|
|
chunk = _slice(SRC, "const MTB={", "open(i,el){const d=this.el()")
|
|
order = ["['plus'", "['turn'", "['at'", "['comment'", "['image'", "['trash'",
|
|
"['left'", "['right'", "['up'", "['down'", "['undo'", "['redo'",
|
|
"['color'", "['more'", "['kbd'"]
|
|
positions = [chunk.index(o) for o in order]
|
|
assert positions == sorted(positions), positions
|
|
# les 4 entrées « texte + chevron » de la description
|
|
for w in ("'plus'", "'turn'", "'color'", "'more'"):
|
|
assert w in chunk, w
|
|
assert "fd-mtb-wide" in chunk and "fd-mtb-caret" in chunk
|
|
|
|
|
|
# ── Chaque bouton → une action réelle (pas de bouton mort) ─────────────────
|
|
|
|
def test_bar_actions_are_real():
|
|
act = _slice(SRC, "act(k,btn){", "\n };")
|
|
for token in ("sheet('Insert block'", "sheet('Turn into'", "sheet('Color'",
|
|
"blockMenuOpen(", "a.blur()", "E.openMention(el)",
|
|
"E.commentBlock(idx)", "E.applySlash('image')", "E.deleteSelected(idx)",
|
|
"E.moveBlock(bid,k)", "E.undo()", "E.redo()"):
|
|
assert token in act, token
|
|
# « More » ouvre bien le menu contextuel de bloc existant (desktop)
|
|
assert "this.close();blockMenuOpen(" in act
|
|
|
|
|
|
# ── Feuille plein écran : titre centré + Cancel + listes réutilisées ───────
|
|
|
|
def test_fullscreen_sheet():
|
|
sheet = _slice(SRC, "sheet(title,html,handler){", "_insertHtml(){")
|
|
for token in ("fd-msheet-head", "fd-msheet-title", "fd-msheet-cancel", "Cancel",
|
|
"fd-msheet-list block-menu"):
|
|
assert token in sheet, token
|
|
# contenu : items slash partagés + sous-menus de bloc partagés
|
|
ins = _slice(SRC, "_insertHtml(){", "_bmAct(")
|
|
assert "_GROUP_LABEL[g.name]" in ins and "_slashItemHtml" in ins
|
|
assert "function _blockTurnItems(bid)" in SRC and "function _blockColorItems(bid)" in SRC
|
|
# choix d'un item → applySlash via SM.sel (ferme tout)
|
|
assert "SM.sel(it.dataset.sid)" in SRC
|
|
|
|
|
|
# ── Branchement sur le slash : mobile = barre, desktop = popup intacte ─────
|
|
|
|
def test_mobile_hook():
|
|
for token in ("function _fdMobile()", "max-width: 768px",
|
|
"if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}",
|
|
"MTB.close();", "!MTB.contains(ev.target)",
|
|
"else if(SM.isOpen)"):
|
|
assert token in SRC, token
|
|
# le popup desktop (positionnement + focus de la recherche) est toujours là
|
|
assert "SM._e.style.display='flex'" in SRC and "SM._in.focus()" in SRC
|
|
# la barre se referme quand le / disparaît
|
|
assert "if(t.charAt(0)!=='/')SM.close();" in SRC
|
|
|
|
|
|
# ── Déplacement de bloc (↑↓ ordre, → indent, ← outdent) ────────────────────
|
|
|
|
def test_move_block_logic():
|
|
m = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
|
|
for token in ("dir==='up'", "dir==='down'", "dir==='right'", "parent.children",
|
|
"pushHistory()", "this.sync()", "_focusBid(bid)"):
|
|
assert token in m, token
|
|
|
|
|
|
# ── Styles ────────────────────────────────────────────────────────────────
|
|
|
|
def test_styles():
|
|
for sel in (".fd-mtb {", ".fd-mtb .fd-mtb-item {", ".fd-mtb .fd-mtb-wide {",
|
|
".fd-msheet {", ".fd-msheet-head {", ".fd-msheet-cancel {",
|
|
".fd-msheet-list.block-menu {", ".fd-msheet .bm-item {",
|
|
".fd-msheet .slash-item {"):
|
|
assert sel in CSS, sel
|
|
|
|
|
|
# ── Comportement réel : moveBlock sous node ───────────────────────────────
|
|
|
|
def test_move_block_for_real():
|
|
node = shutil.which("node")
|
|
if not node:
|
|
pytest.skip("node absent : test comportemental ignoré")
|
|
method = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
|
|
script = f"""
|
|
function _rtSync(){{}}
|
|
const E = {{
|
|
blocks:[{{id:'a',type:'paragraph',content:'A'}},
|
|
{{id:'tg',type:'toggle',content:'T',expanded:true,children:[]}},
|
|
{{id:'c',type:'paragraph',content:'C'}}],
|
|
sync(){{}}, pushHistory(){{}}, dirty:false, autoSave(){{}}, render(){{}},
|
|
_focusBid(){{}}, showToast(){{}}, msgs:[],
|
|
{method}
|
|
}};
|
|
E.moveBlock('c','right'); // C s'indente dans le toggle
|
|
const indent = {{kids:E.blocks[1].children.map(b=>b.id).join(','), top:E.blocks.map(b=>b.id).join(',')}};
|
|
E.moveBlock('c','left'); // C ressort à la racine : a,tg,c
|
|
const top2 = E.blocks.map(b=>b.id).join(',');
|
|
E.moveBlock('a','down'); // A descend : tg,a,c
|
|
const ordre1 = E.blocks.map(b=>b.id).join(',');
|
|
E.moveBlock('c','up'); // C remonte : tg,c,a
|
|
const ordre2 = E.blocks.map(b=>b.id).join(',');
|
|
E.moveBlock('c','up'); // encore : c,tg,a
|
|
const ordre3 = E.blocks.map(b=>b.id).join(',');
|
|
E.moveBlock('c','up'); // déjà en haut → toast, inchangé
|
|
const ordre4 = E.blocks.map(b=>b.id).join(',');
|
|
console.log(JSON.stringify({{indent:indent, top2:top2, ordre1:ordre1, ordre2:ordre2, ordre3:ordre3, ordre4:ordre4}}));
|
|
"""
|
|
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
|
timeout=30, encoding="utf-8")
|
|
assert out.returncode == 0, out.stderr
|
|
d = json.loads(out.stdout)
|
|
assert d["indent"] == {"kids": "c", "top": "a,tg"}, d
|
|
assert d["top2"] == "a,tg,c", d
|
|
assert d["ordre1"] == "tg,a,c", d
|
|
assert d["ordre2"] == "tg,c,a", d
|
|
assert d["ordre3"] == "c,tg,a", d
|
|
assert d["ordre4"] == "c,tg,a", d # garde « déjà en haut »
|