import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it } from "vitest"; import { ThreadView } from "../components/thread/ThreadView"; import { I18nProvider } from "../i18n"; import { ThemeProvider } from "../lib/theme"; import type { MessageEnvelope } from "../lib/types"; function msg(id: string, minutes: number, unread = false): MessageEnvelope { return { id, account_id: "acc", mailbox_id: "mb", thread_id: "t", subject: "Subject", from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }], to_addr: [], cc_addr: [], bcc_addr: [], date: new Date(2026, 0, 1, 12, minutes).toISOString(), flags: unread ? [] : ["\\Seen"], is_unread: unread, is_starred: false, has_attachments: false, has_remote_images: false, images_allowed: false, size_bytes: 1024, snippet: `snippet ${id}`, also_in_account_ids: [], }; } // ASC input always (SPEC §9.4): oldest first. const MESSAGES = [msg("a", 0), msg("b", 1), msg("c", 2)]; function wrap(children: ReactNode) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return ( {children} ); } function renderThread(order: "asc" | "desc") { return render(wrap()); } function messageOrder(): string[] { return screen.getAllByTestId("message").map((el) => el.getAttribute("data-message-id")!); } describe("ThreadView direction-agnostic layout (SPEC §9.4)", () => { it("renders oldest-first when order=asc", () => { renderThread("asc"); expect(messageOrder()).toEqual(["a", "b", "c"]); }); it("renders newest-first when order=desc", () => { renderThread("desc"); expect(messageOrder()).toEqual(["c", "b", "a"]); }); it("attaches the composer directly after the newest message in both orders", () => { // asc: newest 'c' is last -> composer at the very bottom const asc = renderThread("asc"); const ascNodes = Array.from( asc.container.querySelectorAll('[data-testid="message"],[data-testid="composer-anchor"]'), ); const ascComposerIdx = ascNodes.findIndex( (n) => n.getAttribute("data-testid") === "composer-anchor", ); const ascNewestIdx = ascNodes.findIndex((n) => n.getAttribute("data-message-id") === "c"); expect(ascComposerIdx).toBe(ascNewestIdx + 1); expect(ascComposerIdx).toBe(ascNodes.length - 1); // bottom // desc: newest 'c' is first -> composer right after it (top), above older const desc = renderThread("desc"); const descNodes = Array.from( desc.container.querySelectorAll('[data-testid="message"],[data-testid="composer-anchor"]'), ); const descComposerIdx = descNodes.findIndex( (n) => n.getAttribute("data-testid") === "composer-anchor", ); const descNewestIdx = descNodes.findIndex((n) => n.getAttribute("data-message-id") === "c"); expect(descComposerIdx).toBe(descNewestIdx + 1); expect(descNewestIdx).toBe(0); // newest at top }); it("keeps unread messages expanded but read (non-newest) ones collapsed", () => { const messages = [msg("a", 0), msg("b", 1, true), msg("c", 2)]; const { container } = render(wrap()); // An expanded message renders a MessageBody (iframe/loading/error); collapsed does not. const aNode = container.querySelector('[data-message-id="a"]') as HTMLElement; const bNode = container.querySelector('[data-message-id="b"]') as HTMLElement; const hasBody = (el: HTMLElement) => el.querySelector("iframe") !== null || /Loading thread|Couldn't load/i.test(el.textContent ?? ""); expect(hasBody(aNode)).toBe(false); // read, not newest -> collapsed expect(hasBody(bNode)).toBe(true); // unread -> expanded expect(container).toBeTruthy(); }); });