authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 01:44:20-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 10:20:34-08:00
log0c39733cbf505ce1bf7f15d9b4576ba6ee4fd6b7
treeb7aea3b1d4b35477d54da376b7ff5d79fb55e32c
parenta76413594e772f476b98ba6384d0efe9906e75ff

Expand ChatView height parity tests across responsive viewports

- Add viewport matrix coverage for long text and attachment row-height parity - Reuse a mount/measure test harness to validate resize behavior in one session - Ensure production CSS and viewport sizing are applied before measurements

1 files changed, 267 insertions(+), 111 deletions(-)

apps/web/src/components/ChatView.browser.tsx+267-111
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// Production CSS is part of the behavior under test because row height depends on it.
1import "../index.css";2import "../index.css";
23
3import {4import {
...@@ -15,6 +16,7 @@ import {...@@ -15,6 +16,7 @@ import {
15import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";16import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
16import { HttpResponse, http, ws } from "msw";17import { HttpResponse, http, ws } from "msw";
17import { setupWorker } from "msw/browser";18import { setupWorker } from "msw/browser";
19import { page } from "vitest/browser";
18import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";20import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
19import { render } from "vitest-browser-react";21import { render } from "vitest-browser-react";
2022
...@@ -45,6 +47,45 @@ interface TestFixture {...@@ -45,6 +47,45 @@ interface TestFixture {
45let fixture: TestFixture;47let fixture: TestFixture;
46const wsLink = ws.link(/ws(s)?:\/\/.*/);48const wsLink = ws.link(/ws(s)?:\/\/.*/);
4749
50interface ViewportSpec {
51 name: string;
52 width: number;
53 height: number;
54 textTolerancePx: number;
55 attachmentTolerancePx: number;
56}
57
58const DEFAULT_VIEWPORT: ViewportSpec = {
59 name: "desktop",
60 width: 960,
61 height: 1_100,
62 textTolerancePx: 44,
63 attachmentTolerancePx: 56,
64};
65const 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[];
71const 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
77interface UserRowMeasurement {
78 measuredRowHeightPx: number;
79 timelineWidthMeasuredPx: number;
80 renderedInVirtualizedRegion: boolean;
81}
82
83interface MountedChatView {
84 cleanup: () => Promise<void>;
85 measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
86 setViewport: (viewport: ViewportSpec) => Promise<void>;
87}
88
48function isoAt(offsetSeconds: number): string {89function 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}
299340
341async function setViewport(viewport: ViewportSpec): Promise<void> {
342 await page.viewport(viewport.width, viewport.height);
343 await waitForLayout();
344}
345
346async 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
300async function waitForElement<T extends Element>(361async 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}
341402
342async function renderAndMeasureUserRow(options: {403async 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
467async 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();
352474
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);
359483
...@@ -367,68 +491,35 @@ async function renderAndMeasureUserRow(options: {...@@ -367,68 +491,35 @@ async function renderAndMeasureUserRow(options: {
367 container: host,491 container: host,
368 });492 });
369493
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();
399495
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}
406508
407 const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width;509async 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 );
427518
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}
434525
...@@ -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 });
455546
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 });
470562
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 });
483575
484 expect(renderedInVirtualizedRegion).toBe(true);576 try {
577 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
578 await mounted.measureUserRow(targetMessageId);
485579
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 });
490606
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 });
493639
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 });
511657
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 );
520666
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 });
529675
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 });
543689
544 expect(renderedInVirtualizedRegion).toBe(true);690 try {
545691 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 );
554704
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});