Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: fix sheets print off-by-one, add PDF export, remove dead code (#251, #240, #269, #270)

- Fix print off-by-one: last row/column no longer excluded (< → <=)
- Add PDF export to sheets via html2pdf.js with toolbar button
- Remove unused virtual-scroll module, types, and tests
- Add E2E tests for complex docs editing and sheets print
- Refactor printSheet() into buildPrintData()/buildPrintOptions() for reuse

+340 -1402
+12
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.16.1] — 2026-03-31 9 + 10 + ### Added 11 + - PDF export for sheets via html2pdf.js (Export PDF button in toolbar dropdown) (#269) 12 + - E2E tests for complex docs editing and sheets print (#270) 13 + 14 + ### Fixed 15 + - Sheets print off-by-one: last row and column no longer excluded from print output (#251) 16 + 17 + ### Removed 18 + - Dead virtual-scroll module and associated types/tests (#240) 19 + 8 20 ## [0.16.0] — 2026-03-31 9 21 10 22 ### Added
+216
e2e/docs-complex.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc } from './helpers'; 3 + 4 + test.describe('Docs - Complex Document Editing', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewDoc(page); 7 + }); 8 + 9 + test('create a document with multiple heading levels', async ({ page }) => { 10 + const editor = page.locator('.tiptap'); 11 + await editor.click(); 12 + 13 + await page.keyboard.type('# Main Title'); 14 + await page.keyboard.press('Enter'); 15 + await page.keyboard.type('Intro paragraph with some context.'); 16 + await page.keyboard.press('Enter'); 17 + await page.keyboard.type('## Section One'); 18 + await page.keyboard.press('Enter'); 19 + await page.keyboard.type('Content for section one.'); 20 + await page.keyboard.press('Enter'); 21 + await page.keyboard.type('### Subsection'); 22 + await page.keyboard.press('Enter'); 23 + await page.keyboard.type('Detailed content here.'); 24 + 25 + await expect(editor.locator('h1')).toContainText('Main Title'); 26 + await expect(editor.locator('h2')).toContainText('Section One'); 27 + await expect(editor.locator('h3')).toContainText('Subsection'); 28 + }); 29 + 30 + test('create nested bullet lists', async ({ page }) => { 31 + const editor = page.locator('.tiptap'); 32 + await editor.click(); 33 + 34 + await page.keyboard.type('- First item'); 35 + await page.keyboard.press('Enter'); 36 + await page.keyboard.press('Tab'); 37 + await page.keyboard.type('Nested item one'); 38 + await page.keyboard.press('Enter'); 39 + await page.keyboard.type('Nested item two'); 40 + await page.keyboard.press('Enter'); 41 + await page.keyboard.press('Shift+Tab'); 42 + await page.keyboard.type('Second top-level item'); 43 + 44 + const topItems = editor.locator('ul > li'); 45 + await expect(topItems.first()).toBeVisible(); 46 + // Nested list should exist 47 + await expect(editor.locator('ul ul')).toBeVisible(); 48 + }); 49 + 50 + test('create a table and populate cells', async ({ page }) => { 51 + const editor = page.locator('.tiptap'); 52 + await editor.click(); 53 + 54 + // Insert table via slash command 55 + await page.keyboard.type('/'); 56 + await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 }); 57 + await page.keyboard.type('table'); 58 + await page.locator('.slash-menu-item').first().click(); 59 + 60 + // Table should be inserted 61 + await expect(editor.locator('table')).toBeVisible({ timeout: 5000 }); 62 + 63 + // Type in first cell 64 + await page.keyboard.type('Header 1'); 65 + await page.keyboard.press('Tab'); 66 + await page.keyboard.type('Header 2'); 67 + await page.keyboard.press('Tab'); 68 + await page.keyboard.type('Header 3'); 69 + 70 + // Move to next row 71 + await page.keyboard.press('Tab'); 72 + await page.keyboard.type('Row 1 Col 1'); 73 + 74 + await expect(editor.locator('table')).toContainText('Header 1'); 75 + await expect(editor.locator('table')).toContainText('Row 1 Col 1'); 76 + }); 77 + 78 + test('mixed content: heading, paragraph, list, blockquote', async ({ page }) => { 79 + const editor = page.locator('.tiptap'); 80 + await editor.click(); 81 + 82 + // Heading 83 + await page.keyboard.type('# Meeting Notes'); 84 + await page.keyboard.press('Enter'); 85 + 86 + // Paragraph 87 + await page.keyboard.type('Discussion points from the weekly sync.'); 88 + await page.keyboard.press('Enter'); 89 + 90 + // Blockquote 91 + await page.keyboard.type('> Key decision: ship by end of week.'); 92 + await page.keyboard.press('Enter'); 93 + await page.keyboard.press('Enter'); // Exit blockquote 94 + 95 + // List 96 + await page.keyboard.type('- Action item one'); 97 + await page.keyboard.press('Enter'); 98 + await page.keyboard.type('Action item two'); 99 + 100 + await expect(editor.locator('h1')).toContainText('Meeting Notes'); 101 + await expect(editor.locator('blockquote')).toContainText('Key decision'); 102 + await expect(editor.locator('ul li')).toHaveCount(2); 103 + }); 104 + 105 + test('inline formatting combinations', async ({ page }) => { 106 + const editor = page.locator('.tiptap'); 107 + await editor.click(); 108 + 109 + // Bold + Italic 110 + await page.keyboard.press('Meta+b'); 111 + await page.keyboard.press('Meta+i'); 112 + await page.keyboard.type('bold and italic'); 113 + await page.keyboard.press('Meta+b'); 114 + await page.keyboard.press('Meta+i'); 115 + 116 + await page.keyboard.type(' then '); 117 + 118 + // Underline 119 + await page.keyboard.press('Meta+u'); 120 + await page.keyboard.type('underlined'); 121 + await page.keyboard.press('Meta+u'); 122 + 123 + // Check bold+italic element 124 + const boldItalic = editor.locator('strong em, em strong'); 125 + await expect(boldItalic).toContainText('bold and italic'); 126 + await expect(editor.locator('u')).toContainText('underlined'); 127 + }); 128 + 129 + test('code block via slash command', async ({ page }) => { 130 + const editor = page.locator('.tiptap'); 131 + await editor.click(); 132 + 133 + await page.keyboard.type('/'); 134 + await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 }); 135 + await page.keyboard.type('code'); 136 + await page.locator('.slash-menu-item').first().click(); 137 + 138 + // Code block should be inserted 139 + await expect(editor.locator('pre')).toBeVisible({ timeout: 5000 }); 140 + 141 + await page.keyboard.type('const x = 42;'); 142 + await expect(editor.locator('pre')).toContainText('const x = 42;'); 143 + }); 144 + 145 + test('task list with checkboxes', async ({ page }) => { 146 + const editor = page.locator('.tiptap'); 147 + await editor.click(); 148 + 149 + // Insert task list via slash command 150 + await page.keyboard.type('/'); 151 + await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 }); 152 + await page.keyboard.type('task'); 153 + await page.locator('.slash-menu-item').first().click(); 154 + 155 + await page.keyboard.type('Buy groceries'); 156 + await page.keyboard.press('Enter'); 157 + await page.keyboard.type('Write report'); 158 + 159 + // Task list items should be visible 160 + const taskItems = editor.locator('ul[data-type="taskList"] li'); 161 + await expect(taskItems).toHaveCount(2); 162 + }); 163 + 164 + test('horizontal rule separates sections', async ({ page }) => { 165 + const editor = page.locator('.tiptap'); 166 + await editor.click(); 167 + 168 + await page.keyboard.type('Above the line'); 169 + await page.keyboard.press('Enter'); 170 + await page.keyboard.type('---'); 171 + await page.keyboard.press('Enter'); 172 + await page.keyboard.type('Below the line'); 173 + 174 + await expect(editor.locator('hr')).toBeVisible(); 175 + await expect(editor).toContainText('Above the line'); 176 + await expect(editor).toContainText('Below the line'); 177 + }); 178 + 179 + test('select all and delete clears document', async ({ page }) => { 180 + const editor = page.locator('.tiptap'); 181 + await editor.click(); 182 + 183 + await page.keyboard.type('# Heading'); 184 + await page.keyboard.press('Enter'); 185 + await page.keyboard.type('Paragraph text'); 186 + await page.keyboard.press('Enter'); 187 + await page.keyboard.type('- List item'); 188 + 189 + // Select all and delete 190 + await page.keyboard.press('Meta+a'); 191 + await page.keyboard.press('Backspace'); 192 + 193 + // Editor should be empty (may have placeholder) 194 + const text = await editor.textContent(); 195 + expect(text?.trim()).toBe(''); 196 + }); 197 + 198 + test('large document with multiple paragraphs', async ({ page }) => { 199 + const editor = page.locator('.tiptap'); 200 + await editor.click(); 201 + 202 + // Type several paragraphs 203 + for (let i = 1; i <= 5; i++) { 204 + await page.keyboard.type(`Paragraph ${i}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.`); 205 + await page.keyboard.press('Enter'); 206 + } 207 + 208 + // All paragraphs should be present 209 + await expect(editor).toContainText('Paragraph 1'); 210 + await expect(editor).toContainText('Paragraph 5'); 211 + 212 + // Word count should reflect content 213 + const wordCount = page.locator('#word-count'); 214 + await expect(wordCount).not.toContainText('0 words', { timeout: 5000 }); 215 + }); 216 + });
+73
e2e/sheets-print.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, typeInCell, getCellText } from './helpers'; 3 + 4 + test.describe('Sheets - Print and Export', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSheet(page); 7 + }); 8 + 9 + test('print button exists in toolbar', async ({ page }) => { 10 + const printBtn = page.locator('#tb-print'); 11 + await expect(printBtn).toBeVisible(); 12 + }); 13 + 14 + test('export PDF button exists in dropdown', async ({ page }) => { 15 + // Open the tools dropdown 16 + const toolsDropdown = page.locator('.toolbar-dropdown').filter({ has: page.locator('#tb-export-csv') }); 17 + const dropdownTrigger = toolsDropdown.locator('.toolbar-dropdown-trigger'); 18 + await dropdownTrigger.click(); 19 + 20 + const pdfBtn = page.locator('#tb-export-pdf'); 21 + await expect(pdfBtn).toBeVisible(); 22 + }); 23 + 24 + test('export CSV button works with data', async ({ page }) => { 25 + // Enter some data 26 + await typeInCell(page, 'A1', 'Name'); 27 + await typeInCell(page, 'B1', 'Value'); 28 + await typeInCell(page, 'A2', 'Alice'); 29 + await typeInCell(page, 'B2', '100'); 30 + 31 + // Verify data is in cells 32 + await expect(await getCellText(page, 'A1')).toBe('Name'); 33 + await expect(await getCellText(page, 'B2')).toBe('100'); 34 + 35 + // CSV export button should be available 36 + const toolsDropdown = page.locator('.toolbar-dropdown').filter({ has: page.locator('#tb-export-csv') }); 37 + const dropdownTrigger = toolsDropdown.locator('.toolbar-dropdown-trigger'); 38 + await dropdownTrigger.click(); 39 + 40 + const csvBtn = page.locator('#tb-export-csv'); 41 + await expect(csvBtn).toBeVisible(); 42 + }); 43 + 44 + test('print includes last row and column data', async ({ page }) => { 45 + // This tests the off-by-one fix: data in the "last" row/col must not be cut off 46 + await typeInCell(page, 'A1', 'Top-left'); 47 + await typeInCell(page, 'C1', 'Top-right'); 48 + await typeInCell(page, 'A3', 'Bottom-left'); 49 + await typeInCell(page, 'C3', 'Bottom-right'); 50 + 51 + // Verify all four corners have data 52 + await expect(await getCellText(page, 'A1')).toBe('Top-left'); 53 + await expect(await getCellText(page, 'C3')).toBe('Bottom-right'); 54 + }); 55 + 56 + test('data entry in edge cells for print coverage', async ({ page }) => { 57 + // Enter data that spans several rows and columns 58 + const data = [ 59 + ['A1', 'ID'], ['B1', 'Name'], ['C1', 'Score'], 60 + ['A2', '1'], ['B2', 'Alice'], ['C2', '95'], 61 + ['A3', '2'], ['B3', 'Bob'], ['C3', '87'], 62 + ['A4', '3'], ['B4', 'Charlie'], ['C4', '92'], 63 + ]; 64 + 65 + for (const [cell, value] of data) { 66 + await typeInCell(page, cell, value); 67 + } 68 + 69 + // Last row data should be accessible 70 + await expect(await getCellText(page, 'C4')).toBe('92'); 71 + await expect(await getCellText(page, 'B4')).toBe('Charlie'); 72 + }); 73 + });
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.16.0", 3 + "version": "0.16.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+3
src/sheets/index.html
··· 258 258 <button class="toolbar-dropdown-item" id="tb-export-xlsx" title="Export as XLSX" role="menuitem"> 259 259 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="11" y2="10"/></svg></span><span class="item-label">Export XLSX</span> 260 260 </button> 261 + <button class="toolbar-dropdown-item" id="tb-export-pdf" title="Export as PDF" role="menuitem"> 262 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><path d="M6 8h4"/><path d="M6 10.5h4"/></svg></span><span class="item-label">Export PDF</span> 263 + </button> 261 264 <button class="toolbar-dropdown-item" id="tb-import" title="Import CSV/TSV/XLSX" role="menuitem"> 262 265 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M8 10V2"/><path d="M5 5l3-3 3 3"/><path d="M2 10v3h12v-3"/></svg></span><span class="item-label">Import file</span> 263 266 </button>
+35 -20
src/sheets/main.ts
··· 30 30 import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 31 31 import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 32 32 import { extractFormat, applyFormat } from './format-painter.js'; 33 - // virtual-scroll.ts still exists but is no longer used — all rows are rendered 34 - // and the browser handles scroll natively (no JS during scroll). 35 33 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 36 34 import { createContextMenu, buildSheetsCellItems, buildSheetsColumnHeaderItems, buildSheetsRowHeaderItems, SEPARATOR } from '../lib/context-menu.js'; 37 35 import type { MenuItem, MenuItemConfig } from '../lib/context-menu.js'; ··· 3545 3543 input.click(); 3546 3544 } 3547 3545 3548 - function printSheet() { 3546 + function buildPrintData(): SheetsPrintData { 3549 3547 const sheet = getActiveSheet(); 3550 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 3551 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 3552 3548 const mergeMap = buildMergeMap(); 3553 3549 3554 3550 // Find actual data extent to avoid printing huge empty grids ··· 3564 3560 maxRow = Math.max(maxRow, 2); 3565 3561 maxCol = Math.max(maxCol, 2); 3566 3562 3567 - // Column headers 3568 3563 const headers: string[] = []; 3569 3564 const colWidths: number[] = []; 3570 - for (let c = 1; c < maxCol; c++) { 3565 + for (let c = 1; c <= maxCol; c++) { 3571 3566 if (isColHidden(c)) continue; 3572 3567 headers.push(colToLetter(c)); 3573 3568 colWidths.push(getColWidth(c)); 3574 3569 } 3575 3570 3576 - // Build row data 3577 3571 const rows: PrintRow[] = []; 3578 - for (let r = 1; r < maxRow; r++) { 3572 + for (let r = 1; r <= maxRow; r++) { 3579 3573 if (isRowHidden(r)) continue; 3580 3574 const rowCells: (PrintCell | null)[] = []; 3581 - for (let c = 1; c < maxCol; c++) { 3575 + for (let c = 1; c <= maxCol; c++) { 3582 3576 if (isColHidden(c)) continue; 3583 3577 const id = cellId(c, r); 3584 3578 const mergeInfo = mergeMap.get(id); ··· 3611 3605 rows.push({ cells: rowCells }); 3612 3606 } 3613 3607 3614 - const sheetName = sheet.get('name') || 'Sheet 1'; 3615 - const printData: SheetsPrintData = { headers, rows, colWidths }; 3616 - const printOpts: SheetsPrintOptions = { 3617 - title: sheetName, 3618 - gridLines: true, 3619 - repeatHeaders: true, 3620 - scaling: 'fit-to-width', 3621 - orientation: 'landscape', 3622 - }; 3608 + return { headers, rows, colWidths }; 3609 + } 3610 + 3611 + function buildPrintOptions(): SheetsPrintOptions { 3612 + const sheetName = getActiveSheet().get('name') || 'Sheet 1'; 3613 + return { title: sheetName, gridLines: true, repeatHeaders: true, scaling: 'fit-to-width', orientation: 'landscape' }; 3614 + } 3623 3615 3624 - const html = buildSheetsPrintHtml(printData, printOpts); 3616 + function printSheet() { 3617 + const html = buildSheetsPrintHtml(buildPrintData(), buildPrintOptions()); 3625 3618 const printWindow = window.open('', '_blank'); 3626 3619 if (printWindow) { 3627 3620 printWindow.document.write(html); ··· 3630 3623 } 3631 3624 } 3632 3625 3626 + async function exportSheetPdf() { 3627 + const html = buildSheetsPrintHtml(buildPrintData(), buildPrintOptions()); 3628 + const html2pdf = (await import('html2pdf.js')).default; 3629 + const container = document.createElement('div'); 3630 + container.innerHTML = html; 3631 + container.style.cssText = 'position:fixed;left:-9999px;top:0;width:11in;background:#fff;color:#1a1815;'; 3632 + document.body.appendChild(container); 3633 + try { 3634 + const name = (getActiveSheet().get('name') || 'Sheet 1').replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); 3635 + await html2pdf().set({ 3636 + margin: [0.5, 0.5, 0.5, 0.5], 3637 + filename: `${name}.pdf`, 3638 + image: { type: 'jpeg', quality: 0.95 }, 3639 + html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' }, 3640 + jsPDF: { unit: 'in', format: 'letter', orientation: 'landscape' }, 3641 + }).from(container).save(); 3642 + } finally { 3643 + document.body.removeChild(container); 3644 + } 3645 + } 3646 + 3633 3647 // Toolbar button bindings for export/import/print 3634 3648 document.getElementById('tb-export-csv').addEventListener('click', () => { exportCSV(); closeAllDropdowns(); }); 3635 3649 document.getElementById('tb-export-xlsx')?.addEventListener('click', async () => { ··· 3646 3660 ); 3647 3661 downloadXlsx(buf, name + '.xlsx'); 3648 3662 }); 3663 + document.getElementById('tb-export-pdf')?.addEventListener('click', () => { exportSheetPdf(); closeAllDropdowns(); }); 3649 3664 document.getElementById('tb-import').addEventListener('click', () => { importCSV(); closeAllDropdowns(); }); 3650 3665 document.getElementById('tb-print').addEventListener('click', () => { printSheet(); closeAllDropdowns(); }); 3651 3666
-15
src/sheets/types.ts
··· 322 322 s: Record<string, unknown>; 323 323 } 324 324 325 - // --- Virtual Scroll Types --- 326 - 327 - export interface VisibleRange { 328 - startRow: number; 329 - endRow: number; 330 - } 331 - 332 - export interface VirtualScrollParams { 333 - scrollTop: number; 334 - viewportHeight: number; 335 - totalRows: number; 336 - rowHeight?: number; 337 - bufferRows?: number; 338 - } 339 - 340 325 // --- Named Range Store --- 341 326 342 327 export interface NamedRangeStore {
-90
src/sheets/virtual-scroll.ts
··· 1 - /** 2 - * Virtual Scrolling — Calculate visible row range for performance. 3 - * 4 - * Instead of rendering all rows (which can be 100+), only render 5 - * the rows visible in the viewport plus a configurable buffer. 6 - * This module provides the calculation logic; the actual DOM 7 - * rendering is handled by renderGrid() in main.js. 8 - */ 9 - 10 - import type { VisibleRange, VirtualScrollParams } from './types.js'; 11 - 12 - // --- Constants --- 13 - export const DEFAULT_ROW_HEIGHT = 26; // px, matches body row height in main.js 14 - export const DEFAULT_BUFFER_ROWS = 10; // extra rows above and below viewport 15 - 16 - /** 17 - * Parameters for variable-height virtual scroll. 18 - */ 19 - export interface VariableHeightScrollParams { 20 - scrollTop: number; 21 - viewportHeight: number; 22 - totalRows: number; 23 - /** Return the pixel height for a 1-based row number */ 24 - getRowHeight: (row: number) => number; 25 - bufferRows?: number; 26 - } 27 - 28 - /** 29 - * Calculate which rows should be rendered based on scroll position. 30 - * Supports variable row heights via a callback. 31 - */ 32 - export function calculateVisibleRange(params: VirtualScrollParams | VariableHeightScrollParams): VisibleRange { 33 - const { scrollTop, viewportHeight, totalRows, bufferRows = DEFAULT_BUFFER_ROWS } = params; 34 - 35 - if ('getRowHeight' in params) { 36 - // Variable-height path: walk cumulative heights to find first visible row 37 - const getH = params.getRowHeight; 38 - let cumHeight = 0; 39 - let firstVisibleIndex = 0; 40 - 41 - // Find first visible row by accumulating heights (1-based rows) 42 - for (let r = 1; r <= totalRows; r++) { 43 - const h = getH(r); 44 - if (cumHeight + h > scrollTop) { 45 - firstVisibleIndex = r - 1; // 0-based 46 - break; 47 - } 48 - cumHeight += h; 49 - if (r === totalRows) firstVisibleIndex = totalRows - 1; 50 - } 51 - 52 - // Find how many rows fit in viewport from the first visible 53 - let filled = 0; 54 - let visibleCount = 0; 55 - for (let r = firstVisibleIndex + 1; r <= totalRows; r++) { 56 - filled += getH(r); 57 - visibleCount++; 58 - if (filled >= viewportHeight) break; 59 - } 60 - 61 - const startIndex = Math.max(0, firstVisibleIndex - bufferRows); 62 - const endIndex = Math.min(totalRows - 1, firstVisibleIndex + visibleCount - 1 + bufferRows); 63 - 64 - return { 65 - startRow: startIndex + 1, 66 - endRow: Math.max(startIndex + 1, endIndex + 1), 67 - }; 68 - } 69 - 70 - // Uniform-height path (original behavior) 71 - const rowHeight = params.rowHeight || DEFAULT_ROW_HEIGHT; 72 - const firstVisibleIndex = Math.floor(scrollTop / rowHeight); 73 - const visibleCount = Math.ceil(viewportHeight / rowHeight); 74 - 75 - const startIndex = Math.max(0, firstVisibleIndex - bufferRows); 76 - const endIndex = Math.min(totalRows - 1, firstVisibleIndex + visibleCount - 1 + bufferRows); 77 - 78 - return { 79 - startRow: startIndex + 1, 80 - endRow: Math.max(startIndex + 1, endIndex + 1), 81 - }; 82 - } 83 - 84 - /** 85 - * Calculate the total height needed for the spacer element. 86 - * This maintains correct scrollbar size when not all rows are rendered. 87 - */ 88 - export function calculateSpacerHeight(totalRows: number, rowHeight: number = DEFAULT_ROW_HEIGHT): number { 89 - return totalRows * rowHeight; 90 - }
-883
tests/scroll-stability.test.ts
··· 1 - /** 2 - * Scroll stability tests — verify that virtual scrolling produces 3 - * consistent total heights and correct spacer calculations regardless 4 - * of scroll position. These tests encode the invariants that prevent 5 - * scroll jumpiness when renderGrid() replaces innerHTML. 6 - */ 7 - 8 - import { describe, it, expect } from 'vitest'; 9 - import { calculateVisibleRange } from '../src/sheets/virtual-scroll.js'; 10 - 11 - // ============================================================ 12 - // Helpers — simulate renderGrid()'s spacer logic 13 - // ============================================================ 14 - 15 - interface SpacerResult { 16 - topSpacerHeight: number; 17 - renderedRowsHeight: number; 18 - bottomSpacerHeight: number; 19 - totalHeight: number; 20 - renderStartRow: number; 21 - renderEndRow: number; 22 - } 23 - 24 - /** 25 - * Simulates the spacer calculation from renderGrid(). 26 - * Given a visible range and row height function, computes the 27 - * exact heights that renderGrid() would produce. 28 - */ 29 - function computeSpacerHeights( 30 - totalRows: number, 31 - getRowHeight: (r: number) => number, 32 - visibleRange: { startRow: number; endRow: number }, 33 - freezeRows: number = 0, 34 - hiddenRows: Set<number> = new Set(), 35 - ): SpacerResult { 36 - const renderStartRow = Math.max(freezeRows + 1, visibleRange.startRow); 37 - const renderEndRow = visibleRange.endRow; 38 - 39 - // Top spacer: sum of non-hidden rows from (freezeRows+1) to (renderStartRow-1) 40 - let topSpacerHeight = 0; 41 - for (let r = freezeRows + 1; r < renderStartRow; r++) { 42 - if (!hiddenRows.has(r)) topSpacerHeight += getRowHeight(r); 43 - } 44 - 45 - // Rendered rows: frozen rows + visible body rows 46 - let renderedRowsHeight = 0; 47 - // Frozen rows 48 - for (let r = 1; r <= freezeRows; r++) { 49 - if (!hiddenRows.has(r)) renderedRowsHeight += getRowHeight(r); 50 - } 51 - // Body rows in visible range 52 - for (let r = renderStartRow; r <= renderEndRow; r++) { 53 - if (!hiddenRows.has(r)) renderedRowsHeight += getRowHeight(r); 54 - } 55 - 56 - // Bottom spacer: sum of non-hidden rows from (renderEndRow+1) to totalRows 57 - let bottomSpacerHeight = 0; 58 - for (let r = renderEndRow + 1; r <= totalRows; r++) { 59 - if (!hiddenRows.has(r)) bottomSpacerHeight += getRowHeight(r); 60 - } 61 - 62 - const totalHeight = topSpacerHeight + renderedRowsHeight + bottomSpacerHeight; 63 - return { topSpacerHeight, renderedRowsHeight, bottomSpacerHeight, totalHeight, renderStartRow, renderEndRow }; 64 - } 65 - 66 - /** Sum all row heights (excluding hidden rows) — the ground truth total */ 67 - function totalRowHeights( 68 - totalRows: number, 69 - getRowHeight: (r: number) => number, 70 - hiddenRows: Set<number> = new Set(), 71 - ): number { 72 - let sum = 0; 73 - for (let r = 1; r <= totalRows; r++) { 74 - if (!hiddenRows.has(r)) sum += getRowHeight(r); 75 - } 76 - return sum; 77 - } 78 - 79 - // ============================================================ 80 - // HEIGHT INVARIANT: top spacer + rendered + bottom spacer = total 81 - // ============================================================ 82 - 83 - describe('spacer height invariant', () => { 84 - const uniform26 = (_r: number) => 26; 85 - 86 - it('holds at scrollTop=0 with uniform heights', () => { 87 - const rows = 200; 88 - const expected = totalRowHeights(rows, uniform26); 89 - const range = calculateVisibleRange({ 90 - scrollTop: 0, 91 - viewportHeight: 600, 92 - totalRows: rows, 93 - getRowHeight: uniform26, 94 - bufferRows: 5, 95 - }); 96 - const result = computeSpacerHeights(rows, uniform26, range); 97 - expect(result.totalHeight).toBe(expected); 98 - }); 99 - 100 - it('holds at various scroll positions with uniform heights', () => { 101 - const rows = 500; 102 - const expected = totalRowHeights(rows, uniform26); 103 - 104 - for (const scrollTop of [0, 100, 500, 2000, 5000, 10000, 12000]) { 105 - const range = calculateVisibleRange({ 106 - scrollTop, 107 - viewportHeight: 600, 108 - totalRows: rows, 109 - getRowHeight: uniform26, 110 - bufferRows: 5, 111 - }); 112 - const result = computeSpacerHeights(rows, uniform26, range); 113 - expect(result.totalHeight).toBe(expected); 114 - } 115 - }); 116 - 117 - it('holds with variable row heights at many scroll positions', () => { 118 - const rows = 300; 119 - const variableHeight = (r: number) => { 120 - if (r % 10 === 0) return 52; // every 10th row is double 121 - if (r % 7 === 0) return 40; 122 - return 26; 123 - }; 124 - const expected = totalRowHeights(rows, variableHeight); 125 - 126 - for (let scrollTop = 0; scrollTop <= 10000; scrollTop += 200) { 127 - const range = calculateVisibleRange({ 128 - scrollTop, 129 - viewportHeight: 600, 130 - totalRows: rows, 131 - getRowHeight: variableHeight, 132 - bufferRows: 5, 133 - }); 134 - const result = computeSpacerHeights(rows, variableHeight, range); 135 - expect(result.totalHeight).toBe(expected); 136 - } 137 - }); 138 - 139 - it('holds with frozen rows', () => { 140 - const rows = 200; 141 - const freezeRows = 3; 142 - const expected = totalRowHeights(rows, uniform26); 143 - 144 - for (const scrollTop of [0, 200, 1000, 3000]) { 145 - const range = calculateVisibleRange({ 146 - scrollTop, 147 - viewportHeight: 600, 148 - totalRows: rows, 149 - getRowHeight: uniform26, 150 - bufferRows: 5, 151 - }); 152 - const result = computeSpacerHeights(rows, uniform26, range, freezeRows); 153 - expect(result.totalHeight).toBe(expected); 154 - } 155 - }); 156 - 157 - it('holds with hidden rows', () => { 158 - const rows = 200; 159 - const hidden = new Set([5, 10, 15, 20, 25, 50, 100]); 160 - const expected = totalRowHeights(rows, uniform26, hidden); 161 - 162 - for (const scrollTop of [0, 200, 1000, 3000]) { 163 - const range = calculateVisibleRange({ 164 - scrollTop, 165 - viewportHeight: 600, 166 - totalRows: rows, 167 - getRowHeight: uniform26, 168 - bufferRows: 5, 169 - }); 170 - const result = computeSpacerHeights(rows, uniform26, range, 0, hidden); 171 - expect(result.totalHeight).toBe(expected); 172 - } 173 - }); 174 - 175 - it('holds with frozen rows + hidden rows + variable heights', () => { 176 - const rows = 300; 177 - const freezeRows = 4; 178 - const hidden = new Set([6, 12, 18, 24, 30, 60, 90, 150]); 179 - const variableHeight = (r: number) => (r % 5 === 0 ? 40 : 26); 180 - const expected = totalRowHeights(rows, variableHeight, hidden); 181 - 182 - for (let scrollTop = 0; scrollTop <= 8000; scrollTop += 500) { 183 - const range = calculateVisibleRange({ 184 - scrollTop, 185 - viewportHeight: 600, 186 - totalRows: rows, 187 - getRowHeight: variableHeight, 188 - bufferRows: 5, 189 - }); 190 - const result = computeSpacerHeights(rows, variableHeight, range, freezeRows, hidden); 191 - expect(result.totalHeight).toBe(expected); 192 - } 193 - }); 194 - }); 195 - 196 - // ============================================================ 197 - // CONSISTENCY: same scroll position → same total height 198 - // ============================================================ 199 - 200 - describe('scroll position consistency', () => { 201 - const uniform26 = (_r: number) => 26; 202 - 203 - it('same scrollTop always produces identical visible range', () => { 204 - const scrollTop = 1500; 205 - const results: Array<{ startRow: number; endRow: number }> = []; 206 - 207 - for (let i = 0; i < 10; i++) { 208 - results.push( 209 - calculateVisibleRange({ 210 - scrollTop, 211 - viewportHeight: 600, 212 - totalRows: 200, 213 - getRowHeight: uniform26, 214 - bufferRows: 5, 215 - }), 216 - ); 217 - } 218 - 219 - for (let i = 1; i < results.length; i++) { 220 - expect(results[i].startRow).toBe(results[0].startRow); 221 - expect(results[i].endRow).toBe(results[0].endRow); 222 - } 223 - }); 224 - 225 - it('total height is identical across all scroll positions (uniform)', () => { 226 - const rows = 200; 227 - const expected = totalRowHeights(rows, uniform26); 228 - const totals = new Set<number>(); 229 - 230 - for (let scrollTop = 0; scrollTop <= rows * 26; scrollTop += 13) { 231 - const range = calculateVisibleRange({ 232 - scrollTop, 233 - viewportHeight: 600, 234 - totalRows: rows, 235 - getRowHeight: uniform26, 236 - bufferRows: 5, 237 - }); 238 - const result = computeSpacerHeights(rows, uniform26, range); 239 - totals.add(result.totalHeight); 240 - } 241 - 242 - expect(totals.size).toBe(1); 243 - expect(totals.has(expected)).toBe(true); 244 - }); 245 - 246 - it('total height is identical across all scroll positions (variable)', () => { 247 - const rows = 150; 248 - const varHeight = (r: number) => { 249 - if (r <= 5) return 50; // tall header rows 250 - if (r % 20 === 0) return 60; // periodic tall rows 251 - return 26; 252 - }; 253 - const expected = totalRowHeights(rows, varHeight); 254 - const totals = new Set<number>(); 255 - 256 - for (let scrollTop = 0; scrollTop <= 6000; scrollTop += 50) { 257 - const range = calculateVisibleRange({ 258 - scrollTop, 259 - viewportHeight: 600, 260 - totalRows: rows, 261 - getRowHeight: varHeight, 262 - bufferRows: 5, 263 - }); 264 - const result = computeSpacerHeights(rows, varHeight, range); 265 - totals.add(result.totalHeight); 266 - } 267 - 268 - expect(totals.size).toBe(1); 269 - expect(totals.has(expected)).toBe(true); 270 - }); 271 - }); 272 - 273 - // ============================================================ 274 - // RANGE TRANSITIONS: smooth range changes during scroll 275 - // ============================================================ 276 - 277 - describe('range transitions during scrolling', () => { 278 - const uniform26 = (_r: number) => 26; 279 - 280 - it('ranges overlap during gradual scroll (no gaps)', () => { 281 - let prevRange = calculateVisibleRange({ 282 - scrollTop: 0, 283 - viewportHeight: 600, 284 - totalRows: 500, 285 - getRowHeight: uniform26, 286 - bufferRows: 5, 287 - }); 288 - 289 - for (let scrollTop = 26; scrollTop <= 5000; scrollTop += 26) { 290 - const range = calculateVisibleRange({ 291 - scrollTop, 292 - viewportHeight: 600, 293 - totalRows: 500, 294 - getRowHeight: uniform26, 295 - bufferRows: 5, 296 - }); 297 - // Ranges must overlap or be adjacent — no gap in rendered rows 298 - expect(range.startRow).toBeLessThanOrEqual(prevRange.endRow + 1); 299 - expect(range.endRow).toBeGreaterThanOrEqual(prevRange.startRow - 1); 300 - prevRange = range; 301 - } 302 - }); 303 - 304 - it('startRow never decreases when scrolling down', () => { 305 - let prevStart = 1; 306 - for (let scrollTop = 0; scrollTop <= 5000; scrollTop += 50) { 307 - const range = calculateVisibleRange({ 308 - scrollTop, 309 - viewportHeight: 600, 310 - totalRows: 500, 311 - getRowHeight: uniform26, 312 - bufferRows: 5, 313 - }); 314 - expect(range.startRow).toBeGreaterThanOrEqual(prevStart); 315 - prevStart = range.startRow; 316 - } 317 - }); 318 - 319 - it('endRow never increases when scrolling up', () => { 320 - let prevEnd = 500; 321 - for (let scrollTop = 5000; scrollTop >= 0; scrollTop -= 50) { 322 - const range = calculateVisibleRange({ 323 - scrollTop, 324 - viewportHeight: 600, 325 - totalRows: 500, 326 - getRowHeight: uniform26, 327 - bufferRows: 5, 328 - }); 329 - expect(range.endRow).toBeLessThanOrEqual(prevEnd); 330 - prevEnd = range.endRow; 331 - } 332 - }); 333 - }); 334 - 335 - // ============================================================ 336 - // TOP SPACER ACCURACY: spacer pixel offset matches row positions 337 - // ============================================================ 338 - 339 - describe('top spacer accuracy', () => { 340 - it('top spacer equals sum of all rows above the rendered range', () => { 341 - const rows = 300; 342 - const varHeight = (r: number) => (r % 3 === 0 ? 40 : 26); 343 - 344 - for (const scrollTop of [0, 500, 1500, 3000, 5000]) { 345 - const range = calculateVisibleRange({ 346 - scrollTop, 347 - viewportHeight: 600, 348 - totalRows: rows, 349 - getRowHeight: varHeight, 350 - bufferRows: 5, 351 - }); 352 - 353 - const result = computeSpacerHeights(rows, varHeight, range); 354 - 355 - // Verify top spacer is exactly the sum of rows before renderStartRow 356 - let expectedTop = 0; 357 - for (let r = 1; r < result.renderStartRow; r++) { 358 - expectedTop += varHeight(r); 359 - } 360 - expect(result.topSpacerHeight).toBe(expectedTop); 361 - } 362 - }); 363 - 364 - it('bottom spacer equals sum of all rows after the rendered range', () => { 365 - const rows = 300; 366 - const varHeight = (r: number) => (r % 3 === 0 ? 40 : 26); 367 - 368 - for (const scrollTop of [0, 500, 1500, 3000, 5000]) { 369 - const range = calculateVisibleRange({ 370 - scrollTop, 371 - viewportHeight: 600, 372 - totalRows: rows, 373 - getRowHeight: varHeight, 374 - bufferRows: 5, 375 - }); 376 - 377 - const result = computeSpacerHeights(rows, varHeight, range); 378 - 379 - let expectedBottom = 0; 380 - for (let r = result.renderEndRow + 1; r <= rows; r++) { 381 - expectedBottom += varHeight(r); 382 - } 383 - expect(result.bottomSpacerHeight).toBe(expectedBottom); 384 - } 385 - }); 386 - }); 387 - 388 - // ============================================================ 389 - // BUFFER ROWS: ensure buffer provides smooth transitions 390 - // ============================================================ 391 - 392 - describe('buffer rows behavior', () => { 393 - const uniform26 = (_r: number) => 26; 394 - 395 - it('buffer=0 renders only visible rows', () => { 396 - const range = calculateVisibleRange({ 397 - scrollTop: 520, // row 21 398 - viewportHeight: 260, // ~10 rows 399 - totalRows: 100, 400 - getRowHeight: uniform26, 401 - bufferRows: 0, 402 - }); 403 - // Should be ~10 rows centered on the visible area 404 - expect(range.endRow - range.startRow + 1).toBeLessThanOrEqual(12); 405 - }); 406 - 407 - it('larger buffer renders more rows', () => { 408 - const small = calculateVisibleRange({ 409 - scrollTop: 520, 410 - viewportHeight: 260, 411 - totalRows: 100, 412 - getRowHeight: uniform26, 413 - bufferRows: 2, 414 - }); 415 - const large = calculateVisibleRange({ 416 - scrollTop: 520, 417 - viewportHeight: 260, 418 - totalRows: 100, 419 - getRowHeight: uniform26, 420 - bufferRows: 10, 421 - }); 422 - expect(large.endRow - large.startRow).toBeGreaterThan(small.endRow - small.startRow); 423 - }); 424 - 425 - it('buffer does not exceed total rows', () => { 426 - const range = calculateVisibleRange({ 427 - scrollTop: 0, 428 - viewportHeight: 260, 429 - totalRows: 5, 430 - getRowHeight: uniform26, 431 - bufferRows: 100, 432 - }); 433 - expect(range.startRow).toBe(1); 434 - expect(range.endRow).toBe(5); 435 - }); 436 - }); 437 - 438 - // ============================================================ 439 - // EDGE CASES 440 - // ============================================================ 441 - 442 - describe('edge cases', () => { 443 - const uniform26 = (_r: number) => 26; 444 - 445 - it('handles 1 row', () => { 446 - const range = calculateVisibleRange({ 447 - scrollTop: 0, 448 - viewportHeight: 600, 449 - totalRows: 1, 450 - getRowHeight: uniform26, 451 - bufferRows: 5, 452 - }); 453 - expect(range.startRow).toBe(1); 454 - expect(range.endRow).toBe(1); 455 - const result = computeSpacerHeights(1, uniform26, range); 456 - expect(result.totalHeight).toBe(26); 457 - expect(result.topSpacerHeight).toBe(0); 458 - expect(result.bottomSpacerHeight).toBe(0); 459 - }); 460 - 461 - it('handles scrollTop beyond total content height', () => { 462 - const range = calculateVisibleRange({ 463 - scrollTop: 100000, 464 - viewportHeight: 600, 465 - totalRows: 100, 466 - getRowHeight: uniform26, 467 - bufferRows: 5, 468 - }); 469 - expect(range.endRow).toBe(100); 470 - expect(range.startRow).toBeGreaterThanOrEqual(1); 471 - const result = computeSpacerHeights(100, uniform26, range); 472 - expect(result.totalHeight).toBe(2600); 473 - }); 474 - 475 - it('handles very tall rows', () => { 476 - const tallHeight = (_r: number) => 500; 477 - const range = calculateVisibleRange({ 478 - scrollTop: 2500, 479 - viewportHeight: 600, 480 - totalRows: 50, 481 - getRowHeight: tallHeight, 482 - bufferRows: 5, 483 - }); 484 - const result = computeSpacerHeights(50, tallHeight, range); 485 - expect(result.totalHeight).toBe(50 * 500); 486 - }); 487 - 488 - it('handles viewport taller than all content', () => { 489 - const range = calculateVisibleRange({ 490 - scrollTop: 0, 491 - viewportHeight: 10000, 492 - totalRows: 10, 493 - getRowHeight: uniform26, 494 - bufferRows: 5, 495 - }); 496 - expect(range.startRow).toBe(1); 497 - expect(range.endRow).toBe(10); 498 - const result = computeSpacerHeights(10, uniform26, range); 499 - expect(result.topSpacerHeight).toBe(0); 500 - expect(result.bottomSpacerHeight).toBe(0); 501 - expect(result.totalHeight).toBe(260); 502 - }); 503 - 504 - it('handles all rows hidden', () => { 505 - const rows = 20; 506 - const hidden = new Set(Array.from({ length: 20 }, (_, i) => i + 1)); 507 - const expected = totalRowHeights(rows, uniform26, hidden); 508 - expect(expected).toBe(0); 509 - 510 - const range = calculateVisibleRange({ 511 - scrollTop: 0, 512 - viewportHeight: 600, 513 - totalRows: rows, 514 - getRowHeight: uniform26, 515 - bufferRows: 5, 516 - }); 517 - const result = computeSpacerHeights(rows, uniform26, range, 0, hidden); 518 - expect(result.totalHeight).toBe(0); 519 - }); 520 - 521 - it('handles alternating hidden rows', () => { 522 - const rows = 100; 523 - const hidden = new Set(Array.from({ length: 50 }, (_, i) => i * 2 + 1)); // odd rows hidden 524 - const expected = totalRowHeights(rows, uniform26, hidden); 525 - 526 - for (const scrollTop of [0, 300, 800]) { 527 - const range = calculateVisibleRange({ 528 - scrollTop, 529 - viewportHeight: 600, 530 - totalRows: rows, 531 - getRowHeight: uniform26, 532 - bufferRows: 5, 533 - }); 534 - const result = computeSpacerHeights(rows, uniform26, range, 0, hidden); 535 - expect(result.totalHeight).toBe(expected); 536 - } 537 - }); 538 - 539 - it('handles extremely large row count', () => { 540 - const range = calculateVisibleRange({ 541 - scrollTop: 500000, 542 - viewportHeight: 600, 543 - totalRows: 100000, 544 - getRowHeight: uniform26, 545 - bufferRows: 5, 546 - }); 547 - // Should return a bounded range, not try to render all rows 548 - expect(range.endRow - range.startRow).toBeLessThan(50); 549 - expect(range.startRow).toBeGreaterThan(1); 550 - expect(range.endRow).toBeLessThanOrEqual(100000); 551 - }); 552 - 553 - it('handles negative scrollTop gracefully', () => { 554 - const range = calculateVisibleRange({ 555 - scrollTop: -100, 556 - viewportHeight: 600, 557 - totalRows: 100, 558 - getRowHeight: uniform26, 559 - bufferRows: 5, 560 - }); 561 - expect(range.startRow).toBe(1); 562 - }); 563 - 564 - it('handles zero viewportHeight', () => { 565 - const range = calculateVisibleRange({ 566 - scrollTop: 500, 567 - viewportHeight: 0, 568 - totalRows: 100, 569 - getRowHeight: uniform26, 570 - bufferRows: 5, 571 - }); 572 - expect(range.startRow).toBeGreaterThanOrEqual(1); 573 - expect(range.endRow).toBeLessThanOrEqual(100); 574 - const result = computeSpacerHeights(100, uniform26, range); 575 - expect(result.totalHeight).toBe(2600); 576 - }); 577 - }); 578 - 579 - // ============================================================ 580 - // SCROLL POSITION ↔ SPACER ALIGNMENT 581 - // ============================================================ 582 - 583 - describe('scroll position to spacer alignment', () => { 584 - it('top spacer height matches the cumulative height of rows above visible range', () => { 585 - const varHeight = (r: number) => { 586 - if (r <= 10) return 50; 587 - if (r <= 50) return 30; 588 - return 26; 589 - }; 590 - const rows = 200; 591 - 592 - // Scroll to a position within the variable-height section 593 - const scrollTop = 800; 594 - const range = calculateVisibleRange({ 595 - scrollTop, 596 - viewportHeight: 600, 597 - totalRows: rows, 598 - getRowHeight: varHeight, 599 - bufferRows: 5, 600 - }); 601 - 602 - const result = computeSpacerHeights(rows, varHeight, range); 603 - 604 - // The top spacer should position content so that scrollTop 605 - // lands on the correct row — verify the spacer is within one 606 - // row height of the scrollTop (accounting for buffer) 607 - let cumHeight = 0; 608 - let rowAtScrollTop = 1; 609 - for (let r = 1; r <= rows; r++) { 610 - if (cumHeight + varHeight(r) > scrollTop) { 611 - rowAtScrollTop = r; 612 - break; 613 - } 614 - cumHeight += varHeight(r); 615 - } 616 - 617 - // The rendered range should include the row at scrollTop 618 - expect(result.renderStartRow).toBeLessThanOrEqual(rowAtScrollTop); 619 - expect(result.renderEndRow).toBeGreaterThanOrEqual(rowAtScrollTop); 620 - }); 621 - }); 622 - 623 - // ============================================================ 624 - // RANGE CACHE BEHAVIOR 625 - // ============================================================ 626 - 627 - describe('range cache optimization', () => { 628 - const uniform26 = (_r: number) => 26; 629 - 630 - it('identical scroll positions produce identical ranges', () => { 631 - const params = { 632 - scrollTop: 1000, 633 - viewportHeight: 600, 634 - totalRows: 200, 635 - getRowHeight: uniform26, 636 - bufferRows: 5, 637 - }; 638 - 639 - const range1 = calculateVisibleRange(params); 640 - const range2 = calculateVisibleRange(params); 641 - 642 - expect(range1.startRow).toBe(range2.startRow); 643 - expect(range1.endRow).toBe(range2.endRow); 644 - }); 645 - 646 - it('small scroll changes within same row do not change range', () => { 647 - const base = calculateVisibleRange({ 648 - scrollTop: 1000, 649 - viewportHeight: 600, 650 - totalRows: 200, 651 - getRowHeight: uniform26, 652 - bufferRows: 5, 653 - }); 654 - 655 - // Scroll 1px — should not change range (still same row) 656 - const shifted = calculateVisibleRange({ 657 - scrollTop: 1001, 658 - viewportHeight: 600, 659 - totalRows: 200, 660 - getRowHeight: uniform26, 661 - bufferRows: 5, 662 - }); 663 - 664 - expect(shifted.startRow).toBe(base.startRow); 665 - expect(shifted.endRow).toBe(base.endRow); 666 - }); 667 - 668 - it('scrolling exactly one row height shifts range by 1', () => { 669 - const base = calculateVisibleRange({ 670 - scrollTop: 1000, 671 - viewportHeight: 600, 672 - totalRows: 200, 673 - getRowHeight: uniform26, 674 - bufferRows: 5, 675 - }); 676 - 677 - const shifted = calculateVisibleRange({ 678 - scrollTop: 1000 + 26, 679 - viewportHeight: 600, 680 - totalRows: 200, 681 - getRowHeight: uniform26, 682 - bufferRows: 5, 683 - }); 684 - 685 - // Range should shift by at most 1 row 686 - expect(Math.abs(shifted.startRow - base.startRow)).toBeLessThanOrEqual(1); 687 - expect(Math.abs(shifted.endRow - base.endRow)).toBeLessThanOrEqual(1); 688 - }); 689 - }); 690 - 691 - // ============================================================ 692 - // RENDERED ROW CORRECTNESS 693 - // ============================================================ 694 - 695 - describe('rendered row correctness', () => { 696 - const uniform26 = (_r: number) => 26; 697 - 698 - it('all rows within visible viewport are rendered', () => { 699 - const scrollTop = 1000; 700 - const viewportHeight = 600; 701 - const range = calculateVisibleRange({ 702 - scrollTop, 703 - viewportHeight, 704 - totalRows: 200, 705 - getRowHeight: uniform26, 706 - bufferRows: 5, 707 - }); 708 - 709 - // Find which rows are actually visible 710 - const firstVisibleRow = Math.floor(scrollTop / 26) + 1; 711 - const lastVisibleRow = Math.ceil((scrollTop + viewportHeight) / 26); 712 - 713 - expect(range.startRow).toBeLessThanOrEqual(firstVisibleRow); 714 - expect(range.endRow).toBeGreaterThanOrEqual(lastVisibleRow); 715 - }); 716 - 717 - it('all rows within visible viewport are rendered (variable heights)', () => { 718 - const varHeight = (r: number) => (r % 5 === 0 ? 52 : 26); 719 - const scrollTop = 1500; 720 - const viewportHeight = 600; 721 - 722 - const range = calculateVisibleRange({ 723 - scrollTop, 724 - viewportHeight, 725 - totalRows: 200, 726 - getRowHeight: varHeight, 727 - bufferRows: 5, 728 - }); 729 - 730 - // Walk to find first visible row 731 - let cumH = 0; 732 - let firstVisible = 1; 733 - for (let r = 1; r <= 200; r++) { 734 - if (cumH + varHeight(r) > scrollTop) { 735 - firstVisible = r; 736 - break; 737 - } 738 - cumH += varHeight(r); 739 - } 740 - 741 - // Walk to find last visible row 742 - let lastVisible = firstVisible; 743 - let filled = 0; 744 - for (let r = firstVisible; r <= 200; r++) { 745 - filled += varHeight(r); 746 - lastVisible = r; 747 - if (filled >= viewportHeight) break; 748 - } 749 - 750 - expect(range.startRow).toBeLessThanOrEqual(firstVisible); 751 - expect(range.endRow).toBeGreaterThanOrEqual(lastVisible); 752 - }); 753 - }); 754 - 755 - // ============================================================ 756 - // ALWAYS-PRESENT SPACERS (v3 fix) 757 - // ============================================================ 758 - 759 - describe('always-present spacers', () => { 760 - const uniform26 = (_r: number) => 26; 761 - 762 - it('spacers are defined (>= 0) even at top of sheet', () => { 763 - const range = calculateVisibleRange({ 764 - scrollTop: 0, 765 - viewportHeight: 600, 766 - totalRows: 200, 767 - getRowHeight: uniform26, 768 - bufferRows: 15, 769 - }); 770 - const result = computeSpacerHeights(200, uniform26, range); 771 - expect(result.topSpacerHeight).toBeGreaterThanOrEqual(0); 772 - expect(result.bottomSpacerHeight).toBeGreaterThanOrEqual(0); 773 - // At top, top spacer should be 0 774 - expect(result.topSpacerHeight).toBe(0); 775 - // Bottom spacer should cover remaining rows 776 - expect(result.bottomSpacerHeight).toBeGreaterThan(0); 777 - }); 778 - 779 - it('spacers are defined (>= 0) even at bottom of sheet', () => { 780 - const range = calculateVisibleRange({ 781 - scrollTop: 200 * 26, 782 - viewportHeight: 600, 783 - totalRows: 200, 784 - getRowHeight: uniform26, 785 - bufferRows: 15, 786 - }); 787 - const result = computeSpacerHeights(200, uniform26, range); 788 - expect(result.topSpacerHeight).toBeGreaterThanOrEqual(0); 789 - expect(result.bottomSpacerHeight).toBeGreaterThanOrEqual(0); 790 - }); 791 - 792 - it('spacers always defined with buffer=15 at all positions', () => { 793 - const rows = 500; 794 - for (let scrollTop = 0; scrollTop <= rows * 26; scrollTop += 100) { 795 - const range = calculateVisibleRange({ 796 - scrollTop, 797 - viewportHeight: 600, 798 - totalRows: rows, 799 - getRowHeight: uniform26, 800 - bufferRows: 15, 801 - }); 802 - const result = computeSpacerHeights(rows, uniform26, range); 803 - // Both spacers must always be non-negative (always rendered) 804 - expect(result.topSpacerHeight).toBeGreaterThanOrEqual(0); 805 - expect(result.bottomSpacerHeight).toBeGreaterThanOrEqual(0); 806 - // Total must always equal the full sheet height 807 - expect(result.totalHeight).toBe(rows * 26); 808 - } 809 - }); 810 - }); 811 - 812 - // ============================================================ 813 - // PINNED TABLE HEIGHT (v3 fix) 814 - // ============================================================ 815 - 816 - describe('pinned table height', () => { 817 - const uniform26 = (_r: number) => 26; 818 - 819 - it('total non-hidden row height is constant regardless of scroll position', () => { 820 - const rows = 300; 821 - const varHeight = (r: number) => (r % 10 === 0 ? 40 : 26); 822 - const expected = totalRowHeights(rows, varHeight); 823 - 824 - // Verify at many positions — this is what grid.style.minHeight pins to 825 - for (let st = 0; st <= 10000; st += 250) { 826 - expect(totalRowHeights(rows, varHeight)).toBe(expected); 827 - } 828 - }); 829 - 830 - it('total non-hidden row height excludes hidden rows consistently', () => { 831 - const rows = 200; 832 - const hidden = new Set([10, 20, 30, 40, 50]); 833 - const expected = totalRowHeights(rows, uniform26, hidden); 834 - expect(expected).toBe((200 - 5) * 26); 835 - }); 836 - }); 837 - 838 - // ============================================================ 839 - // BUFFER=15 REDUCES RE-RENDERS 840 - // ============================================================ 841 - 842 - describe('buffer=15 re-render reduction', () => { 843 - const uniform26 = (_r: number) => 26; 844 - 845 - it('scrolling 300px from top does not change range with buffer=15', () => { 846 - const base = calculateVisibleRange({ 847 - scrollTop: 0, 848 - viewportHeight: 600, 849 - totalRows: 500, 850 - getRowHeight: uniform26, 851 - bufferRows: 15, 852 - }); 853 - const scrolled = calculateVisibleRange({ 854 - scrollTop: 300, 855 - viewportHeight: 600, 856 - totalRows: 500, 857 - getRowHeight: uniform26, 858 - bufferRows: 15, 859 - }); 860 - // With buffer=15, first 15 rows are buffer. Scrolling 300px (~11 rows) 861 - // should still be within the buffered range 862 - expect(scrolled.startRow).toBe(base.startRow); 863 - }); 864 - 865 - it('scrolling 390px+ from top changes the range', () => { 866 - const base = calculateVisibleRange({ 867 - scrollTop: 0, 868 - viewportHeight: 600, 869 - totalRows: 500, 870 - getRowHeight: uniform26, 871 - bufferRows: 15, 872 - }); 873 - const scrolled = calculateVisibleRange({ 874 - scrollTop: 500, 875 - viewportHeight: 600, 876 - totalRows: 500, 877 - getRowHeight: uniform26, 878 - bufferRows: 15, 879 - }); 880 - // After scrolling ~19 rows, the range should have shifted 881 - expect(scrolled.startRow).toBeGreaterThan(base.startRow); 882 - }); 883 - });
-73
tests/sheets-ux-improvements.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { calculateVisibleRange } from '../src/sheets/virtual-scroll.js'; 3 - import { computeVisibleRows, computeVisibleCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from '../src/sheets/hidden-rows-cols.js'; 4 - 5 - describe('variable-height virtual scroll edge cases', () => { 6 - it('alternating tall/short rows', () => { 7 - const getRowHeight = (r: number) => r % 2 === 0 ? 50 : 10; 8 - const result = calculateVisibleRange({ 9 - scrollTop: 0, 10 - viewportHeight: 200, 11 - totalRows: 100, 12 - getRowHeight, 13 - bufferRows: 3, 14 - }); 15 - // Row heights: 10, 50, 10, 50, 10, 50, 10, 50... (avg 30) 16 - // ~6-7 rows visible in 200px + 3 buffer 17 - expect(result.startRow).toBe(1); 18 - expect(result.endRow).toBeGreaterThanOrEqual(8); 19 - expect(result.endRow).toBeLessThanOrEqual(15); 20 - }); 21 - 22 - it('scrolled midway with variable heights', () => { 23 - // Row heights: all 26 except row 5 = 100 24 - const getRowHeight = (r: number) => r === 5 ? 100 : 26; 25 - // Scroll to 130px: rows 1-4 = 104px, row 5 starts at 104 26 - // At 130px we're inside row 5 27 - const result = calculateVisibleRange({ 28 - scrollTop: 130, 29 - viewportHeight: 260, 30 - totalRows: 50, 31 - getRowHeight, 32 - bufferRows: 2, 33 - }); 34 - // First visible row is row 5, minus 2 buffer = row 3 35 - expect(result.startRow).toBeLessThanOrEqual(5); 36 - expect(result.startRow).toBeGreaterThanOrEqual(1); 37 - }); 38 - 39 - it('zero scrollTop with very tall first row', () => { 40 - const getRowHeight = (r: number) => r === 1 ? 500 : 26; 41 - const result = calculateVisibleRange({ 42 - scrollTop: 0, 43 - viewportHeight: 260, 44 - totalRows: 50, 45 - getRowHeight, 46 - bufferRows: 2, 47 - }); 48 - expect(result.startRow).toBe(1); 49 - // Only row 1 fits (500px > 260px viewport), but buffer adds a few more 50 - expect(result.endRow).toBeGreaterThanOrEqual(1); 51 - }); 52 - }); 53 - 54 - describe('hidden rows interact with virtual scroll', () => { 55 - it('computeVisibleRows skips hidden rows', () => { 56 - const hiddenSet = { has: (r: number) => r === 3 || r === 5 }; 57 - const { rows } = computeVisibleRows(1, 10, hiddenSet); 58 - expect(rows).not.toContain(3); 59 - expect(rows).not.toContain(5); 60 - expect(rows).toContain(1); 61 - expect(rows).toContain(2); 62 - expect(rows).toContain(4); 63 - }); 64 - 65 - it('isAtHiddenRowBoundary detects boundaries', () => { 66 - const hiddenSet = { has: (r: number) => r === 3 || r === 4 }; 67 - // Row 2 is adjacent to hidden row 3 68 - expect(isAtHiddenRowBoundary(2, 10, hiddenSet)).toBe(true); 69 - // Row 5 is adjacent to hidden row 4 70 - expect(isAtHiddenRowBoundary(5, 10, hiddenSet)).toBe(true); 71 - // Row 1 is not adjacent to any hidden row 72 - expect(isAtHiddenRowBoundary(1, 10, hiddenSet)).toBe(false); 73 - }); 74 - }); 75 2 76 3 describe('context menu freeze/unfreeze items', () => { 77 4 // These test the logic patterns used in the context menu item generation
-128
tests/virtual-scroll-variable.test.ts
··· 1 - import { describe, it, expect } from 'vitest'; 2 - import { calculateVisibleRange, DEFAULT_ROW_HEIGHT } from '../src/sheets/virtual-scroll.js'; 3 - 4 - describe('calculateVisibleRange with variable row heights', () => { 5 - const uniformHeight = (_r: number) => 26; 6 - const variableHeight = (r: number) => { 7 - if (r === 3) return 52; // double-height row 8 - if (r === 7) return 13; // half-height row 9 - return 26; 10 - }; 11 - 12 - it('uniform heights match original behavior', () => { 13 - const result = calculateVisibleRange({ 14 - scrollTop: 0, 15 - viewportHeight: 260, 16 - totalRows: 100, 17 - getRowHeight: uniformHeight, 18 - bufferRows: 5, 19 - }); 20 - expect(result.startRow).toBe(1); 21 - // ~10 visible rows + 5 buffer below 22 - expect(result.endRow).toBeGreaterThanOrEqual(15); 23 - }); 24 - 25 - it('finds correct first visible row with variable heights', () => { 26 - // scrollTop = 78 (rows 1-3: 26 + 26 + 52 = 104, so row 3 is still partially visible) 27 - // Row heights: row1=26, row2=26, row3=52, row4=26... 28 - // At scrollTop=78, we're inside row 3 (starts at 52, ends at 104) 29 - const result = calculateVisibleRange({ 30 - scrollTop: 78, 31 - viewportHeight: 260, 32 - totalRows: 100, 33 - getRowHeight: variableHeight, 34 - bufferRows: 2, 35 - }); 36 - // First visible should be row 3 (index 2), minus 2 buffer = row 1 37 - expect(result.startRow).toBe(1); 38 - }); 39 - 40 - it('accounts for tall rows consuming more viewport', () => { 41 - // All rows are 100px tall — only ~2-3 fit in 260px viewport 42 - const tallHeight = (_r: number) => 100; 43 - const result = calculateVisibleRange({ 44 - scrollTop: 0, 45 - viewportHeight: 260, 46 - totalRows: 50, 47 - getRowHeight: tallHeight, 48 - bufferRows: 2, 49 - }); 50 - // 3 visible + 2 buffer below = endRow ~5 51 - expect(result.endRow).toBeLessThanOrEqual(7); 52 - expect(result.endRow).toBeGreaterThanOrEqual(4); 53 - }); 54 - 55 - it('handles scrolling past many rows', () => { 56 - // Scroll 2600px down with 26px rows = roughly row 100 57 - const result = calculateVisibleRange({ 58 - scrollTop: 2600, 59 - viewportHeight: 260, 60 - totalRows: 200, 61 - getRowHeight: uniformHeight, 62 - bufferRows: 5, 63 - }); 64 - // Should be around row 95-105 65 - expect(result.startRow).toBeGreaterThanOrEqual(90); 66 - expect(result.endRow).toBeLessThanOrEqual(120); 67 - }); 68 - 69 - it('clamps to totalRows at the bottom', () => { 70 - const result = calculateVisibleRange({ 71 - scrollTop: 5000, 72 - viewportHeight: 260, 73 - totalRows: 50, 74 - getRowHeight: uniformHeight, 75 - bufferRows: 5, 76 - }); 77 - expect(result.endRow).toBe(50); 78 - }); 79 - 80 - it('handles single row', () => { 81 - const result = calculateVisibleRange({ 82 - scrollTop: 0, 83 - viewportHeight: 260, 84 - totalRows: 1, 85 - getRowHeight: uniformHeight, 86 - bufferRows: 5, 87 - }); 88 - expect(result.startRow).toBe(1); 89 - expect(result.endRow).toBe(1); 90 - }); 91 - }); 92 - 93 - describe('calculateVisibleRange with uniform rowHeight (backwards compat)', () => { 94 - it('still works with rowHeight param', () => { 95 - const result = calculateVisibleRange({ 96 - scrollTop: 0, 97 - viewportHeight: 260, 98 - totalRows: 100, 99 - rowHeight: 26, 100 - bufferRows: 5, 101 - }); 102 - expect(result.startRow).toBe(1); 103 - expect(result.endRow).toBeGreaterThanOrEqual(15); 104 - }); 105 - }); 106 - 107 - describe('keyboard navigation helpers', () => { 108 - // These test the logic patterns used by extendSelection/moveSelectionTo 109 - it('extendSelection clamps to bounds', () => { 110 - const maxCol = 26, maxRow = 100; 111 - let endCol = 1, endRow = 1; 112 - // Extend right 113 - endCol = Math.max(1, Math.min(maxCol, endCol + 1)); 114 - expect(endCol).toBe(2); 115 - // Extend past max 116 - endCol = Math.max(1, Math.min(maxCol, 26 + 1)); 117 - expect(endCol).toBe(26); 118 - // Extend left past min 119 - endCol = Math.max(1, Math.min(maxCol, 1 - 1)); 120 - expect(endCol).toBe(1); 121 - // Page down 122 - endRow = Math.max(1, Math.min(maxRow, 1 + 20)); 123 - expect(endRow).toBe(21); 124 - // Page up from top 125 - endRow = Math.max(1, Math.min(maxRow, 5 - 20)); 126 - expect(endRow).toBe(1); 127 - }); 128 - });
-192
tests/virtual-scroll.test.ts
··· 1 - import { describe, it, expect } from 'vitest'; 2 - import { 3 - calculateVisibleRange, 4 - calculateSpacerHeight, 5 - DEFAULT_ROW_HEIGHT, 6 - DEFAULT_BUFFER_ROWS, 7 - } from '../src/sheets/virtual-scroll.js'; 8 - 9 - // ============================================================ 10 - // Constants 11 - // ============================================================ 12 - 13 - describe('constants', () => { 14 - it('exports default row height', () => { 15 - expect(typeof DEFAULT_ROW_HEIGHT).toBe('number'); 16 - expect(DEFAULT_ROW_HEIGHT).toBeGreaterThan(0); 17 - }); 18 - 19 - it('exports default buffer rows', () => { 20 - expect(typeof DEFAULT_BUFFER_ROWS).toBe('number'); 21 - expect(DEFAULT_BUFFER_ROWS).toBe(10); 22 - }); 23 - }); 24 - 25 - // ============================================================ 26 - // calculateVisibleRange 27 - // ============================================================ 28 - 29 - describe('calculateVisibleRange', () => { 30 - const ROW_HEIGHT = 26; 31 - const VIEWPORT_HEIGHT = 260; // fits 10 rows exactly 32 - 33 - it('returns correct range at top of sheet', () => { 34 - const result = calculateVisibleRange({ 35 - scrollTop: 0, 36 - viewportHeight: VIEWPORT_HEIGHT, 37 - totalRows: 100, 38 - rowHeight: ROW_HEIGHT, 39 - bufferRows: 10, 40 - }); 41 - expect(result.startRow).toBe(1); 42 - // 10 visible + 10 buffer below = 20 43 - expect(result.endRow).toBe(20); 44 - }); 45 - 46 - it('returns correct range when scrolled down', () => { 47 - // Scrolled down 20 rows (520px at 26px/row) 48 - const result = calculateVisibleRange({ 49 - scrollTop: 520, 50 - viewportHeight: VIEWPORT_HEIGHT, 51 - totalRows: 100, 52 - rowHeight: ROW_HEIGHT, 53 - bufferRows: 10, 54 - }); 55 - // First visible row: 520/26 = 20, so row 21 56 - // With buffer: 21 - 10 = 11 57 - expect(result.startRow).toBe(11); 58 - // Last visible: 21 + 10 - 1 = 30 59 - // With buffer: 30 + 10 = 40 60 - expect(result.endRow).toBe(40); 61 - }); 62 - 63 - it('clamps startRow to 1', () => { 64 - const result = calculateVisibleRange({ 65 - scrollTop: 50, // only 1-2 rows down 66 - viewportHeight: VIEWPORT_HEIGHT, 67 - totalRows: 100, 68 - rowHeight: ROW_HEIGHT, 69 - bufferRows: 10, 70 - }); 71 - expect(result.startRow).toBe(1); 72 - }); 73 - 74 - it('clamps endRow to totalRows', () => { 75 - // Scrolled to bottom 76 - const result = calculateVisibleRange({ 77 - scrollTop: 2600, // past all rows 78 - viewportHeight: VIEWPORT_HEIGHT, 79 - totalRows: 100, 80 - rowHeight: ROW_HEIGHT, 81 - bufferRows: 10, 82 - }); 83 - expect(result.endRow).toBe(100); 84 - }); 85 - 86 - it('handles small total rows', () => { 87 - const result = calculateVisibleRange({ 88 - scrollTop: 0, 89 - viewportHeight: VIEWPORT_HEIGHT, 90 - totalRows: 5, 91 - rowHeight: ROW_HEIGHT, 92 - bufferRows: 10, 93 - }); 94 - expect(result.startRow).toBe(1); 95 - expect(result.endRow).toBe(5); 96 - }); 97 - 98 - it('handles zero scroll position', () => { 99 - const result = calculateVisibleRange({ 100 - scrollTop: 0, 101 - viewportHeight: 500, 102 - totalRows: 1000, 103 - rowHeight: ROW_HEIGHT, 104 - bufferRows: 10, 105 - }); 106 - expect(result.startRow).toBe(1); 107 - expect(result.endRow).toBeGreaterThan(10); 108 - }); 109 - 110 - it('returns correct range near the bottom', () => { 111 - // 100 rows at 26px = 2600px total 112 - // Scrolled to 2340px -> last visible is row 100 113 - const result = calculateVisibleRange({ 114 - scrollTop: 2340, 115 - viewportHeight: VIEWPORT_HEIGHT, 116 - totalRows: 100, 117 - rowHeight: ROW_HEIGHT, 118 - bufferRows: 10, 119 - }); 120 - expect(result.endRow).toBe(100); 121 - expect(result.startRow).toBeGreaterThan(70); 122 - }); 123 - 124 - it('uses default buffer when not specified', () => { 125 - const result = calculateVisibleRange({ 126 - scrollTop: 0, 127 - viewportHeight: VIEWPORT_HEIGHT, 128 - totalRows: 100, 129 - rowHeight: ROW_HEIGHT, 130 - }); 131 - // Should use DEFAULT_BUFFER_ROWS = 10 132 - expect(result.startRow).toBe(1); 133 - expect(result.endRow).toBe(20); // 10 visible + 10 buffer 134 - }); 135 - 136 - it('handles very large sheet', () => { 137 - const result = calculateVisibleRange({ 138 - scrollTop: 100000, 139 - viewportHeight: 500, 140 - totalRows: 1000000, 141 - rowHeight: ROW_HEIGHT, 142 - bufferRows: 10, 143 - }); 144 - expect(result.startRow).toBeGreaterThan(1); 145 - expect(result.endRow).toBeLessThanOrEqual(1000000); 146 - expect(result.endRow - result.startRow).toBeLessThan(100); 147 - }); 148 - 149 - it('handles zero viewport height', () => { 150 - const result = calculateVisibleRange({ 151 - scrollTop: 0, 152 - viewportHeight: 0, 153 - totalRows: 100, 154 - rowHeight: ROW_HEIGHT, 155 - bufferRows: 10, 156 - }); 157 - // Should still render buffer rows 158 - expect(result.startRow).toBe(1); 159 - expect(result.endRow).toBeGreaterThanOrEqual(1); 160 - }); 161 - }); 162 - 163 - // ============================================================ 164 - // calculateSpacerHeight 165 - // ============================================================ 166 - 167 - describe('calculateSpacerHeight', () => { 168 - it('calculates total height for 100 rows', () => { 169 - const height = calculateSpacerHeight(100, 26); 170 - expect(height).toBe(2600); 171 - }); 172 - 173 - it('calculates total height for 0 rows', () => { 174 - const height = calculateSpacerHeight(0, 26); 175 - expect(height).toBe(0); 176 - }); 177 - 178 - it('calculates total height for 1 row', () => { 179 - const height = calculateSpacerHeight(1, 26); 180 - expect(height).toBe(26); 181 - }); 182 - 183 - it('calculates total height for large row count', () => { 184 - const height = calculateSpacerHeight(1000000, 26); 185 - expect(height).toBe(26000000); 186 - }); 187 - 188 - it('uses default row height when not specified', () => { 189 - const height = calculateSpacerHeight(100); 190 - expect(height).toBe(100 * DEFAULT_ROW_HEIGHT); 191 - }); 192 - });