feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
This commit is contained in:
+200
-15
@@ -41,6 +41,8 @@ const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// 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);
|
||||
|
||||
@@ -85,19 +87,17 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
// 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 }) => {
|
||||
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>).
|
||||
// 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).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/);
|
||||
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);
|
||||
@@ -286,11 +286,12 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
|
||||
// — 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');
|
||||
// #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('C500');
|
||||
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
|
||||
@@ -358,27 +359,160 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#xlsx-structure-btn').click();
|
||||
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 Export se ferme au clic extérieur', async ({ page }) => {
|
||||
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-export-btn').click();
|
||||
const menu = page.locator('#xlsx-export-menu');
|
||||
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 : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
|
||||
await login(page);
|
||||
@@ -402,3 +536,54 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user