7f271ef2e0
Render the stored user avatar in the Chat Header when available while preserving the existing Profile icon fallback, navigation, accessibility label, and analytics.
438 lines
16 KiB
TypeScript
438 lines
16 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}
|
|
onClick={() => undefined}
|
|
onPointerDownSend={() => undefined}
|
|
/>,
|
|
);
|
|
const emptyHtml = renderToStaticMarkup(
|
|
<ChatSendButton
|
|
disabled={false}
|
|
hasContent={false}
|
|
onClick={() => undefined}
|
|
onPointerDownSend={() => undefined}
|
|
/>,
|
|
);
|
|
const disabledHtml = renderToStaticMarkup(
|
|
<ChatSendButton
|
|
disabled={true}
|
|
hasContent={true}
|
|
onClick={() => 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,40px)");
|
|
expect(activeHtml).toContain(
|
|
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
|
);
|
|
expect(emptyHtml).toContain("bg-[#f8a8ce]");
|
|
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]");
|
|
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>,
|
|
);
|
|
}
|