feat(gui): éditeur de masques en fenêtre dédiée (GUI V6)

Remplace l'éditeur de masquage encastré dans l'onglet Configuration —
jugé inutilisable par Dom (document trop à l'étroit, non défilable) —
par une fenêtre dédiée où le document est majoritaire et réellement
navigable.

- gui_v6/mask_editor_model.py : couche logique pure (rectangles par
  page, conversions écran↔PDF, hit-test, sérialisation template)
  testable sans display ; réutilise MaskRect/Template de
  pdf_mask_designer → format de template inchangé (compat moteur).
- gui_v6/mask_editor_window.py : MaskEditorWindow (CTkToplevel)
  redimensionnable — canvas + scrollbars H+V câblées + molette (le
  manque qui rendait l'éditeur inutilisable), zoom + ajuster
  largeur/page, navigation pages, rectangles au glisser-déposer,
  sélection (clic) + suppression (Suppr / clic-droit), templates
  JSON/YAML, mode aperçu d'exemple sans PDF.
- tab_config.py : l'onglet Masquage lance la fenêtre dédiée ; retrait
  du canvas encastré et de ~290 lignes de code mort associé.
- tests/unit/test_gui_v6_mask_editor.py : 13 tests logique + 3 smoke
  headless (scrollbars, ajout/sélection/suppression, save/load
  roundtrip, câblage onglet→fenêtre).

Sans nouvelle dépendance. V5, moteur et app_aivanov non touchés.
221 tests unit OK (0 régression), self-test GUI V6 OK.
Verdict Qwen requis avant push/build/diffusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:01:13 +02:00
parent 696f6bf27c
commit 13b79db417
4 changed files with 1004 additions and 462 deletions

View File

@@ -0,0 +1,260 @@
"""Couche logique de l'éditeur de masques en fenêtre dédiée (sans display).
Ces tests valident le modèle pur (rectangles/pages/conversions/templates) qui
sous-tend `gui_v6/mask_editor_window.py`. La fenêtre Tk elle-même est couverte par
un smoke headless séparé.
"""
from __future__ import annotations
import json
import pytest
from gui_v6.mask_editor_model import (
ZOOM_MAX,
ZOOM_MIN,
MaskEditorModel,
clamp_page,
clamp_zoom,
fit_page_zoom,
fit_width_zoom,
pdf_to_screen,
screen_to_pdf,
)
# --- Conversions / clamps ---------------------------------------------------
def test_clamp_zoom_bounds():
assert clamp_zoom(0.001) == ZOOM_MIN
assert clamp_zoom(999.0) == ZOOM_MAX
assert clamp_zoom(1.0) == 1.0
def test_clamp_page_bounds():
assert clamp_page(-3, 5) == 0
assert clamp_page(10, 5) == 4
assert clamp_page(2, 5) == 2
assert clamp_page(0, 0) == 0 # aucun document
def test_screen_pdf_roundtrip_applies_zoom():
assert screen_to_pdf(200.0, 100.0, zoom=2.0) == (100.0, 50.0)
assert pdf_to_screen(100.0, 50.0, zoom=2.0) == (200.0, 100.0)
def test_fit_width_zoom():
assert fit_width_zoom(595.0, 1190.0, padding=0.0) == 2.0
def test_fit_page_zoom_uses_min_ratio():
# ratio largeur = 2.0, ratio hauteur = 1.0 -> on prend le plus petit
assert fit_page_zoom(595.0, 842.0, 1190.0, 842.0, padding=0.0) == 1.0
# --- Modèle rectangles / pages ---------------------------------------------
def test_add_rect_normalizes_corners():
m = MaskEditorModel()
r = m.add_rect(page=0, x0=100, y0=80, x1=40, y1=20)
assert r is not None
assert (r.x0, r.y0, r.x1, r.y1) == (40.0, 20.0, 100.0, 80.0)
assert r.page == 0
assert m.count_total() == 1
def test_add_rect_ignores_tiny_rectangles():
m = MaskEditorModel()
assert m.add_rect(page=0, x0=10, y0=10, x1=11, y1=11) is None
assert m.count_total() == 0
def test_rect_at_returns_topmost_then_none():
m = MaskEditorModel()
a = m.add_rect(0, 0, 0, 100, 100)
b = m.add_rect(0, 50, 50, 150, 150)
assert m.rect_at(0, 60, 60) is b # zone de recouvrement -> dernier dessiné
assert m.rect_at(0, 10, 10) is a # seulement dans a
assert m.rect_at(0, 500, 500) is None
assert m.rect_at(1, 60, 60) is None # autre page
def test_delete_rect():
m = MaskEditorModel()
a = m.add_rect(0, 0, 0, 50, 50)
assert m.delete_rect(a) is True
assert m.count_total() == 0
assert m.delete_rect(a) is False
def test_clear_page_and_clear_all():
m = MaskEditorModel()
m.add_rect(0, 0, 0, 50, 50)
m.add_rect(0, 60, 60, 90, 90)
m.add_rect(1, 0, 0, 50, 50)
assert m.count_page(0) == 2
assert m.clear_page(0) == 2
assert m.count_page(0) == 0
assert m.count_total() == 1
assert m.clear_all() == 1
assert m.count_total() == 0
def test_set_page_clamps_and_updates_index():
m = MaskEditorModel(page_count=3)
assert m.set_page(5) == 2
assert m.page_index == 2
assert m.set_page(-1) == 0
assert m.page_index == 0
# --- Templates (compat moteur / pdf_mask_designer) --------------------------
def test_payload_shape_and_roundtrip():
m = MaskEditorModel(page_count=1, page_sizes=[(595.0, 842.0)], name="demo")
m.add_rect(0, 10, 20, 110, 60, label="NOM")
payload = m.to_payload()
assert payload["version"] == 1
assert payload["name"] == "demo"
assert payload["page_size"] == {"width": 595.0, "height": 842.0}
assert payload["masks"][0] == {
"page": 0,
"x0": 10.0,
"y0": 20.0,
"x1": 110.0,
"y1": 60.0,
"label": "NOM",
}
# le payload doit survivre à une sérialisation JSON
payload2 = json.loads(json.dumps(payload))
m2 = MaskEditorModel.from_payload(payload2)
assert m2.name == "demo"
assert m2.count_total() == 1
r = m2.masks_for_page(0)[0]
assert (r.x0, r.y0, r.x1, r.y1, r.label) == (10.0, 20.0, 110.0, 60.0, "NOM")
def test_payload_is_compatible_with_pdf_mask_designer_template():
from pdf_mask_designer import Template
m = MaskEditorModel(page_count=1, page_sizes=[(595.0, 842.0)], name="demo")
m.add_rect(0, 10, 20, 110, 60, label="NOM")
tpl = Template.from_dict(m.to_payload())
assert tpl.name == "demo"
assert tpl.page_size == (595.0, 842.0)
assert len(tpl.masks) == 1
assert tpl.masks[0].label == "NOM"
# --- Smoke headless de la fenêtre Tk (skip si pas de display) ---------------
@pytest.fixture
def ctk_root():
ctk = pytest.importorskip("customtkinter")
try:
root = ctk.CTk()
except Exception as exc: # pas de display disponible
pytest.skip(f"display Tk indisponible: {exc}")
root.withdraw()
try:
yield root
finally:
try:
root.destroy()
except Exception:
pass
def test_window_smoke_scrollbars_add_select_delete_save(ctk_root, tmp_path):
from gui_v6.mask_editor_window import MaskEditorWindow
win = MaskEditorWindow(ctk_root, templates_dir=tmp_path)
win.update_idletasks()
# 1) la fenêtre expose bien deux scrollbars (le manque qui rendait l'éditeur inutilisable)
assert win.has_scrollbars()
# 2) ajout d'un rectangle (sur l'aperçu d'exemple, sans PDF réel)
win.model.page_count = 1
win.model.page_sizes = [(595.0, 842.0)]
rect = win.add_mask_rect_pdf(0, 30, 40, 200, 120, label="NOM")
assert rect is not None
assert win.model.count_total() == 1
# 3) sauvegarde du template (JSON) -> relisible et compatible
out = win.save_template_to(tmp_path / "demo.json")
assert out.exists()
reloaded = json.loads(out.read_text(encoding="utf-8"))
assert reloaded["masks"][0]["label"] == "NOM"
# 4) sélection + suppression individuelle
win.select_rect(win.model.masks_for_page(0)[0])
win.delete_selected()
assert win.model.count_total() == 0
win.destroy()
def test_window_smoke_load_template_roundtrip(ctk_root, tmp_path):
from gui_v6.mask_editor_window import MaskEditorWindow
win = MaskEditorWindow(ctk_root, templates_dir=tmp_path)
win.model.page_count = 1
win.model.page_sizes = [(595.0, 842.0)]
win.add_mask_rect_pdf(0, 10, 20, 110, 60, label="ADRESSE")
path = win.save_template_to(tmp_path / "rt.yml")
win.clear_all()
assert win.model.count_total() == 0
win.load_template_path(path)
assert win.model.count_total() == 1
assert win.model.masks_for_page(0)[0].label == "ADRESSE"
win.destroy()
def test_config_tab_launches_dedicated_window(ctk_root, tmp_path, monkeypatch):
"""Garde-fou de recâblage : l'onglet Configuration construit sans l'éditeur
encastré et ouvre bien la fenêtre dédiée (avec scrollbars)."""
from gui_v6.tabs import tab_config
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
tab = tab_config.ConfigTab(ctk_root)
tab.update_idletasks()
# plus d'éditeur encastré
assert not hasattr(tab, "_mask_canvas")
tab._open_full_mask_editor()
win = tab._mask_editor_window
assert win is not None
assert win.has_scrollbars()
# un template sauvé via la fenêtre rafraîchit la sélection côté Réglages
win.model.page_count = 1
win.model.page_sizes = [(595.0, 842.0)]
win.add_mask_rect_pdf(0, 10, 20, 110, 60, label="NOM")
saved = win.save_template_to(tmp_path / "config" / "mask_templates" / "depuis_fenetre.json")
assert saved.exists()
# A14 : sauver depuis la fenêtre active le template comme masque manuel du run
assert tab._state.manual_mask_template == saved
win.destroy()
tab.destroy()
def test_window_loads_initial_template(ctk_root, tmp_path):
"""B5 / continuité : un template fourni à l'ouverture est chargé (l'utilisateur
reprend son travail au lieu de repartir d'une page vierge)."""
from gui_v6.mask_editor_window import MaskEditorWindow
seed = MaskEditorModel(page_count=1, page_sizes=[(595.0, 842.0)], name="seed")
seed.add_rect(0, 5, 5, 90, 40, label="IPP")
path = tmp_path / "seed.json"
path.write_text(json.dumps(seed.to_payload(), ensure_ascii=False), encoding="utf-8")
win = MaskEditorWindow(ctk_root, templates_dir=tmp_path, initial_template=path)
assert win.model.count_total() == 1
assert win.model.masks_for_page(0)[0].label == "IPP"
win.destroy()