205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
/* @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<HTMLAnchorElement>('[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(<ChatInputBar disabled={disabled} />));
|
|
}
|
|
|
|
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<HTMLButtonElement>("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 }));
|
|
});
|
|
}
|