/* @vitest-environment jsdom */ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), })); vi.mock("@/stores/chat/chat-context", () => ({ useChatDispatch: () => mocks.dispatch, })); vi.mock("@/providers/character-provider", () => ({ useActiveCharacter: () => ({ id: "maya-tan" }), useActiveCharacterRoutes: () => ({ tip: "/characters/maya/tip", }), })); vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({ useChatKeyboardAvoidance: () => undefined, })); vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({ useChatKeyboardDiagnostics: () => undefined, })); import { ChatInputBar } from "../chat-input-bar"; describe("ChatInputBar", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) .IS_REACT_ACT_ENVIRONMENT = true; mocks.dispatch.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); it("opens the ordered action menu, blurs input, and links to Tip", () => { renderBar(); const textarea = getTextarea(); act(() => textarea.focus()); expect(document.activeElement).toBe(textarea); act(() => getButton("Open chat actions").click()); expect(document.activeElement).not.toBe(textarea); const menu = container.querySelector('[aria-label="Chat actions"]'); expect(menu).not.toBeNull(); expect( Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()), ).toEqual(["Image", "Voice", "Tip"]); expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe( "true", ); expect( menu ?.querySelector('[data-analytics-key="chat.open_tip"]') ?.getAttribute("href"), ).toBe("/characters/maya/tip"); act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); }); it("injects image and voice promotions without persisting UI state", () => { renderBar(); act(() => getButton("Open chat actions").click()); act(() => getButton("Image").click()); const imageEvent = mocks.dispatch.mock.calls[0]?.[0]; expect(imageEvent).toMatchObject({ type: "ChatPromotionInjected", promotion: { characterId: "maya-tan", promotionType: "image", lockType: "image_paywall", }, }); expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/); expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); act(() => getButton("Open chat actions").click()); act(() => getButton("Voice").click()); const voiceEvent = mocks.dispatch.mock.calls[1]?.[0]; expect(voiceEvent).toMatchObject({ type: "ChatPromotionInjected", promotion: { characterId: "maya-tan", promotionType: "voice", lockType: "voice_message", }, }); expect(voiceEvent.promotion.clientLockId).not.toBe( imageEvent.promotion.clientLockId, ); }); it("switches from actions to Send for non-whitespace input", () => { renderBar(); act(() => getButton("Open chat actions").click()); setTextareaValue(getTextarea(), "Hello Maya"); expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); expect(getButton("Send message")).not.toBeNull(); act(() => getButton("Send message").click()); expect(mocks.dispatch).toHaveBeenCalledWith({ type: "ChatSendMessage", content: "Hello Maya", }); expect(getTextarea().value).toBe(""); expect(document.activeElement).toBe(getTextarea()); setTextareaValue(getTextarea(), " "); expect(getButton("Open chat actions")).not.toBeNull(); }); it("suppresses the Facebook WebView click that follows a pointer send for 300ms", () => { const now = vi.spyOn(Date, "now").mockReturnValue(1_000); renderBar(); setTextareaValue(getTextarea(), "Hello Maya"); const sendButton = getButton("Send message"); const pointerDown = new Event("pointerdown", { bubbles: true, cancelable: true, }); Object.defineProperty(pointerDown, "pointerType", { value: "touch" }); act(() => sendButton.dispatchEvent(pointerDown)); expect(mocks.dispatch).toHaveBeenCalledTimes(1); expect(mocks.dispatch).toHaveBeenCalledWith({ type: "ChatSendMessage", content: "Hello Maya", }); expect(getTextarea().value).toBe(""); expect(getButton("Open chat actions")).toBe(sendButton); now.mockReturnValue(1_299); act(() => sendButton.click()); expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); now.mockReturnValue(1_300); act(() => sendButton.click()); expect(container.querySelector('[aria-label="Chat actions"]')).not.toBeNull(); }); it("closes the menu on outside interaction and when disabled", () => { renderBar(); act(() => getButton("Open chat actions").click()); act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }))); expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); act(() => getButton("Open chat actions").click()); renderBar(true); expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull(); expect(getButton("Open chat actions").disabled).toBe(true); }); function renderBar(disabled = false): void { act(() => root.render()); } function getTextarea(): HTMLTextAreaElement { const textarea = container.querySelector("textarea"); if (!textarea) throw new Error("Missing chat textarea"); return textarea; } function getButton(label: string): HTMLButtonElement { const button = Array.from( container.querySelectorAll("button"), ).find( (item) => item.getAttribute("aria-label") === label || item.textContent?.trim() === label, ); if (!button) throw new Error(`Missing button: ${label}`); return button; } }); function setTextareaValue( textarea: HTMLTextAreaElement, value: string, ): void { const setter = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, "value", )?.set; act(() => { setter?.call(textarea, value); textarea.dispatchEvent(new Event("input", { bubbles: true })); }); }