L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille entiere et laissait le dernier-ecrivain gagner entre processus. - A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu - A11 : export de la selection + Markdown/HTML/impression et recherche sur toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable, retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille) - A14 : outils IA .xlsm/.csv + search_workbook, analyze_range, edit_xlsx_structure - tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34), TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
317 lines
14 KiB
JavaScript
317 lines
14 KiB
JavaScript
/**
|
|
* 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 (`<f>B1*2</f><v>200</v>`) 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 (<v>200</v>).
|
|
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);
|
|
|
|
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();
|
|
|
|
// #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
|
|
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
|
|
await expect(dialog).toBeVisible({ timeout: 10000 });
|
|
await expect(dialog).toContainText('segments');
|
|
await dialog.locator('[data-dialog="confirm"]').click();
|
|
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 thémée, puis reprise avec force (le toggle reste désactivé).
|
|
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();
|
|
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
|
|
// A lossy workbook asks for confirmation. A previous test's save may already
|
|
// have dropped the slicers part, in which case the PUT succeeds directly.
|
|
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
|
|
if (await dialog.isVisible()) {
|
|
await dialog.locator('[data-dialog="confirm"]').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"');
|
|
});
|
|
|
|
test('le bouton « charger la suite » ajoute les lignes cachées (#153 A9bis)', async ({ page }) => {
|
|
await login(page);
|
|
await openXlsx(page, LARGE);
|
|
|
|
// La ligne 500 est la dernière rendue ; le pied de page l'annonce.
|
|
const foot = page.locator('#content-area .xlsx-load-more');
|
|
await expect(foot).toBeVisible();
|
|
await expect(foot).toContainText('Charger la suite');
|
|
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
|
|
|
|
// Un clic fetch la suite (offset 500, 20 lignes) et l'insère dans la table.
|
|
await foot.click();
|
|
await expect(page.locator('#content-area td[data-cell="A520"]')).toBeVisible({ timeout: 10000 });
|
|
// Une ligne nouvellement arrivée est éditable comme les autres.
|
|
const cell = page.locator('#content-area td[data-cell="A520"]');
|
|
await cell.click();
|
|
await expect(cell).toBeFocused();
|
|
// Tout est chargé → le pied de page est masqué.
|
|
await expect(foot).toBeHidden();
|
|
});
|
|
|
|
test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => {
|
|
await login(page);
|
|
await openXlsx(page, LARGE);
|
|
|
|
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
|
// (#156-A7 : la zone Nom est un input, donc on lit sa valeur.)
|
|
const cell = page.locator('#content-area td[data-cell="B2"]');
|
|
await cell.click();
|
|
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2');
|
|
const bar = page.locator('#xlsx-formula-input');
|
|
await expect(bar).toHaveValue(/Operation 1/);
|
|
|
|
// Les flèches déplacent la cellule active.
|
|
await cell.press('ArrowDown');
|
|
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3');
|
|
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
|
await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3');
|
|
|
|
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
|
await bar.fill('Operation 2 modifiee');
|
|
await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/);
|
|
await page.locator('#content-area td[data-cell="C3"]').press('Escape');
|
|
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ────────────
|
|
|
|
test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => {
|
|
test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => {
|
|
await login(page);
|
|
await openXlsx(page, LARGE);
|
|
|
|
const nameBox = page.locator('#xlsx-active-cell');
|
|
const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`);
|
|
const textOf = async (ref) => (await cellOf(ref).innerText()).trim();
|
|
|
|
// — A7 : la zone Nom est éditable et sert à « Atteindre ».
|
|
await nameBox.fill('B12');
|
|
await nameBox.press('Enter');
|
|
await expect(nameBox).toHaveValue('B12');
|
|
await expect(cellOf('B12')).toHaveClass(/xlsx-active/);
|
|
|
|
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
|
|
await cellOf('B12').click();
|
|
await page.keyboard.press('Control+a');
|
|
await expect(nameBox).toHaveValue('A1:C500');
|
|
await page.keyboard.press('Control+Home');
|
|
await expect(nameBox).toHaveValue('A1');
|
|
await page.keyboard.press('Control+End');
|
|
await expect(nameBox).toHaveValue('C500');
|
|
|
|
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
|
// utilise le presse-papiers interne de la grille : aucune permission
|
|
// navigateur n'est requise.
|
|
const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') };
|
|
await cellOf('A1').click();
|
|
await cellOf('B2').click({ modifiers: ['Shift'] });
|
|
await expect(nameBox).toHaveValue('A1:B2');
|
|
await cellOf('A1').click({ button: 'right' });
|
|
await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click();
|
|
await cellOf('A10').click({ button: 'right' });
|
|
await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click();
|
|
expect(await textOf('A10')).toBe(src.a1);
|
|
expect(await textOf('B10')).toBe(src.b1);
|
|
expect(await textOf('B11')).toBe(src.b2);
|
|
expect(await textOf('C10')).toBe(src.c10);
|
|
|
|
// — A6 : Suppr vide la sélection (et seulement elle).
|
|
await cellOf('A10').click();
|
|
await cellOf('B11').click({ modifiers: ['Shift'] });
|
|
await expect(nameBox).toHaveValue('A10:B11');
|
|
await page.keyboard.press('Delete');
|
|
expect(await textOf('A10')).toBe('');
|
|
expect(await textOf('B11')).toBe('');
|
|
expect(await textOf('C10')).toBe(src.c10);
|
|
});
|
|
});
|