Files
flowdeck/tests/test_v769_selection_persistence.py
T
bruno 3d3b46b58b
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 17m31s
FlowDeck CI / docker (push) Skipped
fix: sélection multi-blocs fonctionnelle (Ctrl+C/X, drag souris, clic droit) + identité visuelle (v7.69.0)
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.

1) Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN.
   Cause : le navigateur ne sait pas sérialiser une sélection qui traverse
   deux `contenteditable` (chaque bloc en a un). `getSelection().toString()`
   renvoie `""` et Chromium n'émet AUCUN événement `copy` — les handlers
   `ct.addEventListener('copy')` n'étaient donc jamais atteints.
   → le raccourci est intercepté au keydown (capture sur document, garde
   `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) et le markdown
   est écrit dans le presse-papier par nos soins. Sélection mono-bloc =
   comportement natif inchangé.

2) Glisser la souris d'un bloc à l'autre ne sélectionnait qu'un bloc.
   Cause : `mouseup` (on reconstruit la plage) → `click` → `focusin` → le
   navigateur efface la sélection. La plage était reconstruite puis détruite
   dans la foulée.
   → la plage reconstruite est mise en attente (`_mdSelRange`) et restaurée
   au tour de boucle suivant si le focus l'a effectivement effacée. Chaque
   nouveau geste (mousedown) remet l'attente à zéro, donc un clic pour
   placer le curseur reste normal.

3) Le clic droit effaçait le surlignage.
   Cause : `.bm-search` prenait le focus 10 ms après l'ouverture du menu.
   → le menu ne vole plus le focus quand un texte est sélectionné. Les
   actions Clipboard utilisaient déjà la sélection figée à l'ouverture
   (`d._clipSt`) ; le `preventDefault` du clic droit est remonté sur
   `mousedown` (sur `contextmenu` il arrive trop tard).

Identité visuelle (bannière + logo du projet ajoutés à la racine) :
- assets optimisés servis : `static/img/banner.webp` 22,5 Ko (l'original
  fait 1,6 Mo) + JPEG de secours, `logo.webp` 5,3 Ko + `logo-512.jpg` ;
  originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge
  maskable sur 192/512) + apple-touch-icon.
- landing : logo dans la barre de navigation, bannière en <picture>
  (WebP + JPEG) avec dimensions déclarées (pas de décalage de mise en page).
- `og:image`, `twitter:card` et `meta description` ajoutés sur la landing
  ET dans base.html — la landing a son propre <head>.

Tests : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, vrais gestes : drag souris,
Ctrl+C/Ctrl+X au clavier, clic droit). `e2e/v768_columns_clipboard.spec.js`
corrigé : les gestes simulés (`execCommand`, plage fabriquée en evaluate)
sont remplacés par de vrais gestes, et le token statique obsolète
`si>=ei)return null;` aligné sur la garde `isCollapsed` réelle.

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
2026-10-08 17:27:14 -04:00

139 lines
6.3 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")
assert 'property="og:image" content="/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_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