""" Tests for the Multi-Pane Split View (#75) — I1 + I2 + I4 combined. Covers: I1: Unit tests for PaneManager JS module structure I2: Integration tests (JS code patterns, DOM flow, data formats, CSS classes) I4: Backend regression tests """ import json import re import pytest from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent # ============================================================================ # I1 — Unit tests: JS module structure, exports, methods # ============================================================================ class TestPaneManagerModuleStructure: """Validate pane-manager.js module exports, methods, and internal structure.""" @pytest.fixture(autouse=True) def _load(self): self.path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" self.content = self.path.read_text(encoding="utf-8") def test_file_exists(self): assert self.path.exists() def test_exports_default(self): assert "export default PaneManager" in self.content def test_exposes_globals(self): assert "window.PaneManager = PaneManager" in self.content assert "window.getActiveTabManager = getActiveTabManager" in self.content assert "window.getActiveContentArea = getActiveContentArea" in self.content def test_factory_function_exists(self): assert "function createPaneTabManager(" in self.content def test_all_split_methods_present(self): for method in ["splitRight", "splitDown", "closePane", "closeActivePane", "closeOtherPanes", "isSplit", "getActiveContentArea", "getActiveTabManager", "transferTab", "setActivePane", "_buildGrid", "_createPaneAt", "_addPane", "_collapseToSingle", "_setupPaneDropZone", "_persist", "_computePanePositions", "_rebuildResizeHandles"]: assert method + "(" in self.content or method + " (" in self.content, \ f"Missing method: {method}" def test_pane_tab_manager_methods(self): """Factory creates objects with all required methods.""" for method in ["openPreview", "openPersistent", "open", "activate", "close", "closeAll", "closeRight", "closeOthers", "moveTab", "transferTabTo", "_renderTabs", "_showTabContextMenu", "init", "getContentArea"]: assert method in self.content, f"Missing PaneTabManager method: {method}" def test_max_panes_constant(self): assert "PANE_MAX = 4" in self.content or "PANE_MAX=4" in self.content def test_brace_balance(self): for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: close_map = {"{": "}", "(": ")", "[": "]"} assert self.content.count(char) == self.content.count(close_map[char]), \ f"Unbalanced {name}" # ============================================================================ # I2 — Integration tests: JS code patterns, data formats, DOM flow # ============================================================================ class TestPaneManagerDataFlow: """Validate data format conventions, migration logic, and persistence.""" def test_tab_drag_payload_format(self): """dragstart sets correct JSON payload on text/plain.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") # Must use JSON.stringify for paneId/tabId/index assert 'JSON.stringify' in content assert 'paneId' in content assert 'tabId' in content def test_tree_drag_format(self): """Tree drags use 'tree:' prefix to distinguish from tab drags.""" path = PROJECT_ROOT / "frontend" / "js" / "dragdrop.js" content = path.read_text(encoding="utf-8") assert "tree:" in content def test_singleton_dragstart_uses_json(self): """Singleton TabManager dragstart uses JSON format, not plain index.""" path = PROJECT_ROOT / "frontend" / "js" / "ui.js" content = path.read_text(encoding="utf-8") # Should have JSON.stringify in dragstart dragstart_section = content.split('"dragstart"')[1].split('"dragend"')[0] \ if '"dragstart"' in content else "" assert 'JSON.stringify' in dragstart_section or \ 'paneId' in dragstart_section, \ "Singleton TabManager dragstart must use JSON format" def test_singleton_drop_handles_json(self): """Singleton TabManager drop handler parses JSON with fallback.""" path = PROJECT_ROOT / "frontend" / "js" / "ui.js" content = path.read_text(encoding="utf-8") assert 'JSON.parse' in content def test_drop_handler_handles_tree_drags(self): """Container drop handler checks for 'tree:' prefix.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "startsWith('tree:')" in content or '.startsWith("tree:")' in content def test_drop_handler_handles_tab_drags(self): """Container drop handler handles tab JSON payloads.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "'text/plain'" in content or '"text/plain"' in content def test_migration_logic_exists(self): """Tab migration singleton→PaneTabManager exists in _buildGrid.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "_singletonTabManager" in content assert "_tabs" in content def test_reverse_migration_exists(self): """Tab migration PaneTabManager→singleton exists in _collapseToSingle.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") # _collapseToSingle should reference _singletonTabManager collapse_section = content.split("_collapseToSingle()")[1].split(" },")[0] \ if "_collapseToSingle()" in content else "" assert "_singletonTabManager" in collapse_section or \ "stm._tabs" in content, \ "Reverse migration missing in _collapseToSingle" def test_localstorage_key(self): """Persistence uses 'obsigate-panes' key.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "obsigate-panes" in content def test_localstorage_payload_structure(self): """_persist saves layout, panes array with id/width/activeTab/tabs.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "layout" in content assert "panes" in content assert "obsigate-panes" in content class TestPaneManagerDOMFlow: """Validate DOM creation, manipulation, and destruction patterns.""" def test_build_grid_creates_pane_containers(self): """_buildGrid creates .pane-grid and .pane-container elements.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "pane-grid" in content assert "pane-container" in content def test_create_pane_suffixes_ids(self): """Pane IDs are suffixed: tab-bar-N, tab-list-N, content-area-N.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "tab-bar-' +" in content or 'tab-bar-" +' in content or \ "`tab-bar-" in content assert "content-area-' +" in content or 'content-area-" +' in content or \ "`content-area-" in content def test_pane_zero_reuses_original_ids(self): """Pane 0 reuses #tab-bar and #content-area (no suffix).""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "id === 0" in content or "i === 0" in content def test_panes_remain_visible_when_inactive(self): """Split view keeps all panes visible — no display:none hiding. Lazy rendering optimization removed because it broke split visibility.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") active_section = content.split("setActivePane(paneId)")[1].split(" },")[0] \ if "setActivePane(paneId)" in content else "" # setActivePane should NOT hide inactive panes (they must stay visible) assert "display" not in active_section.lower() or \ "display:" not in active_section.lower(), \ "setActivePane must NOT hide inactive panes — split view requires both visible" def test_collapse_restores_original_dom(self): """_collapseToSingle restores #tab-bar and #content-area IDs.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "_collapseToSingle" in content # Should reference tab-bar and content-area restoration assert "tab-bar" in content assert "content-area" in content def test_empty_pane_closes_itself(self): """When all tabs are closed in a non-last pane, the pane closes.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "closePane(this.paneId)" in content or \ "closePane(" in content def test_transfer_tab_empties_source(self): """transferTabTo removes tab from source and adds to target.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") transfer_section = content.split("transferTabTo(")[1].split(" },")[0] \ if "transferTabTo(" in content else "" assert "_tabs.splice" in content # removal assert "targetTm._tabs.push" in content # addition class TestPaneManagerKeyboardShortcuts: """Validate all keyboard shortcuts are registered correctly.""" def test_split_right_shortcut(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") # Ctrl+Alt+\ → splitRight assert "splitRight()" in content def test_split_down_shortcut(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "splitDown()" in content def test_close_pane_shortcut(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "closeActivePane()" in content def test_close_other_panes_shortcut(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "closeOtherPanes()" in content def test_navigate_panes_shortcut(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "ArrowRight" in content assert "ArrowLeft" in content def test_all_shortcuts_have_prevent_default(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") shortcut_section = content.split("// Keyboard shortcuts")[1].split("// Init")[0] \ if "// Keyboard shortcuts" in content else "" prevent_count = shortcut_section.count("preventDefault()") assert prevent_count >= 4, f"Expected >=4 preventDefault calls, got {prevent_count}" class TestPaneManagerContextMenu: """Validate context menu structure and actions.""" def test_menu_has_split_actions(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "Diviser à droite" in content assert "Diviser en bas" in content assert "Fermer le panneau" in content def test_menu_has_move_to_submenu(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "Déplacer vers" in content def test_menu_has_close_actions(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "Fermer" in content or "Fermer" in content assert "Fermer les autres" in content assert "Fermer à droite" in content assert "Fermer tout" in content def test_ui_singleton_menu_has_split_actions(self): path = PROJECT_ROOT / "frontend" / "js" / "ui.js" content = path.read_text(encoding="utf-8") assert "Diviser à droite" in content assert "Diviser en bas" in content class TestPaneManagerCSS: """Validate CSS classes used by the pane system.""" def test_pane_grid_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert ".pane-grid" in content assert "display: grid" in content def test_pane_container_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert ".pane-container" in content assert "position: relative" in content # needed for drop indicators def test_resize_handle_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert ".pane-resize-handle" in content # Cursor (col-resize/row-resize) is set in JS, not CSS js = (PROJECT_ROOT / "frontend" / "js" / "pane-manager.js").read_text(encoding="utf-8") assert "col-resize" in js or "row-resize" in js def test_drop_zone_indicator_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert "pane-drop-zone-indicator" in content def test_tab_split_buttons_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert ".tab-split-buttons" in content assert ".tab-split-btn" in content def test_drop_target_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert ".tab-bar.drop-target" in content def test_responsive_css(self): path = PROJECT_ROOT / "frontend" / "style.css" content = path.read_text(encoding="utf-8") assert "@media (max-width: 768px)" in content # Should hide non-first panes on mobile assert "not(:first-child)" in content class TestPaneManagerHTML: """Validate HTML structure for pane support.""" def test_content_wrapper_exists(self): path = PROJECT_ROOT / "frontend" / "index.html" content = path.read_text(encoding="utf-8") assert 'class="content-wrapper"' in content def test_tab_bar_exists(self): path = PROJECT_ROOT / "frontend" / "index.html" content = path.read_text(encoding="utf-8") assert 'id="tab-bar"' in content assert 'id="tab-list"' in content def test_content_area_exists(self): path = PROJECT_ROOT / "frontend" / "index.html" content = path.read_text(encoding="utf-8") assert 'id="content-area"' in content def test_es_modules_loaded(self): path = PROJECT_ROOT / "frontend" / "index.html" content = path.read_text(encoding="utf-8") assert 'type="module"' in content def test_pane_manager_imported_in_app(self): path = PROJECT_ROOT / "frontend" / "js" / "app.js" content = path.read_text(encoding="utf-8") assert "pane-manager.js" in content or "PaneManager" in content class TestPaneManagerPalette: """Validate command palette integration.""" def test_palette_has_split_commands(self): path = PROJECT_ROOT / "frontend" / "js" / "palette.js" content = path.read_text(encoding="utf-8") assert "Diviser à droite" in content assert "Diviser en bas" in content assert "Fermer le panneau actif" in content assert "Panneau suivant" in content assert "Panneau précédent" in content def test_palette_has_reset_command(self): path = PROJECT_ROOT / "frontend" / "js" / "palette.js" content = path.read_text(encoding="utf-8") assert "Réinitialiser les panneaux" in content def test_palette_category_panneaux(self): path = PROJECT_ROOT / "frontend" / "js" / "palette.js" content = path.read_text(encoding="utf-8") assert "cat:'Panneaux'" in content or 'cat:"Panneaux"' in content class TestEditorLock: """Validate editor lock prevents duplicate file in multiple panes.""" def test_editor_lock_check_exists(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") # The 'open' method should check other panes for same file open_section = content.split("async open(vault, path)")[1].split(" async")[0] \ if "async open(vault, path)" in content else content assert "tabManagers" in open_section or \ "isSplit()" in content, \ "Editor lock check missing in PaneTabManager.open()" def test_editor_lock_focuses_existing(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text(encoding="utf-8") assert "setActivePane" in content assert "focus" in content.lower() or "activate" in content.lower() # ============================================================================ # I4 — Backend regression tests # ============================================================================ class TestBackendRegression: """Verify all backend APIs still work after split view changes.""" def test_health_endpoint(self, client): resp = client.get("/api/health") assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" def test_file_view_endpoint(self, client): resp = client.get("/api/file/TestVault", params={"path": "note1.md"}) assert resp.status_code == 200 data = resp.json() assert "title" in data or "path" in data def test_vault_list(self, client): resp = client.get("/api/vaults") assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_search_works(self, client): resp = client.get("/api/search?q=test") assert resp.status_code == 200 assert "results" in resp.json() def test_sidebar_tree(self, client): resp = client.get("/api/sidebar/tree/TestVault") assert resp.status_code == 200 def test_bookmarks_endpoint(self, client): resp = client.get("/api/bookmarks") assert resp.status_code == 200 def test_recent_files_endpoint(self, client): resp = client.get("/api/recent") assert resp.status_code == 200 def test_static_css(self, client): resp = client.get("/static/style.css") assert resp.status_code == 200 assert "text/css" in resp.headers.get("content-type", "") def test_static_js_app(self, client): resp = client.get("/static/js/app.js") assert resp.status_code == 200 def test_static_js_pane_manager(self, client): resp = client.get("/static/js/pane-manager.js") assert resp.status_code == 200 assert "PaneManager" in resp.text assert len(resp.text) > 30000 # should be substantial def test_static_js_viewer(self, client): resp = client.get("/static/js/viewer.js") assert resp.status_code == 200 assert "getContentArea" in resp.text def test_static_js_ui(self, client): resp = client.get("/static/js/ui.js") assert resp.status_code == 200 def test_static_js_palette(self, client): resp = client.get("/static/js/palette.js") assert resp.status_code == 200 def test_static_js_dragdrop(self, client): resp = client.get("/static/js/dragdrop.js") assert resp.status_code == 200 def test_index_html(self, client): resp = client.get("/") assert resp.status_code in (200, 302, 401) class TestDashboardRegression: """Verify dashboard widgets (bookmarks, recent) use TabManager.""" def test_dashboard_uses_tabmanager_not_openfile(self, client): """Dashboard must use TabManager.openPreview, not openFile directly.""" path = PROJECT_ROOT / "frontend" / "js" / "dashboard.js" content = path.read_text(encoding="utf-8") # openFile should NOT be called directly from dashboard click handlers card_click = content.split("card.addEventListener")[1].split("});")[0] \ if "card.addEventListener" in content else "" assert "openFile" not in card_click, \ "Dashboard must NOT call openFile() directly — use TabManager.openPreview() instead" assert "TabManager.openPreview" in card_click, \ "Dashboard must use TabManager.openPreview() for file clicks"