test(frontend): add useChatSession + ChatMessagesContainer coverage tests

- useChatSession.test.ts: 5 tests for newestSequence and forwardPaginated memos
- ChatMessagesContainer.test.tsx: 5 tests verifying top/bottom sentinel placement
  based on forwardPaginated prop
This commit is contained in:
Zamil Majdy
2026-04-15 21:36:44 +07:00
parent ce22b21824
commit 6d1688b0f0
2 changed files with 295 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatSession } from "../useChatSession";
const mockUseGetV2GetSession = vi.fn();
vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({
useGetV2GetSession: (...args: unknown[]) => mockUseGetV2GetSession(...args),
usePostV2CreateSession: () => ({ mutateAsync: vi.fn(), isPending: false }),
getGetV2GetSessionQueryKey: (id: string) => ["session", id],
getGetV2ListSessionsQueryKey: () => ["sessions"],
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
}));
vi.mock("nuqs", () => ({
parseAsString: { withDefault: (v: unknown) => v },
useQueryState: () => ["sess-1", vi.fn()],
}));
vi.mock("../helpers/convertChatSessionToUiMessages", () => ({
convertChatSessionMessagesToUiMessages: vi.fn(() => ({
messages: [],
historicalDurations: new Map(),
})),
}));
vi.mock("../helpers", () => ({
resolveSessionDryRun: vi.fn(() => false),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
function makeQueryResult(data: object | null) {
return {
data: data ? { status: 200, data } : undefined,
isLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
};
}
describe("useChatSession — newestSequence and forwardPaginated", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns null / false when no session data", () => {
mockUseGetV2GetSession.mockReturnValue(makeQueryResult(null));
const { result } = renderHook(() => useChatSession());
expect(result.current.newestSequence).toBeNull();
expect(result.current.forwardPaginated).toBe(false);
});
it("returns newestSequence from session data", () => {
mockUseGetV2GetSession.mockReturnValue(
makeQueryResult({
messages: [],
has_more_messages: true,
oldest_sequence: 0,
newest_sequence: 99,
forward_paginated: false,
active_stream: null,
}),
);
const { result } = renderHook(() => useChatSession());
expect(result.current.newestSequence).toBe(99);
});
it("returns null for newestSequence when field is missing", () => {
mockUseGetV2GetSession.mockReturnValue(
makeQueryResult({
messages: [],
has_more_messages: false,
oldest_sequence: 0,
newest_sequence: null,
forward_paginated: false,
active_stream: null,
}),
);
const { result } = renderHook(() => useChatSession());
expect(result.current.newestSequence).toBeNull();
});
it("returns forwardPaginated=true when session is forward-paginated", () => {
mockUseGetV2GetSession.mockReturnValue(
makeQueryResult({
messages: [],
has_more_messages: true,
oldest_sequence: 0,
newest_sequence: 49,
forward_paginated: true,
active_stream: null,
}),
);
const { result } = renderHook(() => useChatSession());
expect(result.current.forwardPaginated).toBe(true);
});
it("returns forwardPaginated=false when session is backward-paginated", () => {
mockUseGetV2GetSession.mockReturnValue(
makeQueryResult({
messages: [],
has_more_messages: true,
oldest_sequence: 50,
newest_sequence: 99,
forward_paginated: false,
active_stream: null,
}),
);
const { result } = renderHook(() => useChatSession());
expect(result.current.forwardPaginated).toBe(false);
});
});

View File

@@ -0,0 +1,173 @@
import { render, screen, cleanup } from "@/tests/integrations/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatMessagesContainer } from "../ChatMessagesContainer";
const mockScrollEl = {
scrollHeight: 100,
scrollTop: 0,
clientHeight: 500,
};
vi.mock("use-stick-to-bottom", () => ({
useStickToBottomContext: () => ({ scrollRef: { current: mockScrollEl } }),
Conversation: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ConversationContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ConversationScrollButton: () => null,
}));
vi.mock("@/components/ai-elements/conversation", () => ({
Conversation: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ConversationContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ConversationScrollButton: () => null,
}));
vi.mock("@/components/ai-elements/message", () => ({
Message: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MessageContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MessageActions: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../components/AssistantMessageActions", () => ({
AssistantMessageActions: () => null,
}));
vi.mock("../components/CopyButton", () => ({ CopyButton: () => null }));
vi.mock("../components/CollapsedToolGroup", () => ({
CollapsedToolGroup: () => null,
}));
vi.mock("../components/MessageAttachments", () => ({
MessageAttachments: () => null,
}));
vi.mock("../components/MessagePartRenderer", () => ({
MessagePartRenderer: () => null,
}));
vi.mock("../components/ReasoningCollapse", () => ({
ReasoningCollapse: () => null,
}));
vi.mock("../components/ThinkingIndicator", () => ({
ThinkingIndicator: () => null,
}));
vi.mock("../../JobStatsBar/TurnStatsBar", () => ({
TurnStatsBar: () => null,
}));
vi.mock("../../JobStatsBar/useElapsedTimer", () => ({
useElapsedTimer: () => ({ elapsedSeconds: 0 }),
}));
vi.mock("../../CopilotPendingReviews/CopilotPendingReviews", () => ({
CopilotPendingReviews: () => null,
}));
vi.mock("../helpers", () => ({
buildRenderSegments: () => [],
getTurnMessages: () => [],
parseSpecialMarkers: () => ({ markerType: null }),
splitReasoningAndResponse: (parts: unknown[]) => ({
reasoningParts: [],
responseParts: parts,
}),
}));
type ObserverCallback = (entries: { isIntersecting: boolean }[]) => void;
class MockIntersectionObserver {
static lastCallback: ObserverCallback | null = null;
private callback: ObserverCallback;
constructor(cb: ObserverCallback) {
this.callback = cb;
MockIntersectionObserver.lastCallback = cb;
}
observe() {}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "";
thresholds = [];
}
const BASE_PROPS = {
messages: [],
status: "ready" as const,
error: undefined,
isLoading: false,
sessionID: "sess-1",
hasMoreMessages: true,
isLoadingMore: false,
onLoadMore: vi.fn(),
onRetry: vi.fn(),
};
describe("ChatMessagesContainer", () => {
beforeEach(() => {
mockScrollEl.scrollHeight = 100;
mockScrollEl.scrollTop = 0;
mockScrollEl.clientHeight = 500;
MockIntersectionObserver.lastCallback = null;
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders top sentinel when forwardPaginated is false (backward pagination)", () => {
render(<ChatMessagesContainer {...BASE_PROPS} forwardPaginated={false} />);
expect(
screen.getByRole("button", { name: /load older messages/i }),
).toBeDefined();
});
it("renders top sentinel when forwardPaginated is undefined (default, backward)", () => {
render(<ChatMessagesContainer {...BASE_PROPS} />);
expect(
screen.getByRole("button", { name: /load older messages/i }),
).toBeDefined();
});
it("renders bottom sentinel when forwardPaginated is true (forward pagination)", () => {
render(<ChatMessagesContainer {...BASE_PROPS} forwardPaginated={true} />);
expect(
screen.getByRole("button", { name: /load older messages/i }),
).toBeDefined();
});
it("hides sentinel when hasMoreMessages is false", () => {
render(
<ChatMessagesContainer
{...BASE_PROPS}
hasMoreMessages={false}
forwardPaginated={true}
/>,
);
expect(
screen.queryByRole("button", { name: /load older messages/i }),
).toBeNull();
});
it("hides sentinel when onLoadMore is not provided", () => {
render(
<ChatMessagesContainer
{...BASE_PROPS}
onLoadMore={undefined}
forwardPaginated={true}
/>,
);
expect(
screen.queryByRole("button", { name: /load older messages/i }),
).toBeNull();
});
});