101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
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 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: [],
|
||
|
|
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,
|
||
|
|
snippet: `snippet ${id}`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 (
|
||
|
|
<I18nProvider>
|
||
|
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||
|
|
</I18nProvider>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderThread(order: "asc" | "desc") {
|
||
|
|
return render(wrap(<ThreadView subject="Subject" messages={MESSAGES} order={order} />));
|
||
|
|
}
|
||
|
|
|
||
|
|
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(<ThreadView subject="Subject" messages={messages} order="asc" />));
|
||
|
|
// An expanded message renders a MessageBody (iframe/loading/error); collapsed does not.
|
||
|
|
const aNode = screen.getByText("snippet a").closest('[data-testid="message"]') as HTMLElement;
|
||
|
|
const bNode = screen.getByText("snippet b").closest('[data-testid="message"]') 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();
|
||
|
|
});
|
||
|
|
});
|