| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| | 1 | // Production CSS is part of the behavior under test because row height depends on it. |
| 1 | import "../index.css"; | 2 | import "../index.css"; |
| 2 | | 3 | |
| 3 | import { | 4 | import { |
| ... | @@ -15,6 +16,7 @@ import { | ... | @@ -15,6 +16,7 @@ import { |
| 15 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | 16 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 16 | import { HttpResponse, http, ws } from "msw"; | 17 | import { HttpResponse, http, ws } from "msw"; |
| 17 | import { setupWorker } from "msw/browser"; | 18 | import { setupWorker } from "msw/browser"; |
| | 19 | import { page } from "vitest/browser"; |
| 18 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | 20 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| 19 | import { render } from "vitest-browser-react"; | 21 | import { render } from "vitest-browser-react"; |
| 20 | | 22 | |
| ... | @@ -45,6 +47,45 @@ interface TestFixture { | ... | @@ -45,6 +47,45 @@ interface TestFixture { |
| 45 | let fixture: TestFixture; | 47 | let fixture: TestFixture; |
| 46 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | 48 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 47 | | 49 | |
| | 50 | interface ViewportSpec { |
| | 51 | name: string; |
| | 52 | width: number; |
| | 53 | height: number; |
| | 54 | textTolerancePx: number; |
| | 55 | attachmentTolerancePx: number; |
| | 56 | } |
| | 57 | |
| | 58 | const DEFAULT_VIEWPORT: ViewportSpec = { |
| | 59 | name: "desktop", |
| | 60 | width: 960, |
| | 61 | height: 1_100, |
| | 62 | textTolerancePx: 44, |
| | 63 | attachmentTolerancePx: 56, |
| | 64 | }; |
| | 65 | const TEXT_VIEWPORT_MATRIX = [ |
| | 66 | DEFAULT_VIEWPORT, |
| | 67 | { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, |
| | 68 | { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, |
| | 69 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, |
| | 70 | ] as const satisfies readonly ViewportSpec[]; |
| | 71 | const ATTACHMENT_VIEWPORT_MATRIX = [ |
| | 72 | DEFAULT_VIEWPORT, |
| | 73 | { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, |
| | 74 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, |
| | 75 | ] as const satisfies readonly ViewportSpec[]; |
| | 76 | |
| | 77 | interface UserRowMeasurement { |
| | 78 | measuredRowHeightPx: number; |
| | 79 | timelineWidthMeasuredPx: number; |
| | 80 | renderedInVirtualizedRegion: boolean; |
| | 81 | } |
| | 82 | |
| | 83 | interface MountedChatView { |
| | 84 | cleanup: () => Promise<void>; |
| | 85 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; |
| | 86 | setViewport: (viewport: ViewportSpec) => Promise<void>; |
| | 87 | } |
| | 88 | |
| 48 | function isoAt(offsetSeconds: number): string { | 89 | function isoAt(offsetSeconds: number): string { |
| 49 | return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); | 90 | return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); |
| 50 | } | 91 | } |
| ... | @@ -297,6 +338,26 @@ async function waitForLayout(): Promise<void> { | ... | @@ -297,6 +338,26 @@ async function waitForLayout(): Promise<void> { |
| 297 | await nextFrame(); | 338 | await nextFrame(); |
| 298 | } | 339 | } |
| 299 | | 340 | |
| | 341 | async function setViewport(viewport: ViewportSpec): Promise<void> { |
| | 342 | await page.viewport(viewport.width, viewport.height); |
| | 343 | await waitForLayout(); |
| | 344 | } |
| | 345 | |
| | 346 | async function waitForProductionStyles(): Promise<void> { |
| | 347 | await vi.waitFor( |
| | 348 | () => { |
| | 349 | expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe( |
| | 350 | "", |
| | 351 | ); |
| | 352 | expect(getComputedStyle(document.body).marginTop).toBe("0px"); |
| | 353 | }, |
| | 354 | { |
| | 355 | timeout: 4_000, |
| | 356 | interval: 16, |
| | 357 | }, |
| | 358 | ); |
| | 359 | } |
| | 360 | |
| 300 | async function waitForElement<T extends Element>( | 361 | async function waitForElement<T extends Element>( |
| 301 | query: () => T | null, | 362 | query: () => T | null, |
| 302 | errorMessage: string, | 363 | errorMessage: string, |
| ... | @@ -339,21 +400,84 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> { | ... | @@ -339,21 +400,84 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 339 | await waitForLayout(); | 400 | await waitForLayout(); |
| 340 | } | 401 | } |
| 341 | | 402 | |
| 342 | async function renderAndMeasureUserRow(options: { | 403 | async function measureUserRow(options: { |
| 343 | timelineWidthPx: number; | 404 | host: HTMLElement; |
| 344 | targetMessageId: MessageId; | 405 | targetMessageId: MessageId; |
| | 406 | }): Promise<UserRowMeasurement> { |
| | 407 | const { host, targetMessageId } = options; |
| | 408 | const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; |
| | 409 | |
| | 410 | const scrollContainer = await waitForElement( |
| | 411 | () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"), |
| | 412 | "Unable to find ChatView message scroll container.", |
| | 413 | ); |
| | 414 | |
| | 415 | let row: HTMLElement | null = null; |
| | 416 | await vi.waitFor( |
| | 417 | async () => { |
| | 418 | scrollContainer.scrollTop = 0; |
| | 419 | scrollContainer.dispatchEvent(new Event("scroll")); |
| | 420 | await waitForLayout(); |
| | 421 | row = host.querySelector<HTMLElement>(rowSelector); |
| | 422 | expect(row, "Unable to locate targeted user message row.").toBeTruthy(); |
| | 423 | }, |
| | 424 | { |
| | 425 | timeout: 8_000, |
| | 426 | interval: 16, |
| | 427 | }, |
| | 428 | ); |
| | 429 | |
| | 430 | await waitForImagesToLoad(row!); |
| | 431 | scrollContainer.scrollTop = 0; |
| | 432 | scrollContainer.dispatchEvent(new Event("scroll")); |
| | 433 | await nextFrame(); |
| | 434 | |
| | 435 | const timelineRoot = |
| | 436 | row!.closest<HTMLElement>('[data-timeline-root="true"]') ?? |
| | 437 | host.querySelector<HTMLElement>('[data-timeline-root="true"]'); |
| | 438 | if (!(timelineRoot instanceof HTMLElement)) { |
| | 439 | throw new Error("Unable to locate timeline root container."); |
| | 440 | } |
| | 441 | |
| | 442 | let timelineWidthMeasuredPx = 0; |
| | 443 | let measuredRowHeightPx = 0; |
| | 444 | let renderedInVirtualizedRegion = false; |
| | 445 | await vi.waitFor( |
| | 446 | async () => { |
| | 447 | scrollContainer.scrollTop = 0; |
| | 448 | scrollContainer.dispatchEvent(new Event("scroll")); |
| | 449 | await nextFrame(); |
| | 450 | const measuredRow = host.querySelector<HTMLElement>(rowSelector); |
| | 451 | expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); |
| | 452 | timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; |
| | 453 | measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; |
| | 454 | renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; |
| | 455 | expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); |
| | 456 | expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); |
| | 457 | }, |
| | 458 | { |
| | 459 | timeout: 4_000, |
| | 460 | interval: 16, |
| | 461 | }, |
| | 462 | ); |
| | 463 | |
| | 464 | return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; |
| | 465 | } |
| | 466 | |
| | 467 | async function mountChatView(options: { |
| | 468 | viewport: ViewportSpec; |
| 345 | snapshot: OrchestrationReadModel; | 469 | snapshot: OrchestrationReadModel; |
| 346 | }): Promise<{ | 470 | }): Promise<MountedChatView> { |
| 347 | measuredRowHeightPx: number; | | |
| 348 | timelineWidthMeasuredPx: number; | | |
| 349 | renderedInVirtualizedRegion: boolean; | | |
| 350 | }> { | | |
| 351 | fixture = buildFixture(options.snapshot); | 471 | fixture = buildFixture(options.snapshot); |
| | 472 | await setViewport(options.viewport); |
| | 473 | await waitForProductionStyles(); |
| 352 | | 474 | |
| 353 | const host = document.createElement("div"); | 475 | const host = document.createElement("div"); |
| 354 | host.style.width = `${options.timelineWidthPx}px`; | 476 | host.style.position = "fixed"; |
| 355 | host.style.height = "920px"; | 477 | host.style.inset = "0"; |
| 356 | host.style.display = "flex"; | 478 | host.style.width = "100vw"; |
| | 479 | host.style.height = "100vh"; |
| | 480 | host.style.display = "grid"; |
| 357 | host.style.overflow = "hidden"; | 481 | host.style.overflow = "hidden"; |
| 358 | document.body.append(host); | 482 | document.body.append(host); |
| 359 | | 483 | |
| ... | @@ -367,68 +491,35 @@ async function renderAndMeasureUserRow(options: { | ... | @@ -367,68 +491,35 @@ async function renderAndMeasureUserRow(options: { |
| 367 | container: host, | 491 | container: host, |
| 368 | }); | 492 | }); |
| 369 | | 493 | |
| 370 | try { | 494 | await waitForLayout(); |
| 371 | await waitForLayout(); | | |
| 372 | | | |
| 373 | const scrollContainer = await waitForElement( | | |
| 374 | () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"), | | |
| 375 | "Unable to find ChatView message scroll container.", | | |
| 376 | ); | | |
| 377 | | | |
| 378 | let row: HTMLElement | null = null; | | |
| 379 | await vi.waitFor( | | |
| 380 | async () => { | | |
| 381 | scrollContainer.scrollTop = 0; | | |
| 382 | scrollContainer.dispatchEvent(new Event("scroll")); | | |
| 383 | await waitForLayout(); | | |
| 384 | row = host.querySelector<HTMLElement>( | | |
| 385 | `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, | | |
| 386 | ); | | |
| 387 | expect(row, "Unable to locate targeted user message row.").toBeTruthy(); | | |
| 388 | }, | | |
| 389 | { | | |
| 390 | timeout: 8_000, | | |
| 391 | interval: 16, | | |
| 392 | }, | | |
| 393 | ); | | |
| 394 | | | |
| 395 | await waitForImagesToLoad(row!); | | |
| 396 | scrollContainer.scrollTop = 0; | | |
| 397 | scrollContainer.dispatchEvent(new Event("scroll")); | | |
| 398 | await nextFrame(); | | |
| 399 | | 495 | |
| 400 | const timelineRoot = | 496 | return { |
| 401 | row!.closest<HTMLElement>('[data-timeline-root="true"]') ?? | 497 | cleanup: async () => { |
| 402 | host.querySelector<HTMLElement>('[data-timeline-root="true"]'); | 498 | await screen.unmount(); |
| 403 | if (!(timelineRoot instanceof HTMLElement)) { | 499 | host.remove(); |
| 404 | throw new Error("Unable to locate timeline root container."); | 500 | }, |
| 405 | } | 501 | measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), |
| | 502 | setViewport: async (viewport: ViewportSpec) => { |
| | 503 | await setViewport(viewport); |
| | 504 | await waitForProductionStyles(); |
| | 505 | }, |
| | 506 | }; |
| | 507 | } |
| 406 | | 508 | |
| 407 | const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; | 509 | async function measureUserRowAtViewport(options: { |
| 408 | let measuredRowHeightPx = 0; | 510 | snapshot: OrchestrationReadModel; |
| 409 | let renderedInVirtualizedRegion = false; | 511 | targetMessageId: MessageId; |
| 410 | const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`; | 512 | viewport: ViewportSpec; |
| 411 | await vi.waitFor( | 513 | }): Promise<UserRowMeasurement> { |
| 412 | async () => { | 514 | const mounted = await mountChatView({ |
| 413 | scrollContainer.scrollTop = 0; | 515 | viewport: options.viewport, |
| 414 | scrollContainer.dispatchEvent(new Event("scroll")); | 516 | snapshot: options.snapshot, |
| 415 | await nextFrame(); | 517 | }); |
| 416 | const measuredRow = host.querySelector<HTMLElement>(rowSelector); | | |
| 417 | expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); | | |
| 418 | measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; | | |
| 419 | renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; | | |
| 420 | expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); | | |
| 421 | }, | | |
| 422 | { | | |
| 423 | timeout: 4_000, | | |
| 424 | interval: 16, | | |
| 425 | }, | | |
| 426 | ); | | |
| 427 | | 518 | |
| 428 | return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; | 519 | try { |
| | 520 | return await mounted.measureUserRow(options.targetMessageId); |
| 429 | } finally { | 521 | } finally { |
| 430 | await screen.unmount(); | 522 | await mounted.cleanup(); |
| 431 | host.remove(); | | |
| 432 | } | 523 | } |
| 433 | } | 524 | } |
| 434 | | 525 | |
| ... | @@ -453,7 +544,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -453,7 +544,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 453 | await worker.stop(); | 544 | await worker.stop(); |
| 454 | }); | 545 | }); |
| 455 | | 546 | |
| 456 | beforeEach(() => { | 547 | beforeEach(async () => { |
| | 548 | await setViewport(DEFAULT_VIEWPORT); |
| 457 | localStorage.clear(); | 549 | localStorage.clear(); |
| 458 | document.body.innerHTML = ""; | 550 | document.body.innerHTML = ""; |
| 459 | useStore.setState({ | 551 | useStore.setState({ |
| ... | @@ -468,57 +560,111 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -468,57 +560,111 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 468 | document.body.innerHTML = ""; | 560 | document.body.innerHTML = ""; |
| 469 | }); | 561 | }); |
| 470 | | 562 | |
| 471 | it("keeps long user message estimate close to actual rendered virtualized ChatView row height", async () => { | 563 | it.each(TEXT_VIEWPORT_MATRIX)( |
| 472 | const userText = "x".repeat(3_200); | 564 | "keeps long user message estimate close at the $name viewport", |
| 473 | const targetMessageId = "msg-user-target-long" as MessageId; | 565 | async (viewport) => { |
| 474 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = | 566 | const userText = "x".repeat(3_200); |
| 475 | await renderAndMeasureUserRow({ | 567 | const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; |
| 476 | timelineWidthPx: 960, | 568 | const mounted = await mountChatView({ |
| 477 | targetMessageId, | 569 | viewport, |
| 478 | snapshot: createSnapshotForTargetUser({ | 570 | snapshot: createSnapshotForTargetUser({ |
| 479 | targetMessageId, | 571 | targetMessageId, |
| 480 | targetText: userText, | 572 | targetText: userText, |
| 481 | }), | 573 | }), |
| 482 | }); | 574 | }); |
| 483 | | 575 | |
| 484 | expect(renderedInVirtualizedRegion).toBe(true); | 576 | try { |
| | 577 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = |
| | 578 | await mounted.measureUserRow(targetMessageId); |
| 485 | | 579 | |
| 486 | const estimatedHeightPx = estimateTimelineMessageHeight( | 580 | expect(renderedInVirtualizedRegion).toBe(true); |
| 487 | { role: "user", text: userText, attachments: [] }, | 581 | |
| 488 | { timelineWidthPx: timelineWidthMeasuredPx }, | 582 | const estimatedHeightPx = estimateTimelineMessageHeight( |
| 489 | ); | 583 | { role: "user", text: userText, attachments: [] }, |
| | 584 | { timelineWidthPx: timelineWidthMeasuredPx }, |
| | 585 | ); |
| | 586 | |
| | 587 | expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( |
| | 588 | viewport.textTolerancePx, |
| | 589 | ); |
| | 590 | } finally { |
| | 591 | await mounted.cleanup(); |
| | 592 | } |
| | 593 | }, |
| | 594 | ); |
| | 595 | |
| | 596 | it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { |
| | 597 | const userText = "x".repeat(3_200); |
| | 598 | const targetMessageId = "msg-user-target-resize" as MessageId; |
| | 599 | const mounted = await mountChatView({ |
| | 600 | viewport: TEXT_VIEWPORT_MATRIX[0], |
| | 601 | snapshot: createSnapshotForTargetUser({ |
| | 602 | targetMessageId, |
| | 603 | targetText: userText, |
| | 604 | }), |
| | 605 | }); |
| 490 | | 606 | |
| 491 | expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(44); | 607 | try { |
| | 608 | const measurements: Array<UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }> = []; |
| | 609 | |
| | 610 | for (const viewport of TEXT_VIEWPORT_MATRIX) { |
| | 611 | await mounted.setViewport(viewport); |
| | 612 | const measurement = await mounted.measureUserRow(targetMessageId); |
| | 613 | const estimatedHeightPx = estimateTimelineMessageHeight( |
| | 614 | { role: "user", text: userText, attachments: [] }, |
| | 615 | { timelineWidthPx: measurement.timelineWidthMeasuredPx }, |
| | 616 | ); |
| | 617 | |
| | 618 | expect(measurement.renderedInVirtualizedRegion).toBe(true); |
| | 619 | expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( |
| | 620 | viewport.textTolerancePx, |
| | 621 | ); |
| | 622 | measurements.push({ ...measurement, viewport, estimatedHeightPx }); |
| | 623 | } |
| | 624 | |
| | 625 | expect(new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))).size).toBeGreaterThanOrEqual(3); |
| | 626 | |
| | 627 | const byMeasuredWidth = measurements.toSorted( |
| | 628 | (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, |
| | 629 | ); |
| | 630 | const narrowest = byMeasuredWidth[0]!; |
| | 631 | const widest = byMeasuredWidth.at(-1)!; |
| | 632 | expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); |
| | 633 | expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); |
| | 634 | expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); |
| | 635 | } finally { |
| | 636 | await mounted.cleanup(); |
| | 637 | } |
| 492 | }); | 638 | }); |
| 493 | | 639 | |
| 494 | it("tracks additional rendered wrapping when ChatView width narrows", async () => { | 640 | it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { |
| 495 | const userText = "x".repeat(2_400); | 641 | const userText = "x".repeat(2_400); |
| 496 | const targetMessageId = "msg-user-target-wrap" as MessageId; | 642 | const targetMessageId = "msg-user-target-wrap" as MessageId; |
| 497 | const snapshot = createSnapshotForTargetUser({ | 643 | const snapshot = createSnapshotForTargetUser({ |
| 498 | targetMessageId, | 644 | targetMessageId, |
| 499 | targetText: userText, | 645 | targetText: userText, |
| 500 | }); | 646 | }); |
| 501 | const desktop = await renderAndMeasureUserRow({ | 647 | const desktopMeasurement = await measureUserRowAtViewport({ |
| 502 | timelineWidthPx: 960, | 648 | viewport: TEXT_VIEWPORT_MATRIX[0], |
| 503 | targetMessageId, | | |
| 504 | snapshot, | 649 | snapshot, |
| 505 | }); | | |
| 506 | const mobile = await renderAndMeasureUserRow({ | | |
| 507 | timelineWidthPx: 360, | | |
| 508 | targetMessageId, | 650 | targetMessageId, |
| | 651 | }); |
| | 652 | const mobileMeasurement = await measureUserRowAtViewport({ |
| | 653 | viewport: TEXT_VIEWPORT_MATRIX[2], |
| 509 | snapshot, | 654 | snapshot, |
| | 655 | targetMessageId, |
| 510 | }); | 656 | }); |
| 511 | | 657 | |
| 512 | const estimatedDesktopPx = estimateTimelineMessageHeight( | 658 | const estimatedDesktopPx = estimateTimelineMessageHeight( |
| 513 | { role: "user", text: userText, attachments: [] }, | 659 | { role: "user", text: userText, attachments: [] }, |
| 514 | { timelineWidthPx: desktop.timelineWidthMeasuredPx }, | 660 | { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, |
| 515 | ); | 661 | ); |
| 516 | const estimatedMobilePx = estimateTimelineMessageHeight( | 662 | const estimatedMobilePx = estimateTimelineMessageHeight( |
| 517 | { role: "user", text: userText, attachments: [] }, | 663 | { role: "user", text: userText, attachments: [] }, |
| 518 | { timelineWidthPx: mobile.timelineWidthMeasuredPx }, | 664 | { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, |
| 519 | ); | 665 | ); |
| 520 | | 666 | |
| 521 | const measuredDeltaPx = mobile.measuredRowHeightPx - desktop.measuredRowHeightPx; | 667 | const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; |
| 522 | const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; | 668 | const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; |
| 523 | expect(measuredDeltaPx).toBeGreaterThan(0); | 669 | expect(measuredDeltaPx).toBeGreaterThan(0); |
| 524 | expect(estimatedDeltaPx).toBeGreaterThan(0); | 670 | expect(estimatedDeltaPx).toBeGreaterThan(0); |
| ... | @@ -527,13 +673,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -527,13 +673,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 527 | expect(ratio).toBeLessThan(1.35); | 673 | expect(ratio).toBeLessThan(1.35); |
| 528 | }); | 674 | }); |
| 529 | | 675 | |
| 530 | it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { | 676 | it.each(ATTACHMENT_VIEWPORT_MATRIX)( |
| 531 | const targetMessageId = "msg-user-target-attachments" as MessageId; | 677 | "keeps user attachment estimate close at the $name viewport", |
| 532 | const userText = "message with image attachments"; | 678 | async (viewport) => { |
| 533 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = | 679 | const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; |
| 534 | await renderAndMeasureUserRow({ | 680 | const userText = "message with image attachments"; |
| 535 | timelineWidthPx: 960, | 681 | const mounted = await mountChatView({ |
| 536 | targetMessageId, | 682 | viewport, |
| 537 | snapshot: createSnapshotForTargetUser({ | 683 | snapshot: createSnapshotForTargetUser({ |
| 538 | targetMessageId, | 684 | targetMessageId, |
| 539 | targetText: userText, | 685 | targetText: userText, |
| ... | @@ -541,17 +687,27 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -541,17 +687,27 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 541 | }), | 687 | }), |
| 542 | }); | 688 | }); |
| 543 | | 689 | |
| 544 | expect(renderedInVirtualizedRegion).toBe(true); | 690 | try { |
| 545 | | 691 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = |
| 546 | const estimatedHeightPx = estimateTimelineMessageHeight( | 692 | await mounted.measureUserRow(targetMessageId); |
| 547 | { | 693 | |
| 548 | role: "user", | 694 | expect(renderedInVirtualizedRegion).toBe(true); |
| 549 | text: userText, | 695 | |
| 550 | attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], | 696 | const estimatedHeightPx = estimateTimelineMessageHeight( |
| 551 | }, | 697 | { |
| 552 | { timelineWidthPx: timelineWidthMeasuredPx }, | 698 | role: "user", |
| 553 | ); | 699 | text: userText, |
| | 700 | attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], |
| | 701 | }, |
| | 702 | { timelineWidthPx: timelineWidthMeasuredPx }, |
| | 703 | ); |
| 554 | | 704 | |
| 555 | expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(56); | 705 | expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( |
| 556 | }); | 706 | viewport.attachmentTolerancePx, |
| | 707 | ); |
| | 708 | } finally { |
| | 709 | await mounted.cleanup(); |
| | 710 | } |
| | 711 | }, |
| | 712 | ); |
| 557 | }); | 713 | }); |