"""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 »