test(agent): extend coverage — agent handler, confirmations, conversation API, tools
- test_agent_handler: additional edge cases for streaming, HITL, file upload - test_confirmations: HITL confirm/deny lifecycle coverage - test_conversation_api: conversation save/load persistence tests - test_langchain_tools: tool registration, dual-auth header propagation - ConversationList.test.ts (frontend): conversation list component tests - conftest: shared fixtures for agent tests - task_manager/manager: minor fixes from test coverage - tasks.md/test-documentation.md: spec and test documentation updates - speckit.test.md: speckit workflow documentation update
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// #region TestAgentChat.ConversationList [C:2] [TYPE Module] [SEMANTICS test,conversation,list,ui]
|
||||
// @BRIEF L2 UX tests for ConversationList — renders conversations, groups by date, search, delete.
|
||||
// @RELATION BINDS_TO -> [AgentChat.ConversationList]
|
||||
// @UX_STATE loading -> skeleton visible
|
||||
// @UX_STATE loaded -> grouped list with conversation titles
|
||||
// @UX_STATE empty -> "No conversations" placeholder
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
import ConversationList from "../ConversationList.svelte";
|
||||
|
||||
// Mock i18n — t must be a store with subscribe() returning assistant namespace
|
||||
vi.mock("$lib/i18n/index.svelte.js", () => {
|
||||
const tStore = {
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({
|
||||
assistant: {
|
||||
search_placeholder: "Search conversations...",
|
||||
no_conversations: "No conversations",
|
||||
delete: "Delete",
|
||||
load_more: "Load more",
|
||||
delete_confirm: "Delete this conversation?",
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
return { t: tStore };
|
||||
});
|
||||
|
||||
const MOCK_CONVERSATIONS = [
|
||||
{ id: "c1", title: "Chat About Dashboards", updated_at: new Date().toISOString(), message_count: 5 },
|
||||
{ id: "c2", title: "Deploy Configuration", updated_at: new Date(Date.now() - 86400000).toISOString(), message_count: 3 },
|
||||
{ id: "c3", title: "Migration Plan", updated_at: new Date(Date.now() - 86400000 * 3).toISOString(), message_count: 12 },
|
||||
];
|
||||
|
||||
// #region TestAgentChat.ConversationList.Render [C:2] [TYPE Test]
|
||||
// @UX_STATE loaded — conversations render with titles, grouped by date.
|
||||
describe("ConversationList — loaded state", () => {
|
||||
it("renders conversation titles", () => {
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("Chat About Dashboards")).toBeTruthy();
|
||||
expect(screen.getByText("Deploy Configuration")).toBeTruthy();
|
||||
expect(screen.getByText("Migration Plan")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("highlights active conversation with bg class", () => {
|
||||
const { container } = render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: "c1",
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
// The active conversation has class "bg-surface-muted"
|
||||
const items = container.querySelectorAll('[role="button"]');
|
||||
let found = false;
|
||||
items.forEach((item) => {
|
||||
if (item.className.includes("bg-surface-muted")) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
it("shows message count when > 0", () => {
|
||||
const { container } = render(ConversationList, {
|
||||
props: {
|
||||
conversations: [{ id: "c1", title: "Test", updated_at: new Date().toISOString(), message_count: 5 }],
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain("5");
|
||||
expect(container.textContent).toContain("msgs");
|
||||
});
|
||||
});
|
||||
// #endregion TestAgentChat.ConversationList.Render
|
||||
|
||||
// #region TestAgentChat.ConversationList.Loading [C:2] [TYPE Test]
|
||||
// @UX_STATE loading — skeleton cards visible.
|
||||
describe("ConversationList — loading state", () => {
|
||||
it("shows skeleton when isLoading is true and no conversations", () => {
|
||||
const { container } = render(ConversationList, {
|
||||
props: {
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
isLoading: true,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
const skeleton = container.querySelector(".animate-pulse");
|
||||
expect(skeleton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show skeleton when conversations are already loaded", () => {
|
||||
const { container } = render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: true,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
const skeleton = container.querySelector(".animate-pulse");
|
||||
expect(skeleton).toBeFalsy();
|
||||
});
|
||||
});
|
||||
// #endregion TestAgentChat.ConversationList.Loading
|
||||
|
||||
// #region TestAgentChat.ConversationList.Empty [C:2] [TYPE Test]
|
||||
// @UX_STATE empty — "No conversations" placeholder shown when no conversations and not loading.
|
||||
describe("ConversationList — empty state", () => {
|
||||
it("shows empty placeholder when no conversations", () => {
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
const emptyText = screen.queryByText(/no conversations/i);
|
||||
expect(emptyText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// #endregion TestAgentChat.ConversationList.Empty
|
||||
|
||||
// #region TestAgentChat.ConversationList.Search [C:2] [TYPE Test]
|
||||
// @UX_STATE loaded — search fires onsearch with debounce.
|
||||
describe("ConversationList — search", () => {
|
||||
it("fires onsearch with 300ms debounce when typing", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onsearch = vi.fn();
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
onsearch,
|
||||
},
|
||||
});
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
expect(input).toBeTruthy();
|
||||
await fireEvent.input(input, { target: { value: "dash" } });
|
||||
// Should not fire immediately (debounce 300ms)
|
||||
expect(onsearch).not.toHaveBeenCalled();
|
||||
// Advance past debounce
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
expect(onsearch).toHaveBeenCalledWith("dash");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
// #endregion TestAgentChat.ConversationList.Search
|
||||
|
||||
// #region TestAgentChat.ConversationList.Delete [C:2] [TYPE Test]
|
||||
// @UX_STATE loaded — delete button calls ondelete after window.confirm.
|
||||
describe("ConversationList — delete action", () => {
|
||||
it("calls ondelete after window.confirm returns true", () => {
|
||||
// Set up window.confirm to return true
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn(() => true);
|
||||
const ondelete = vi.fn();
|
||||
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
ondelete,
|
||||
},
|
||||
});
|
||||
|
||||
// Find delete buttons by title attribute
|
||||
const deleteBtns = screen.getAllByTitle(/delete/i);
|
||||
expect(deleteBtns.length).toBeGreaterThan(0);
|
||||
fireEvent.click(deleteBtns[0]);
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(ondelete).toHaveBeenCalledWith("c1");
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("does not call ondelete when window.confirm returns false", () => {
|
||||
window.confirm = vi.fn(() => false);
|
||||
const ondelete = vi.fn();
|
||||
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
ondelete,
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBtns = screen.getAllByTitle(/delete/i);
|
||||
fireEvent.click(deleteBtns[0]);
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(ondelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
// #endregion TestAgentChat.ConversationList.Delete
|
||||
|
||||
// #region TestAgentChat.ConversationList.Props [C:2] [TYPE Test]
|
||||
// @UX_STATE loaded — callbacks are properly wired.
|
||||
describe("ConversationList — callbacks", () => {
|
||||
it("fires onselect when clicking a conversation", () => {
|
||||
const onselect = vi.fn();
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
onselect,
|
||||
},
|
||||
});
|
||||
const chatItem = screen.getByText("Chat About Dashboards");
|
||||
fireEvent.click(chatItem);
|
||||
expect(onselect).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("renders load more button when hasNext is true", () => {
|
||||
const onloadmore = vi.fn();
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: true,
|
||||
onloadmore,
|
||||
},
|
||||
});
|
||||
const loadMore = screen.queryByText(/load more/i);
|
||||
expect(loadMore).toBeTruthy();
|
||||
if (loadMore) {
|
||||
fireEvent.click(loadMore);
|
||||
expect(onloadmore).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render load more button when hasNext is false", () => {
|
||||
render(ConversationList, {
|
||||
props: {
|
||||
conversations: MOCK_CONVERSATIONS,
|
||||
currentConversationId: null,
|
||||
isLoading: false,
|
||||
hasNext: false,
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText(/load more/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
// #endregion TestAgentChat.ConversationList.Props
|
||||
// #endregion TestAgentChat.ConversationList
|
||||
Reference in New Issue
Block a user