/** * E2E tests for the ObsiGate image viewer (roadmap #108). * * Fixtures : `test_vault/sample-image.png` (96x64) + `test_vault/sample-vector.svg`. * * Run (local): * BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js * BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js --headed */ import { test, expect } from '@playwright/test'; const BASE = process.env.BASE_URL || 'http://localhost:2029'; const CREDS = { username: process.env.OBSIGATE_USER || 'admin', password: process.env.OBSIGATE_PASS || 'test123', }; async function login(page) { await page.goto(BASE); const loginForm = page.locator('#login-screen'); await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {}); 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.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 }); } async function openFile(page, vault, filePath) { const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`); if (!(await treeItem.count())) { await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click(); await treeItem.waitFor({ state: 'attached', timeout: 8000 }); } await treeItem.dblclick({ timeout: 5000 }); } test.describe('Image viewer — zoom / pan / navigation (#108)', () => { test('affiche l\'image dans la visionneuse dédiée (URL /api/image)', async ({ page }) => { // #108-B1 — l'image isolée doit pointer vers /api/image (octets), pas /raw (JSON). const imageResponsePromise = page.waitForResponse( (r) => r.url().includes('/api/image/') && r.status() === 200, { timeout: 15000 }, ); await login(page); await openFile(page, 'TestVault', 'sample-image.png'); const main = page.locator('#content-area .image-viewer-container img.image-main'); await expect(main).toBeVisible({ timeout: 10000 }); await expect(main).toHaveAttribute('src', /\/api\/image\/TestVault\?path=/); const resp = await imageResponsePromise; expect(resp.headers()['content-type']).toContain('image/png'); // Le badge de zoom démarre à 100 %. await expect(page.locator('#content-area .image-zoom-badge')).toHaveText('100%'); }); test('le zoom molette et le reset modifient la transform', async ({ page }) => { await login(page); await openFile(page, 'TestVault', 'sample-image.png'); await expect(page.locator('#content-area .image-stage')).toBeVisible({ timeout: 10000 }); const badge = page.locator('#content-area .image-zoom-badge'); await expect(badge).toHaveText('100%'); await page.locator('#content-area .image-stage').hover(); await page.mouse.wheel(0, -240); await expect(badge).not.toHaveText('100%', { timeout: 5000 }); const transform = await page.locator('#content-area img.image-main').evaluate( (el) => getComputedStyle(el).transform, ); expect(transform).not.toBe('none'); // Double-clic = réinitialisation. await page.locator('#content-area .image-stage').dblclick(); await expect(badge).toHaveText('100%'); }); test('navigue entre les images du dossier via la pellicule', async ({ page }) => { await login(page); await openFile(page, 'TestVault', 'sample-image.png'); const strip = page.locator('#content-area .image-nav-strip'); await expect(strip).toBeVisible({ timeout: 10000 }); // sample-image.png et sample-vector.svg partagent le dossier racine. await expect(strip.locator('img.image-thumb')).toHaveCount(2); await page.locator('#content-area .image-nav-strip img.image-thumb').first().click(); await expect(page.locator('#content-area .image-title')).toBeVisible(); }); test('conserve le plein écran et le panneau métadonnées à la navigation (#BUG-072)', async ({ page }) => { await login(page); await openFile(page, 'TestVault', 'sample-image.png'); const container = page.locator('#content-area .image-viewer-container'); const metaPanel = page.locator('#content-area .image-meta-panel'); const title = page.locator('#content-area .image-title'); await expect(container).toBeVisible({ timeout: 10000 }); // Métadonnées : barre latérale à droite de l'image (pas sous la pellicule). await page.locator('#content-area .image-btn-metadata').click(); await expect(metaPanel).toBeVisible(); const stageBox = await page.locator('#content-area .image-stage').boundingBox(); const metaBox = await metaPanel.boundingBox(); expect(metaBox.x).toBeGreaterThanOrEqual(stageBox.x + stageBox.width - 1); // Plein écran activé. await page.locator('#content-area .image-btn-lightbox').click(); await expect(container).toHaveClass(/lightbox/); // Naviguer (flèche droite) : les deux états doivent survivre au re-render. const titleBefore = await title.innerText(); await page.keyboard.press('ArrowRight'); await expect(container).toHaveClass(/lightbox/); await expect(metaPanel).toBeVisible(); await expect(title).not.toHaveText(titleBefore); }); test('navigation en place : flèches latérales, pellicule persistante et cadre ajusté (#111)', async ({ page }) => { await login(page); await openFile(page, 'TestVault', 'sample-image.png'); const container = page.locator('#content-area .image-viewer-container'); const strip = page.locator('#content-area .image-nav-strip'); const main = page.locator('#content-area img.image-main'); await expect(strip).toBeVisible({ timeout: 10000 }); await expect(strip.locator('img.image-thumb')).toHaveCount(2); // Marqueur : la navigation en place ne doit PAS recréer le conteneur. await container.evaluate((el) => { el.dataset.inplace = '1'; }); // L'image reste contenue dans le cadre (jamais plus grande). const stageBox = await page.locator('#content-area .image-stage').boundingBox(); const imgBox = await main.boundingBox(); expect(imgBox.width).toBeLessThanOrEqual(stageBox.width + 1); expect(imgBox.height).toBeLessThanOrEqual(stageBox.height + 1); // Flèche superposée révélée au survol. const nextArrow = page.locator('#content-area .image-nav-arrow-next'); await expect(nextArrow).toBeAttached(); await nextArrow.hover(); await expect.poll(() => nextArrow.evaluate((el) => getComputedStyle(el).opacity)).toBe('1'); const title = page.locator('#content-area .image-title'); const before = await title.innerText(); await nextArrow.click(); await expect(title).not.toHaveText(before); await expect(strip).toBeVisible(); await expect(strip.locator('img.image-thumb')).toHaveCount(2); await expect(container).toHaveAttribute('data-inplace', '1'); await expect(page.locator('#content-area .image-counter')).toContainText('/'); }); test('la pellicule est défilable (molette + flèches latérales) sans changer d\'image (#112)', async ({ page }) => { await login(page); await openFile(page, 'TestVault', 'sample-image.png'); const nav = page.locator('#content-area .image-nav'); const strip = page.locator('#content-area .image-nav-strip'); await expect(strip).toBeVisible({ timeout: 10000 }); await expect(nav).toBeAttached(); // Les deux flèches de défilement existent (translucides, révélées au survol). const prev = page.locator('#content-area .image-strip-arrow-prev'); const next = page.locator('#content-area .image-strip-arrow-next'); await expect(prev).toBeAttached(); await expect(next).toBeAttached(); await expect .poll(() => next.evaluate((el) => getComputedStyle(el).opacity)) .not.toBe('1'); // au repos, la flèche n'est pas pleinement visible // Faire défiler la pellicule ne change PAS l'image courante. const title = page.locator('#content-area .image-title'); const before = await title.innerText(); await next.hover(); await next.click(); await prev.click(); await expect(title).toHaveText(before); }); });