import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import { AbstractApiData } from "../../../../core/abstracts/AbstractApiData";
import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
import type { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
import type { ApiDataInterface } from "../../../../core";
import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface";
import { MessageItem } from "../MessageItem";
// Test-only account model to exercise the sources panel references list
class TestAccount extends AbstractApiData {
static identifierFields: string[] = ["name"];
rehydrate(data: any): this {
super.rehydrate(data);
return this;
}
createJsonApi(): any {
return {};
}
}
const testAccountModule = {
name: "test-accounts",
pageUrl: "/accounts",
model: TestAccount,
} as unknown as ApiRequestDataTypeInterface;
beforeAll(() => {
DataClassRegistry.clear();
DataClassRegistry.registerObjectClass(testAccountModule, TestAccount);
ModuleRegistry.register("TestAccount" as any, testAccountModule as any);
});
afterAll(() => DataClassRegistry.clear());
function makeRehydratedAccount({ id, name }: { id: string; name: string }): ApiDataInterface {
const acct = new TestAccount();
acct.rehydrate({ jsonApi: { type: "test-accounts", id, attributes: { name } }, included: [] });
return acct as unknown as ApiDataInterface;
}
function buildMessageStub(p: {
role: "user" | "assistant";
content?: string;
references?: ApiDataInterface[];
suggestedQuestions?: string[];
}): AssistantMessageInterface {
return {
id: Math.random().toString(36).slice(2),
type: "assistant-messages",
role: p.role,
content: p.content ?? "",
position: 0,
references: p.references ?? [],
citations: [],
suggestedQuestions: p.suggestedQuestions ?? [],
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as AssistantMessageInterface;
}
describe("MessageItem", () => {
it("user message is in a right-aligned bubble with plain text", () => {
const msg = buildMessageStub({ role: "user", content: "hello" });
render();
expect(screen.getByText("hello")).toBeInTheDocument();
// User bubble shouldn't include the assistant agent label
expect(screen.queryByText("features.assistant.agent_name")).not.toBeInTheDocument();
});
it("assistant message renders agent label and the sources panel toggle when sources exist", () => {
const msg = buildMessageStub({
role: "assistant",
content: "**bold** reply",
references: [makeRehydratedAccount({ id: "acc-1", name: "Acme" })],
suggestedQuestions: ["follow-up 1"],
});
render();
expect(screen.getByText("features.assistant.agent_name")).toBeInTheDocument();
// The MessageSourcesPanel toggle uses the message.sources.toggle translation key.
expect(screen.getByRole("button", { name: /sources\.toggle/i })).toBeInTheDocument();
});
it("assistant message NOT latest with only suggestions: no sources panel rendered", () => {
const msg = buildMessageStub({ role: "assistant", content: "prior", suggestedQuestions: ["x"] });
const { container } = render();
// No sources/citations/references and suggestions are gated by isLatestAssistant -> panel renders nothing.
expect(screen.queryByRole("button", { name: /sources\.toggle/i })).not.toBeInTheDocument();
// Sanity: the assistant bubble itself still rendered.
expect(container).not.toBeEmptyDOMElement();
});
it("failed user message: renders retry control and calls onRetry with the id", async () => {
const msg = buildMessageStub({ role: "user", content: "oops" });
(msg as any).id = "tmp-123";
const onRetry = vi.fn();
render(
,
);
expect(screen.getByText("features.assistant.send_failed")).toBeInTheDocument();
const retryBtn = screen.getByRole("button", { name: "features.assistant.retry" });
retryBtn.click();
expect(onRetry).toHaveBeenCalledWith("tmp-123");
});
});