feat(chat): add composer action menu
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/* @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("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 }));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user