|
2 | 2 | * Tests for virtual-scroll.ts |
3 | 3 | */ |
4 | 4 | import { describe, it, expect } from 'vitest' |
5 | | -import { calculateVirtualWindow, getScrollToPosition, type VirtualScrollConfig } from './virtual-scroll' |
| 5 | +import { |
| 6 | + calculateVirtualWindow, |
| 7 | + calculateVirtualWindowVariable, |
| 8 | + getScrollToPosition, |
| 9 | + getScrollToPositionVariable, |
| 10 | + type VirtualScrollConfig, |
| 11 | +} from './virtual-scroll' |
| 12 | + |
| 13 | +/** Helper: build a prefix-sum array from per-item widths. */ |
| 14 | +function prefixSumsFrom(widths: number[]): number[] { |
| 15 | + const sums = new Array<number>(widths.length + 1) |
| 16 | + sums[0] = 0 |
| 17 | + for (let i = 0; i < widths.length; i++) { |
| 18 | + sums[i + 1] = sums[i] + widths[i] |
| 19 | + } |
| 20 | + return sums |
| 21 | +} |
6 | 22 |
|
7 | 23 | describe('calculateVirtualWindow', () => { |
8 | 24 | const baseConfig: VirtualScrollConfig = { |
@@ -273,3 +289,228 @@ describe('getScrollToPosition', () => { |
273 | 289 | }) |
274 | 290 | }) |
275 | 291 | }) |
| 292 | + |
| 293 | +describe('calculateVirtualWindowVariable', () => { |
| 294 | + describe('basic calculations', () => { |
| 295 | + it('returns all zeros for empty widths', () => { |
| 296 | + const result = calculateVirtualWindowVariable([0], 5, 600, 0, 0) |
| 297 | + expect(result.startIndex).toBe(0) |
| 298 | + expect(result.endIndex).toBe(0) |
| 299 | + expect(result.visibleCount).toBe(0) |
| 300 | + expect(result.totalSize).toBe(0) |
| 301 | + expect(result.offset).toBe(0) |
| 302 | + }) |
| 303 | + |
| 304 | + it('handles a single column wider than the container', () => { |
| 305 | + // One column 800px wide in a 600px viewport, scrolled to 0. |
| 306 | + const widths = [800] |
| 307 | + const prefixSums = prefixSumsFrom(widths) // [0, 800] |
| 308 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 600, 0, widths.length) |
| 309 | + expect(result.startIndex).toBe(0) |
| 310 | + expect(result.endIndex).toBe(1) |
| 311 | + expect(result.visibleCount).toBe(1) |
| 312 | + expect(result.totalSize).toBe(800) |
| 313 | + expect(result.offset).toBe(0) |
| 314 | + }) |
| 315 | + |
| 316 | + it('handles a single column wider than the container, scrolled mid-column', () => { |
| 317 | + const widths = [800] |
| 318 | + const prefixSums = prefixSumsFrom(widths) |
| 319 | + // Scrolled to 200 — the (only) column still starts at 0 (off-left), so it's the first visible. |
| 320 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 600, 200, widths.length) |
| 321 | + expect(result.startIndex).toBe(0) |
| 322 | + expect(result.endIndex).toBe(1) |
| 323 | + expect(result.offset).toBe(0) |
| 324 | + }) |
| 325 | + |
| 326 | + it('finds the correct range for many small columns scrolled to the middle', () => { |
| 327 | + // 20 columns of 100px each. Container 300px, scrolled to 1000 (column 10 starts at 1000). |
| 328 | + const widths = new Array<number>(20).fill(100) |
| 329 | + const prefixSums = prefixSumsFrom(widths) |
| 330 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 300, 1000, widths.length) |
| 331 | + expect(result.startIndex).toBe(10) |
| 332 | + expect(result.endIndex).toBe(13) // 1000 + 300 = 1300; column 13 starts at 1300 |
| 333 | + expect(result.totalSize).toBe(2000) |
| 334 | + expect(result.offset).toBe(prefixSums[result.startIndex]) |
| 335 | + }) |
| 336 | + |
| 337 | + it('worked example from plan review round 3', () => { |
| 338 | + // prefixSums=[0,100,200,350,500,700], scrollLeft=150, containerWidth=300, buffer=0 |
| 339 | + // → startIndex=1 (col 1 starts at 100), endIndex=4 (col 3 ends at 500, intersects 150..450). |
| 340 | + const prefixSums = [0, 100, 200, 350, 500, 700] |
| 341 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 300, 150, 5) |
| 342 | + expect(result.startIndex).toBe(1) |
| 343 | + expect(result.endIndex).toBe(4) |
| 344 | + expect(result.visibleCount).toBe(3) |
| 345 | + expect(result.totalSize).toBe(700) |
| 346 | + expect(result.offset).toBe(100) |
| 347 | + }) |
| 348 | + }) |
| 349 | + |
| 350 | + describe('boundaries', () => { |
| 351 | + it('treats an item whose right edge exactly equals viewport right as fully visible', () => { |
| 352 | + // 4 columns × 100px in a 200px viewport, scrolled to 100. prefixSums = [0,100,200,300,400]. |
| 353 | + // viewportEnd = 300 = prefixSums[3]. The loop stops at j=3 because prefixSums[3] >= 300. |
| 354 | + // So columns 1 and 2 are visible (endIndex=3), not column 3. |
| 355 | + const widths = [100, 100, 100, 100] |
| 356 | + const prefixSums = prefixSumsFrom(widths) |
| 357 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 200, 100, widths.length) |
| 358 | + expect(result.startIndex).toBe(1) |
| 359 | + expect(result.endIndex).toBe(3) |
| 360 | + expect(result.offset).toBe(100) |
| 361 | + }) |
| 362 | + |
| 363 | + it('treats an item whose left edge exactly equals viewport left as the first visible', () => { |
| 364 | + // Scroll to 200, the boundary aligns with column 2's left edge. Column 2 is the first visible. |
| 365 | + const widths = [100, 100, 100, 100] |
| 366 | + const prefixSums = prefixSumsFrom(widths) // [0,100,200,300,400] |
| 367 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 200, 200, widths.length) |
| 368 | + expect(result.startIndex).toBe(2) |
| 369 | + expect(result.endIndex).toBe(4) |
| 370 | + expect(result.offset).toBe(200) |
| 371 | + }) |
| 372 | + |
| 373 | + it('handles totalSize correctly when scrolled to the far right', () => { |
| 374 | + const widths = [100, 100, 100, 100, 100] |
| 375 | + const prefixSums = prefixSumsFrom(widths) // total 500 |
| 376 | + const result = calculateVirtualWindowVariable(prefixSums, 0, 200, 300, widths.length) |
| 377 | + // viewport 300..500 → first visible is column 3, walk to end of list |
| 378 | + expect(result.startIndex).toBe(3) |
| 379 | + expect(result.endIndex).toBe(5) |
| 380 | + expect(result.totalSize).toBe(500) |
| 381 | + }) |
| 382 | + }) |
| 383 | + |
| 384 | + describe('buffer', () => { |
| 385 | + it('applies buffer symmetrically in the middle of the list', () => { |
| 386 | + // 20 columns × 100px, container 300px, scrolled to 1000 (column 10 start), buffer 2. |
| 387 | + // Without buffer: start=10, end=13. With buffer 2: start=8, end=15. |
| 388 | + const widths = new Array<number>(20).fill(100) |
| 389 | + const prefixSums = prefixSumsFrom(widths) |
| 390 | + const result = calculateVirtualWindowVariable(prefixSums, 2, 300, 1000, widths.length) |
| 391 | + expect(result.startIndex).toBe(8) |
| 392 | + expect(result.endIndex).toBe(15) |
| 393 | + expect(result.visibleCount).toBe(7) |
| 394 | + }) |
| 395 | + |
| 396 | + it("doesn't shrink the right buffer when the left buffer clamps at 0 (off-by-buffer guard)", () => { |
| 397 | + // 20 columns × 100px, container 300px, scrolled to 0, buffer 5. |
| 398 | + // Without the guard, a naive `endIndex = startIndex + visibleCount + 2 * bufferSize` style |
| 399 | + // would lose the 5 left-buffer slots and end at firstVisible + viewportColumns + 5 = 3 + 5 = 8. |
| 400 | + // With the correct math, startIndex = max(0, 0 - 5) = 0, but endIndex still gets the full |
| 401 | + // bufferSize=5 on the right: lastVisibleEnd=3 (300/100), endIndex = min(20, 3 + 5) = 8. |
| 402 | + // (Note: in this case the visible end happens to match — the bug only shows up when |
| 403 | + // the naive formula tries to "compensate" or when bufferSize is large enough that the |
| 404 | + // *right* edge gets clipped because the left clamp ate the buffer. See next test.) |
| 405 | + const widths = new Array<number>(20).fill(100) |
| 406 | + const prefixSums = prefixSumsFrom(widths) |
| 407 | + const result = calculateVirtualWindowVariable(prefixSums, 5, 300, 0, widths.length) |
| 408 | + expect(result.startIndex).toBe(0) |
| 409 | + expect(result.endIndex).toBe(8) // lastVisibleEnd=3, +5 buffer |
| 410 | + }) |
| 411 | + |
| 412 | + it("doesn't shrink the left buffer when the right buffer clamps at totalItems (off-by-buffer guard, mirrored)", () => { |
| 413 | + // 20 columns × 100px, container 300px, scrolled to 1700 (last 3 columns visible), buffer 5. |
| 414 | + // firstVisibleIndex = 17, lastVisibleEnd = 20 (clamped by totalItems). |
| 415 | + // startIndex = max(0, 17 - 5) = 12, endIndex = min(20, 20 + 5) = 20. |
| 416 | + // A naive formula tying end-buffer to start-buffer would shrink one when the other clamps. |
| 417 | + const widths = new Array<number>(20).fill(100) |
| 418 | + const prefixSums = prefixSumsFrom(widths) |
| 419 | + const result = calculateVirtualWindowVariable(prefixSums, 5, 300, 1700, widths.length) |
| 420 | + expect(result.startIndex).toBe(12) |
| 421 | + expect(result.endIndex).toBe(20) |
| 422 | + expect(result.visibleCount).toBe(8) |
| 423 | + }) |
| 424 | + |
| 425 | + it('buffer larger than available room clamps both ends independently', () => { |
| 426 | + // 5 columns × 100px, container 200px, scrolled to 0, buffer 100. |
| 427 | + // startIndex = max(0, 0 - 100) = 0 |
| 428 | + // lastVisibleEnd = 2, endIndex = min(5, 2 + 100) = 5 |
| 429 | + const widths = [100, 100, 100, 100, 100] |
| 430 | + const prefixSums = prefixSumsFrom(widths) |
| 431 | + const result = calculateVirtualWindowVariable(prefixSums, 100, 200, 0, widths.length) |
| 432 | + expect(result.startIndex).toBe(0) |
| 433 | + expect(result.endIndex).toBe(5) |
| 434 | + expect(result.visibleCount).toBe(5) |
| 435 | + }) |
| 436 | + }) |
| 437 | + |
| 438 | + describe('invariants', () => { |
| 439 | + it('throws when prefixSums length does not match totalItems + 1', () => { |
| 440 | + expect(() => calculateVirtualWindowVariable([0, 100, 200], 0, 100, 0, 5)).toThrow(/prefixSums.length/) |
| 441 | + }) |
| 442 | + |
| 443 | + it('accepts the empty case (totalItems=0, prefixSums=[0])', () => { |
| 444 | + // Mirror of the empty test above, but explicit about the invariant boundary. |
| 445 | + expect(() => calculateVirtualWindowVariable([0], 0, 100, 0, 0)).not.toThrow() |
| 446 | + }) |
| 447 | + }) |
| 448 | +}) |
| 449 | + |
| 450 | +describe('getScrollToPositionVariable', () => { |
| 451 | + const widths = [100, 150, 200, 50, 100] // total 600 |
| 452 | + const prefixSums = prefixSumsFrom(widths) // [0, 100, 250, 450, 500, 600] |
| 453 | + const containerSize = 300 |
| 454 | + |
| 455 | + describe('item is visible', () => { |
| 456 | + it('returns undefined when item is fully inside the viewport', () => { |
| 457 | + // Viewport 100..400. Item 1 spans 100..250 — fully visible. |
| 458 | + const result = getScrollToPositionVariable(prefixSums, 1, 100, containerSize) |
| 459 | + expect(result).toBeUndefined() |
| 460 | + }) |
| 461 | + |
| 462 | + it('returns undefined when item left edge exactly equals viewport left edge', () => { |
| 463 | + // Viewport 100..400. Item 1 starts at 100. |
| 464 | + const result = getScrollToPositionVariable(prefixSums, 1, 100, containerSize) |
| 465 | + expect(result).toBeUndefined() |
| 466 | + }) |
| 467 | + |
| 468 | + it('returns undefined when item right edge exactly equals viewport right edge', () => { |
| 469 | + // Item 2 ends at 450. Viewport ending at 450 = scrollOffset 150. |
| 470 | + const result = getScrollToPositionVariable(prefixSums, 2, 150, containerSize) |
| 471 | + expect(result).toBeUndefined() |
| 472 | + }) |
| 473 | + }) |
| 474 | + |
| 475 | + describe('item is off-left', () => { |
| 476 | + it("returns the item's left edge X when off-left", () => { |
| 477 | + // Viewport 200..500. Item 0 spans 0..100 — off-left. Want to scroll to 0. |
| 478 | + const result = getScrollToPositionVariable(prefixSums, 0, 200, containerSize) |
| 479 | + expect(result).toBe(0) |
| 480 | + }) |
| 481 | + |
| 482 | + it('returns prefixSums[index] when item starts before scrollOffset', () => { |
| 483 | + // Viewport 300..600. Item 1 starts at 100, off-left. Scroll target = 100. |
| 484 | + const result = getScrollToPositionVariable(prefixSums, 1, 300, containerSize) |
| 485 | + expect(result).toBe(100) |
| 486 | + }) |
| 487 | + }) |
| 488 | + |
| 489 | + describe('item is off-right', () => { |
| 490 | + it('returns right − containerSize when item is off-right', () => { |
| 491 | + // Viewport 0..300. Item 3 spans 450..500 — off-right. Scroll target = 500 - 300 = 200. |
| 492 | + const result = getScrollToPositionVariable(prefixSums, 3, 0, containerSize) |
| 493 | + expect(result).toBe(200) |
| 494 | + }) |
| 495 | + |
| 496 | + it('scrolls to fit the last item at the right edge', () => { |
| 497 | + // Viewport 0..300. Item 4 spans 500..600. Scroll = 600 - 300 = 300. |
| 498 | + const result = getScrollToPositionVariable(prefixSums, 4, 0, containerSize) |
| 499 | + expect(result).toBe(300) |
| 500 | + }) |
| 501 | + }) |
| 502 | + |
| 503 | + describe('invariants', () => { |
| 504 | + it('throws on negative index', () => { |
| 505 | + expect(() => getScrollToPositionVariable(prefixSums, -1, 0, containerSize)).toThrow(/out of range/) |
| 506 | + }) |
| 507 | + |
| 508 | + it('throws when index equals totalItems', () => { |
| 509 | + expect(() => getScrollToPositionVariable(prefixSums, 5, 0, containerSize)).toThrow(/out of range/) |
| 510 | + }) |
| 511 | + |
| 512 | + it('throws when index exceeds totalItems', () => { |
| 513 | + expect(() => getScrollToPositionVariable(prefixSums, 100, 0, containerSize)).toThrow(/out of range/) |
| 514 | + }) |
| 515 | + }) |
| 516 | +}) |
0 commit comments