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>
261 lines
8.2 KiB
Python
261 lines
8.2 KiB
Python
"""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()
|