test: I2 + I3 — 46 tests d'intégration + suite E2E Playwright
CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 16s

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)
This commit is contained in:
2026-07-28 10:27:15 -04:00
parent 66d9c17985
commit 38569c2f9f
4 changed files with 877 additions and 116 deletions
+11
View File
@@ -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"
}
}
+22
View File
@@ -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'] },
},
],
});
+430
View File
@@ -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);
});
});
+414 -116
View File
@@ -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: Covers:
1. Backend integration: all file/sidebar/vault APIs still work (no regression) I1: Unit tests for PaneManager JS module structure
2. Static file serving: pane-manager.js is served correctly I2: Integration tests (JS code patterns, DOM flow, data formats, CSS classes)
3. CSS: style.css includes pane grid classes I4: Backend regression tests
4. HTML: index.html includes the content-wrapper structure needed for panes
""" """
import pytest import json
import re import re
import pytest
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent 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): class TestPaneManagerModuleStructure:
"""PaneManager module file is present.""" """Validate pane-manager.js module exports, methods, and internal structure."""
path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
assert path.exists(), f"pane-manager.js not found at {path}"
def test_pane_manager_js_exports(self): @pytest.fixture(autouse=True)
"""PaneManager exports the expected API.""" 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" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
content = path.read_text() content = path.read_text()
assert "export default PaneManager" in content # Must use JSON.stringify for paneId/tabId/index
assert "window.PaneManager = PaneManager" in content assert 'JSON.stringify' in content
assert 'paneId' in content
assert 'tabId' in content
def test_pane_manager_has_split_methods(self): def test_tree_drag_format(self):
"""PaneManager has splitRight and splitDown methods.""" """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" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
content = path.read_text() 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 "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): def test_split_down_shortcut(self):
"""Keyboard shortcuts are registered."""
path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
content = path.read_text() content = path.read_text()
assert "ctrlKey" in content assert "splitDown()" in content
assert '"\\\\"' in content or "'\\\\'" in content # backslash key
def test_style_css_has_pane_grid_classes(self): def test_close_pane_shortcut(self):
"""style.css includes pane grid CSS classes.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
path = PROJECT_ROOT / "frontend" / "style.css"
content = path.read_text() content = path.read_text()
assert ".pane-grid" in content assert "closeActivePane()" in content
assert ".pane-container" in content
assert ".pane-resize-handle" in content
assert ".pane-active" in content
def test_style_css_has_responsive_split(self): def test_close_other_panes_shortcut(self):
"""Responsive rules disable split below 768px.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
path = PROJECT_ROOT / "frontend" / "style.css"
content = path.read_text() content = path.read_text()
assert "@media (max-width: 768px)" in content assert "closeOtherPanes()" in content
assert ".pane-grid" in content
def test_viewer_uses_pane_aware_helper(self): def test_navigate_panes_shortcut(self):
"""viewer.js has getContentArea() helper and uses it.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
path = PROJECT_ROOT / "frontend" / "js" / "viewer.js"
content = path.read_text() content = path.read_text()
assert "function getContentArea()" in content assert "ArrowRight" in content
assert "window.PaneManager" in content assert "ArrowLeft" 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}"
def test_ui_tab_manager_has_delegation(self): def test_all_shortcuts_have_prevent_default(self):
"""TabManager in ui.js delegates to getActiveTabManager() when split.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
path = PROJECT_ROOT / "frontend" / "js" / "ui.js"
content = path.read_text() content = path.read_text()
assert "getActiveTabManager" in content shortcut_section = content.split("// Keyboard shortcuts")[1].split("// Init")[0] \
assert "PaneManager.isSplit()" in content if "// Keyboard shortcuts" in content else ""
# Must have delegation in entry-point methods prevent_count = shortcut_section.count("preventDefault()")
assert "window.getActiveTabManager().openPreview" in content assert prevent_count >= 4, f"Expected >=4 preventDefault calls, got {prevent_count}"
assert "window.getActiveTabManager().openPersistent" in content
assert "window.getActiveTabManager().close(" in content
def test_ui_context_menu_has_split_actions(self):
"""Tab context menu includes 'Diviser à droite' and 'Diviser en bas'.""" class TestPaneManagerContextMenu:
path = PROJECT_ROOT / "frontend" / "js" / "ui.js" """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() content = path.read_text()
assert "Diviser à droite" in content assert "Diviser à droite" in content
assert "Diviser en bas" in content assert "Diviser en bas" in content
assert "Fermer le panneau" 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): def test_menu_has_move_to_submenu(self):
"""index.html has the content-wrapper structure needed for panes.""" 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</span>" 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" path = PROJECT_ROOT / "frontend" / "index.html"
content = path.read_text() content = path.read_text()
assert 'class="content-wrapper"' in content 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-bar"' in content
assert 'id="tab-list"' in content assert 'id="tab-list"' in content
def test_html_uses_es_modules(self): def test_content_area_exists(self):
"""index.html uses ES module scripts for the frontend modules.""" 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" path = PROJECT_ROOT / "frontend" / "index.html"
content = path.read_text() content = path.read_text()
# Should load the ES module app.js orchestrator
assert 'type="module"' in content assert 'type="module"' in content
def test_brace_balance_pane_manager(self): def test_pane_manager_imported_in_app(self):
"""PaneManager JS has balanced braces, parens, brackets.""" 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" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
content = path.read_text() content = path.read_text()
for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: # The 'open' method should check other panes for same file
close_map = {"{": "}", "(": ")", "[": "]"} open_section = content.split("async open(vault, path)")[1].split(" async")[0] \
assert content.count(char) == content.count(close_map[char]), \ if "async open(vault, path)" in content else content
f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" assert "tabManagers" in open_section or \
"isSplit()" in content, \
"Editor lock check missing in PaneTabManager.open()"
def test_brace_balance_viewer(self): def test_editor_lock_focuses_existing(self):
"""viewer.js has balanced braces, parens, brackets.""" path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js"
path = PROJECT_ROOT / "frontend" / "js" / "viewer.js"
content = path.read_text() content = path.read_text()
for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: assert "setActivePane" in content
close_map = {"{": "}", "(": ")", "[": "]"} assert "focus" in content.lower() or "activate" in content.lower()
assert content.count(char) == content.count(close_map[char]), \
f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes"
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: 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): 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"}) resp = client.get("/api/file/TestVault", params={"path": "note1.md"})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "title" in data or "path" in data assert "title" in data or "path" in data
def test_vault_list_endpoint(self, client): def test_vault_list(self, client):
"""GET /api/vaults still works."""
resp = client.get("/api/vaults") resp = client.get("/api/vaults")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() assert isinstance(resp.json(), list)
assert isinstance(data, list)
def test_search_endpoint(self, client): def test_search_works(self, client):
"""GET /api/search still works."""
resp = client.get("/api/search?q=test") resp = client.get("/api/search?q=test")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() assert "results" in resp.json()
assert "results" in data
def test_sidebar_tree_endpoint(self, client): def test_sidebar_tree(self, client):
"""GET /api/sidebar/tree/{vault} still works.""" resp = client.get("/api/sidebar/tree/TestVault")
resp = client.get("/api/sidebar/tree/vault1")
assert resp.status_code == 200 assert resp.status_code == 200
def test_static_css_served(self, client): def test_bookmarks_endpoint(self, client):
"""style.css is served correctly.""" 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") resp = client.get("/static/style.css")
assert resp.status_code == 200 assert resp.status_code == 200
assert "text/css" in resp.headers.get("content-type", "") assert "text/css" in resp.headers.get("content-type", "")
def test_static_js_served(self, client): def test_static_js_app(self, client):
"""app.js (ES module) is served correctly."""
resp = client.get("/static/js/app.js") resp = client.get("/static/js/app.js")
assert resp.status_code == 200 assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "").lower()
def test_pane_manager_js_served(self, client): def test_static_js_pane_manager(self, client):
"""pane-manager.js is served as static asset."""
resp = client.get("/static/js/pane-manager.js") resp = client.get("/static/js/pane-manager.js")
assert resp.status_code == 200 assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "").lower()
assert "PaneManager" in resp.text assert "PaneManager" in resp.text
assert len(resp.text) > 30000 # should be substantial
def test_index_html_served(self, client): def test_static_js_viewer(self, client):
"""index.html is served and contains content-wrapper.""" 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("/") resp = client.get("/")
# May redirect or return 200 depending on auth
assert resp.status_code in (200, 302, 401) 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"