/** * E2E tests — Excel viewer, write guards (ROADMAP #153 P0). * * Fixture : `test_vault/sample-xlsx-lossy.xlsx` — a plain 2x2 workbook whose * sheet XML carries a cached formula result (`B1*2200`) and whose * package contains `xl/slicers/slicer1.xml`. Both are dropped by an openpyxl * round-trip, so the read response must report * `xlsx_lossy_features: ["cached_values", "slicers"]` (BUG-085 A1). * * Covered : * - the warning banner lists both features ; * - saving a cell on that workbook asks for confirmation (native dialog) and * then succeeds (the client retries with `force: true`) ; * - the f(x) toggle is off by default, so "=B1*3" is stored as text ; * - the value Excel last computed is shown under the formula (#153 A12). * * Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) : * - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ; * - the column headers stay pinned while the sheet scrolls (#153 A8) ; * - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide, * with the real A1 coordinates (#153 A9). * * The fixture is restored byte-for-byte in `afterAll` so a local run never * dirties the working copy. * * Run (local) : BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xlsx-viewer.spec.js */ import { test, expect } from '@playwright/test'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import path from 'node:path'; const BASE = process.env.BASE_URL || 'http://localhost:2029'; const VAULT = 'TestVault'; const FIXTURE = 'sample-xlsx-lossy.xlsx'; // #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so // the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and // committed next to the other fixture; nothing in the suite writes to it. const LARGE = 'sample-xlsx-large.xlsx'; // Playwright runs from the repository root (run-e2e-local.* / CI both do). const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE); let originalBytes = null; 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', process.env.OBSIGATE_USER || 'admin'); await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123'); await page.click('#login-btn'); } await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 }); } async function openFixture(page) { return openXlsx(page, FIXTURE); } async function openXlsx(page, file) { const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`); 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 }); await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 }); } test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => { test.beforeAll(() => { if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH); }); test.afterAll(() => { if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes); }); // Read-only assertions come FIRST, before the mutating tests: saving through // the viewer rewrites the workbook and an openpyxl round-trip drops the cached // formula results (BUG-085), so the shadow line only exists on a pristine // fixture. test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => { await login(page); await openFixture(page); // B2 is "=B1*2" and the package keeps its last result (200). const formulaCell = page.locator('#content-area td[data-cell="B2"]'); await expect(formulaCell).toContainText('=B1*2'); const cached = formulaCell.locator('.xlsx-cached'); await expect(cached).toHaveCount(1); await expect(cached).toHaveText('200'); // The tooltip is translated client-side, never hardcoded by the backend. await expect(cached).toHaveAttribute('title', /Excel/); // A plain value cell must not be duplicated with a shadow line. await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0); }); test('affiche la bannière listant les éléments non préservés', async ({ page }) => { await login(page); await openFixture(page); const banner = page.locator('#content-area .xlsx-warning'); await expect(banner).toBeVisible(); // 2 features : valeurs calculées + segments (jamais de couleur codée en dur, // les libellés viennent bien des locales). await expect(banner.locator('.xlsx-warning-tag')).toHaveCount(2); await expect(banner).toContainText('segments'); await expect(banner).toContainText('valeurs calculées'); }); test('demande confirmation puis enregistre la cellule', async ({ page }) => { await login(page); await openFixture(page); let dialogMessage = null; page.on('dialog', async (dialog) => { dialogMessage = dialog.message(); await dialog.accept(); }); const cell = page.locator('#content-area td[data-cell="A2"]'); await cell.click(); await cell.fill('Total confirmé'); await cell.press('Enter'); const save = page.locator('#xlsx-save-btn'); await expect(save).toBeEnabled(); await save.click(); await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments'); await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); // La cellule reste modifiée côté UI (plus de marque « sale »). await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0); }); test('le toggle f(x) est désactivé par défaut (formule stockée en texte)', async ({ page }) => { await login(page); await openFixture(page); const toggle = page.locator('#xlsx-formula-btn'); await expect(toggle).toHaveAttribute('aria-pressed', 'false'); // 409 → confirmation, puis reprise avec force (le toggle reste désactivé). page.on('dialog', (dialog) => dialog.accept()); const cell = page.locator('#content-area td[data-cell="B2"]'); await cell.click(); await cell.fill('=B1*3'); await cell.press('Enter'); await page.locator('#xlsx-save-btn').click(); await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); }); }); // ── A8 — troncature annoncée + en-têtes figés ─────────────────────────────── test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => { test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => { await login(page); await openXlsx(page, LARGE); const note = page.locator('#content-area .xlsx-truncated'); await expect(note).toBeVisible(); // Libellé traduit (jamais de texte UI backend, jamais de couleur en dur). await expect(note).toContainText('Feuille tronquée'); await expect(note).toContainText('500 lignes affichées sur 520'); // La dernière ligne rendue est la 500e ; les suivantes ne sont pas là. await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1); await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0); }); test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => { await login(page); await openXlsx(page, LARGE); const header = page.locator('#content-area .xlsx-table thead th').nth(1); const before = await header.boundingBox(); await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; }); await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 }) .toBeLessThanOrEqual(before.y + 1); // Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto` // de A8) et la première ligne de données reste lisible sous l'en-tête. const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox(); const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox(); expect(second.y - first.y).toBeGreaterThan(4); }); test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => { await login(page); await openXlsx(page, LARGE); const res = await page.request.get( `${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50` ); expect(res.status()).toBe(200); const win = await res.json(); expect(win.total_rows).toBe(520); expect(win.offset).toBe(500); expect(win.has_more).toBe(false); // Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre : // la ligne 520 est servie comme A520, pas comme A20. expect(win.html).toContain('data-cell="A520"'); expect(win.html).toContain('Operation 519'); expect(win.html).not.toContain('data-cell="A1"'); }); });