Files
ObsiGate/tests/e2e/xlsx-viewer.spec.js
bruno 36b4962efb
CI / lint (push) Successful in 2m42s
CI / security (push) Successful in 1m38s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m12s
feat: tableur mobile — barres repliées par défaut, bascule unique #189
Sous 768 px, la barre de menus, le ruban et le groupe Recherche sont
repliés par défaut : la barre de formule (fx), les onglets et Enregistrer
restent visibles et la grille récupère la hauteur d'écran. Un bouton
unique (#xlsx-tools-toggle) dans la barre des feuilles déplie/replie
l'ensemble (aria-expanded, état actif) ; desktop inchangé.

Ergonomie tactile : cibles ≥ 44 px, onglets en défilement horizontal,
champs de la barre de formule en 16 px (anti-zoom iOS), barre de formule
en 2 lignes quand les outils sont ouverts.

Tests : JSDOM xlsx-viewer.test.mjs (+2, 165/165), E2E mobile (repli par
défaut, dépliage, no-overflow ruban, cibles 44 px) ; openXlsx() déplie
automatiquement ≤ 768 px pour les tests desktop.
2026-10-08 15:45:39 -04:00

624 lines
29 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).
*
* Third block (#179) : menus carry one Lucide icon per entry and close on
* focus loss (outside click, Escape); mobile viewport checks the ribbon
* wrapping and 44px touch targets.
*
* 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';
const LARGE_PATH = path.resolve(process.cwd(), 'test_vault', LARGE);
let originalLargeBytes = null;
// 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 });
// #189 — sous 768px la barre de menus et le ruban sont repliés par défaut.
// Les tests « desktop » qui les manipulent les déplient d'abord ; le test
// mobile dédié ouvre lui-même la feuille pour vérifier l'état replié.
const toggle = page.locator('#xlsx-tools-toggle');
if (await toggle.isVisible().catch(() => false)) {
await toggle.click();
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
}
}
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('calcule les formules à l’écran, garde le texte en barre (#153 A12)', async ({ page }) => {
await login(page);
await openFixture(page);
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>): the
// local engine computes it, so the cell shows the value (not the shadow).
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
await expect(formulaCell).toHaveText('200');
await expect(formulaCell.locator('.xlsx-cached')).toHaveCount(0);
await formulaCell.click();
await expect(page.locator('#xlsx-formula-input')).toHaveValue('=B1*2');
// 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');
// #184 — la feuille rendue va jusqu'à Z (la grille est posée A → Z).
await expect(nameBox).toHaveValue('A1:Z500');
await page.keyboard.press('Control+Home');
await expect(nameBox).toHaveValue('A1');
await page.keyboard.press('Control+End');
await expect(nameBox).toHaveValue('Z500');
// — 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);
});
});
// ── #179 — icônes des menus, fermeture au focus, polish mobile ──────────────
test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)', () => {
test('chaque entrée du menu contextuel porte une icône', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
// Icône rendue par Lucide (svg) ou à rendre (<i data-lucide>) si le CDN
// est injoignable — dans les deux cas l'entrée est iconisée.
for (const label of ['Couper', 'Copier', 'Coller']) {
const entry = menu.locator('.xlsx-context-item', { hasText: label });
await expect(entry.locator('i[data-lucide], svg')).toHaveCount(1);
}
await expect(menu.locator('.xlsx-context-item i[data-lucide="trash-2"], .xlsx-context-item svg.lucide-trash-2')).not.toHaveCount(0);
});
test('le menu contextuel se ferme au clic extérieur et à Échap', async ({ page }) => {
await login(page);
await openFixture(page);
const cell = page.locator('#content-area td[data-cell="A1"]');
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.locator('#content-area .xlsx-table').click({ position: { x: 5, y: 5 } });
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
});
test('le menu Structure se ferme quand le focus le quitte', async ({ page }) => {
await login(page);
await openFixture(page);
const toggle = page.locator('#xlsx-structure-btn');
await toggle.click();
const menu = page.locator('.xlsx-structure-menu');
await expect(menu).toBeVisible();
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
// The menu opens right under its button, inside the toolbar width.
const [btnBox, menuBox, toolbarBox] = await Promise.all([
toggle.boundingBox(), menu.boundingBox(),
page.locator('#content-area .xlsx-toolbar').boundingBox(),
]);
expect(menuBox.y).toBeGreaterThanOrEqual(btnBox.y + btnBox.height - 2);
expect(menuBox.y).toBeLessThanOrEqual(btnBox.y + btnBox.height + 24);
// Le menu reste DANS la barre : près du bord droit il est décalé vers la
// gauche (clamp de anchorUnder) au lieu de dépasser.
expect(menuBox.x).toBeGreaterThanOrEqual(toolbarBox.x - 2);
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 2);
// Clic dans la grille = le focus quitte le menu → fermeture.
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
});
test('le menu Fichier (Télécharger/CSV/Export) se ferme au clic extérieur', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-file-btn').click();
const menu = page.locator('#xlsx-file-menu');
await expect(menu).toBeVisible();
// Les 5 sorties sont réunies dans ce seul menu (#185 : il est passé dans
// la barre de menus, au-dessus du ruban), repliées dans la section
// « Télécharger » — les sections du menu sont des accordéons.
await menu.locator('.xlsx-menu-item', { hasText: 'Télécharger' }).first().click();
await expect(menu.locator('#xlsx-download-btn')).toBeVisible();
await expect(menu.locator('#xlsx-csv-btn')).toBeVisible();
await expect(page.locator('#content-area .xlsx-sheetbar-end #xlsx-save-btn')).toBeVisible();
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(menu).toBeHidden();
});
test('#185 : les 10 menus Google Sheets au-dessus du ruban, et leurs sections', async ({ page }) => {
await login(page);
await openFixture(page);
const bar = page.locator('#content-area .xlsx-menubar');
await expect(bar).toBeVisible();
await expect(bar.locator('.xlsx-menubtn')).toHaveText([
'Fichier', 'Modifier', 'Affichage', 'Insertion', 'Format',
'Données', 'Outils', 'Gemini', 'Extensions', 'Aide',
]);
// Le ruban est bien SOUS les menus.
const [barBox, ribbonBox] = await Promise.all([
bar.boundingBox(),
page.locator('#content-area .xlsx-cmdbar').boundingBox(),
]);
expect(ribbonBox.y).toBeGreaterThan(barBox.y);
// Une section s'ouvre en accordéon, comme dans Sheets.
await page.locator('#content-area [data-xlsx-menu="format"]').click();
const format = page.locator('#xlsx-menu-format');
await expect(format).toBeVisible();
const number = format.locator('.xlsx-menu-item', { hasText: 'Nombre' }).first();
await number.click();
await expect(number).toHaveAttribute('aria-expanded', 'true');
await expect(format.locator('[data-xlsx-action="numfmt"]').first()).toBeVisible();
// Une option non implémentée le dit au lieu de ne rien faire.
const ghost = format.locator('[data-xlsx-unavailable]').first();
await ghost.click();
await expect(page.locator('.toast').first()).toBeVisible();
await expect(format).toBeHidden();
});
test('#185 : la grille est unie (1px sur les quatre côtés) et se masque', async ({ page }) => {
await login(page);
await openFixture(page);
const cell = page.locator('#content-area td[data-cell="B2"]');
const sides = await cell.evaluate((td) => {
const cs = getComputedStyle(td);
return [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth];
});
expect(new Set(sides).size).toBe(1);
expect(sides[0]).toBe('1px');
await page.locator('#content-area [data-xlsx-menu="view"]').click();
const view = page.locator('#xlsx-menu-view');
await expect(view).toBeVisible();
// Les entrées d'affichage vivent dans la section « Afficher » (accordéon).
await view.locator('.xlsx-menu-item', { hasText: 'Afficher' }).first().click();
await page.locator('#xlsx-menu-view [data-xlsx-action="toggle-grid"]').click();
await expect(page.locator('#content-area .xlsx-viewer')).toHaveClass(/xlsx-no-grid/);
const hidden = await cell.evaluate((td) => getComputedStyle(td).borderTopWidth);
expect(hidden).toBe('0px');
});
// Mon seul test mutant : restaure la fixture comme le bloc lossy.
test.beforeAll(() => {
if (existsSync(LARGE_PATH)) originalLargeBytes = readFileSync(LARGE_PATH);
});
test.afterAll(() => {
if (originalLargeBytes) writeFileSync(LARGE_PATH, originalLargeBytes);
});
test('insère une colonne à droite et une ligne en fin de feuille', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
// #184 — la grille posée va jusqu'à Z : le compte d'en-têtes ne peut plus
// témoigner de l'insertion (un re-rendu re-complète à 26 colonnes). Le
// toast est le signal de l'opération, la fixture restaurée sa preuve.
const headers = () => page.locator('#content-area .xlsx-table thead th:not(.xlsx-corner)');
await expect(headers()).toHaveCount(26);
await headers().nth(2).click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'droite' }).click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// Insérée localement (pas de re-rendu complet) : la colonne s'ajoute à
// la grille posée, 26 → 27.
await expect(headers()).toHaveCount(27);
// La ligne 501 dépasse le plafond de rendu : l'insertion réussit
// (toast) même si la ligne reste hors champ.
await page.locator('#content-area th.xlsx-rownum').nth(499).click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'dessous' }).click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
test('la barre façon Sheets expose ses contrôles et agit', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
for (const id of ['#xs-search', '#xs-print', '#xs-paint', '#xs-zoom', '#xs-cur', '#xs-pct',
'#xs-numfmt', '#xs-b', '#xs-i', '#xs-s', '#xs-u', '#xs-borders', '#xs-merge',
'#xs-alignh', '#xs-alignv', '#xs-wrap', '#xs-rot', '#xs-more', '#xlsx-save-btn']) {
await expect(page.locator(id)).toBeVisible();
}
// Zoom : la grille suit le sélecteur.
await page.locator('#xs-zoom').selectOption('150');
await expect.poll(async () => page.locator('#content-area .xlsx-panels').evaluate(
(el) => el.style.zoom || '100%')).toBe('150%');
await page.locator('#xs-zoom').selectOption('100');
// Gras via la barre : PUT + toast, sans dialogue.
await page.locator('#content-area td[data-cell="A1"]').click();
await page.locator('#xs-b').click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// Mini-barre contextuelle sur une plage.
await page.locator('#content-area td[data-cell="A1"]').click();
await page.locator('#content-area td[data-cell="B2"]').click({ modifiers: ['Shift'] });
await expect(page.locator('#xlsx-minibar')).toBeVisible();
});
test('mobile : barres repliées par défaut, ruban sans débordement et cibles 44px (#189)', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
await login(page);
await page.evaluate((f) => window.TabManager.open('TestVault', f), FIXTURE);
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
// #189 — par défaut la barre de menus et le ruban sont repliés : la grille
// garde la hauteur d'écran. La barre de formule, elle, reste visible.
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeHidden();
await expect(page.locator('#content-area .xlsx-formula-bar')).toBeVisible();
await expect(page.locator('#content-area .xlsx-sheetbar')).toBeVisible();
const toggle = page.locator('#xlsx-tools-toggle');
await expect(toggle).toBeVisible();
// Un clic déplie menus + ruban (état annoncé par aria-expanded).
await toggle.click();
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
// Le ruban s'enroule au lieu de déborder horizontalement.
const overflow = await page.evaluate(() => {
const bar = document.querySelector('#content-area .xlsx-cmdbar');
return bar ? bar.scrollWidth - bar.clientWidth : -1;
});
expect(overflow).toBeLessThanOrEqual(1);
// Cibles tactiles : le basculeur et chaque commande du ruban font 44px.
const toggleBox = await toggle.boundingBox();
expect(toggleBox.height).toBeGreaterThanOrEqual(44);
const ribbonBtnBox = await page.locator('#xs-b').boundingBox();
expect(ribbonBtnBox.height).toBeGreaterThanOrEqual(44);
// Repli : la grille retrouve toute sa hauteur.
await toggle.click();
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
const box = await menu.boundingBox();
expect(box.width).toBeLessThanOrEqual(viewport.width - 8);
const itemBox = await menu.locator('.xlsx-context-item').first().boundingBox();
expect(itemBox.height).toBeGreaterThanOrEqual(44);
});
});
// ── #186 — création d'un classeur depuis la modale « Créer un fichier » ──────
test.describe('Excel viewer — création depuis l\'UI (#186)', () => {
// Le nom saisi dans la modale ne porte que le nom court : le dossier parent
// (« IT ») est déjà choisi par le menu contextuel.
const stem = `e2e-cree-${Date.now()}`;
const CREATED = `IT/${stem}.xlsx`;
async function cleanup(request) {
await request.fetch(
`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`,
{ method: 'DELETE' },
).catch(() => {});
}
test('la liste des types propose Excel et le classeur créé s\'ouvre dans la visionneuse', async ({ page, request }) => {
await cleanup(request);
await login(page);
// Menu contextuel du dossier « IT » → « Nouveau fichier ici ».
await page.locator('.tree-item.vault-item[data-vault="TestVault"]').first().click();
await page.locator('.tree-item[data-vault="TestVault"][data-path="IT"]').first()
.click({ button: 'right' });
const menu = page.locator('#context-menu.active');
await expect(menu).toBeVisible({ timeout: 5000 });
await menu.locator('.context-menu-item', { hasText: 'Nouveau fichier ici' }).click();
await page.locator('.obsigate-modal-overlay.active').waitFor({ state: 'visible', timeout: 5000 });
const extSelect = page.locator('#file-ext-select');
// L'option Excel existe, et son libellé vient des locales (jamais du dur).
await expect(extSelect.locator('option[value=".xlsx"]')).toHaveText('Excel (.xlsx)');
await extSelect.selectOption('.xlsx');
await page.fill('#file-name-input', stem);
await page.click('#file-create-btn');
// Le classeur s'ouvre dans la visionneuse : une grille, pas une erreur.
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
await expect(page.locator('#content-area .xlsx-tab', { hasText: 'Feuille1' })).toBeVisible();
// Et c'est un vrai classeur : l'API le sert en tableur, avec sa feuille.
const body = await request
.get(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`)
.then((r) => r.json());
expect(body.is_xlsx).toBe(true);
expect(body.xlsx_sheets.map((s) => s.name)).toEqual(['Feuille1']);
await cleanup(request);
});
});