lib/chat-input-layout.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
const h = vi.hoisted(() => ({
uploads: {
pending: [], inputs: [], uploading: false,
addFiles: vi.fn(), remove: vi.fn(), clear: vi.fn(),
},
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
vi.mock("./auth-context", () => ({ useAuth: () => ({ user: null }), useAuthOptional: () => null }));
vi.mock("./use-attachment-uploads", () => ({ useAttachmentUploads: () => h.uploads }));
vi.mock("./analytics", () => ({ trackEvent: vi.fn() }));
vi.mock("@/components/theme-provider", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTheme: () => ({ resolvedTheme: "light" }),
}));
import { ChatInput } from "@/components/assistant/chat-input";
let root: Root;
let container: HTMLDivElement;
const editor = () => container.querySelector<HTMLElement>("[role=textbox]")!;
// The compact row's text cell carries its own vertical padding.
const compact = (element: HTMLElement) => !!element.closest('[class*="pt-[15px]"]');
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
vi.stubGlobal("ResizeObserver", class {
observe() {}
unobserve() {}
disconnect() {}
});
// A narrow panel: the compact row leaves the text less room than the
// placeholder needs, so an empty composer's placeholder wraps there,
// while an empty composer has no text width at all.
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
return this.getAttribute("role") === "textbox" ? (compact(this) ? 60 : 240) : 260;
});
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (this: HTMLElement) {
return this.getAttribute("role") === "textbox" && compact(this) ? 44 : 22;
});
Range.prototype.getBoundingClientRect = () => ({ width: 0 } as DOMRect);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("expands an empty composer whose placeholder overflows a narrow panel, without flipping back", async () => {
// The placeholder measures 150 px, wider than the compact row's 60 px.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
font: "", measureText: () => ({ width: 150 }),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(window, "getComputedStyle").mockReturnValue({
font: "14px sans-serif", lineHeight: "22px", paddingLeft: "0px", paddingRight: "0px",
} as CSSStyleDeclaration);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await act(async () => {
root.render(createElement(TooltipProvider, {
children: createElement(ChatInput, { onSend: vi.fn(), hideAttach: true }),
}));
});
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/Maximum update depth/);
expect(editor().textContent).toBe("");
expect(compact(editor())).toBe(false);
});
it("keeps a line that wraps in the compact row on its own row, without flipping back", async () => {
// One line of text, wider than the compact row's room but far narrower
// than an estimate from the controls alone.
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (this: HTMLElement) {
return this.getAttribute("role") === "textbox" && compact(this) ? 44 : 22;
});
Range.prototype.getBoundingClientRect = () => ({ width: 100 } as DOMRect);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await act(async () => {
root.render(createElement(TooltipProvider, {
children: createElement(ChatInput, { onSend: vi.fn(), hideAttach: true, draftHtml: "Summarize the open triage" }),
}));
});
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/Maximum update depth/);
expect(editor().textContent).toBe("Summarize the open triage");
expect(compact(editor())).toBe(false);
});
it("folds an expanded placeholder back once the controls leave it room again", async () => {
// The controls take 160 px of the 260 px row while Numo replies, then 80 px.
const geometry = { trail: 120 };
const observers: Array<() => void> = [];
vi.stubGlobal("ResizeObserver", class {
constructor(callback: () => void) { observers.push(callback); }
observe() {}
unobserve() {}
disconnect() {}
});
const textRoom = () => 260 - 40 - geometry.trail - 20;
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
if (this.className.includes("[grid-area:lead]")) return 40;
if (this.className.includes("[grid-area:trail]")) return geometry.trail;
return 0;
});
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
return this.getAttribute("role") === "textbox" ? (compact(this) ? textRoom() : 240) : 260;
});
// The 150 px placeholder wraps in the compact row whenever it lacks room.
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (this: HTMLElement) {
return this.getAttribute("role") === "textbox" && compact(this) && textRoom() < 150 ? 44 : 22;
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
font: "", measureText: () => ({ width: 150 }),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(window, "getComputedStyle").mockReturnValue({
font: "14px sans-serif", lineHeight: "22px", paddingLeft: "0px", paddingRight: "0px",
} as CSSStyleDeclaration);
await act(async () => {
root.render(createElement(TooltipProvider, {
children: createElement(ChatInput, { onSend: vi.fn(), hideAttach: true }),
}));
});
expect(compact(editor())).toBe(false);
// The reply ends: Stop gives way to the narrower Send.
geometry.trail = 40;
await act(async () => { for (const notify of observers) notify(); });
expect(compact(editor())).toBe(true);
});