"""v7.69.0 — sélection multi-blocs qui survit aux gestes + identité visuelle. Trois bugs d'éditeur, une cause chacune : 1. Ctrl+C multi-blocs : le navigateur ne sérialise PAS une sélection qui traverse deux contenteditable → il n'émet aucun événement copy → le raccourci ne fait rien. On l'intercepte au keydown. 2. Glisser la souris : le « click » du contenteditable efface la sélection juste après qu'on l'a reconstruite (mouseup → click → focusin → collapse). 3. Clic droit : la recherche du menu prenait le focus et effaçait le surlignage. """ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8") def _norm(src: str) -> str: return "".join(src.split()) # ── 1. Ctrl+C / Ctrl+X multi-blocs : interception clavier ── class TestKeyboardShortcut: def test_ctrl_shortcut_is_bound_on_document(self): # capture sur document : le focus peut être n'importe où dans l'éditeur assert _norm("document.addEventListener('keydown',function(ev){") in _norm(JS) def test_shortcut_handles_both_c_and_x(self): assert _norm("if(k!=='c'&&k!=='x')return;") in _norm(JS) def test_shortcut_guards_outside_the_editor(self): # ne doit JAMAIS voler Ctrl+C quand le focus est dans un champ du menu assert "closest('#_blocksCt')" in JS def test_shortcut_falls_back_to_native_for_single_block(self): # sélection mono-bloc → on laisse le navigateur faire son travail assert _norm("const st=E._clipState();if(!st||st.si>=st.ei)return;") in _norm(JS) def test_shortcut_writes_to_clipboard_and_prevents_default(self): assert _norm("ev.preventDefault();ev.stopPropagation();") in _norm(JS) assert "navigator.clipboard.writeText(md)" in JS def test_shortcut_is_registered_next_to_the_copy_listeners(self): # le handler clavier vit au même endroit que copy/cut (bloc ct de v7.68) cut = JS.index("ct.addEventListener('cut'") shortcut = JS.index("closest('#_blocksCt')") assert cut < shortcut < JS.index("ct.addEventListener('mousedown'") # ── 2. La sélection reconstruite survit au collapse du focus ── class TestSelectionPersistence: def test_pending_range_variable_exists(self): assert "var _mdSelRange=null;" in JS def test_build_sel_at_stashes_the_range(self): # sans cette copie, le « click » suivant détruit la sélection assert _norm("s.removeAllRanges();s.addRange(a);") in _norm(JS) assert _norm("_mdSelRange=a;") in _norm(JS) def test_new_gesture_clears_the_pending_range(self): # un clic ultérieur doit placer un caret normal, pas ressusciter l'ancien assert _norm("_mdSelRange=null;") in _norm(JS) def test_focusin_restores_after_the_synchronous_collapse(self): # focusin se déclenche AVANT le collapse : la restauration doit être # reportée au tour de boucle suivant (setTimeout 0) assert "document.addEventListener('focusin'" in JS restore = JS[JS.index("document.addEventListener('focusin'") :] assert "setTimeout(function(){" in restore.split("document.addEventListener('mousedown'")[0] assert "s.addRange(r)" in restore def test_restore_is_a_noop_when_the_selection_survived(self): assert _norm("if(s&&s.rangeCount&&!s.isCollapsed)return;") in _norm(JS) # ── 3. Le menu contextuel n'efface plus le surlignage ── class TestContextMenuKeepsHighlight: def test_search_field_only_steals_focus_without_a_selection(self): assert _norm("if(si&&!hasSel)setTimeout(function(){si.focus();},10);") in _norm(JS) def test_right_click_prevents_default_to_freeze_the_selection(self): # le preventDefault doit être sur mousedown (contextmenu arrive trop tard) assert _norm("if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}") in _norm(JS) def test_menu_freezes_the_clipboard_state_at_open_time(self): # getSelection() n'est plus fiable une fois le menu ouvert assert _norm("var clipSt=(E._clipState?E._clipState():null);") in _norm(JS) assert "d._clipSt=clipSt;" in JS # ── 4. Identité visuelle (logo + bannière du projet) ── class TestBranding: def test_banner_and_logo_assets_exist_and_are_optimised(self): img = ROOT / "static" / "img" banner, logo = img / "banner.webp", img / "logo.webp" assert banner.exists() and logo.exists() # les originaux pèsent des Mo : les assets servis doivent rester légers assert banner.stat().st_size < 200_000 assert logo.stat().st_size < 100_000 # un JPEG de secours existe pour les crawlers qui n'aiment pas le WebP assert (img / "banner.jpg").exists() assert (img / "logo-512.jpg").exists() def test_source_images_are_kept_for_regeneration(self): src = ROOT / "assets" assert (src / "banner.jpg").exists() assert (src / "logo.jpg").exists() def test_pwa_icons_generated_from_the_real_logo(self): icons = ROOT / "static" / "icons" for size in (72, 96, 128, 144, 152, 192, 384, 512): f = icons / f"icon-{size}x{size}.png" assert f.exists(), f"icône {size} manquante" assert (icons / "apple-touch-icon.png").exists() def test_base_head_declares_open_graph(self): base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8") # URL ABSOLUE : les crawlers ignorent les URL relatives assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in base assert 'name="twitter:card" content="summary_large_image"' in base assert '= {(16, 16), (32, 32), (48, 48)} # le placeholder SVG doit avoir disparu assert not (ROOT / "static" / "favicon.svg").exists() def test_every_template_points_at_the_real_favicon(self): for tpl in ("base.html", "import.html", "landing.html", "public_page.html", "welcome.html"): src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8") assert 'rel="icon" href="/static/favicon.ico"' in src, tpl assert "favicon.svg" not in src, tpl # le service worker pré-met en cache la bonne URL sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") assert "'/static/favicon.ico'," in sw assert "favicon.svg" not in sw def test_landing_shows_logo_and_banner(self): landing = (ROOT / "app" / "templates" / "landing.html").read_text(encoding="utf-8") # le logo remplace l'icône générique dans la barre de navigation assert 'class="brand-mark" src="/static/img/logo.webp"' in landing # la bannière est servie en WebP avec secours JPEG assert '' in landing assert 'src="/static/img/banner.jpg"' in landing # l'image ne doit pas déclencher de refont : dimensions déclarées assert 'width="2400" height="793"' in landing