diff --git a/apps/loopover-miner-ui/src/chat-message-components.test.tsx b/apps/loopover-miner-ui/src/chat-message-components.test.tsx new file mode 100644 index 0000000000..68d05f8552 --- /dev/null +++ b/apps/loopover-miner-ui/src/chat-message-components.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MessageList } from "./components/chat/message-list"; +import { MessageBubble } from "./components/chat/message-bubble"; +import { TypingIndicator } from "./components/chat/typing-indicator"; +import { emptyConversation, multiTurnConversation, singleMessage, type ChatMessage } from "./components/chat/fixtures"; + +describe("MessageList (#6515) — StateBoundary branches", () => { + it("renders one bubble per message in the populated state", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(multiTurnConversation.length); + expect(screen.getByText(multiTurnConversation[0]!.content)).toBeTruthy(); + }); + + it("shows the empty state (not a bare list) when there are no messages", () => { + render(); + expect(screen.getByText(/No messages yet/i)).toBeTruthy(); + expect(screen.queryAllByRole("listitem")).toHaveLength(0); + }); + + it("shows the loading state", () => { + render(); + expect(screen.getByText(/Loading conversation/i)).toBeTruthy(); + expect(screen.queryAllByRole("listitem")).toHaveLength(0); + }); + + it("shows the error state", () => { + render(); + expect(screen.getByText(/Couldn't load the conversation/i)).toBeTruthy(); + }); + + it("renders the typing indicator (below the list) when composing, independent of message state", () => { + render(); + expect(screen.getByRole("status", { name: /is typing/i })).toBeTruthy(); + // still renders the list itself + expect(screen.getAllByRole("listitem")).toHaveLength(1); + }); + + it("does not render the typing indicator when not composing", () => { + render(); + expect(screen.queryByRole("status", { name: /is typing/i })).toBeNull(); + }); +}); + +describe("MessageBubble (#6515) — role-color + avatar branches", () => { + const base: ChatMessage = { + id: "x", + role: "assistant", + content: "hello world", + timestamp: "2026-07-16T08:00:00.000Z", + }; + + it("applies a distinct role-colored background per role, from theme tokens only", () => { + const roleToClass: Record = { + user: "bg-primary", + assistant: "bg-muted", + system: "bg-secondary", + }; + for (const [role, cls] of Object.entries(roleToClass) as [ChatMessage["role"], string][]) { + const { unmount } = render(); + expect(screen.getByText(`c-${role}`).className).toContain(cls); + unmount(); + } + }); + + it("renders the initials fallback from the author name (or role when unnamed)", () => { + const { unmount } = render(); + expect(screen.getByText("OP")).toBeTruthy(); + unmount(); + render(); // no authorName → falls back to the role + expect(screen.getByText("AS")).toBeTruthy(); + }); + + it("renders no and shows the initials fallback when no avatarUrl is given", () => { + const { container } = render(); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("AS")).toBeTruthy(); + }); + + it("accepts an avatarUrl without breaking the fallback path", () => { + // Radix AvatarImage mounts the real only after it loads (which never happens in jsdom), so the + // initials fallback is the state that renders here — exercising the avatarUrl-present branch safely. + render( + , + ); + expect(screen.getByText("OP")).toBeTruthy(); + }); + + it("renders a machine-readable timestamp", () => { + render(); + const time = document.querySelector("time"); + expect(time?.getAttribute("dateTime")).toBe(base.timestamp); + }); +}); + +describe("TypingIndicator (#6515) — composing branches + accessible name", () => { + it("renders a typing-specific accessible name (not 'loading') when composing", () => { + render(); + const status = screen.getByRole("status", { name: /LoopOver is typing/i }); + expect(status).toBeTruthy(); + expect(status.getAttribute("aria-label")).toMatch(/typing/i); + expect(status.getAttribute("aria-label")).not.toMatch(/loading/i); + }); + + it("renders nothing when not composing", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/loopover-miner-ui/src/components/chat/fixtures.ts b/apps/loopover-miner-ui/src/components/chat/fixtures.ts new file mode 100644 index 0000000000..7d00c2f664 --- /dev/null +++ b/apps/loopover-miner-ui/src/components/chat/fixtures.ts @@ -0,0 +1,74 @@ +// Fixture data for the miner-ui chat components (#6515). These components are backend-agnostic — driven +// entirely by props (a message array + a composing flag); a real data source arrives in a later, +// separately-scoped issue. The shapes below are exactly what MessageBubble / MessageList render against. + +export type ChatRole = "user" | "assistant" | "system"; + +export interface ChatMessage { + id: string; + role: ChatRole; + content: string; + /** ISO-8601 timestamp; MessageBubble renders it through a