463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getCharacterBySlug } from "@/data/constants/character";
|
|
import { CharacterProvider } from "@/providers/character-provider";
|
|
|
|
import { BrowserHintOverlay } from "../browser-hint-overlay";
|
|
import { ChatHeader } from "../chat-header";
|
|
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
|
|
import { ChatInputTextField } from "../chat-input-text-field";
|
|
import { ChatSendButton } from "../chat-send-button";
|
|
import { HistoryUnlockDialog } from "../history-unlock-dialog";
|
|
import { ImageBubble } from "../image-bubble";
|
|
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
|
|
import { MessageAvatar } from "../message-avatar";
|
|
import { PrivateMessageCard } from "../private-message-card";
|
|
import { PwaInstallDialog } from "../pwa-install-dialog";
|
|
import { TextBubble } from "../text-bubble";
|
|
|
|
const userMocks = vi.hoisted(() => ({
|
|
avatarUrl: null as string | null,
|
|
}));
|
|
|
|
vi.mock("@/router/use-app-navigator", () => ({
|
|
useAppNavigator: () => ({
|
|
openSubscription: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/stores/user/user-context", () => ({
|
|
useUserSelector: (
|
|
selector: (state: { context: { avatarUrl: string | null } }) => unknown,
|
|
) => selector({ context: { avatarUrl: userMocks.avatarUrl } }),
|
|
}));
|
|
|
|
describe("chat Tailwind components", () => {
|
|
beforeEach(() => {
|
|
userMocks.avatarUrl = null;
|
|
});
|
|
|
|
it("renders MessageAvatar AI and user branches with image utilities", () => {
|
|
const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />);
|
|
const userHtml = renderWithCharacter(
|
|
<MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />,
|
|
);
|
|
|
|
expect(aiHtml).toContain("size-(--chat-avatar-size,43px)");
|
|
expect(aiHtml).toContain("size-full object-cover");
|
|
expect(aiHtml).toContain("%2Fimages%2Favatar%2Felio.png");
|
|
expect(userHtml).toContain('aria-label="User avatar"');
|
|
expect(userHtml).toContain("%2Fuser-avatar.png");
|
|
});
|
|
|
|
it("renders message avatar actions with explicit destinations", () => {
|
|
const aiHtml = renderWithCharacter(
|
|
<MessageAvatar isFromAI={true} onClick={() => undefined} />,
|
|
);
|
|
const userHtml = renderWithCharacter(
|
|
<MessageAvatar isFromAI={false} onClick={() => undefined} />,
|
|
);
|
|
|
|
expect(aiHtml).toContain('<button type="button"');
|
|
expect(aiHtml).toContain(
|
|
'data-analytics-key="chat.open_private_room_from_avatar"',
|
|
);
|
|
expect(aiHtml).toContain(
|
|
'aria-label="Open Elio Silvestri's private room"',
|
|
);
|
|
expect(userHtml).toContain('<button type="button"');
|
|
expect(userHtml).toContain(
|
|
'data-analytics-key="chat.open_profile_from_avatar"',
|
|
);
|
|
expect(userHtml).toContain('aria-label="Open profile"');
|
|
});
|
|
|
|
it("renders the active character avatar", () => {
|
|
const maya = getCharacterBySlug("maya");
|
|
expect(maya).not.toBeNull();
|
|
const html = renderToStaticMarkup(
|
|
<CharacterProvider character={maya!}>
|
|
<MessageAvatar isFromAI={true} />
|
|
</CharacterProvider>,
|
|
);
|
|
|
|
expect(html).toContain("%2Fimages%2Favatar%2Fmaya.png");
|
|
expect(html).toContain('alt="Maya Tan"');
|
|
});
|
|
|
|
it("renders TextBubble AI and user visual variants", () => {
|
|
const aiHtml = renderToStaticMarkup(
|
|
<TextBubble content={"hello\nthere"} isFromAI={true} />,
|
|
);
|
|
const userHtml = renderToStaticMarkup(
|
|
<TextBubble content="from user" isFromAI={false} />,
|
|
);
|
|
|
|
expect(aiHtml).toContain("whitespace-pre-wrap");
|
|
expect(aiHtml).toContain("rounded-tl-none");
|
|
expect(aiHtml).toContain("bg-white");
|
|
expect(aiHtml).toContain("hello\nthere");
|
|
expect(userHtml).toContain("rounded-tr-none");
|
|
expect(userHtml).toContain(
|
|
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
|
);
|
|
expect(userHtml).toContain("from user");
|
|
});
|
|
|
|
it("renders PrivateMessageCard with fallback and loading states", () => {
|
|
const fallbackHtml = renderWithCharacter(<PrivateMessageCard />);
|
|
const unlockingHtml = renderWithCharacter(
|
|
<PrivateMessageCard hint="Premium secret" isUnlocking onUnlock={() => undefined} />,
|
|
);
|
|
|
|
expect(fallbackHtml).toContain('role="group"');
|
|
expect(fallbackHtml).toContain('aria-label="Locked private message"');
|
|
expect(fallbackHtml).toContain("rounded-tl-none");
|
|
expect(fallbackHtml).toContain("Elio has a private message for you.");
|
|
expect(fallbackHtml).toContain("disabled");
|
|
expect(unlockingHtml).toContain("Premium secret");
|
|
expect(unlockingHtml).toContain("Unlocking...");
|
|
});
|
|
|
|
it("renders ChatSendButton visual states", () => {
|
|
const activeHtml = renderToStaticMarkup(
|
|
<ChatSendButton
|
|
disabled={false}
|
|
hasContent={true}
|
|
isMenuOpen={false}
|
|
menuId="chat-actions"
|
|
onClick={() => undefined}
|
|
onMenuToggle={() => undefined}
|
|
onPointerDownSend={() => undefined}
|
|
/>,
|
|
);
|
|
const emptyHtml = renderToStaticMarkup(
|
|
<ChatSendButton
|
|
disabled={false}
|
|
hasContent={false}
|
|
isMenuOpen={false}
|
|
menuId="chat-actions"
|
|
onClick={() => undefined}
|
|
onMenuToggle={() => undefined}
|
|
onPointerDownSend={() => undefined}
|
|
/>,
|
|
);
|
|
const openHtml = renderToStaticMarkup(
|
|
<ChatSendButton
|
|
disabled={false}
|
|
hasContent={false}
|
|
isMenuOpen={true}
|
|
menuId="chat-actions"
|
|
onClick={() => undefined}
|
|
onMenuToggle={() => undefined}
|
|
onPointerDownSend={() => undefined}
|
|
/>,
|
|
);
|
|
const disabledHtml = renderToStaticMarkup(
|
|
<ChatSendButton
|
|
disabled={true}
|
|
hasContent={true}
|
|
isMenuOpen={false}
|
|
menuId="chat-actions"
|
|
onClick={() => undefined}
|
|
onMenuToggle={() => undefined}
|
|
onPointerDownSend={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
expect(activeHtml).toContain('aria-label="Send message"');
|
|
expect(activeHtml).toContain('data-analytics-ignore="true"');
|
|
expect(activeHtml).not.toContain("data-analytics-key");
|
|
expect(activeHtml).toContain("size-(--chat-send-button-size,42px)");
|
|
expect(activeHtml).toContain(
|
|
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
|
);
|
|
expect(emptyHtml).toContain('aria-label="Open chat actions"');
|
|
expect(emptyHtml).toContain('aria-expanded="false"');
|
|
expect(emptyHtml).toContain('aria-controls="chat-actions"');
|
|
expect(emptyHtml).toContain('data-analytics-key="chat.toggle_actions"');
|
|
expect(openHtml).toContain('aria-label="Close chat actions"');
|
|
expect(openHtml).toContain('aria-expanded="true"');
|
|
expect(openHtml).toContain("bg-[#38262d]");
|
|
expect(disabledHtml).toContain("disabled");
|
|
});
|
|
|
|
it("renders ImageBubble openable and paywalled states", () => {
|
|
const openableHtml = renderToStaticMarkup(
|
|
<ImageBubble
|
|
characterId="elio"
|
|
displayMessageId="server:message-1:assistant"
|
|
remoteMessageId="message-1"
|
|
imageUrl="/chat-image.png"
|
|
onOpenImage={() => undefined}
|
|
/>,
|
|
);
|
|
const paywalledHtml = renderToStaticMarkup(
|
|
<ImageBubble
|
|
characterId="elio"
|
|
displayMessageId="server:message-2:assistant"
|
|
imageUrl="/locked-image.png"
|
|
imagePaywalled
|
|
/>,
|
|
);
|
|
|
|
expect(openableHtml).toContain('role="button"');
|
|
expect(openableHtml).toContain('tabindex="0"');
|
|
expect(openableHtml).toContain('aria-label="Open image in fullscreen"');
|
|
expect(openableHtml).toContain("rounded-tl-none");
|
|
expect(openableHtml).toContain("block h-auto w-full object-cover");
|
|
expect(openableHtml).toContain("data:image/png;base64,/chat-image.png");
|
|
expect(paywalledHtml).toContain("scale-104 blur-sm");
|
|
expect(paywalledHtml).toContain(
|
|
"bg-[linear-gradient(180deg,rgba(255,255,255,0.04)_0%,rgba(255,255,255,0.14)_100%)]",
|
|
);
|
|
expect(paywalledHtml).toContain("data:image/png;base64,/locked-image.png");
|
|
});
|
|
|
|
it("renders ChatInsufficientCreditsBanner with split title and CTA", () => {
|
|
const html = renderToStaticMarkup(
|
|
<ChatInsufficientCreditsBanner
|
|
title={"Need credits\nTop up now"}
|
|
description="Credits keep the conversation going."
|
|
ctaLabel="Top up"
|
|
onUnlock={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
expect(html).toContain('role="status"');
|
|
expect(html).toContain('aria-live="polite"');
|
|
expect(html).toContain("Need credits");
|
|
expect(html).toContain("<br/>");
|
|
expect(html).toContain("Top up now");
|
|
expect(html).toContain("Credits keep the conversation going.");
|
|
expect(html).toContain('aria-label="Top up"');
|
|
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
|
expect(html).toContain("active:scale-98");
|
|
});
|
|
|
|
it("renders ChatHeader guest and member branches", () => {
|
|
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
|
|
const memberHtml = renderWithCharacter(
|
|
<ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />,
|
|
);
|
|
const memberWithHintHtml = renderWithCharacter(
|
|
<ChatHeader isGuest={false} showBrowserHint />,
|
|
);
|
|
const guestWithHintHtml = renderWithCharacter(
|
|
<ChatHeader isGuest={true} showBrowserHint />,
|
|
);
|
|
|
|
expect(guestHtml).toContain('aria-label="Sign up to unlock more features"');
|
|
expect(guestHtml).toContain("bg-accent");
|
|
expect(guestHtml).toContain("size-(--icon-size-sm,16px)");
|
|
expect(guestHtml).toContain('href="/characters/elio/splash"');
|
|
expect(guestHtml).toContain('aria-label="Back to home"');
|
|
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
|
|
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
|
|
expect(guestHtml).not.toContain('aria-label="Profile"');
|
|
expect(memberHtml).toContain("Offer");
|
|
expect(memberHtml).toContain('href="/characters/elio/splash"');
|
|
expect(memberHtml).toContain('aria-label="Back to home"');
|
|
expect(memberHtml).toContain('aria-label="Profile"');
|
|
expect(memberHtml).toContain('data-analytics-key="chat.open_profile"');
|
|
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
|
|
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
|
|
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
|
|
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
|
|
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
|
|
expect(memberHtml).toContain("lucide-user-round");
|
|
expect(memberWithHintHtml).toContain(
|
|
'data-analytics-key="chat.external_browser_hint"',
|
|
);
|
|
expect(memberWithHintHtml).toContain(
|
|
"grid-cols-[auto_minmax(0,1fr)_auto]",
|
|
);
|
|
expect(memberWithHintHtml.indexOf('aria-label="Back to home"')).toBeLessThan(
|
|
memberWithHintHtml.indexOf(
|
|
'data-analytics-key="chat.external_browser_hint"',
|
|
),
|
|
);
|
|
expect(
|
|
memberWithHintHtml.indexOf(
|
|
'data-analytics-key="chat.external_browser_hint"',
|
|
),
|
|
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"'));
|
|
expect(guestWithHintHtml).not.toContain(
|
|
'data-analytics-key="chat.external_browser_hint"',
|
|
);
|
|
});
|
|
|
|
it("renders the user avatar in the ChatHeader Profile action", () => {
|
|
userMocks.avatarUrl = "/images/avatar/profile-user.png";
|
|
|
|
const html = renderWithCharacter(<ChatHeader isGuest={false} />);
|
|
|
|
expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png");
|
|
expect(html).toContain('aria-label="Profile"');
|
|
expect(html).toContain('data-analytics-key="chat.open_profile"');
|
|
expect(html).toContain(
|
|
"var(--responsive-icon-button-size, 42px)",
|
|
);
|
|
expect(html).not.toContain("lucide-user-round");
|
|
});
|
|
|
|
it("links guest chat back to the active character splash", () => {
|
|
const maya = getCharacterBySlug("maya");
|
|
if (!maya) throw new Error("Missing Maya character fixture");
|
|
|
|
const html = renderToStaticMarkup(
|
|
<CharacterProvider character={maya}>
|
|
<ChatHeader isGuest={true} />
|
|
</CharacterProvider>,
|
|
);
|
|
|
|
expect(html).toContain('href="/characters/maya/splash"');
|
|
expect(html).not.toContain('aria-label="Profile"');
|
|
expect(html).not.toContain(
|
|
'data-analytics-key="chat.external_browser_hint"',
|
|
);
|
|
});
|
|
|
|
it("renders ChatInputTextField with textarea utilities", () => {
|
|
const html = renderToStaticMarkup(
|
|
<ChatInputTextField
|
|
value="Hello"
|
|
onChange={() => undefined}
|
|
onSubmit={() => undefined}
|
|
placeholder="Message Elio"
|
|
/>,
|
|
);
|
|
|
|
expect(html).toContain("<textarea");
|
|
expect(html).toContain('aria-label="Message"');
|
|
expect(html).toContain("min-h-(--chat-send-button-size,40px)");
|
|
expect(html).toContain("max-h-[min(30vh,120px)]");
|
|
expect(html).toContain("caret-accent");
|
|
expect(html).toContain("placeholder:text-(--color-text-hint,#757575)");
|
|
expect(html).toContain("Message Elio");
|
|
expect(html).toContain("Hello");
|
|
});
|
|
|
|
it("renders BrowserHintOverlay visibility and expanded styles", () => {
|
|
const html = renderToStaticMarkup(
|
|
<BrowserHintOverlay
|
|
title="Open elsewhere"
|
|
description="Better experience"
|
|
/>,
|
|
);
|
|
|
|
expect(html).not.toContain("aria-expanded");
|
|
expect(html).toContain('aria-label="Open elsewhere. Better experience"');
|
|
expect(html).toContain("grid-cols-[auto_minmax(0,1fr)]");
|
|
expect(html).not.toContain("absolute");
|
|
expect(html).toContain("max-w-full");
|
|
expect(html).toContain("size-7.5");
|
|
expect(html).toContain("bg-[#17131f]");
|
|
expect(html).toContain("text-[#f3dfe8]");
|
|
expect(html).not.toContain("Show external browser hint");
|
|
expect(html).toContain("Open elsewhere");
|
|
expect(html).toContain("Better experience");
|
|
});
|
|
|
|
it("renders PwaInstallDialog with Tailwind dialog layout", () => {
|
|
const html = renderToStaticMarkup(
|
|
<PwaInstallDialog onClose={() => undefined} onInstall={() => undefined} />,
|
|
);
|
|
|
|
expect(html).toContain('role="dialog"');
|
|
expect(html).toContain('aria-modal="true"');
|
|
expect(html).toContain('aria-labelledby="pwa-dialog-title"');
|
|
expect(html).toContain("fixed inset-0");
|
|
expect(html).toContain("max-w-(--pwa-install-dialog-max-width,360px)");
|
|
expect(html).toContain("size-(--icon-size-86,86px)");
|
|
expect(html).toContain("min-h-(--pwa-button-height,44px)");
|
|
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
|
|
expect(html).toContain("%2Fimages%2Ficons%2Ficon-add-to-home.png");
|
|
expect(html).toContain("Add to Home Screen");
|
|
expect(html).toContain("Cancel");
|
|
expect(html).toContain("OK");
|
|
});
|
|
|
|
it("renders HistoryUnlockDialog open and loading states", () => {
|
|
expect(
|
|
renderToStaticMarkup(
|
|
<HistoryUnlockDialog
|
|
open={false}
|
|
lockedCount={2}
|
|
onClose={() => undefined}
|
|
onConfirm={() => undefined}
|
|
/>,
|
|
),
|
|
).toBe("");
|
|
|
|
const html = renderToStaticMarkup(
|
|
<HistoryUnlockDialog
|
|
open
|
|
lockedCount={3}
|
|
isLoading
|
|
errorMessage="Could not unlock history."
|
|
onClose={() => undefined}
|
|
onConfirm={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
expect(html).toContain('aria-labelledby="history-unlock-title"');
|
|
expect(html).toContain("z-200");
|
|
expect(html).toContain("Unlock previous messages?");
|
|
expect(html).toContain("We found 3 locked messages");
|
|
expect(html).toContain("Could not unlock history.");
|
|
expect(html).toContain("Unlocking...");
|
|
expect(html).toContain('data-analytics-key="chat.unlock_history"');
|
|
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
|
|
expect(html).toContain("disabled");
|
|
});
|
|
|
|
it("renders InsufficientCreditsDialog with credit details", () => {
|
|
expect(
|
|
renderToStaticMarkup(
|
|
<InsufficientCreditsDialog
|
|
open={false}
|
|
creditBalance={0}
|
|
requiredCredits={0}
|
|
shortfallCredits={0}
|
|
onClose={() => undefined}
|
|
onConfirm={() => undefined}
|
|
/>,
|
|
),
|
|
).toBe("");
|
|
|
|
const html = renderToStaticMarkup(
|
|
<InsufficientCreditsDialog
|
|
open
|
|
creditBalance={2}
|
|
requiredCredits={5}
|
|
shortfallCredits={3}
|
|
onClose={() => undefined}
|
|
onConfirm={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
expect(html).toContain('aria-labelledby="insufficient-credits-title"');
|
|
expect(html).toContain("z-220");
|
|
expect(html).toContain("Not enough credits");
|
|
expect(html).toContain("Balance");
|
|
expect(html).toContain("Required");
|
|
expect(html).toContain("Still needed");
|
|
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
|
|
expect(html).toContain("bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)]");
|
|
expect(html).toContain("Top up now");
|
|
expect(html).toContain('data-analytics-key="chat.topup"');
|
|
});
|
|
|
|
});
|
|
|
|
function renderWithCharacter(node: ReactNode, slug = "elio"): string {
|
|
const character = getCharacterBySlug(slug);
|
|
if (!character) throw new Error(`Missing ${slug} character fixture`);
|
|
return renderToStaticMarkup(
|
|
<CharacterProvider character={character}>{node}</CharacterProvider>,
|
|
);
|
|
}
|