"""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()