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:
2026-06-10 16:38:06 +03:00
parent 1fd5e55db7
commit 7760214170
12 changed files with 1385 additions and 138 deletions

View File

@@ -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