Le favicon était un carré bleu #2383E2 avec les lettres « FD » écrites en <text> SVG : génériques, sans aucun lien avec la marque, et rendues avec la police installée chez le visiteur (donc instables). static/favicon.ico est désormais généré depuis assets/logo.jpg — la même source que la bannière et les icônes PWA. 16/32/48 px dans un seul fichier, un seul <link> pour tous les navigateurs, 4,1 Ko. Pourquoi un fond sombre et pas le logo nu : le logo est blanc à ~82 % (mesuré via Pillow — 50,7 % de blanc pur, plus 32 % de blancs proches). Posé nu, il se dissoudrait sur un onglet clair, qui est le cas nominal de Firefox et de Safari. Il est donc posé sur un carré #191919 arrondi, LE MÊME fond que les icônes PWA générées en v7.69.0, ce qui garde l'identité cohérente entre onglet, écran d'accueil et partage. Contraste mesuré à 16 px : σ = 106 avec le fond sombre, contre σ = 49 pour le logo seul — le fond sombre double littéralement la lisibilité à cette taille. Le test statique verrouille les tailles embarquées dans l'ICO. Mise à jour des 5 templates qui pointaient vers l'ancien SVG (base, import, landing, public_page, welcome) + la liste de pré-cache du service worker. static/favicon.svg SUPPRIMÉ : plus aucune référence dans le dépôt, le test le vérifie pour éviter qu'il revienne. pytest 1416 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.2
173 lines
8.2 KiB
Python
173 lines
8.2 KiB
Python
"""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 '<meta name="description"' in base
|
|
|
|
def test_og_image_url_is_absolute_via_base_url(self):
|
|
# le global expose la même source que les liens de partage de pages
|
|
templating = (ROOT / "app" / "templating.py").read_text(encoding="utf-8")
|
|
assert 'ENV.globals["app_base_url"]' in templating
|
|
for tpl in ("base.html", "landing.html"):
|
|
src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8")
|
|
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in src, tpl
|
|
# forme RELATIVE interdite (le guillemet avant la barre est ce qui
|
|
# distingue : la bonne valeur contient l'ancienne en sous-chaîne)
|
|
assert 'content="/static/img/' not in src, tpl
|
|
|
|
def test_favicon_is_the_real_logo(self):
|
|
"""L'ancien favicon était un placeholder « FD » en SVG — pas la marque."""
|
|
ico = ROOT / "static" / "favicon.ico"
|
|
assert ico.exists()
|
|
assert ico.stat().st_size < 20_000 # un favicon pèse des Ko, pas des Mo
|
|
# 16/32/48 : la taille de l'onglet ET le repli rétro
|
|
from PIL import Image
|
|
|
|
assert Image.open(ico).info.get("sizes") >= {(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 '<source srcset="/static/img/banner.webp" type="image/webp">' 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
|