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

Configure Feed

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

test: replace stub tests with real coverage, add E2E for new features

Replace 3 stub test files that were testing CSS constants and inline
calculations with meaningful tests against real exported functions:
- sheets-ux-improvements: cell-styles.ts borders/wrap/striped + sort.ts
- ux-iteration-3: formulas.ts parseRef/colToLetter/formatCell + row-col-ops.ts
- landing-overhaul: all 20+ functions from landing-utils.ts

Add new unit tests for blob-upload.ts (13 tests covering upload/download/
list/delete/readFileAsBuffer/blobToObjectUrl with proper FileReader mock).

Add E2E tests for slides, diagrams, and forms features with Playwright
helpers (createNewSlides, createNewDiagram, createNewForm).

Resolves #296, #297, #298, #299, #300, #301

+1845 -193
+421
e2e/diagrams.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDiagram } from './helpers'; 3 + 4 + test.describe('Diagrams - Canvas and Toolbar', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewDiagram(page); 7 + }); 8 + 9 + test('page loads with SVG canvas visible', async ({ page }) => { 10 + const canvas = page.locator('#diagram-canvas'); 11 + await expect(canvas).toBeVisible(); 12 + // Canvas should have the grid pattern background 13 + await expect(page.locator('.diagrams-grid')).toBeVisible(); 14 + // The shape layer should exist (empty initially) 15 + await expect(page.locator('#diagram-layer')).toBeAttached(); 16 + }); 17 + 18 + test('URL matches /diagrams/{id}#{key} pattern', async ({ page }) => { 19 + const url = page.url(); 20 + expect(url).toMatch(/\/diagrams\/[^/]+#.+/); 21 + }); 22 + 23 + test('title input shows default name', async ({ page }) => { 24 + const title = page.locator('#diagram-title'); 25 + await expect(title).toBeVisible(); 26 + await expect(title).toHaveValue('Untitled Diagram'); 27 + }); 28 + 29 + test('all tool buttons exist and are visible', async ({ page }) => { 30 + const tools = ['select', 'rectangle', 'ellipse', 'diamond', 'text', 'freehand', 'arrow']; 31 + for (const tool of tools) { 32 + await expect(page.locator(`#tool-${tool}`)).toBeVisible(); 33 + } 34 + }); 35 + 36 + test('select tool is active by default', async ({ page }) => { 37 + await expect(page.locator('#tool-select')).toHaveClass(/active/); 38 + // Other tools should not be active 39 + await expect(page.locator('#tool-rectangle')).not.toHaveClass(/active/); 40 + await expect(page.locator('#tool-ellipse')).not.toHaveClass(/active/); 41 + }); 42 + 43 + test('clicking a tool button activates it', async ({ page }) => { 44 + await page.click('#tool-rectangle'); 45 + await expect(page.locator('#tool-rectangle')).toHaveClass(/active/); 46 + await expect(page.locator('#tool-select')).not.toHaveClass(/active/); 47 + 48 + await page.click('#tool-ellipse'); 49 + await expect(page.locator('#tool-ellipse')).toHaveClass(/active/); 50 + await expect(page.locator('#tool-rectangle')).not.toHaveClass(/active/); 51 + }); 52 + }); 53 + 54 + test.describe('Diagrams - Shape Creation', () => { 55 + test.beforeEach(async ({ page }) => { 56 + await createNewDiagram(page); 57 + }); 58 + 59 + test('add rectangle shape by selecting tool and clicking canvas', async ({ page }) => { 60 + // Select rectangle tool 61 + await page.click('#tool-rectangle'); 62 + await expect(page.locator('#tool-rectangle')).toHaveClass(/active/); 63 + 64 + // Click on the canvas to place a shape 65 + const canvas = page.locator('#diagram-canvas'); 66 + await canvas.click({ position: { x: 300, y: 300 } }); 67 + 68 + // A shape should appear in the diagram layer 69 + const shape = page.locator('.diagram-shape'); 70 + await expect(shape).toHaveCount(1); 71 + await expect(shape).toBeVisible(); 72 + 73 + // Shape should contain a rect element (rectangle kind) 74 + await expect(shape.locator('rect')).toBeAttached(); 75 + 76 + // Tool should revert to select after placing a shape 77 + await expect(page.locator('#tool-select')).toHaveClass(/active/); 78 + }); 79 + 80 + test('add ellipse shape', async ({ page }) => { 81 + await page.click('#tool-ellipse'); 82 + const canvas = page.locator('#diagram-canvas'); 83 + await canvas.click({ position: { x: 300, y: 300 } }); 84 + 85 + const shape = page.locator('.diagram-shape'); 86 + await expect(shape).toHaveCount(1); 87 + // Ellipse shape should contain an ellipse SVG element 88 + await expect(shape.locator('ellipse')).toBeAttached(); 89 + }); 90 + 91 + test('add diamond shape', async ({ page }) => { 92 + await page.click('#tool-diamond'); 93 + const canvas = page.locator('#diagram-canvas'); 94 + await canvas.click({ position: { x: 300, y: 300 } }); 95 + 96 + const shape = page.locator('.diagram-shape'); 97 + await expect(shape).toHaveCount(1); 98 + // Diamond uses a polygon element 99 + await expect(shape.locator('polygon')).toBeAttached(); 100 + }); 101 + 102 + test('add text shape', async ({ page }) => { 103 + await page.click('#tool-text'); 104 + const canvas = page.locator('#diagram-canvas'); 105 + await canvas.click({ position: { x: 300, y: 300 } }); 106 + 107 + const shape = page.locator('.diagram-shape'); 108 + await expect(shape).toHaveCount(1); 109 + // Text shape should have a label "Text" 110 + await expect(shape.locator('text')).toHaveText('Text'); 111 + }); 112 + 113 + test('add multiple shapes to the canvas', async ({ page }) => { 114 + const canvas = page.locator('#diagram-canvas'); 115 + 116 + // Add a rectangle 117 + await page.click('#tool-rectangle'); 118 + await canvas.click({ position: { x: 200, y: 200 } }); 119 + 120 + // Add an ellipse 121 + await page.click('#tool-ellipse'); 122 + await canvas.click({ position: { x: 400, y: 200 } }); 123 + 124 + // Add a diamond 125 + await page.click('#tool-diamond'); 126 + await canvas.click({ position: { x: 300, y: 400 } }); 127 + 128 + // Three shapes should exist 129 + await expect(page.locator('.diagram-shape')).toHaveCount(3); 130 + }); 131 + }); 132 + 133 + test.describe('Diagrams - Zoom Controls', () => { 134 + test.beforeEach(async ({ page }) => { 135 + await createNewDiagram(page); 136 + }); 137 + 138 + test('zoom label shows 100% by default', async ({ page }) => { 139 + await expect(page.locator('#zoom-label')).toHaveText('100%'); 140 + }); 141 + 142 + test('zoom in button increases zoom level', async ({ page }) => { 143 + await page.click('#btn-zoom-in'); 144 + await expect(page.locator('#zoom-label')).toHaveText('125%'); 145 + }); 146 + 147 + test('zoom out button decreases zoom level', async ({ page }) => { 148 + await page.click('#btn-zoom-out'); 149 + await expect(page.locator('#zoom-label')).toHaveText('75%'); 150 + }); 151 + 152 + test('multiple zoom in clicks accumulate', async ({ page }) => { 153 + await page.click('#btn-zoom-in'); 154 + await page.click('#btn-zoom-in'); 155 + await expect(page.locator('#zoom-label')).toHaveText('150%'); 156 + }); 157 + 158 + test('zoom has a lower bound', async ({ page }) => { 159 + // Click zoom out many times to hit the floor 160 + for (let i = 0; i < 20; i++) { 161 + await page.click('#btn-zoom-out'); 162 + } 163 + const text = await page.locator('#zoom-label').textContent(); 164 + const percent = parseInt(text || '0'); 165 + // Minimum zoom is 0.1 = 10% 166 + expect(percent).toBeGreaterThanOrEqual(10); 167 + }); 168 + 169 + test('zoom has an upper bound', async ({ page }) => { 170 + // Click zoom in many times to hit the ceiling 171 + for (let i = 0; i < 40; i++) { 172 + await page.click('#btn-zoom-in'); 173 + } 174 + const text = await page.locator('#zoom-label').textContent(); 175 + const percent = parseInt(text || '0'); 176 + // Maximum zoom is 5 = 500% 177 + expect(percent).toBeLessThanOrEqual(500); 178 + }); 179 + }); 180 + 181 + test.describe('Diagrams - Snap to Grid', () => { 182 + test.beforeEach(async ({ page }) => { 183 + await createNewDiagram(page); 184 + }); 185 + 186 + test('snap-to-grid button exists', async ({ page }) => { 187 + await expect(page.locator('#btn-snap-grid')).toBeVisible(); 188 + }); 189 + 190 + test('snap-to-grid is active by default', async ({ page }) => { 191 + // WhiteboardState defaults snapToGrid=true; the button should have active class 192 + await expect(page.locator('#btn-snap-grid')).toHaveClass(/active/); 193 + }); 194 + 195 + test('clicking snap-to-grid toggles it off and on', async ({ page }) => { 196 + const btn = page.locator('#btn-snap-grid'); 197 + 198 + // Initially active 199 + await expect(btn).toHaveClass(/active/); 200 + 201 + // Click to toggle off 202 + await btn.click(); 203 + await expect(btn).not.toHaveClass(/active/); 204 + 205 + // Click to toggle back on 206 + await btn.click(); 207 + await expect(btn).toHaveClass(/active/); 208 + }); 209 + }); 210 + 211 + test.describe('Diagrams - Shape Selection and Properties Panel', () => { 212 + test.beforeEach(async ({ page }) => { 213 + await createNewDiagram(page); 214 + }); 215 + 216 + test('properties panel is hidden when no shape is selected', async ({ page }) => { 217 + await expect(page.locator('#props-panel')).not.toBeVisible(); 218 + }); 219 + 220 + test('selecting a shape shows the properties panel', async ({ page }) => { 221 + const canvas = page.locator('#diagram-canvas'); 222 + 223 + // Add a rectangle 224 + await page.click('#tool-rectangle'); 225 + await canvas.click({ position: { x: 300, y: 300 } }); 226 + 227 + // Now we are back in select mode; click on the shape to select it 228 + // The shape was placed around (300,300) in screen coords; click same spot 229 + await canvas.click({ position: { x: 300, y: 300 } }); 230 + 231 + // Properties panel should be visible 232 + await expect(page.locator('#props-panel')).toBeVisible(); 233 + // The selected shape should have the .selected class 234 + await expect(page.locator('.diagram-shape.selected')).toHaveCount(1); 235 + }); 236 + 237 + test('properties panel shows width and height inputs', async ({ page }) => { 238 + const canvas = page.locator('#diagram-canvas'); 239 + 240 + // Add and select a rectangle 241 + await page.click('#tool-rectangle'); 242 + await canvas.click({ position: { x: 300, y: 300 } }); 243 + await canvas.click({ position: { x: 300, y: 300 } }); 244 + 245 + await expect(page.locator('#props-panel')).toBeVisible(); 246 + await expect(page.locator('#prop-width')).toBeVisible(); 247 + await expect(page.locator('#prop-height')).toBeVisible(); 248 + await expect(page.locator('#prop-label')).toBeVisible(); 249 + }); 250 + 251 + test('properties panel shows correct default dimensions', async ({ page }) => { 252 + const canvas = page.locator('#diagram-canvas'); 253 + 254 + // Add and select a rectangle (default size is 120x80) 255 + await page.click('#tool-rectangle'); 256 + await canvas.click({ position: { x: 300, y: 300 } }); 257 + await canvas.click({ position: { x: 300, y: 300 } }); 258 + 259 + await expect(page.locator('#props-panel')).toBeVisible(); 260 + await expect(page.locator('#prop-width')).toHaveValue('120'); 261 + await expect(page.locator('#prop-height')).toHaveValue('80'); 262 + }); 263 + 264 + test('clicking empty canvas deselects shape and hides properties panel', async ({ page }) => { 265 + const canvas = page.locator('#diagram-canvas'); 266 + 267 + // Add a rectangle and select it 268 + await page.click('#tool-rectangle'); 269 + await canvas.click({ position: { x: 300, y: 300 } }); 270 + await canvas.click({ position: { x: 300, y: 300 } }); 271 + await expect(page.locator('#props-panel')).toBeVisible(); 272 + 273 + // Click on an empty area of the canvas 274 + await canvas.click({ position: { x: 50, y: 50 } }); 275 + await expect(page.locator('#props-panel')).not.toBeVisible(); 276 + await expect(page.locator('.diagram-shape.selected')).toHaveCount(0); 277 + }); 278 + }); 279 + 280 + test.describe('Diagrams - Delete Shape', () => { 281 + test.beforeEach(async ({ page }) => { 282 + await createNewDiagram(page); 283 + }); 284 + 285 + test('delete button exists', async ({ page }) => { 286 + await expect(page.locator('#btn-delete')).toBeVisible(); 287 + }); 288 + 289 + test('delete button removes a selected shape', async ({ page }) => { 290 + const canvas = page.locator('#diagram-canvas'); 291 + 292 + // Add a rectangle 293 + await page.click('#tool-rectangle'); 294 + await canvas.click({ position: { x: 300, y: 300 } }); 295 + await expect(page.locator('.diagram-shape')).toHaveCount(1); 296 + 297 + // Select the shape 298 + await canvas.click({ position: { x: 300, y: 300 } }); 299 + await expect(page.locator('.diagram-shape.selected')).toHaveCount(1); 300 + 301 + // Delete it 302 + await page.click('#btn-delete'); 303 + await expect(page.locator('.diagram-shape')).toHaveCount(0); 304 + 305 + // Properties panel should be hidden 306 + await expect(page.locator('#props-panel')).not.toBeVisible(); 307 + }); 308 + 309 + test('delete with keyboard (Delete key) removes selected shape', async ({ page }) => { 310 + const canvas = page.locator('#diagram-canvas'); 311 + 312 + // Add and select a rectangle 313 + await page.click('#tool-rectangle'); 314 + await canvas.click({ position: { x: 300, y: 300 } }); 315 + await canvas.click({ position: { x: 300, y: 300 } }); 316 + await expect(page.locator('.diagram-shape')).toHaveCount(1); 317 + 318 + // Press Delete key 319 + await page.keyboard.press('Delete'); 320 + await expect(page.locator('.diagram-shape')).toHaveCount(0); 321 + }); 322 + 323 + test('delete with Backspace key removes selected shape', async ({ page }) => { 324 + const canvas = page.locator('#diagram-canvas'); 325 + 326 + // Add and select a rectangle 327 + await page.click('#tool-rectangle'); 328 + await canvas.click({ position: { x: 300, y: 300 } }); 329 + await canvas.click({ position: { x: 300, y: 300 } }); 330 + await expect(page.locator('.diagram-shape')).toHaveCount(1); 331 + 332 + // Press Backspace key 333 + await page.keyboard.press('Backspace'); 334 + await expect(page.locator('.diagram-shape')).toHaveCount(0); 335 + }); 336 + 337 + test('delete does nothing when no shape is selected', async ({ page }) => { 338 + const canvas = page.locator('#diagram-canvas'); 339 + 340 + // Add a rectangle but do not select it (click empty space after) 341 + await page.click('#tool-rectangle'); 342 + await canvas.click({ position: { x: 300, y: 300 } }); 343 + await canvas.click({ position: { x: 50, y: 50 } }); 344 + await expect(page.locator('.diagram-shape')).toHaveCount(1); 345 + await expect(page.locator('.diagram-shape.selected')).toHaveCount(0); 346 + 347 + // Click delete -- nothing should happen 348 + await page.click('#btn-delete'); 349 + await expect(page.locator('.diagram-shape')).toHaveCount(1); 350 + }); 351 + }); 352 + 353 + test.describe('Diagrams - Keyboard Shortcuts', () => { 354 + test.beforeEach(async ({ page }) => { 355 + await createNewDiagram(page); 356 + }); 357 + 358 + test('pressing V activates select tool', async ({ page }) => { 359 + // First switch away from select 360 + await page.click('#tool-rectangle'); 361 + await expect(page.locator('#tool-rectangle')).toHaveClass(/active/); 362 + 363 + // Press V to go back to select 364 + await page.keyboard.press('v'); 365 + await expect(page.locator('#tool-select')).toHaveClass(/active/); 366 + await expect(page.locator('#tool-rectangle')).not.toHaveClass(/active/); 367 + }); 368 + 369 + test('pressing R activates rectangle tool', async ({ page }) => { 370 + await page.keyboard.press('r'); 371 + await expect(page.locator('#tool-rectangle')).toHaveClass(/active/); 372 + }); 373 + 374 + test('pressing E activates ellipse tool', async ({ page }) => { 375 + await page.keyboard.press('e'); 376 + await expect(page.locator('#tool-ellipse')).toHaveClass(/active/); 377 + }); 378 + 379 + test('pressing D activates diamond tool', async ({ page }) => { 380 + await page.keyboard.press('d'); 381 + await expect(page.locator('#tool-diamond')).toHaveClass(/active/); 382 + }); 383 + 384 + test('pressing T activates text tool', async ({ page }) => { 385 + await page.keyboard.press('t'); 386 + await expect(page.locator('#tool-text')).toHaveClass(/active/); 387 + }); 388 + 389 + test('pressing P activates freehand tool', async ({ page }) => { 390 + await page.keyboard.press('p'); 391 + await expect(page.locator('#tool-freehand')).toHaveClass(/active/); 392 + }); 393 + 394 + test('pressing A activates arrow tool', async ({ page }) => { 395 + await page.keyboard.press('a'); 396 + await expect(page.locator('#tool-arrow')).toHaveClass(/active/); 397 + }); 398 + 399 + test('shortcuts are ignored when typing in an input field', async ({ page }) => { 400 + // Focus the title input 401 + const titleInput = page.locator('#diagram-title'); 402 + await titleInput.click(); 403 + await titleInput.fill(''); 404 + 405 + // Type R -- should go into the input, not activate rectangle tool 406 + await page.keyboard.type('r'); 407 + await expect(page.locator('#tool-select')).toHaveClass(/active/); 408 + await expect(page.locator('#tool-rectangle')).not.toHaveClass(/active/); 409 + }); 410 + 411 + test('keyboard shortcut to create shape then place it on canvas', async ({ page }) => { 412 + const canvas = page.locator('#diagram-canvas'); 413 + 414 + // Press R then click canvas 415 + await page.keyboard.press('r'); 416 + await canvas.click({ position: { x: 300, y: 300 } }); 417 + 418 + await expect(page.locator('.diagram-shape')).toHaveCount(1); 419 + await expect(page.locator('.diagram-shape rect')).toBeAttached(); 420 + }); 421 + });
+383
e2e/forms.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewForm } from './helpers'; 3 + 4 + test.describe('Forms - Builder', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewForm(page); 7 + }); 8 + 9 + test('form builder loads with toolbar and empty state', async ({ page }) => { 10 + // Toolbar buttons should be visible 11 + await expect(page.locator('#btn-add-question')).toBeVisible(); 12 + await expect(page.locator('#btn-preview')).toBeVisible(); 13 + await expect(page.locator('#btn-responses')).toBeVisible(); 14 + await expect(page.locator('#btn-settings')).toBeVisible(); 15 + 16 + // Title input should show default 17 + await expect(page.locator('#form-title')).toHaveValue('Untitled Form'); 18 + 19 + // Description textarea should be empty 20 + await expect(page.locator('#form-description')).toHaveValue(''); 21 + 22 + // Empty state message should be visible 23 + await expect(page.locator('#form-questions')).toContainText('No questions yet'); 24 + }); 25 + 26 + test('add question dialog opens and shows all question types', async ({ page }) => { 27 + await page.click('#btn-add-question'); 28 + 29 + // Dialog overlay should appear 30 + await expect(page.locator('.add-question-overlay')).toBeVisible(); 31 + 32 + // All 10 question type buttons should be present 33 + const typeButtons = page.locator('.form-type-btn'); 34 + await expect(typeButtons).toHaveCount(10); 35 + 36 + // Verify specific types are listed 37 + await expect(page.locator('.form-type-btn[data-type="short_text"]')).toBeVisible(); 38 + await expect(page.locator('.form-type-btn[data-type="long_text"]')).toBeVisible(); 39 + await expect(page.locator('.form-type-btn[data-type="number"]')).toBeVisible(); 40 + await expect(page.locator('.form-type-btn[data-type="email"]')).toBeVisible(); 41 + await expect(page.locator('.form-type-btn[data-type="single_choice"]')).toBeVisible(); 42 + await expect(page.locator('.form-type-btn[data-type="multiple_choice"]')).toBeVisible(); 43 + await expect(page.locator('.form-type-btn[data-type="dropdown"]')).toBeVisible(); 44 + await expect(page.locator('.form-type-btn[data-type="date"]')).toBeVisible(); 45 + await expect(page.locator('.form-type-btn[data-type="rating"]')).toBeVisible(); 46 + await expect(page.locator('.form-type-btn[data-type="scale"]')).toBeVisible(); 47 + 48 + // Cancel button should close the dialog 49 + await page.click('#add-q-cancel'); 50 + await expect(page.locator('.add-question-overlay')).not.toBeVisible(); 51 + }); 52 + 53 + test('add a short text question', async ({ page }) => { 54 + await page.click('#btn-add-question'); 55 + await page.click('.form-type-btn[data-type="short_text"]'); 56 + 57 + // Dialog should close 58 + await expect(page.locator('.add-question-overlay')).not.toBeVisible(); 59 + 60 + // A question card should appear 61 + const card = page.locator('.form-question-card'); 62 + await expect(card).toHaveCount(1); 63 + 64 + // Card should show question number and type badge 65 + await expect(card.locator('.form-question-number')).toHaveText('1'); 66 + await expect(card.locator('.form-question-type-badge')).toHaveText('Short Text'); 67 + 68 + // Card should have a label input, description input, and controls 69 + await expect(card.locator('.form-question-label')).toBeVisible(); 70 + await expect(card.locator('.form-question-desc')).toBeVisible(); 71 + await expect(card.locator('.form-question-required')).toBeVisible(); 72 + await expect(card.locator('.form-question-delete')).toBeVisible(); 73 + }); 74 + 75 + test('add a single choice question with default options', async ({ page }) => { 76 + await page.click('#btn-add-question'); 77 + await page.click('.form-type-btn[data-type="single_choice"]'); 78 + 79 + const card = page.locator('.form-question-card'); 80 + await expect(card).toHaveCount(1); 81 + await expect(card.locator('.form-question-type-badge')).toHaveText('Single Choice'); 82 + 83 + // Should have 2 default options 84 + const optionInputs = card.locator('.form-option-input'); 85 + await expect(optionInputs).toHaveCount(2); 86 + await expect(optionInputs.first()).toHaveValue('Option 1'); 87 + await expect(optionInputs.last()).toHaveValue('Option 2'); 88 + 89 + // Should have an "Add option" button 90 + await expect(card.locator('.form-add-option')).toBeVisible(); 91 + 92 + // Add a third option 93 + await card.locator('.form-add-option').click(); 94 + await expect(card.locator('.form-option-input')).toHaveCount(3); 95 + await expect(card.locator('.form-option-input').last()).toHaveValue('Option 3'); 96 + }); 97 + 98 + test('edit question label', async ({ page }) => { 99 + // Add a short text question 100 + await page.click('#btn-add-question'); 101 + await page.click('.form-type-btn[data-type="short_text"]'); 102 + 103 + const labelInput = page.locator('.form-question-label'); 104 + await labelInput.fill('What is your name?'); 105 + // Trigger change event (change fires on blur/commit, not on input) 106 + await labelInput.dispatchEvent('change'); 107 + 108 + // Verify the label persisted by checking the input value 109 + await expect(labelInput).toHaveValue('What is your name?'); 110 + }); 111 + 112 + test('toggle required checkbox', async ({ page }) => { 113 + await page.click('#btn-add-question'); 114 + await page.click('.form-type-btn[data-type="short_text"]'); 115 + 116 + const checkbox = page.locator('.form-question-required'); 117 + 118 + // Should not be checked by default 119 + await expect(checkbox).not.toBeChecked(); 120 + 121 + // Check it 122 + await checkbox.check(); 123 + await expect(checkbox).toBeChecked(); 124 + 125 + // Uncheck it 126 + await checkbox.uncheck(); 127 + await expect(checkbox).not.toBeChecked(); 128 + }); 129 + 130 + test('reorder questions with move up and move down', async ({ page }) => { 131 + // Add two questions 132 + await page.click('#btn-add-question'); 133 + await page.click('.form-type-btn[data-type="short_text"]'); 134 + 135 + await page.click('#btn-add-question'); 136 + await page.click('.form-type-btn[data-type="number"]'); 137 + 138 + // Verify initial order 139 + const cards = page.locator('.form-question-card'); 140 + await expect(cards).toHaveCount(2); 141 + await expect(cards.first().locator('.form-question-type-badge')).toHaveText('Short Text'); 142 + await expect(cards.last().locator('.form-question-type-badge')).toHaveText('Number'); 143 + 144 + // First question's move-up should be disabled (already at top) 145 + await expect(cards.first().locator('.form-question-move-up')).toBeDisabled(); 146 + // Last question's move-down should be disabled (already at bottom) 147 + await expect(cards.last().locator('.form-question-move-down')).toBeDisabled(); 148 + 149 + // Move the first question down 150 + await cards.first().locator('.form-question-move-down').click(); 151 + 152 + // Order should be reversed now 153 + const reorderedCards = page.locator('.form-question-card'); 154 + await expect(reorderedCards.first().locator('.form-question-type-badge')).toHaveText('Number'); 155 + await expect(reorderedCards.last().locator('.form-question-type-badge')).toHaveText('Short Text'); 156 + 157 + // Move the last question up to restore original order 158 + await reorderedCards.last().locator('.form-question-move-up').click(); 159 + const restoredCards = page.locator('.form-question-card'); 160 + await expect(restoredCards.first().locator('.form-question-type-badge')).toHaveText('Short Text'); 161 + await expect(restoredCards.last().locator('.form-question-type-badge')).toHaveText('Number'); 162 + }); 163 + 164 + test('delete a question', async ({ page }) => { 165 + // Add two questions 166 + await page.click('#btn-add-question'); 167 + await page.click('.form-type-btn[data-type="short_text"]'); 168 + await page.click('#btn-add-question'); 169 + await page.click('.form-type-btn[data-type="email"]'); 170 + 171 + await expect(page.locator('.form-question-card')).toHaveCount(2); 172 + 173 + // Delete the first question 174 + await page.locator('.form-question-card').first().locator('.form-question-delete').click(); 175 + 176 + // Only one question should remain 177 + await expect(page.locator('.form-question-card')).toHaveCount(1); 178 + await expect(page.locator('.form-question-type-badge')).toHaveText('Email'); 179 + }); 180 + 181 + test('delete last question shows empty state', async ({ page }) => { 182 + await page.click('#btn-add-question'); 183 + await page.click('.form-type-btn[data-type="short_text"]'); 184 + await expect(page.locator('.form-question-card')).toHaveCount(1); 185 + 186 + // Delete the only question 187 + await page.locator('.form-question-delete').click(); 188 + 189 + await expect(page.locator('.form-question-card')).toHaveCount(0); 190 + await expect(page.locator('#form-questions')).toContainText('No questions yet'); 191 + }); 192 + 193 + test('form title editing', async ({ page }) => { 194 + const titleInput = page.locator('#form-title'); 195 + await expect(titleInput).toHaveValue('Untitled Form'); 196 + 197 + await titleInput.fill('Employee Feedback Survey'); 198 + await titleInput.dispatchEvent('change'); 199 + 200 + await expect(titleInput).toHaveValue('Employee Feedback Survey'); 201 + }); 202 + 203 + test('form description editing', async ({ page }) => { 204 + const descInput = page.locator('#form-description'); 205 + await descInput.fill('Please fill out this quarterly survey.'); 206 + await descInput.dispatchEvent('change'); 207 + 208 + await expect(descInput).toHaveValue('Please fill out this quarterly survey.'); 209 + }); 210 + }); 211 + 212 + test.describe('Forms - Preview Mode', () => { 213 + test.beforeEach(async ({ page }) => { 214 + await createNewForm(page); 215 + }); 216 + 217 + test('switch to preview mode shows preview pane', async ({ page }) => { 218 + // Add a question so preview has content 219 + await page.click('#btn-add-question'); 220 + await page.click('.form-type-btn[data-type="short_text"]'); 221 + 222 + // Set a label so we can verify it appears in preview 223 + const labelInput = page.locator('.form-question-label'); 224 + await labelInput.fill('Your name'); 225 + await labelInput.dispatchEvent('change'); 226 + 227 + // Switch to preview 228 + await page.click('#btn-preview'); 229 + 230 + // Preview pane should be visible, questions container should be hidden 231 + await expect(page.locator('#form-preview')).toBeVisible(); 232 + await expect(page.locator('#form-questions')).not.toBeVisible(); 233 + 234 + // Preview should show the form title 235 + await expect(page.locator('.form-preview-container h2')).toHaveText('Untitled Form'); 236 + 237 + // Preview should show the question 238 + await expect(page.locator('.form-preview-question')).toHaveCount(1); 239 + await expect(page.locator('.form-preview-label')).toContainText('Your name'); 240 + 241 + // Preview should have a submit button 242 + await expect(page.locator('#preview-submit')).toBeVisible(); 243 + }); 244 + 245 + test('preview shows form description when set', async ({ page }) => { 246 + const descInput = page.locator('#form-description'); 247 + await descInput.fill('A test description'); 248 + await descInput.dispatchEvent('change'); 249 + 250 + await page.click('#btn-preview'); 251 + await expect(page.locator('.form-preview-desc')).toHaveText('A test description'); 252 + }); 253 + 254 + test('preview renders input types correctly', async ({ page }) => { 255 + // Add short_text 256 + await page.click('#btn-add-question'); 257 + await page.click('.form-type-btn[data-type="short_text"]'); 258 + 259 + // Add single_choice 260 + await page.click('#btn-add-question'); 261 + await page.click('.form-type-btn[data-type="single_choice"]'); 262 + 263 + // Add date 264 + await page.click('#btn-add-question'); 265 + await page.click('.form-type-btn[data-type="date"]'); 266 + 267 + // Switch to preview 268 + await page.click('#btn-preview'); 269 + 270 + const questions = page.locator('.form-preview-question'); 271 + await expect(questions).toHaveCount(3); 272 + 273 + // Short text should render a text input 274 + await expect(questions.nth(0).locator('input[type="text"]')).toBeVisible(); 275 + 276 + // Single choice should render radio buttons 277 + await expect(questions.nth(1).locator('input[type="radio"]')).toHaveCount(2); 278 + 279 + // Date should render a date input 280 + await expect(questions.nth(2).locator('input[type="date"]')).toBeVisible(); 281 + }); 282 + 283 + test('preview shows required marker for required questions', async ({ page }) => { 284 + await page.click('#btn-add-question'); 285 + await page.click('.form-type-btn[data-type="short_text"]'); 286 + 287 + // Mark as required 288 + await page.locator('.form-question-required').check(); 289 + 290 + await page.click('#btn-preview'); 291 + 292 + // Required mark should appear 293 + await expect(page.locator('.form-required-mark')).toBeVisible(); 294 + await expect(page.locator('.form-required-mark')).toHaveText('*'); 295 + }); 296 + 297 + test('switch back to builder from preview', async ({ page }) => { 298 + await page.click('#btn-preview'); 299 + 300 + // Should be in preview mode 301 + await expect(page.locator('#form-preview')).toBeVisible(); 302 + 303 + // Click preview again to toggle back 304 + await page.click('#btn-preview'); 305 + 306 + // Should be back in builder mode 307 + await expect(page.locator('#form-questions')).toBeVisible(); 308 + await expect(page.locator('#form-preview')).not.toBeVisible(); 309 + }); 310 + }); 311 + 312 + test.describe('Forms - Responses View', () => { 313 + test.beforeEach(async ({ page }) => { 314 + await createNewForm(page); 315 + }); 316 + 317 + test('switch to responses view shows empty state', async ({ page }) => { 318 + await page.click('#btn-responses'); 319 + 320 + // Responses pane should be visible 321 + await expect(page.locator('#form-responses-view')).toBeVisible(); 322 + await expect(page.locator('#form-questions')).not.toBeVisible(); 323 + await expect(page.locator('#form-preview')).not.toBeVisible(); 324 + 325 + // Should show "No responses yet" message 326 + await expect(page.locator('#form-responses-view')).toContainText('No responses yet'); 327 + }); 328 + 329 + test('switch back to builder from responses view', async ({ page }) => { 330 + await page.click('#btn-responses'); 331 + await expect(page.locator('#form-responses-view')).toBeVisible(); 332 + 333 + // Click responses again to toggle back 334 + await page.click('#btn-responses'); 335 + await expect(page.locator('#form-questions')).toBeVisible(); 336 + await expect(page.locator('#form-responses-view')).not.toBeVisible(); 337 + }); 338 + }); 339 + 340 + test.describe('Forms - Add Question Dialog', () => { 341 + test.beforeEach(async ({ page }) => { 342 + await createNewForm(page); 343 + }); 344 + 345 + test('clicking overlay background closes dialog', async ({ page }) => { 346 + await page.click('#btn-add-question'); 347 + await expect(page.locator('.add-question-overlay')).toBeVisible(); 348 + 349 + // Click the overlay background (not the dialog content) 350 + await page.locator('.add-question-overlay').click({ position: { x: 5, y: 5 } }); 351 + await expect(page.locator('.add-question-overlay')).not.toBeVisible(); 352 + }); 353 + 354 + test('add multiple questions of different types', async ({ page }) => { 355 + // Add short text 356 + await page.click('#btn-add-question'); 357 + await page.click('.form-type-btn[data-type="short_text"]'); 358 + 359 + // Add rating 360 + await page.click('#btn-add-question'); 361 + await page.click('.form-type-btn[data-type="rating"]'); 362 + 363 + // Add dropdown 364 + await page.click('#btn-add-question'); 365 + await page.click('.form-type-btn[data-type="dropdown"]'); 366 + 367 + const cards = page.locator('.form-question-card'); 368 + await expect(cards).toHaveCount(3); 369 + 370 + // Verify numbering 371 + await expect(cards.nth(0).locator('.form-question-number')).toHaveText('1'); 372 + await expect(cards.nth(1).locator('.form-question-number')).toHaveText('2'); 373 + await expect(cards.nth(2).locator('.form-question-number')).toHaveText('3'); 374 + 375 + // Verify types 376 + await expect(cards.nth(0).locator('.form-question-type-badge')).toHaveText('Short Text'); 377 + await expect(cards.nth(1).locator('.form-question-type-badge')).toHaveText('Rating'); 378 + await expect(cards.nth(2).locator('.form-question-type-badge')).toHaveText('Dropdown'); 379 + 380 + // Dropdown should have options section 381 + await expect(cards.nth(2).locator('.form-option-input')).toHaveCount(2); 382 + }); 383 + });
+42
e2e/helpers.ts
··· 55 55 } 56 56 57 57 /** 58 + * Create a new presentation via the landing page and wait for the canvas to load. 59 + * Returns the full URL of the new presentation. 60 + */ 61 + export async function createNewSlides(page: Page): Promise<string> { 62 + await goToLanding(page); 63 + // Click triggers async createDocument (fetch + window.location.href), so wait for navigation 64 + await Promise.all([ 65 + page.waitForURL(/\/slides\//, { timeout: 15000 }), 66 + page.click('#new-slide'), 67 + ]); 68 + // Wait for the slide canvas to render 69 + await page.waitForSelector('#slide-canvas', { timeout: 15000 }); 70 + // Wait for thumbnails to populate (at least one slide) 71 + await page.waitForSelector('.slides-thumbnail', { timeout: 15000 }); 72 + return page.url(); 73 + } 74 + 75 + /** 76 + * Create a new form via the landing page and wait for the builder to load. 77 + * Returns the full URL of the new form. 78 + */ 79 + export async function createNewForm(page: Page): Promise<string> { 80 + await goToLanding(page); 81 + await page.click('#new-form'); 82 + // Wait for the form builder toolbar to render 83 + await page.waitForSelector('#form-toolbar', { timeout: 15000 }); 84 + return page.url(); 85 + } 86 + 87 + /** 88 + * Create a new diagram via the landing page and wait for the SVG canvas to load. 89 + * Returns the full URL of the new diagram. 90 + */ 91 + export async function createNewDiagram(page: Page): Promise<string> { 92 + await goToLanding(page); 93 + await page.click('#new-diagram'); 94 + // Wait for the SVG canvas to render 95 + await page.waitForSelector('#diagram-canvas', { timeout: 15000 }); 96 + return page.url(); 97 + } 98 + 99 + /** 58 100 * Click a cell in the spreadsheet by its cell ID (e.g. "A1", "B3"). 59 101 */ 60 102 export async function clickCell(page: Page, cellId: string): Promise<void> {
+218
e2e/slides.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSlides } from './helpers'; 3 + 4 + test.describe('Slides - Basic', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSlides(page); 7 + }); 8 + 9 + test('page loads with slide canvas visible', async ({ page }) => { 10 + const canvas = page.locator('#slide-canvas'); 11 + await expect(canvas).toBeVisible(); 12 + 13 + // Canvas should have fixed dimensions (960x540) 14 + const style = await canvas.getAttribute('style'); 15 + expect(style).toContain('960px'); 16 + expect(style).toContain('540px'); 17 + 18 + // Toolbar should be visible 19 + await expect(page.locator('#slides-toolbar')).toBeVisible(); 20 + 21 + // At least one thumbnail should exist (the initial slide) 22 + const thumbs = page.locator('.slides-thumbnail'); 23 + await expect(thumbs).toHaveCount(1); 24 + 25 + // Title input should show default title 26 + await expect(page.locator('#deck-title')).toHaveValue('Untitled Presentation'); 27 + }); 28 + 29 + test('add slide button creates a new slide', async ({ page }) => { 30 + // Start with one thumbnail 31 + await expect(page.locator('.slides-thumbnail')).toHaveCount(1); 32 + 33 + // Click the add slide button 34 + await page.click('#btn-add-slide'); 35 + 36 + // Should now have two thumbnails 37 + await expect(page.locator('.slides-thumbnail')).toHaveCount(2); 38 + 39 + // Second thumbnail should be active (navigated to new slide) 40 + const secondThumb = page.locator('.slides-thumbnail').nth(1); 41 + await expect(secondThumb).toHaveClass(/active/); 42 + }); 43 + 44 + test('add text element places text on the canvas', async ({ page }) => { 45 + // Canvas should start empty 46 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(0); 47 + 48 + // Click the add text button 49 + await page.click('#btn-add-text'); 50 + 51 + // A text element should appear on the canvas 52 + const element = page.locator('#slide-canvas .slide-element'); 53 + await expect(element).toHaveCount(1); 54 + 55 + // It should contain an editable text region 56 + const textContent = element.locator('[contenteditable="true"]'); 57 + await expect(textContent).toBeVisible(); 58 + await expect(textContent).toContainText('Click to edit'); 59 + }); 60 + 61 + test('add shape element places shape on the canvas', async ({ page }) => { 62 + // Canvas should start empty 63 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(0); 64 + 65 + // Click the add shape button 66 + await page.click('#btn-add-shape'); 67 + 68 + // A shape element should appear on the canvas 69 + const element = page.locator('#slide-canvas .slide-element'); 70 + await expect(element).toHaveCount(1); 71 + 72 + // Shape should render as SVG 73 + const svg = element.locator('svg'); 74 + await expect(svg).toBeVisible(); 75 + }); 76 + 77 + test('thumbnail navigation switches to clicked slide', async ({ page }) => { 78 + // Add a second slide 79 + await page.click('#btn-add-slide'); 80 + await expect(page.locator('.slides-thumbnail')).toHaveCount(2); 81 + 82 + // Add a text element to the second slide so it is distinguishable 83 + await page.click('#btn-add-text'); 84 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(1); 85 + 86 + // Click the first thumbnail to navigate back 87 + await page.locator('.slides-thumbnail').first().click(); 88 + 89 + // First thumbnail should now be active 90 + await expect(page.locator('.slides-thumbnail').first()).toHaveClass(/active/); 91 + 92 + // Canvas should be empty (first slide has no elements) 93 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(0); 94 + 95 + // Click the second thumbnail again 96 + await page.locator('.slides-thumbnail').nth(1).click(); 97 + 98 + // Second thumbnail should be active, and the text element should be back 99 + await expect(page.locator('.slides-thumbnail').nth(1)).toHaveClass(/active/); 100 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(1); 101 + }); 102 + 103 + test('theme selector changes theme', async ({ page }) => { 104 + const themeSelect = page.locator('#theme-select'); 105 + await expect(themeSelect).toBeVisible(); 106 + 107 + // Get the initial theme value 108 + const initialTheme = await themeSelect.inputValue(); 109 + 110 + // Get all available theme options 111 + const options = await themeSelect.locator('option').all(); 112 + expect(options.length).toBeGreaterThan(1); 113 + 114 + // Select a different theme 115 + const secondOption = await options[1].getAttribute('value'); 116 + expect(secondOption).toBeTruthy(); 117 + await themeSelect.selectOption(secondOption!); 118 + 119 + // Theme should have changed 120 + const newTheme = await themeSelect.inputValue(); 121 + expect(newTheme).toBe(secondOption); 122 + expect(newTheme).not.toBe(initialTheme); 123 + }); 124 + 125 + test('layout selector works', async ({ page }) => { 126 + const layoutSelect = page.locator('#layout-select'); 127 + await expect(layoutSelect).toBeVisible(); 128 + 129 + // Get all available layout options 130 + const options = await layoutSelect.locator('option').all(); 131 + expect(options.length).toBeGreaterThan(1); 132 + 133 + // Select a different layout 134 + const secondOption = await options[1].getAttribute('value'); 135 + expect(secondOption).toBeTruthy(); 136 + await layoutSelect.selectOption(secondOption!); 137 + 138 + // Layout should have changed 139 + const newLayout = await layoutSelect.inputValue(); 140 + expect(newLayout).toBe(secondOption); 141 + }); 142 + 143 + test('presenter mode opens and can be exited', async ({ page }) => { 144 + const overlay = page.locator('#presenter-overlay'); 145 + 146 + // Overlay should be hidden initially 147 + await expect(overlay).toBeHidden(); 148 + 149 + // Click the present button 150 + await page.click('#btn-present'); 151 + 152 + // Overlay should now be visible 153 + await expect(overlay).toBeVisible({ timeout: 5000 }); 154 + 155 + // Body should have presenting class 156 + await expect(page.locator('body')).toHaveClass(/presenting/); 157 + 158 + // Timer and progress should be visible 159 + await expect(page.locator('#presenter-timer')).toBeVisible(); 160 + await expect(page.locator('#presenter-progress')).toContainText('1 / 1'); 161 + 162 + // Click exit button 163 + await page.click('#btn-presenter-exit'); 164 + 165 + // Overlay should be hidden again 166 + await expect(overlay).toBeHidden(); 167 + await expect(page.locator('body')).not.toHaveClass(/presenting/); 168 + }); 169 + 170 + test('notes input accepts and displays speaker notes', async ({ page }) => { 171 + const notesInput = page.locator('#notes-input'); 172 + await expect(notesInput).toBeVisible(); 173 + 174 + // Type speaker notes 175 + await notesInput.click(); 176 + await page.keyboard.type('Remember to pause here for questions'); 177 + 178 + // Verify notes are in the input 179 + await expect(notesInput).toHaveValue('Remember to pause here for questions'); 180 + 181 + // Navigate away and back to verify notes persist per slide 182 + await page.click('#btn-add-slide'); 183 + await expect(page.locator('.slides-thumbnail')).toHaveCount(2); 184 + 185 + // New slide should have empty notes 186 + await expect(notesInput).toHaveValue(''); 187 + 188 + // Navigate back to first slide 189 + await page.locator('.slides-thumbnail').first().click(); 190 + 191 + // Notes should still be there 192 + await expect(notesInput).toHaveValue('Remember to pause here for questions'); 193 + }); 194 + 195 + test('Delete key removes selected element', async ({ page }) => { 196 + // Add a text element 197 + await page.click('#btn-add-text'); 198 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(1); 199 + 200 + // Click the element to select it (mousedown on the element) 201 + await page.locator('#slide-canvas .slide-element').click(); 202 + 203 + // The element should get the selected class 204 + await expect(page.locator('#slide-canvas .slide-element.selected')).toHaveCount(1); 205 + 206 + // Click on canvas background first to ensure focus is not on a text input 207 + // then re-select the element 208 + await page.locator('#slide-canvas').click({ position: { x: 5, y: 5 } }); 209 + await page.locator('#slide-canvas .slide-element').click(); 210 + await expect(page.locator('#slide-canvas .slide-element.selected')).toHaveCount(1); 211 + 212 + // Press Delete to remove the element 213 + await page.keyboard.press('Delete'); 214 + 215 + // Element should be gone 216 + await expect(page.locator('#slide-canvas .slide-element')).toHaveCount(0); 217 + }); 218 + });
+193
tests/blob-upload.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { 3 + uploadBlob, 4 + downloadBlob, 5 + listDocBlobs, 6 + deleteBlob, 7 + readFileAsBuffer, 8 + blobToObjectUrl, 9 + } from '../src/lib/blob-upload.js'; 10 + 11 + // Mock fetch globally 12 + const mockFetch = vi.fn(); 13 + vi.stubGlobal('fetch', mockFetch); 14 + 15 + // Mock URL.createObjectURL 16 + const mockCreateObjectURL = vi.fn(() => 'blob:mock-url'); 17 + vi.stubGlobal('URL', { ...URL, createObjectURL: mockCreateObjectURL }); 18 + 19 + beforeEach(() => { 20 + mockFetch.mockReset(); 21 + mockCreateObjectURL.mockClear(); 22 + }); 23 + 24 + describe('uploadBlob', () => { 25 + it('sends encrypted data with correct headers', async () => { 26 + mockFetch.mockResolvedValueOnce({ 27 + ok: true, 28 + json: () => Promise.resolve({ id: 'blob-123', size: 1024 }), 29 + }); 30 + 31 + const data = new Uint8Array([1, 2, 3, 4]); 32 + const result = await uploadBlob('doc-1', data, 'photo.png', 'image/png'); 33 + 34 + expect(mockFetch).toHaveBeenCalledOnce(); 35 + const [url, opts] = mockFetch.mock.calls[0]; 36 + expect(url).toBe('/api/blobs'); 37 + expect(opts.method).toBe('POST'); 38 + expect(opts.headers['Content-Type']).toBe('application/octet-stream'); 39 + expect(opts.headers['x-document-id']).toBe('doc-1'); 40 + expect(opts.headers['x-file-name']).toBe('photo.png'); 41 + expect(opts.headers['x-mime-type']).toBe('image/png'); 42 + expect(result).toEqual({ id: 'blob-123', size: 1024 }); 43 + }); 44 + 45 + it('accepts ArrayBuffer input', async () => { 46 + mockFetch.mockResolvedValueOnce({ 47 + ok: true, 48 + json: () => Promise.resolve({ id: 'blob-456', size: 4 }), 49 + }); 50 + 51 + const buf = new ArrayBuffer(4); 52 + const result = await uploadBlob('doc-1', buf, 'data.bin', 'application/octet-stream'); 53 + expect(result.id).toBe('blob-456'); 54 + }); 55 + 56 + it('throws on upload failure with error message', async () => { 57 + mockFetch.mockResolvedValueOnce({ 58 + ok: false, 59 + status: 413, 60 + json: () => Promise.resolve({ error: 'File too large' }), 61 + }); 62 + 63 + await expect(uploadBlob('doc-1', new Uint8Array([1]), 'big.bin', 'application/octet-stream')) 64 + .rejects.toThrow('File too large'); 65 + }); 66 + 67 + it('throws generic message when error response is not JSON', async () => { 68 + mockFetch.mockResolvedValueOnce({ 69 + ok: false, 70 + status: 500, 71 + json: () => Promise.reject(new Error('not json')), 72 + }); 73 + 74 + await expect(uploadBlob('doc-1', new Uint8Array([1]), 'f.bin', 'application/octet-stream')) 75 + .rejects.toThrow('Upload failed'); 76 + }); 77 + }); 78 + 79 + describe('downloadBlob', () => { 80 + it('returns data with mime type and file name', async () => { 81 + const mockArrayBuffer = new ArrayBuffer(8); 82 + mockFetch.mockResolvedValueOnce({ 83 + ok: true, 84 + headers: { 85 + get: (name: string) => { 86 + if (name === 'Content-Type') return 'image/png'; 87 + if (name === 'Content-Disposition') return 'attachment; filename="photo.png"'; 88 + return null; 89 + }, 90 + }, 91 + arrayBuffer: () => Promise.resolve(mockArrayBuffer), 92 + }); 93 + 94 + const result = await downloadBlob('blob-123'); 95 + expect(result.data).toBe(mockArrayBuffer); 96 + expect(result.mimeType).toBe('image/png'); 97 + expect(result.fileName).toBe('photo.png'); 98 + }); 99 + 100 + it('uses defaults when headers are missing', async () => { 101 + mockFetch.mockResolvedValueOnce({ 102 + ok: true, 103 + headers: { 104 + get: () => null, 105 + }, 106 + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), 107 + }); 108 + 109 + const result = await downloadBlob('blob-456'); 110 + expect(result.mimeType).toBe('application/octet-stream'); 111 + expect(result.fileName).toBe('file'); 112 + }); 113 + 114 + it('throws when blob not found', async () => { 115 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 116 + await expect(downloadBlob('nonexistent')).rejects.toThrow('Blob not found: nonexistent'); 117 + }); 118 + }); 119 + 120 + describe('listDocBlobs', () => { 121 + it('returns blob list for a document', async () => { 122 + const blobs = [ 123 + { id: 'b1', file_name: 'a.png', mime_type: 'image/png', size: 100, created_at: '2026-01-01' }, 124 + ]; 125 + mockFetch.mockResolvedValueOnce({ 126 + ok: true, 127 + json: () => Promise.resolve(blobs), 128 + }); 129 + 130 + const result = await listDocBlobs('doc-1'); 131 + expect(result).toEqual(blobs); 132 + expect(mockFetch).toHaveBeenCalledWith('/api/documents/doc-1/blobs'); 133 + }); 134 + 135 + it('returns empty array on failure', async () => { 136 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 137 + const result = await listDocBlobs('nonexistent'); 138 + expect(result).toEqual([]); 139 + }); 140 + }); 141 + 142 + describe('deleteBlob', () => { 143 + it('sends DELETE request', async () => { 144 + mockFetch.mockResolvedValueOnce({ ok: true }); 145 + await deleteBlob('blob-123'); 146 + expect(mockFetch).toHaveBeenCalledWith('/api/blobs/blob-123', { method: 'DELETE' }); 147 + }); 148 + }); 149 + 150 + describe('readFileAsBuffer', () => { 151 + beforeEach(() => { 152 + // Mock FileReader for Node environment 153 + class MockFileReader { 154 + result: ArrayBuffer | null = null; 155 + onload: (() => void) | null = null; 156 + onerror: (() => void) | null = null; 157 + readAsArrayBuffer(file: File) { 158 + file.arrayBuffer().then((buf) => { 159 + this.result = buf; 160 + this.onload?.(); 161 + }).catch(() => { 162 + this.onerror?.(); 163 + }); 164 + } 165 + } 166 + vi.stubGlobal('FileReader', MockFileReader); 167 + }); 168 + 169 + it('reads a file as ArrayBuffer', async () => { 170 + const content = new Uint8Array([72, 101, 108, 108, 111]); 171 + const file = new File([content], 'test.txt', { type: 'text/plain' }); 172 + const buffer = await readFileAsBuffer(file); 173 + expect(new Uint8Array(buffer)).toEqual(content); 174 + }); 175 + 176 + it('handles empty file', async () => { 177 + const file = new File([], 'empty.txt', { type: 'text/plain' }); 178 + const buffer = await readFileAsBuffer(file); 179 + expect(buffer.byteLength).toBe(0); 180 + }); 181 + }); 182 + 183 + describe('blobToObjectUrl', () => { 184 + it('creates object URL with correct MIME type', () => { 185 + const data = new ArrayBuffer(4); 186 + const url = blobToObjectUrl(data, 'image/png'); 187 + expect(url).toBe('blob:mock-url'); 188 + expect(mockCreateObjectURL).toHaveBeenCalledOnce(); 189 + const blob = mockCreateObjectURL.mock.calls[0][0]; 190 + expect(blob).toBeInstanceOf(Blob); 191 + expect(blob.type).toBe('image/png'); 192 + }); 193 + });
+308 -66
tests/landing-overhaul.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { readFileSync } from 'fs'; 3 - import { resolve } from 'path'; 2 + import { 3 + compareDocuments, 4 + sortDocuments, 5 + toggleStar, 6 + starredIdsSet, 7 + addToTrash, 8 + restoreFromTrash, 9 + purgeExpiredTrash, 10 + isInTrash, 11 + removeFromTrash, 12 + partitionDocuments, 13 + createFolder, 14 + renameFolder, 15 + deleteFolder, 16 + moveToFolder, 17 + getDocsInFolder, 18 + buildBreadcrumbs, 19 + clearFolderAssignments, 20 + filterBySearch, 21 + validateUsername, 22 + trackRecentDoc, 23 + getRecentDocs, 24 + SORT_OPTIONS, 25 + DEFAULT_SORT, 26 + } from '../src/landing-utils.js'; 27 + import type { DocumentMeta, TrashEntry } from '../src/landing-types.js'; 4 28 5 - function loadFile(relativePath: string) { 6 - return readFileSync(resolve(__dirname, '..', relativePath), 'utf-8'); 29 + function doc(id: string, overrides: Partial<DocumentMeta> = {}): DocumentMeta { 30 + return { 31 + id, 32 + type: 'doc', 33 + created_at: '2026-01-01T00:00:00Z', 34 + updated_at: '2026-01-01T00:00:00Z', 35 + _decryptedName: `Doc ${id}`, 36 + ...overrides, 37 + } as DocumentMeta; 7 38 } 8 39 9 - describe('Landing page overhaul', () => { 10 - describe('HTML structure', () => { 11 - let html: string; 40 + describe('compareDocuments', () => { 41 + it('sorts by name alphabetically', () => { 42 + const a = doc('1', { _decryptedName: 'Zebra' }); 43 + const b = doc('2', { _decryptedName: 'Alpha' }); 44 + expect(compareDocuments(a, b, 'name')).toBeGreaterThan(0); 45 + expect(compareDocuments(b, a, 'name')).toBeLessThan(0); 46 + }); 12 47 13 - beforeEach(() => { 14 - html = loadFile('src/index.html'); 15 - }); 48 + it('sorts by name case-insensitively', () => { 49 + const a = doc('1', { _decryptedName: 'apple' }); 50 + const b = doc('2', { _decryptedName: 'Banana' }); 51 + expect(compareDocuments(a, b, 'name')).toBeLessThan(0); 52 + }); 16 53 17 - it('has pinned section container', () => { 18 - expect(html).toMatch(/id="pinned-section"/); 19 - }); 54 + it('sorts encrypted documents (no name) last', () => { 55 + const a = doc('1', { _decryptedName: undefined }); 56 + const b = doc('2', { _decryptedName: 'A' }); 57 + expect(compareDocuments(a, b, 'name')).toBeGreaterThan(0); 58 + }); 59 + 60 + it('sorts by created date newest first', () => { 61 + const a = doc('1', { created_at: '2026-01-01' }); 62 + const b = doc('2', { created_at: '2026-06-01' }); 63 + expect(compareDocuments(a, b, 'created')).toBeGreaterThan(0); 64 + }); 20 65 21 - it('has view toggle button', () => { 22 - expect(html).toMatch(/id="view-toggle"/); 23 - }); 66 + it('sorts by updated date newest first (default)', () => { 67 + const a = doc('1', { updated_at: '2026-03-01' }); 68 + const b = doc('2', { updated_at: '2026-01-01' }); 69 + expect(compareDocuments(a, b, 'updated')).toBeLessThan(0); 70 + }); 24 71 25 - it('view toggle has grid and list icons', () => { 26 - expect(html).toMatch(/view-icon-grid/); 27 - expect(html).toMatch(/view-icon-list/); 28 - }); 72 + it('sorts by type then updated', () => { 73 + const a = doc('1', { type: 'sheet', updated_at: '2026-01-01' }); 74 + const b = doc('2', { type: 'doc', updated_at: '2026-06-01' }); 75 + expect(compareDocuments(a, b, 'type')).toBeGreaterThan(0); // sheet > doc 76 + }); 77 + }); 29 78 30 - it('has cell type buttons in sheets toolbar', () => { 31 - const sheetsHtml = loadFile('src/sheets/index.html'); 32 - expect(sheetsHtml).toMatch(/id="tb-celltype-checkbox"/); 33 - expect(sheetsHtml).toMatch(/id="tb-celltype-rating"/); 34 - expect(sheetsHtml).toMatch(/id="tb-celltype-progress"/); 35 - expect(sheetsHtml).toMatch(/id="tb-celltype-clear"/); 36 - }); 79 + describe('sortDocuments', () => { 80 + it('puts starred documents first', () => { 81 + const docs = [doc('1'), doc('2'), doc('3')]; 82 + const stars = new Set(['3']); 83 + const sorted = sortDocuments(docs, 'updated', stars); 84 + expect(sorted[0].id).toBe('3'); 37 85 }); 38 86 39 - describe('CSS styles', () => { 40 - let css: string; 87 + it('sorts within starred and non-starred groups', () => { 88 + const docs = [ 89 + doc('1', { _decryptedName: 'Zebra' }), 90 + doc('2', { _decryptedName: 'Alpha' }), 91 + doc('3', { _decryptedName: 'Beta' }), 92 + ]; 93 + const sorted = sortDocuments(docs, 'name'); 94 + expect(sorted.map(d => d._decryptedName)).toEqual(['Alpha', 'Beta', 'Zebra']); 95 + }); 41 96 42 - beforeEach(() => { 43 - css = loadFile('src/css/app.css'); 44 - }); 97 + it('does not mutate input', () => { 98 + const docs = [doc('2'), doc('1')]; 99 + const original = [...docs]; 100 + sortDocuments(docs, 'name'); 101 + expect(docs).toEqual(original); 102 + }); 103 + }); 45 104 46 - it('has pinned section styles', () => { 47 - expect(css).toMatch(/\.pinned-section/); 48 - expect(css).toMatch(/\.pinned-heading/); 49 - expect(css).toMatch(/\.pinned-list/); 50 - expect(css).toMatch(/\.pinned-card/); 51 - }); 105 + describe('Stars', () => { 106 + it('toggleStar adds a star', () => { 107 + const stars = toggleStar({}, 'doc-1'); 108 + expect(stars['doc-1']).toBe(true); 109 + }); 52 110 53 - it('has view toggle styles', () => { 54 - expect(css).toMatch(/\.view-toggle/); 55 - expect(css).toMatch(/\.view-icon/); 56 - }); 111 + it('toggleStar removes a star', () => { 112 + const stars = toggleStar({ 'doc-1': true }, 'doc-1'); 113 + expect(stars['doc-1']).toBeUndefined(); 114 + }); 57 115 58 - it('has grid view styles', () => { 59 - expect(css).toMatch(/\.doc-list\.grid-view/); 60 - expect(css).toMatch(/\.doc-grid-card/); 61 - expect(css).toMatch(/\.doc-grid-card-header/); 62 - expect(css).toMatch(/\.doc-grid-card-name/); 63 - expect(css).toMatch(/\.doc-grid-card-footer/); 64 - expect(css).toMatch(/\.doc-grid-card-actions/); 65 - }); 116 + it('starredIdsSet converts to Set', () => { 117 + const set = starredIdsSet({ a: true, b: true }); 118 + expect(set.has('a')).toBe(true); 119 + expect(set.size).toBe(2); 120 + }); 66 121 67 - it('grid view uses CSS grid layout', () => { 68 - expect(css).toMatch(/\.doc-list\.grid-view[\s\S]*?display:\s*grid/); 69 - expect(css).toMatch(/grid-template-columns.*auto-fill/); 70 - }); 122 + it('starredIdsSet handles null', () => { 123 + expect(starredIdsSet(null).size).toBe(0); 124 + expect(starredIdsSet(undefined).size).toBe(0); 125 + }); 126 + }); 71 127 72 - it('grid card actions hidden by default, shown on hover', () => { 73 - expect(css).toMatch(/\.doc-grid-card-actions[\s\S]*?display:\s*none/); 74 - expect(css).toMatch(/\.doc-grid-card:hover\s+\.doc-grid-card-actions[\s\S]*?display:\s*flex/); 75 - }); 128 + describe('Trash', () => { 129 + it('addToTrash adds entry', () => { 130 + const trash = addToTrash([], 'doc-1', 1000); 131 + expect(trash).toHaveLength(1); 132 + expect(trash[0]).toEqual({ id: 'doc-1', deletedAt: 1000 }); 133 + }); 76 134 77 - it('has rich cell type styles', () => { 78 - expect(css).toMatch(/\.cell-checkbox/); 79 - expect(css).toMatch(/\.cell-rating/); 80 - expect(css).toMatch(/\.cell-rating-star/); 81 - expect(css).toMatch(/\.cell-progress/); 82 - expect(css).toMatch(/\.cell-progress-bar/); 83 - }); 135 + it('addToTrash prevents duplicates', () => { 136 + const trash = addToTrash([{ id: 'doc-1', deletedAt: 1000 }], 'doc-1', 2000); 137 + expect(trash).toHaveLength(1); 138 + }); 139 + 140 + it('restoreFromTrash removes entry', () => { 141 + const trash: TrashEntry[] = [ 142 + { id: 'doc-1', deletedAt: 1000 }, 143 + { id: 'doc-2', deletedAt: 2000 }, 144 + ]; 145 + expect(restoreFromTrash(trash, 'doc-1')).toHaveLength(1); 146 + }); 147 + 148 + it('purgeExpiredTrash removes old entries', () => { 149 + const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; 150 + const trash: TrashEntry[] = [ 151 + { id: 'old', deletedAt: 0 }, 152 + { id: 'fresh', deletedAt: THIRTY_DAYS + 1000 }, 153 + ]; 154 + const result = purgeExpiredTrash(trash, THIRTY_DAYS + 1000); 155 + expect(result.expired).toEqual(['old']); 156 + expect(result.kept).toHaveLength(1); 157 + expect(result.kept[0].id).toBe('fresh'); 158 + }); 159 + 160 + it('isInTrash checks presence', () => { 161 + const trash: TrashEntry[] = [{ id: 'doc-1', deletedAt: 1000 }]; 162 + expect(isInTrash(trash, 'doc-1')).toBe(true); 163 + expect(isInTrash(trash, 'doc-2')).toBe(false); 164 + }); 165 + 166 + it('partitionDocuments splits active from trashed', () => { 167 + const docs = [doc('1'), doc('2'), doc('3')]; 168 + const trash: TrashEntry[] = [{ id: '2', deletedAt: 1000 }]; 169 + const { active, trashed } = partitionDocuments(docs, trash); 170 + expect(active).toHaveLength(2); 171 + expect(trashed).toHaveLength(1); 172 + expect(trashed[0].id).toBe('2'); 84 173 }); 85 174 }); 86 175 87 - import { beforeEach } from 'vitest'; 176 + describe('Folders', () => { 177 + it('createFolder adds a folder', () => { 178 + const folders = createFolder([], 'Work', 'f1'); 179 + expect(folders).toHaveLength(1); 180 + expect(folders[0].name).toBe('Work'); 181 + expect(folders[0].id).toBe('f1'); 182 + }); 183 + 184 + it('renameFolder changes name', () => { 185 + const folders = createFolder([], 'Old', 'f1'); 186 + const renamed = renameFolder(folders, 'f1', 'New'); 187 + expect(renamed[0].name).toBe('New'); 188 + }); 189 + 190 + it('deleteFolder removes folder', () => { 191 + const folders = createFolder(createFolder([], 'A', 'f1'), 'B', 'f2'); 192 + const deleted = deleteFolder(folders, 'f1'); 193 + expect(deleted).toHaveLength(1); 194 + expect(deleted[0].id).toBe('f2'); 195 + }); 196 + 197 + it('moveToFolder assigns doc to folder', () => { 198 + const assignments = moveToFolder({}, 'doc-1', 'f1'); 199 + expect(assignments['doc-1']).toBe('f1'); 200 + }); 201 + 202 + it('moveToFolder removes assignment with null', () => { 203 + const assignments = moveToFolder({ 'doc-1': 'f1' }, 'doc-1', null); 204 + expect(assignments['doc-1']).toBeUndefined(); 205 + }); 206 + 207 + it('getDocsInFolder filters by folder', () => { 208 + const docs = [doc('1'), doc('2'), doc('3')]; 209 + const assignments = { '1': 'f1', '3': 'f1' }; 210 + expect(getDocsInFolder(docs, assignments, 'f1')).toHaveLength(2); 211 + }); 212 + 213 + it('getDocsInFolder with null returns unassigned docs', () => { 214 + const docs = [doc('1'), doc('2'), doc('3')]; 215 + const assignments = { '1': 'f1' }; 216 + const root = getDocsInFolder(docs, assignments, null); 217 + expect(root).toHaveLength(2); 218 + expect(root.map(d => d.id)).toContain('2'); 219 + expect(root.map(d => d.id)).toContain('3'); 220 + }); 221 + 222 + it('buildBreadcrumbs includes root', () => { 223 + const crumbs = buildBreadcrumbs([], null); 224 + expect(crumbs).toEqual([{ id: null, name: 'All Documents' }]); 225 + }); 226 + 227 + it('buildBreadcrumbs includes folder', () => { 228 + const folders = [{ id: 'f1', name: 'Work', createdAt: 1000 }]; 229 + const crumbs = buildBreadcrumbs(folders, 'f1'); 230 + expect(crumbs).toHaveLength(2); 231 + expect(crumbs[1].name).toBe('Work'); 232 + }); 233 + 234 + it('clearFolderAssignments removes all assignments for a folder', () => { 235 + const assignments = { 'doc-1': 'f1', 'doc-2': 'f1', 'doc-3': 'f2' }; 236 + const cleared = clearFolderAssignments(assignments, 'f1'); 237 + expect(Object.keys(cleared)).toEqual(['doc-3']); 238 + }); 239 + }); 240 + 241 + describe('Search', () => { 242 + it('filterBySearch matches by name', () => { 243 + const docs = [ 244 + doc('1', { _decryptedName: 'Budget Report' }), 245 + doc('2', { _decryptedName: 'Meeting Notes' }), 246 + ]; 247 + expect(filterBySearch(docs, 'budget')).toHaveLength(1); 248 + }); 249 + 250 + it('filterBySearch is case-insensitive', () => { 251 + const docs = [doc('1', { _decryptedName: 'Hello World' })]; 252 + expect(filterBySearch(docs, 'HELLO')).toHaveLength(1); 253 + }); 254 + 255 + it('filterBySearch returns all for empty query', () => { 256 + const docs = [doc('1'), doc('2')]; 257 + expect(filterBySearch(docs, '')).toHaveLength(2); 258 + expect(filterBySearch(docs, null)).toHaveLength(2); 259 + expect(filterBySearch(docs, undefined)).toHaveLength(2); 260 + }); 261 + }); 262 + 263 + describe('Username', () => { 264 + it('validateUsername rejects empty', () => { 265 + expect(validateUsername('').valid).toBe(false); 266 + expect(validateUsername(' ').valid).toBe(false); 267 + }); 268 + 269 + it('validateUsername rejects too-long names', () => { 270 + expect(validateUsername('A'.repeat(51)).valid).toBe(false); 271 + }); 272 + 273 + it('validateUsername accepts valid names', () => { 274 + expect(validateUsername('Alice').valid).toBe(true); 275 + expect(validateUsername('A'.repeat(50)).valid).toBe(true); 276 + }); 277 + }); 278 + 279 + describe('Recent Documents', () => { 280 + it('trackRecentDoc prepends new doc', () => { 281 + const recent = trackRecentDoc(['a', 'b'], 'c'); 282 + expect(recent).toEqual(['c', 'a', 'b']); 283 + }); 284 + 285 + it('trackRecentDoc deduplicates', () => { 286 + const recent = trackRecentDoc(['a', 'b', 'c'], 'b'); 287 + expect(recent).toEqual(['b', 'a', 'c']); 288 + }); 289 + 290 + it('trackRecentDoc caps at maxSize', () => { 291 + const recent = trackRecentDoc(['a', 'b', 'c'], 'd', 3); 292 + expect(recent).toEqual(['d', 'a', 'b']); 293 + }); 294 + 295 + it('getRecentDocs resolves IDs to docs in order', () => { 296 + const docs = [doc('a'), doc('b'), doc('c')]; 297 + const keys = { a: 'key', b: 'key', c: 'key' }; 298 + const recent = getRecentDocs(['c', 'a'], docs, keys); 299 + expect(recent.map(d => d.id)).toEqual(['c', 'a']); 300 + }); 301 + 302 + it('getRecentDocs filters out missing docs and keys', () => { 303 + const docs = [doc('a'), doc('b')]; 304 + const keys = { a: 'key' }; // b has no key 305 + const recent = getRecentDocs(['a', 'b', 'missing'], docs, keys); 306 + expect(recent).toHaveLength(1); 307 + expect(recent[0].id).toBe('a'); 308 + }); 309 + 310 + it('getRecentDocs respects displayCount', () => { 311 + const docs = [doc('a'), doc('b'), doc('c')]; 312 + const keys = { a: 'k', b: 'k', c: 'k' }; 313 + const recent = getRecentDocs(['a', 'b', 'c'], docs, keys, 2); 314 + expect(recent).toHaveLength(2); 315 + }); 316 + }); 317 + 318 + describe('Constants', () => { 319 + it('SORT_OPTIONS includes all sort fields', () => { 320 + expect(SORT_OPTIONS).toContain('updated'); 321 + expect(SORT_OPTIONS).toContain('created'); 322 + expect(SORT_OPTIONS).toContain('name'); 323 + expect(SORT_OPTIONS).toContain('type'); 324 + }); 325 + 326 + it('DEFAULT_SORT is updated', () => { 327 + expect(DEFAULT_SORT).toBe('updated'); 328 + }); 329 + });
+166 -69
tests/sheets-ux-improvements.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 + import { 3 + buildBorderStyle, 4 + applyBorderPreset, 5 + getWrapStyle, 6 + getStripedRowClass, 7 + } from '../src/sheets/cell-styles.js'; 8 + import { multiColumnSort } from '../src/sheets/sort.js'; 2 9 3 - describe('context menu freeze/unfreeze items', () => { 4 - // These test the logic patterns used in the context menu item generation 5 - it('freeze column generates correct label', () => { 6 - const col = 3; 7 - const colToLetter = (c: number) => String.fromCharCode(64 + c); 8 - const label = 'Freeze up to column ' + colToLetter(col); 9 - expect(label).toBe('Freeze up to column C'); 10 + describe('Cell Styles — buildBorderStyle', () => { 11 + it('returns empty string for null/undefined', () => { 12 + expect(buildBorderStyle(null)).toBe(''); 13 + expect(buildBorderStyle(undefined)).toBe(''); 14 + }); 15 + 16 + it('returns empty string for empty borders object', () => { 17 + expect(buildBorderStyle({})).toBe(''); 18 + }); 19 + 20 + it('builds single border side', () => { 21 + expect(buildBorderStyle({ top: '1px solid #000' })).toBe('border-top:1px solid #000;'); 22 + expect(buildBorderStyle({ bottom: '2px dashed red' })).toBe('border-bottom:2px dashed red;'); 23 + }); 24 + 25 + it('builds multiple border sides', () => { 26 + const style = buildBorderStyle({ 27 + top: '1px solid #000', 28 + left: '1px solid #000', 29 + }); 30 + expect(style).toContain('border-top:1px solid #000;'); 31 + expect(style).toContain('border-left:1px solid #000;'); 32 + }); 33 + 34 + it('builds all four borders', () => { 35 + const style = buildBorderStyle({ 36 + top: '1px solid black', 37 + bottom: '1px solid black', 38 + left: '1px solid black', 39 + right: '1px solid black', 40 + }); 41 + expect(style).toContain('border-top:'); 42 + expect(style).toContain('border-bottom:'); 43 + expect(style).toContain('border-left:'); 44 + expect(style).toContain('border-right:'); 10 45 }); 46 + }); 11 47 12 - it('freeze row generates correct label', () => { 13 - const row = 5; 14 - const label = 'Freeze at row ' + row; 15 - expect(label).toBe('Freeze at row 5'); 48 + describe('Cell Styles — applyBorderPreset', () => { 49 + const borderVal = '1px solid #333'; 50 + 51 + it('returns empty for null preset', () => { 52 + expect(applyBorderPreset(null, borderVal)).toEqual({}); 16 53 }); 17 54 18 - it('unfreeze items only shown when freeze is active', () => { 19 - // Simulate the spread pattern used in context menus 20 - const freezeRows = 3; 21 - const items = [ 22 - ...(freezeRows > 0 ? [{ label: 'Unfreeze Rows' }] : []), 23 - ]; 24 - expect(items).toHaveLength(1); 25 - expect(items[0].label).toBe('Unfreeze Rows'); 55 + it('applies "all" preset to all sides', () => { 56 + const result = applyBorderPreset('all', borderVal); 57 + expect(result.top).toBe(borderVal); 58 + expect(result.bottom).toBe(borderVal); 59 + expect(result.left).toBe(borderVal); 60 + expect(result.right).toBe(borderVal); 61 + }); 62 + 63 + it('applies "outline" same as "all"', () => { 64 + const result = applyBorderPreset('outline', borderVal); 65 + expect(result.top).toBe(borderVal); 66 + expect(result.right).toBe(borderVal); 67 + }); 68 + 69 + it('applies "none" preset', () => { 70 + expect(applyBorderPreset('none', borderVal)).toEqual({}); 71 + }); 72 + 73 + it('applies single-side presets', () => { 74 + expect(applyBorderPreset('top', borderVal)).toEqual({ top: borderVal }); 75 + expect(applyBorderPreset('bottom', borderVal)).toEqual({ bottom: borderVal }); 76 + expect(applyBorderPreset('left', borderVal)).toEqual({ left: borderVal }); 77 + expect(applyBorderPreset('right', borderVal)).toEqual({ right: borderVal }); 78 + }); 79 + 80 + it('returns empty for unknown preset', () => { 81 + expect(applyBorderPreset('diagonal', borderVal)).toEqual({}); 82 + }); 83 + }); 84 + 85 + describe('Cell Styles — getWrapStyle', () => { 86 + it('returns wrap style when true', () => { 87 + const style = getWrapStyle(true); 88 + expect(style).toContain('white-space:normal'); 89 + expect(style).toContain('word-wrap:break-word'); 90 + }); 91 + 92 + it('returns nowrap style when false', () => { 93 + const style = getWrapStyle(false); 94 + expect(style).toContain('white-space:nowrap'); 95 + expect(style).toContain('text-overflow:ellipsis'); 96 + }); 97 + 98 + it('returns nowrap style for null/undefined', () => { 99 + expect(getWrapStyle(null)).toContain('nowrap'); 100 + expect(getWrapStyle(undefined)).toContain('nowrap'); 101 + }); 102 + }); 103 + 104 + describe('Cell Styles — getStripedRowClass', () => { 105 + it('returns class for even rows when enabled', () => { 106 + expect(getStripedRowClass(0, true)).toBe('striped-row'); 107 + expect(getStripedRowClass(2, true)).toBe('striped-row'); 108 + expect(getStripedRowClass(4, true)).toBe('striped-row'); 109 + }); 110 + 111 + it('returns empty for odd rows when enabled', () => { 112 + expect(getStripedRowClass(1, true)).toBe(''); 113 + expect(getStripedRowClass(3, true)).toBe(''); 114 + }); 26 115 27 - const noFreeze = 0; 28 - const items2 = [ 29 - ...(noFreeze > 0 ? [{ label: 'Unfreeze Rows' }] : []), 30 - ]; 31 - expect(items2).toHaveLength(0); 116 + it('returns empty when disabled', () => { 117 + expect(getStripedRowClass(0, false)).toBe(''); 118 + expect(getStripedRowClass(0, null)).toBe(''); 119 + expect(getStripedRowClass(0, undefined)).toBe(''); 32 120 }); 33 121 }); 34 122 35 - describe('selection extension logic', () => { 36 - it('extendSelection clamps within bounds', () => { 37 - const maxCol = 26, maxRow = 100; 38 - // Simulate extending selection right from col 25 39 - let endCol = 25; 40 - endCol = Math.max(1, Math.min(maxCol, endCol + 1)); 41 - expect(endCol).toBe(26); 42 - // Cannot go past max 43 - endCol = Math.max(1, Math.min(maxCol, endCol + 1)); 44 - expect(endCol).toBe(26); 123 + describe('Multi-Column Sort', () => { 124 + it('sorts by single column ascending', () => { 125 + const rows = [{ A: 'Banana' }, { A: 'Apple' }, { A: 'Cherry' }]; 126 + const sorted = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 127 + expect(sorted.map(r => r.A)).toEqual(['Apple', 'Banana', 'Cherry']); 128 + }); 129 + 130 + it('sorts by single column descending', () => { 131 + const rows = [{ A: 1 }, { A: 3 }, { A: 2 }]; 132 + const sorted = multiColumnSort(rows, [{ col: 'A', order: 'desc' }]); 133 + expect(sorted.map(r => r.A)).toEqual([3, 2, 1]); 134 + }); 135 + 136 + it('sorts numbers numerically, not lexicographically', () => { 137 + const rows = [{ A: '10' }, { A: '2' }, { A: '1' }]; 138 + const sorted = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 139 + expect(sorted.map(r => r.A)).toEqual(['1', '2', '10']); 140 + }); 141 + 142 + it('sorts numbers before strings', () => { 143 + const rows = [{ A: 'foo' }, { A: 5 }, { A: 'bar' }, { A: 1 }]; 144 + const sorted = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 145 + expect(sorted[0].A).toBe(1); 146 + expect(sorted[1].A).toBe(5); 45 147 }); 46 148 47 - it('page navigation calculates correct jump', () => { 48 - const viewportHeight = 520; // px 49 - const rowHeight = 26; 50 - const pageRows = Math.max(1, Math.floor(viewportHeight / rowHeight) - 2); 51 - expect(pageRows).toBe(18); // 20 - 2 = 18 rows per page 149 + it('sorts empty values first', () => { 150 + const rows = [{ A: 'hello' }, { A: '' }, { A: 'world' }, { A: null }]; 151 + const sorted = multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 152 + expect(sorted[0].A === '' || sorted[0].A === null).toBe(true); 153 + expect(sorted[1].A === '' || sorted[1].A === null).toBe(true); 52 154 }); 53 155 54 - it('data extent finds last used cell', () => { 55 - // Simulate getDataExtent logic 56 - const cellIds = ['A1', 'C5', 'B10', 'Z1']; 57 - let maxRow = 1, maxCol = 1; 58 - // Simplified parseRef simulation 59 - const refs = [ 60 - { col: 1, row: 0 }, // A1 61 - { col: 3, row: 4 }, // C5 62 - { col: 2, row: 9 }, // B10 63 - { col: 26, row: 0 }, // Z1 156 + it('sorts by multiple columns with priority', () => { 157 + const rows = [ 158 + { dept: 'Sales', name: 'Zara' }, 159 + { dept: 'Eng', name: 'Bob' }, 160 + { dept: 'Sales', name: 'Alice' }, 161 + { dept: 'Eng', name: 'Dan' }, 64 162 ]; 65 - for (const ref of refs) { 66 - if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 67 - if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 68 - } 69 - expect(maxRow).toBe(10); 70 - expect(maxCol).toBe(27); 163 + const sorted = multiColumnSort(rows, [ 164 + { col: 'dept', order: 'asc' }, 165 + { col: 'name', order: 'asc' }, 166 + ]); 167 + expect(sorted.map(r => r.name)).toEqual(['Bob', 'Dan', 'Alice', 'Zara']); 71 168 }); 72 - }); 73 169 74 - describe('scrollCellIntoView estimation', () => { 75 - it('calculates target position for off-screen cell', () => { 76 - const rowHeight = 26; 77 - const colWidth = 96; 78 - const ROW_HEADER_WIDTH = 48; 79 - const targetRow = 50; 80 - const targetCol = 10; 170 + it('does not mutate input array', () => { 171 + const rows = [{ A: 3 }, { A: 1 }, { A: 2 }]; 172 + const original = [...rows]; 173 + multiColumnSort(rows, [{ col: 'A', order: 'asc' }]); 174 + expect(rows).toEqual(original); 175 + }); 81 176 82 - let targetTop = 0; 83 - for (let r = 1; r < targetRow; r++) targetTop += rowHeight; 84 - expect(targetTop).toBe(49 * 26); // 1274px 177 + it('returns empty array for empty input', () => { 178 + expect(multiColumnSort([], [{ col: 'A', order: 'asc' }])).toEqual([]); 179 + }); 85 180 86 - let targetLeft = ROW_HEADER_WIDTH; 87 - for (let c = 1; c < targetCol; c++) targetLeft += colWidth; 88 - expect(targetLeft).toBe(48 + 9 * 96); // 912px 181 + it('returns copy for no sort keys', () => { 182 + const rows = [{ A: 3 }, { A: 1 }]; 183 + const result = multiColumnSort(rows, []); 184 + expect(result).toEqual(rows); 185 + expect(result).not.toBe(rows); 89 186 }); 90 187 });
+114 -58
tests/ux-iteration-3.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseRef, 4 + colToLetter, 5 + letterToCol, 6 + cellId, 7 + formatCell, 8 + } from '../src/sheets/formulas.js'; 9 + import { adjustFormulaRefs } from '../src/sheets/row-col-ops.js'; 2 10 3 - describe('row auto-fit logic', () => { 4 - it('calculates single-line row height', () => { 5 - const MIN_ROW_HEIGHT = 26; 6 - const LINE_HEIGHT = 18; 7 - const PADDING = 8; 8 - // Short text fits in one line 9 - const textWidth = 50; 10 - const colWidth = 96 - PADDING; 11 - const lines = Math.max(1, Math.ceil(textWidth / colWidth)); 12 - const height = lines * LINE_HEIGHT + PADDING; 13 - expect(lines).toBe(1); 14 - expect(height).toBe(26); // 1 * 18 + 8 15 - expect(Math.max(MIN_ROW_HEIGHT, height)).toBe(26); 11 + describe('parseRef', () => { 12 + it('parses single-letter column ref', () => { 13 + expect(parseRef('A1')).toEqual({ col: 1, row: 1 }); 14 + expect(parseRef('C5')).toEqual({ col: 3, row: 5 }); 15 + expect(parseRef('Z99')).toEqual({ col: 26, row: 99 }); 16 16 }); 17 17 18 - it('calculates multi-line row height for wrapped text', () => { 19 - const LINE_HEIGHT = 18; 20 - const PADDING = 8; 21 - // Long text that wraps to 3 lines 22 - const textWidth = 250; 23 - const colWidth = 96 - PADDING; // 88px 24 - const lines = Math.max(1, Math.ceil(textWidth / colWidth)); // ceil(250/88) = 3 25 - const height = lines * LINE_HEIGHT + PADDING; 26 - expect(lines).toBe(3); 27 - expect(height).toBe(62); // 3 * 18 + 8 18 + it('parses multi-letter column ref', () => { 19 + expect(parseRef('AA1')).toEqual({ col: 27, row: 1 }); 20 + expect(parseRef('AZ1')).toEqual({ col: 52, row: 1 }); 28 21 }); 29 22 30 - it('minimum height is 26px', () => { 31 - const MIN_ROW_HEIGHT = 26; 32 - const height = 20; // calculated height less than minimum 33 - expect(Math.max(MIN_ROW_HEIGHT, height)).toBe(26); 23 + it('returns null for invalid refs', () => { 24 + expect(parseRef('')).toBeNull(); 25 + expect(parseRef('123')).toBeNull(); 26 + expect(parseRef('hello')).toBeNull(); 27 + expect(parseRef('a1')).toBeNull(); // lowercase 28 + expect(parseRef('$A1')).toBeNull(); // dollar sign 34 29 }); 35 30 }); 36 31 37 - describe('cell editor enhancements', () => { 38 - it('editor has placeholder text', () => { 39 - const placeholder = 'Type or = for formula'; 40 - expect(placeholder).toContain('formula'); 41 - expect(placeholder).toContain('='); 32 + describe('colToLetter / letterToCol', () => { 33 + it('converts single-digit columns', () => { 34 + expect(colToLetter(1)).toBe('A'); 35 + expect(colToLetter(26)).toBe('Z'); 36 + }); 37 + 38 + it('converts multi-digit columns', () => { 39 + expect(colToLetter(27)).toBe('AA'); 40 + expect(colToLetter(52)).toBe('AZ'); 41 + expect(colToLetter(53)).toBe('BA'); 42 + expect(colToLetter(702)).toBe('ZZ'); 43 + }); 44 + 45 + it('round-trips colToLetter → letterToCol', () => { 46 + for (const col of [1, 5, 26, 27, 52, 100, 256, 702]) { 47 + expect(letterToCol(colToLetter(col))).toBe(col); 48 + } 42 49 }); 43 50 44 - it('editor expands beyond cell width', () => { 45 - // CSS ensures min-width: 100% and no right bound 46 - // Just verifying the concept: editor should be at least as wide as the cell 47 - const cellWidth = 96; 48 - const editorMinWidth = cellWidth; // min-width: 100% 49 - expect(editorMinWidth).toBeGreaterThanOrEqual(cellWidth); 51 + it('round-trips letterToCol → colToLetter', () => { 52 + for (const letter of ['A', 'Z', 'AA', 'AZ', 'BA', 'ZZ']) { 53 + expect(colToLetter(letterToCol(letter))).toBe(letter); 54 + } 50 55 }); 51 56 }); 52 57 53 - describe('header cursor indicates selection action', () => { 54 - it('column headers use s-resize cursor', () => { 55 - // CSS rule: .sheet-grid thead th { cursor: s-resize; } 56 - const cursor = 's-resize'; 57 - expect(cursor).toBe('s-resize'); 58 + describe('cellId', () => { 59 + it('builds cell ID from column number and row', () => { 60 + expect(cellId(1, 1)).toBe('A1'); 61 + expect(cellId(3, 10)).toBe('C10'); 62 + expect(cellId(27, 5)).toBe('AA5'); 63 + }); 64 + }); 65 + 66 + describe('formatCell', () => { 67 + it('formats numbers with no format as-is', () => { 68 + expect(formatCell(42, undefined)).toBe('42'); 58 69 }); 59 70 60 - it('row headers use e-resize cursor', () => { 61 - // CSS rule: .sheet-grid .row-header { cursor: e-resize; } 62 - const cursor = 'e-resize'; 63 - expect(cursor).toBe('e-resize'); 71 + it('formats percentage', () => { 72 + const result = formatCell(0.75, 'percent'); 73 + expect(result).toContain('75'); 74 + expect(result).toContain('%'); 75 + }); 76 + 77 + it('formats currency', () => { 78 + const result = formatCell(1234.5, 'currency'); 79 + expect(result).toContain('$'); 80 + expect(result).toContain('1,234'); 81 + }); 82 + 83 + it('returns string values unchanged', () => { 84 + expect(formatCell('hello', undefined)).toBe('hello'); 64 85 }); 65 86 66 - it('corner cell uses default cursor', () => { 67 - // CSS rule: .sheet-grid thead th.corner { cursor: default; } 68 - const cursor = 'default'; 69 - expect(cursor).toBe('default'); 87 + it('handles null/undefined values', () => { 88 + expect(formatCell(null, undefined)).toBe(''); 89 + expect(formatCell(undefined, undefined)).toBe(''); 70 90 }); 71 91 }); 72 92 73 - describe('border-collapse with background-clip', () => { 74 - it('background-clip: padding-box prevents background from covering borders', () => { 75 - // When a cell has a colored background and border-collapse is used, 76 - // background-clip: padding-box ensures the background doesn't extend 77 - // under the border, keeping borders visible between colored cells 78 - const clip = 'padding-box'; 79 - expect(clip).toBe('padding-box'); 93 + describe('adjustFormulaRefs', () => { 94 + it('shifts row references down on row insert', () => { 95 + const result = adjustFormulaRefs('=A1+B2', { type: 'row', index: 1, delta: 1 }); 96 + // A1 stays (row 1 is at or before insert point), B2 becomes B3 97 + expect(result).toContain('B3'); 98 + }); 99 + 100 + it('shifts row references up on row delete', () => { 101 + const result = adjustFormulaRefs('=A3+B5', { type: 'row', index: 2, delta: -1 }); 102 + expect(result).toContain('A2'); 103 + expect(result).toContain('B4'); 104 + }); 105 + 106 + it('shifts column references on column insert', () => { 107 + const result = adjustFormulaRefs('=B1+C2', { type: 'col', index: 1, delta: 1 }); 108 + // B (col 2) becomes C, C (col 3) becomes D 109 + expect(result).toContain('C1'); 110 + expect(result).toContain('D2'); 111 + }); 112 + 113 + it('does not shift absolute row refs ($)', () => { 114 + const result = adjustFormulaRefs('=A$1', { type: 'row', index: 1, delta: 1 }); 115 + expect(result).toBe('=A$1'); 116 + }); 117 + 118 + it('does not shift absolute column refs ($)', () => { 119 + const result = adjustFormulaRefs('=$B1', { type: 'col', index: 1, delta: 1 }); 120 + expect(result).toBe('=$B1'); 121 + }); 122 + 123 + it('does not modify cross-sheet references', () => { 124 + const result = adjustFormulaRefs("=Sheet1!A1+B2", { type: 'row', index: 1, delta: 1 }); 125 + expect(result).toContain('Sheet1!A1'); 126 + }); 127 + 128 + it('produces #REF! for deleted references', () => { 129 + const result = adjustFormulaRefs('=A2', { type: 'row', index: 2, delta: -1 }); 130 + expect(result).toContain('#REF!'); 131 + }); 132 + 133 + it('handles formulas with no cell references', () => { 134 + expect(adjustFormulaRefs('=1+2', { type: 'row', index: 1, delta: 1 })).toBe('=1+2'); 135 + expect(adjustFormulaRefs('="hello"', { type: 'col', index: 1, delta: 1 })).toBe('="hello"'); 80 136 }); 81 137 });