From 38569c2f9f4681f13a8bebd470b04810d0b646ce Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 28 Jul 2026 10:27:15 -0400 Subject: [PATCH] =?UTF-8?q?test:=20I2=20+=20I3=20=E2=80=94=2046=20tests=20?= =?UTF-8?q?d'int=C3=A9gration=20+=20suite=20E2E=20Playwright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I2 (test_pane_manager.py): 46 nouveaux tests - Module structure: 10 tests (exports, methods, factory, max panes) - Data flow: 8 tests (drag format, tree format, JSON, migration, localStorage) - DOM flow: 7 tests (grid, IDs, lazy render, collapse, empty pane, transfer) - Keyboard: 6 tests (all shortcuts + preventDefault counts) - Context menu: 4 tests (split, move-to, close actions) - CSS: 7 tests (grid, container, resize, drop zone, buttons, responsive) - HTML/App: 5 tests (wrapper, tab-bar, modules, imports) - Palette: 3 tests (commands, reset, category) - Editor lock: 2 tests - Dashboard: 1 test (TabManager vs openFile) I3 (tests/e2e/): suite Playwright complète - package.json, playwright.config.js, split-view.spec.js - 12 test specs: basic ops, tab drag, tree drag, persistence, regression, keyboard nav - Exécution: cd tests/e2e && npm install && npx playwright test Total: 353 tests passent (307 + 46) --- tests/e2e/package.json | 11 + tests/e2e/playwright.config.js | 22 ++ tests/e2e/split-view.spec.js | 430 ++++++++++++++++++++++++++ tests/test_pane_manager.py | 530 +++++++++++++++++++++++++-------- 4 files changed, 877 insertions(+), 116 deletions(-) create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.js create mode 100644 tests/e2e/split-view.spec.js diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..fe8a5ed --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "obsigate-e2e", + "private": true, + "devDependencies": { + "@playwright/test": "^1.45.0" + }, + "scripts": { + "test": "npx playwright test", + "test:headed": "npx playwright test --headed" + } +} diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000..7442862 --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + timeout: 30000, + expect: { timeout: 10000 }, + fullyParallel: false, + retries: 1, + workers: 1, + reporter: 'list', + use: { + baseURL: process.env.OBSIGATE_URL || 'http://192.168.30.101:2020', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/e2e/split-view.spec.js b/tests/e2e/split-view.spec.js new file mode 100644 index 0000000..1d20125 --- /dev/null +++ b/tests/e2e/split-view.spec.js @@ -0,0 +1,430 @@ +/** + * E2E tests for ObsiGate Split View (#75). + * + * Prerequisites: + * cd tests/e2e && npm install && npx playwright install chromium + * + * Run: + * OBSIGATE_URL=http://192.168.30.101:2020 npx playwright test + * OBSIGATE_URL=http://localhost:2021 npx playwright test --headed + */ + +import { test, expect } from '@playwright/test'; + +const CREDS = { + username: process.env.OBSIGATE_USER || 'admin', + password: process.env.OBSIGATE_PASS || 'test123', +}; + +async function login(page) { + await page.goto('/'); + // Wait for login screen + const loginForm = page.locator('#login-screen'); + await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => { + // Already logged in (session cookie) + }); + if (await loginForm.isVisible()) { + await page.fill('#login-username', CREDS.username); + await page.fill('#login-password', CREDS.password); + await page.click('#login-btn'); + await page.waitForSelector('#app:not(.hidden)', { timeout: 10000 }); + } +} + +async function openFile(page, vault, filePath) { + // Use URL hash navigation + await page.goto(`/#/file/${encodeURIComponent(vault)}/${encodeURIComponent(filePath)}`); + await page.waitForTimeout(500); +} + +async function openFileViaSidebar(page, vault, filePath) { + // Click the file in the tree sidebar + const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`); + if (await treeItem.isVisible()) { + await treeItem.click(); + await page.waitForTimeout(500); + } +} + +// ============================================================================= +// Basic split view tests +// ============================================================================= + +test.describe('Split View — Basic Operations', () => { + + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('opens single file in tab', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + // Tab should be visible + const tabBar = page.locator('#tab-bar'); + await expect(tabBar).toBeVisible(); + const tabs = page.locator('.tab-item'); + await expect(tabs).toHaveCount(1); + // Content area should show the file + const content = page.locator('#content-area'); + await expect(content).not.toBeEmpty(); + }); + + test('opens two files and shows two tabs', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await page.locator('#header-logo').click(); // go home + await page.waitForTimeout(300); + await openFile(page, 'TestVault', 'note2.md'); + const tabs = page.locator('.tab-item'); + await expect(tabs).toHaveCount(2); + }); + + test('split right via keyboard shortcut', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + + // Trigger split right + await page.keyboard.press('Control+Alt+\\'); + + // Wait for pane grid to appear + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + const panes = page.locator('.pane-container'); + await expect(panes).toHaveCount(2); + + // Both panes should have content areas + const contentAreas = page.locator('.content-area'); + await expect(contentAreas).toHaveCount(2); + }); + + test('split down via keyboard shortcut', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + + await page.keyboard.press('Control+Alt+Shift+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + const panes = page.locator('.pane-container'); + await expect(panes).toHaveCount(2); + }); + + test('split right via context menu', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + + // Right-click on tab to open context menu + const tab = page.locator('.tab-item').first(); + await tab.click({ button: 'right' }); + + // Click "Diviser à droite" + const menuItem = page.locator('#tab-context-menu .context-menu-item', { hasText: 'Diviser à droite' }); + await expect(menuItem).toBeVisible(); + await menuItem.click(); + + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + const panes = page.locator('.pane-container'); + await expect(panes).toHaveCount(2); + }); + + test('closes active pane via keyboard', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Close active pane + await page.keyboard.press('Control+Alt+w'); + await page.waitForTimeout(500); + + // Should be back to single pane + const panes = page.locator('.pane-container'); + await expect(panes).toHaveCount(0); // collapsed to single + }); +}); + +// ============================================================================= +// Tab drag and drop tests +// ============================================================================= + +test.describe('Split View — Tab Drag & Drop', () => { + + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('drag tab from pane 0 to pane 1', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Get first tab + const sourceTab = page.locator('.tab-item').first(); + + // Get target pane's tab bar + const targetPane = page.locator('.pane-container').nth(1); + const targetTabBar = targetPane.locator('.tab-bar'); + + // Drag tab to target + const sourceBox = await sourceTab.boundingBox(); + const targetBox = await targetTabBar.boundingBox(); + + if (sourceBox && targetBox) { + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); + await page.mouse.down(); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 }); + await page.mouse.up(); + } + + await page.waitForTimeout(500); + // Source pane should have 1 tab, target should have 1 tab + const pane0Tabs = page.locator('.pane-container').nth(0).locator('.tab-item'); + const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item'); + await expect(pane0Tabs).toHaveCount(1); + await expect(pane1Tabs).toHaveCount(1); + }); + + test('drag tab to right edge creates split', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + + const sourceTab = page.locator('.tab-item').first(); + const contentArea = page.locator('#content-area'); + + const tabBox = await sourceTab.boundingBox(); + const areaBox = await contentArea.boundingBox(); + + if (tabBox && areaBox) { + // Start drag on tab + await page.mouse.move(tabBox.x + tabBox.width / 2, tabBox.y + tabBox.height / 2); + await page.mouse.down(); + // Move to right 80% of content area + await page.mouse.move( + areaBox.x + areaBox.width * 0.85, + areaBox.y + areaBox.height / 2, + { steps: 15 } + ); + // Wait for drop zone indicator + await page.waitForTimeout(300); + await page.mouse.up(); + } + + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + const panes = page.locator('.pane-container'); + await expect(panes).toHaveCount(2); + }); + + test('drag from tree to pane opens file there', async ({ page }) => { + // First create split + await openFile(page, 'TestVault', 'note1.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Find a tree item + const treeItem = page.locator('.tree-item[data-path="note2.md"]').first(); + if (await treeItem.isVisible()) { + // Drag from tree to pane 1's content area + const targetPane = page.locator('.pane-container').nth(1); + const targetContent = targetPane.locator('.content-area'); + + const treeBox = await treeItem.boundingBox(); + const targetBox = await targetContent.boundingBox(); + + if (treeBox && targetBox) { + await page.mouse.move(treeBox.x + treeBox.width / 2, treeBox.y + treeBox.height / 2); + await page.mouse.down(); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(500); + } + } + + // Pane 1 should have a tab now + const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item'); + const count = await pane1Tabs.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); +}); + +// ============================================================================= +// Persistence tests +// ============================================================================= + +test.describe('Split View — Persistence', () => { + + test.beforeEach(async ({ page }) => { + await login(page); + // Clear any saved pane state + await page.evaluate(() => localStorage.removeItem('obsigate-panes')); + }); + + test('split state persists across page reload', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Verify localStorage has pane data + const saved = await page.evaluate(() => localStorage.getItem('obsigate-panes')); + expect(saved).toBeTruthy(); + const data = JSON.parse(saved); + expect(data.panes).toHaveLength(2); + + // Reload + await page.reload(); + await login(page); + await page.waitForTimeout(1000); + + // Should restore split + const restored = await page.evaluate(() => localStorage.getItem('obsigate-panes')); + if (restored) { + const restoredData = JSON.parse(restored); + expect(restoredData.panes.length).toBeGreaterThanOrEqual(2); + } + }); + + test('reset panes command removes localStorage', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Open command palette and reset + await page.keyboard.press('Control+Shift+ '); // Ctrl+Shift+Space + await page.waitForTimeout(300); + + // Type "réinitialiser" and select + const input = page.locator('#cp-input'); + if (await input.isVisible()) { + await input.fill('réinitialiser'); + await page.waitForTimeout(200); + await page.keyboard.press('Enter'); + } + + // localStorage should be cleared + const cleared = await page.evaluate(() => localStorage.getItem('obsigate-panes')); + expect(cleared).toBeNull(); + }); +}); + +// ============================================================================= +// Regression tests +// ============================================================================= + +test.describe('Split View — Regression', () => { + + test.beforeEach(async ({ page }) => { + await login(page); + await page.evaluate(() => localStorage.removeItem('obsigate-panes')); + }); + + test('single pane mode works identically to before', async ({ page }) => { + // In single-pane mode, everything should use #content-area + await openFile(page, 'TestVault', 'note1.md'); + + const contentArea = page.locator('#content-area'); + await expect(contentArea).toBeVisible(); + await expect(contentArea).not.toBeEmpty(); + + // No pane grid should exist + const paneGrid = page.locator('.pane-grid'); + await expect(paneGrid).toHaveCount(0); + + // Tab bar should show #tab-list + const tabList = page.locator('#tab-list'); + await expect(tabList).toBeVisible(); + }); + + test('closing last tab shows dashboard', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + + // Close tab via middle-click + const tab = page.locator('.tab-item').first(); + await tab.click({ button: 'middle' }); + await page.waitForTimeout(500); + + // Dashboard should be visible + const dashboard = page.locator('#dashboard-home'); + await expect(dashboard).toBeVisible(); + + // Tab bar should be hidden + const tabBar = page.locator('#tab-bar'); + await expect(tabBar).toBeHidden(); + }); + + test('bookmark click opens file in tab system', async ({ page }) => { + // First bookmark a file for the test + await openFile(page, 'TestVault', 'note1.md'); + + // Go home + await page.locator('#header-logo').click(); + await page.waitForTimeout(500); + + // If bookmark card exists, click it + const bookmarkGrid = page.locator('#dashboard-bookmarks-grid'); + if (await bookmarkGrid.isVisible()) { + const cards = bookmarkGrid.locator('.dashboard-card'); + const count = await cards.count(); + if (count > 0) { + await cards.first().click(); + await page.waitForTimeout(500); + // Should have opened in a tab + const tabs = page.locator('.tab-item'); + await expect(tabs).toHaveCount(1); + } + } + }); +}); + +// ============================================================================= +// Keyboard navigation tests +// ============================================================================= + +test.describe('Split View — Keyboard Navigation', () => { + + test.beforeEach(async ({ page }) => { + await login(page); + await page.evaluate(() => localStorage.removeItem('obsigate-panes')); + }); + + test('navigate between panes with Ctrl+Alt+Arrow', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Pane 0 should be active initially + const pane0 = page.locator('.pane-container').nth(0); + await expect(pane0).toHaveClass(/pane-active/); + + // Navigate right + await page.keyboard.press('Control+Alt+ArrowRight'); + await page.waitForTimeout(300); + + // Pane 1 should now be active + const pane1 = page.locator('.pane-container').nth(1); + await expect(pane1).toHaveClass(/pane-active/); + + // Navigate left back + await page.keyboard.press('Control+Alt+ArrowLeft'); + await page.waitForTimeout(300); + await expect(pane0).toHaveClass(/pane-active/); + }); + + test('close other panes with Ctrl+Shift+Alt+W', async ({ page }) => { + await openFile(page, 'TestVault', 'note1.md'); + await openFile(page, 'TestVault', 'note2.md'); + await page.keyboard.press('Control+Alt+\\'); + await page.waitForSelector('.pane-grid', { timeout: 3000 }); + + // Create a third pane + await page.keyboard.press('Control+Alt+\\'); + await page.waitForTimeout(500); + + let panes = page.locator('.pane-container'); + const initialCount = await panes.count(); + expect(initialCount).toBeGreaterThanOrEqual(2); + + // Close other panes + await page.keyboard.press('Control+Shift+Alt+w'); + await page.waitForTimeout(500); + + // Should be back to single pane + panes = page.locator('.pane-container'); + await expect(panes).toHaveCount(0); + }); +}); diff --git a/tests/test_pane_manager.py b/tests/test_pane_manager.py index 7fa72c1..2e96249 100644 --- a/tests/test_pane_manager.py +++ b/tests/test_pane_manager.py @@ -1,196 +1,494 @@ """ -Tests for the Multi-Pane Split View (#75). +Tests for the Multi-Pane Split View (#75) — I1 + I2 + I4 combined. -Since PaneManager is a frontend JS module, these tests verify: - 1. Backend integration: all file/sidebar/vault APIs still work (no regression) - 2. Static file serving: pane-manager.js is served correctly - 3. CSS: style.css includes pane grid classes - 4. HTML: index.html includes the content-wrapper structure needed for panes +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 pytest +import json import re +import pytest from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent -class TestPaneManagerStaticAssets: - """Verify pane-manager.js, style.css, and index.html are properly structured.""" +# ============================================================================ +# I1 — Unit tests: JS module structure, exports, methods +# ============================================================================ - def test_pane_manager_js_exists(self): - """PaneManager module file is present.""" - path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" - assert path.exists(), f"pane-manager.js not found at {path}" +class TestPaneManagerModuleStructure: + """Validate pane-manager.js module exports, methods, and internal structure.""" - def test_pane_manager_js_exports(self): - """PaneManager exports the expected API.""" + @pytest.fixture(autouse=True) + def _load(self): + self.path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + self.content = self.path.read_text() + + 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", "_createPane", "_addPane", "_collapseToSingle", + "_setupPaneDropZone", "_persist"]: + 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() - assert "export default PaneManager" in content - assert "window.PaneManager = PaneManager" in content + # Must use JSON.stringify for paneId/tabId/index + assert 'JSON.stringify' in content + assert 'paneId' in content + assert 'tabId' in content - def test_pane_manager_has_split_methods(self): - """PaneManager has splitRight and splitDown methods.""" + 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() + 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() + # 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() + 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() + 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() + 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() + assert "_singletonTabManager" in content + assert "migrating" in content.lower() + + 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() + # _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() + 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() + 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() + 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() + 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() + assert "id === 0" in content or "i === 0" in content + + def test_lazy_rendering_hides_inactive(self): + """setActivePane hides old pane content with display:none.""" + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + content = path.read_text() + active_section = content.split("setActivePane(paneId)")[1].split(" },")[0] \ + if "setActivePane(paneId)" in content else "" + assert "display" in active_section.lower(), \ + "setActivePane must toggle display for lazy rendering" + + 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() + 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() + 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() + 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() + # Ctrl+Alt+\ → splitRight assert "splitRight()" in content - assert "splitDown()" in content - assert "closePane(" in content - assert "closeActivePane()" in content - assert "isSplit()" in content - assert "getActiveContentArea()" in content - def test_pane_manager_has_keyboard_shortcuts(self): - """Keyboard shortcuts are registered.""" + def test_split_down_shortcut(self): path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text() - assert "ctrlKey" in content - assert '"\\\\"' in content or "'\\\\'" in content # backslash key + assert "splitDown()" in content - def test_style_css_has_pane_grid_classes(self): - """style.css includes pane grid CSS classes.""" - path = PROJECT_ROOT / "frontend" / "style.css" + def test_close_pane_shortcut(self): + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text() - assert ".pane-grid" in content - assert ".pane-container" in content - assert ".pane-resize-handle" in content - assert ".pane-active" in content + assert "closeActivePane()" in content - def test_style_css_has_responsive_split(self): - """Responsive rules disable split below 768px.""" - path = PROJECT_ROOT / "frontend" / "style.css" + def test_close_other_panes_shortcut(self): + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text() - assert "@media (max-width: 768px)" in content - assert ".pane-grid" in content + assert "closeOtherPanes()" in content - def test_viewer_uses_pane_aware_helper(self): - """viewer.js has getContentArea() helper and uses it.""" - path = PROJECT_ROOT / "frontend" / "js" / "viewer.js" + def test_navigate_panes_shortcut(self): + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text() - assert "function getContentArea()" in content - assert "window.PaneManager" in content - # Should NOT have direct document.getElementById("content-area") except in fallback - direct_refs = [l for l in content.split("\n") - if 'document.getElementById("content-area")' in l - and "fallback" not in l.lower()] - # Only the fallback inside getContentArea should remain - assert len(direct_refs) <= 2, f"Too many direct content-area refs: {direct_refs}" + assert "ArrowRight" in content + assert "ArrowLeft" in content - def test_ui_tab_manager_has_delegation(self): - """TabManager in ui.js delegates to getActiveTabManager() when split.""" - path = PROJECT_ROOT / "frontend" / "js" / "ui.js" + def test_all_shortcuts_have_prevent_default(self): + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text() - assert "getActiveTabManager" in content - assert "PaneManager.isSplit()" in content - # Must have delegation in entry-point methods - assert "window.getActiveTabManager().openPreview" in content - assert "window.getActiveTabManager().openPersistent" in content - assert "window.getActiveTabManager().close(" in content + 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}" - def test_ui_context_menu_has_split_actions(self): - """Tab context menu includes 'Diviser à droite' and 'Diviser en bas'.""" - path = PROJECT_ROOT / "frontend" / "js" / "ui.js" + +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() assert "Diviser à droite" in content assert "Diviser en bas" in content assert "Fermer le panneau" in content - assert 'data-action="splitRight"' in content - assert 'data-action="splitDown"' in content - def test_html_has_content_wrapper(self): - """index.html has the content-wrapper structure needed for panes.""" + def test_menu_has_move_to_submenu(self): + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + content = path.read_text() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + assert "pane-drop-zone-indicator" in content + + def test_tab_split_buttons_css(self): + path = PROJECT_ROOT / "frontend" / "style.css" + content = path.read_text() + 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() + assert ".tab-bar.drop-target" in content + + def test_responsive_css(self): + path = PROJECT_ROOT / "frontend" / "style.css" + content = path.read_text() + 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() assert 'class="content-wrapper"' in content - assert 'id="content-area"' in content + + def test_tab_bar_exists(self): + path = PROJECT_ROOT / "frontend" / "index.html" + content = path.read_text() assert 'id="tab-bar"' in content assert 'id="tab-list"' in content - def test_html_uses_es_modules(self): - """index.html uses ES module scripts for the frontend modules.""" + def test_content_area_exists(self): + path = PROJECT_ROOT / "frontend" / "index.html" + content = path.read_text() + assert 'id="content-area"' in content + + def test_es_modules_loaded(self): path = PROJECT_ROOT / "frontend" / "index.html" content = path.read_text() - # Should load the ES module app.js orchestrator assert 'type="module"' in content - def test_brace_balance_pane_manager(self): - """PaneManager JS has balanced braces, parens, brackets.""" + def test_pane_manager_imported_in_app(self): + path = PROJECT_ROOT / "frontend" / "js" / "app.js" + content = path.read_text() + 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() + 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() + assert "Réinitialiser les panneaux" in content + + def test_palette_category_panneaux(self): + path = PROJECT_ROOT / "frontend" / "js" / "palette.js" + content = path.read_text() + 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() - for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: - close_map = {"{": "}", "(": ")", "[": "]"} - assert content.count(char) == content.count(close_map[char]), \ - f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" + # 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_brace_balance_viewer(self): - """viewer.js has balanced braces, parens, brackets.""" - path = PROJECT_ROOT / "frontend" / "js" / "viewer.js" + def test_editor_lock_focuses_existing(self): + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" content = path.read_text() - for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: - close_map = {"{": "}", "(": ")", "[": "]"} - assert content.count(char) == content.count(close_map[char]), \ - f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" + assert "setActivePane" in content + assert "focus" in content.lower() or "activate" in content.lower() - def test_brace_balance_ui(self): - """ui.js has balanced braces, parens, brackets.""" - path = PROJECT_ROOT / "frontend" / "js" / "ui.js" - content = path.read_text() - for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: - close_map = {"{": "}", "(": ")", "[": "]"} - assert content.count(char) == content.count(close_map[char]), \ - f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" +# ============================================================================ +# I4 — Backend regression tests +# ============================================================================ class TestBackendRegression: - """Verify backend APIs still work with frontend changes.""" + """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): - """GET /api/file/{vault} still returns expected fields.""" 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_endpoint(self, client): - """GET /api/vaults still works.""" + def test_vault_list(self, client): resp = client.get("/api/vaults") assert resp.status_code == 200 - data = resp.json() - assert isinstance(data, list) + assert isinstance(resp.json(), list) - def test_search_endpoint(self, client): - """GET /api/search still works.""" + def test_search_works(self, client): resp = client.get("/api/search?q=test") assert resp.status_code == 200 - data = resp.json() - assert "results" in data + assert "results" in resp.json() - def test_sidebar_tree_endpoint(self, client): - """GET /api/sidebar/tree/{vault} still works.""" - resp = client.get("/api/sidebar/tree/vault1") + def test_sidebar_tree(self, client): + resp = client.get("/api/sidebar/tree/TestVault") assert resp.status_code == 200 - def test_static_css_served(self, client): - """style.css is served correctly.""" + 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_served(self, client): - """app.js (ES module) is served correctly.""" + def test_static_js_app(self, client): resp = client.get("/static/js/app.js") assert resp.status_code == 200 - assert "javascript" in resp.headers.get("content-type", "").lower() - def test_pane_manager_js_served(self, client): - """pane-manager.js is served as static asset.""" + def test_static_js_pane_manager(self, client): resp = client.get("/static/js/pane-manager.js") assert resp.status_code == 200 - assert "javascript" in resp.headers.get("content-type", "").lower() assert "PaneManager" in resp.text + assert len(resp.text) > 30000 # should be substantial - def test_index_html_served(self, client): - """index.html is served and contains content-wrapper.""" + 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("/") - # May redirect or return 200 depending on auth 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() + # 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"