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// Production CSS is part of the behavior under test because row height depends on it.
12import "../index.css";
23
34import {
......@@ -15,6 +16,7 @@ import {
1516import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
1617import { HttpResponse, http, ws } from "msw";
1718import { setupWorker } from "msw/browser";
19import { page } from "vitest/browser";
1820import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
1921import { render } from "vitest-browser-react";
2022
......@@ -45,6 +47,45 @@ interface TestFixture {
4547let fixture: TestFixture;
4648const 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
4889function isoAt(offsetSeconds: number): string {
4990 return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString();
5091}
......@@ -297,6 +338,26 @@ async function waitForLayout(): Promise<void> {
297338 await nextFrame();
298339}
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
300361async function waitForElement<T extends Element>(
301362 query: () => T | null,
302363 errorMessage: string,
......@@ -339,21 +400,84 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
339400 await waitForLayout();
340401}
341402
342async function renderAndMeasureUserRow(options: {
343 timelineWidthPx: number;
403async function measureUserRow(options: {
404 host: HTMLElement;
344405 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;
345469 snapshot: OrchestrationReadModel;
346}): Promise<{
347 measuredRowHeightPx: number;
348 timelineWidthMeasuredPx: number;
349 renderedInVirtualizedRegion: boolean;
350}> {
470}): Promise<MountedChatView> {
351471 fixture = buildFixture(options.snapshot);
472 await setViewport(options.viewport);
473 await waitForProductionStyles();
352474
353475 const host = document.createElement("div");
354 host.style.width = `${options.timelineWidthPx}px`;
355 host.style.height = "920px";
356 host.style.display = "flex";
476 host.style.position = "fixed";
477 host.style.inset = "0";
478 host.style.width = "100vw";
479 host.style.height = "100vh";
480 host.style.display = "grid";
357481 host.style.overflow = "hidden";
358482 document.body.append(host);
359483
......@@ -367,68 +491,35 @@ async function renderAndMeasureUserRow(options: {
367491 container: host,
368492 });
369493
370 try {
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();
494 await waitForLayout();
399495
400 const timelineRoot =
401 row!.closest<HTMLElement>('[data-timeline-root="true"]') ??
402 host.querySelector<HTMLElement>('[data-timeline-root="true"]');
403 if (!(timelineRoot instanceof HTMLElement)) {
404 throw new Error("Unable to locate timeline root container.");
405 }
496 return {
497 cleanup: async () => {
498 await screen.unmount();
499 host.remove();
500 },
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;
408 let measuredRowHeightPx = 0;
409 let renderedInVirtualizedRegion = false;
410 const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`;
411 await vi.waitFor(
412 async () => {
413 scrollContainer.scrollTop = 0;
414 scrollContainer.dispatchEvent(new Event("scroll"));
415 await nextFrame();
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 );
509async function measureUserRowAtViewport(options: {
510 snapshot: OrchestrationReadModel;
511 targetMessageId: MessageId;
512 viewport: ViewportSpec;
513}): Promise<UserRowMeasurement> {
514 const mounted = await mountChatView({
515 viewport: options.viewport,
516 snapshot: options.snapshot,
517 });
427518
428 return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion };
519 try {
520 return await mounted.measureUserRow(options.targetMessageId);
429521 } finally {
430 await screen.unmount();
431 host.remove();
522 await mounted.cleanup();
432523 }
433524}
434525
......@@ -453,7 +544,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
453544 await worker.stop();
454545 });
455546
456 beforeEach(() => {
547 beforeEach(async () => {
548 await setViewport(DEFAULT_VIEWPORT);
457549 localStorage.clear();
458550 document.body.innerHTML = "";
459551 useStore.setState({
......@@ -468,57 +560,111 @@ describe("ChatView timeline estimator parity (full app)", () => {
468560 document.body.innerHTML = "";
469561 });
470562
471 it("keeps long user message estimate close to actual rendered virtualized ChatView row height", async () => {
472 const userText = "x".repeat(3_200);
473 const targetMessageId = "msg-user-target-long" as MessageId;
474 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
475 await renderAndMeasureUserRow({
476 timelineWidthPx: 960,
477 targetMessageId,
563 it.each(TEXT_VIEWPORT_MATRIX)(
564 "keeps long user message estimate close at the $name viewport",
565 async (viewport) => {
566 const userText = "x".repeat(3_200);
567 const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId;
568 const mounted = await mountChatView({
569 viewport,
478570 snapshot: createSnapshotForTargetUser({
479571 targetMessageId,
480572 targetText: userText,
481573 }),
482574 });
483575
484 expect(renderedInVirtualizedRegion).toBe(true);
576 try {
577 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
578 await mounted.measureUserRow(targetMessageId);
485579
486 const estimatedHeightPx = estimateTimelineMessageHeight(
487 { role: "user", text: userText, attachments: [] },
488 { timelineWidthPx: timelineWidthMeasuredPx },
489 );
580 expect(renderedInVirtualizedRegion).toBe(true);
581
582 const estimatedHeightPx = estimateTimelineMessageHeight(
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 }
492638 });
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 () => {
495641 const userText = "x".repeat(2_400);
496642 const targetMessageId = "msg-user-target-wrap" as MessageId;
497643 const snapshot = createSnapshotForTargetUser({
498644 targetMessageId,
499645 targetText: userText,
500646 });
501 const desktop = await renderAndMeasureUserRow({
502 timelineWidthPx: 960,
503 targetMessageId,
647 const desktopMeasurement = await measureUserRowAtViewport({
648 viewport: TEXT_VIEWPORT_MATRIX[0],
504649 snapshot,
505 });
506 const mobile = await renderAndMeasureUserRow({
507 timelineWidthPx: 360,
508650 targetMessageId,
651 });
652 const mobileMeasurement = await measureUserRowAtViewport({
653 viewport: TEXT_VIEWPORT_MATRIX[2],
509654 snapshot,
655 targetMessageId,
510656 });
511657
512658 const estimatedDesktopPx = estimateTimelineMessageHeight(
513659 { role: "user", text: userText, attachments: [] },
514 { timelineWidthPx: desktop.timelineWidthMeasuredPx },
660 { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx },
515661 );
516662 const estimatedMobilePx = estimateTimelineMessageHeight(
517663 { role: "user", text: userText, attachments: [] },
518 { timelineWidthPx: mobile.timelineWidthMeasuredPx },
664 { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx },
519665 );
520666
521 const measuredDeltaPx = mobile.measuredRowHeightPx - desktop.measuredRowHeightPx;
667 const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx;
522668 const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx;
523669 expect(measuredDeltaPx).toBeGreaterThan(0);
524670 expect(estimatedDeltaPx).toBeGreaterThan(0);
......@@ -527,13 +673,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
527673 expect(ratio).toBeLessThan(1.35);
528674 });
529675
530 it("keeps user attachment estimate close to actual rendered ChatView row height", async () => {
531 const targetMessageId = "msg-user-target-attachments" as MessageId;
532 const userText = "message with image attachments";
533 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
534 await renderAndMeasureUserRow({
535 timelineWidthPx: 960,
536 targetMessageId,
676 it.each(ATTACHMENT_VIEWPORT_MATRIX)(
677 "keeps user attachment estimate close at the $name viewport",
678 async (viewport) => {
679 const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId;
680 const userText = "message with image attachments";
681 const mounted = await mountChatView({
682 viewport,
537683 snapshot: createSnapshotForTargetUser({
538684 targetMessageId,
539685 targetText: userText,
......@@ -541,17 +687,27 @@ describe("ChatView timeline estimator parity (full app)", () => {
541687 }),
542688 });
543689
544 expect(renderedInVirtualizedRegion).toBe(true);
545
546 const estimatedHeightPx = estimateTimelineMessageHeight(
547 {
548 role: "user",
549 text: userText,
550 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }],
551 },
552 { timelineWidthPx: timelineWidthMeasuredPx },
553 );
690 try {
691 const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
692 await mounted.measureUserRow(targetMessageId);
693
694 expect(renderedInVirtualizedRegion).toBe(true);
695
696 const estimatedHeightPx = estimateTimelineMessageHeight(
697 {
698 role: "user",
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);
556 });
705 expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(
706 viewport.attachmentTolerancePx,
707 );
708 } finally {
709 await mounted.cleanup();
710 }
711 },
712 );
557713});