From 634053ee57135048ebbc569d9052bf4adb71a1a0 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 13 Dec 2023 14:26:16 +0800 Subject: [PATCH] test: add unit test for chat history page (#55) * test: add unit test for chst history page Signed-off-by: Lin Wang * test: add unit tests for chat history list Signed-off-by: Lin Wang * test: add unit tests for chat history search list Signed-off-by: Lin Wang * test: remove unnecessary act Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- public/services/sessions_service.ts | 1 + .../__tests__/chat_history_list.test.tsx | 70 ++++++ .../__tests__/chat_history_page.test.tsx | 212 ++++++++++++++++-- .../chat_history_search_list.test.tsx | 60 ++++- public/tabs/history/chat_history_list.tsx | 2 +- public/tabs/history/chat_history_page.tsx | 4 +- .../tabs/history/chat_history_search_list.tsx | 2 +- 7 files changed, 315 insertions(+), 36 deletions(-) create mode 100644 public/tabs/history/__tests__/chat_history_list.test.tsx diff --git a/public/services/sessions_service.ts b/public/services/sessions_service.ts index 78e83e70..574c8305 100644 --- a/public/services/sessions_service.ts +++ b/public/services/sessions_service.ts @@ -34,6 +34,7 @@ export class SessionsService { this.abortController = new AbortController(); this._options = query; try { + this.status$.next('loading'); this.sessions$.next( await this._http.get(ASSISTANT_API.SESSIONS, { query: this._options as HttpFetchQuery, diff --git a/public/tabs/history/__tests__/chat_history_list.test.tsx b/public/tabs/history/__tests__/chat_history_list.test.tsx new file mode 100644 index 00000000..f561d5a4 --- /dev/null +++ b/public/tabs/history/__tests__/chat_history_list.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { ChatHistoryList } from '../chat_history_list'; + +describe('', () => { + it('should render two history titles, update times and one horizontal rule', async () => { + const { getByText, getAllByLabelText } = render( + + ); + + expect(getByText('foo')).toBeInTheDocument(); + expect(getByText('bar')).toBeInTheDocument(); + expect(getByText('January 1, 1970 at 12:0 AM')).toBeInTheDocument(); + expect(getByText('January 1, 1970 at 12:6 AM')).toBeInTheDocument(); + expect(getAllByLabelText('history horizontal rule')).toHaveLength(1); + }); + + it('should call onChatHistoryTitleClick with id and title', () => { + const onChatHistoryTitleClickMock = jest.fn(); + const { getByText } = render( + + ); + + expect(onChatHistoryTitleClickMock).not.toHaveBeenCalled(); + fireEvent.click(getByText('foo')); + expect(onChatHistoryTitleClickMock).toHaveBeenCalledWith('1', 'foo'); + }); + + it('should call onChatHistoryEditClick with id and title', () => { + const onChatHistoryEditClickMock = jest.fn(); + const { getByLabelText } = render( + + ); + + expect(onChatHistoryEditClickMock).not.toHaveBeenCalled(); + fireEvent.click(getByLabelText('Edit conversation name')); + expect(onChatHistoryEditClickMock).toHaveBeenCalledWith({ id: '1', title: 'foo' }); + }); + + it('should call onChatHistoryDeleteClick with id and title', () => { + const onChatHistoryDeleteClickMock = jest.fn(); + const { getByLabelText } = render( + + ); + + expect(onChatHistoryDeleteClickMock).not.toHaveBeenCalled(); + fireEvent.click(getByLabelText('Delete conversation')); + expect(onChatHistoryDeleteClickMock).toHaveBeenCalledWith({ id: '1' }); + }); +}); diff --git a/public/tabs/history/__tests__/chat_history_page.test.tsx b/public/tabs/history/__tests__/chat_history_page.test.tsx index 2f8fbbd4..38d45782 100644 --- a/public/tabs/history/__tests__/chat_history_page.test.tsx +++ b/public/tabs/history/__tests__/chat_history_page.test.tsx @@ -4,39 +4,45 @@ */ import React from 'react'; -import { act, fireEvent, render } from '@testing-library/react'; -import { BehaviorSubject } from 'rxjs'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { I18nProvider } from '@osd/i18n/react'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { HttpStart } from '../../../../../../src/core/public'; + import * as useChatStateExports from '../../../hooks/use_chat_state'; import * as chatContextExports from '../../../contexts/chat_context'; import * as coreContextExports from '../../../contexts/core_context'; +import { SessionsService } from '../../../services/sessions_service'; import { ChatHistoryPage } from '../chat_history_page'; -const setup = () => { +const mockGetSessionsHttp = () => { + const http = coreMock.createStart().http; + http.get.mockImplementation(async () => ({ + objects: [ + { + id: '1', + title: 'foo', + }, + ], + total: 1, + })); + return http; +}; + +const setup = ({ + http = mockGetSessionsHttp(), + chatContext = {}, +}: { + http?: HttpStart; + chatContext?: { flyoutFullScreen?: boolean }; +} = {}) => { const useCoreMock = { services: { - notifications: { - toasts: { - addSuccess: jest.fn(), - addDanger: jest.fn(), - addError: jest.fn(), - }, - }, - sessions: { - sessions$: new BehaviorSubject({ - objects: [ - { - id: '1', - title: 'foo', - }, - ], - total: 1, - }), - status$: new BehaviorSubject('idle'), - load: jest.fn(), - }, + ...coreMock.createStart(), + http, + sessions: new SessionsService(http), sessionLoad: {}, }, }; @@ -47,6 +53,8 @@ const setup = () => { sessionId: '1', setSessionId: jest.fn(), setTitle: jest.fn(), + setSelectedTabId: jest.fn(), + ...chatContext, }; jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); jest.spyOn(useChatStateExports, 'useChatState').mockReturnValue(useChatStateMock); @@ -68,7 +76,13 @@ const setup = () => { describe('', () => { it('should clear old session data after current session deleted', async () => { - const { renderResult, useChatStateMock, useChatContextMock } = setup(); + const { renderResult, useChatStateMock, useChatContextMock } = setup({ + http: mockGetSessionsHttp(), + }); + + await waitFor(() => { + expect(renderResult.getByLabelText('Delete conversation')).toBeTruthy(); + }); act(() => { fireEvent.click(renderResult.getByLabelText('Delete conversation')); @@ -86,4 +100,154 @@ describe('', () => { expect(useChatContextMock.setTitle).toHaveBeenLastCalledWith(undefined); expect(useChatStateMock.chatStateDispatch).toHaveBeenLastCalledWith({ type: 'reset' }); }); + + it('should render empty screen', async () => { + const http = coreMock.createStart().http; + http.get.mockImplementation(async () => { + return { + objects: [], + total: 0, + }; + }); + const { renderResult } = setup({ + http, + }); + + await waitFor(async () => { + expect( + renderResult.getByText( + 'No conversation has been recorded. Start a conversation in the assistant to have it saved.' + ) + ).toBeTruthy(); + }); + }); + + it('should render full screen back icon button instead of back', async () => { + const { renderResult } = setup({ + chatContext: { + flyoutFullScreen: true, + }, + }); + await waitFor(async () => { + expect(renderResult.getByLabelText('full screen back')).toBeTruthy(); + expect(renderResult.queryByRole('button', { name: 'Back' })).toBeFalsy(); + }); + }); + + it('should render back button and history list', async () => { + const { renderResult } = setup(); + await waitFor(async () => { + expect(renderResult.getByRole('button', { name: 'Back' })).toBeTruthy(); + expect(renderResult.getByText('foo')).toBeTruthy(); + }); + }); + + it('should call get sessions with search text', async () => { + const { renderResult, useCoreMock } = setup(); + await waitFor(async () => { + expect(renderResult.getByPlaceholderText('Search by conversation name')).toBeTruthy(); + }); + act(() => { + fireEvent.change(renderResult.getByPlaceholderText('Search by conversation name'), { + target: { + value: 'bar', + }, + }); + }); + await waitFor(() => { + expect(useCoreMock.services.http.get).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + search: 'bar', + page: 1, + }), + }) + ); + }); + }); + + it('should call get sessions with new page size', async () => { + const { renderResult, useCoreMock } = setup(); + act(() => { + fireEvent.click(renderResult.getByTestId('tablePaginationPopoverButton')); + }); + act(() => { + fireEvent.click(renderResult.getByTestId('tablePagination-50-rows')); + }); + await waitFor(() => { + expect(useCoreMock.services.http.get).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + page: 1, + perPage: 50, + }), + }) + ); + }); + }); + + it('should call setSelectedTabId with "chat" after back button click', async () => { + const { renderResult, useChatContextMock } = setup(); + + expect(useChatContextMock.setSelectedTabId).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(renderResult.getByRole('button', { name: 'Back' })); + }); + await waitFor(() => { + expect(useChatContextMock.setSelectedTabId).toHaveBeenLastCalledWith('chat'); + }); + }); + + it('should call setSelectedTabId with "chat" after full screen back button click', async () => { + const { renderResult, useChatContextMock } = setup({ + chatContext: { + flyoutFullScreen: true, + }, + }); + + expect(useChatContextMock.setSelectedTabId).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(renderResult.getByLabelText('full screen back')); + }); + await waitFor(() => { + expect(useChatContextMock.setSelectedTabId).toHaveBeenLastCalledWith('chat'); + }); + }); + + it('should call sessions.reload after shouldRefresh change', async () => { + const { renderResult, useCoreMock } = setup(); + + jest.spyOn(useCoreMock.services.sessions, 'reload'); + + expect(useCoreMock.services.sessions.reload).not.toHaveBeenCalled(); + + renderResult.rerender( + + + + ); + + await waitFor(() => { + expect(useCoreMock.services.sessions.reload).toHaveBeenCalled(); + }); + }); + + it('should call sessions.abortController.abort after unmount', async () => { + const { renderResult, useCoreMock } = setup(); + + await waitFor(() => { + expect(useCoreMock.services.sessions.abortController).toBeTruthy(); + }); + const abortMock = jest.spyOn(useCoreMock.services.sessions.abortController!, 'abort'); + + expect(abortMock).not.toHaveBeenCalled(); + + renderResult.unmount(); + + await waitFor(() => { + expect(abortMock).toHaveBeenCalled(); + }); + }); }); diff --git a/public/tabs/history/__tests__/chat_history_search_list.test.tsx b/public/tabs/history/__tests__/chat_history_search_list.test.tsx index ec929a7a..fa49326a 100644 --- a/public/tabs/history/__tests__/chat_history_search_list.test.tsx +++ b/public/tabs/history/__tests__/chat_history_search_list.test.tsx @@ -11,9 +11,17 @@ import { coreMock } from '../../../../../../src/core/public/mocks'; import * as chatContextExports from '../../../contexts/chat_context'; import * as coreContextExports from '../../../contexts/core_context'; -import { ChatHistorySearchList } from '../chat_history_search_list'; +import { ChatHistorySearchList, ChatHistorySearchListProps } from '../chat_history_search_list'; -const setup = () => { +const setup = ({ + loading = false, + histories = [{ id: '1', title: 'foo', updatedTimeMs: 0 }], + onSearchChange = jest.fn(), + onLoadChat = jest.fn(), + onRefresh = jest.fn(), + onHistoryDeleted = jest.fn(), + ...restProps +}: Partial = {}) => { const useChatContextMock = { sessionId: '1', setTitle: jest.fn(), @@ -22,18 +30,20 @@ const setup = () => { services: coreMock.createStart(), }; useCoreMock.services.http.put.mockImplementation(() => Promise.resolve()); + useCoreMock.services.http.delete.mockImplementation(() => Promise.resolve()); jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue(useChatContextMock); const renderResult = render( ); @@ -68,4 +78,38 @@ describe('', () => { expect(useChatContextMock.setTitle).toHaveBeenLastCalledWith('bar'); }); }); + + it('should call onRefresh and onHistoryDeleted after conversation deleted', async () => { + const onRefreshMock = jest.fn(); + const onHistoryDeletedMock = jest.fn(); + + const { renderResult } = setup({ + onRefresh: onRefreshMock, + onHistoryDeleted: onHistoryDeletedMock, + }); + + act(() => { + fireEvent.click(renderResult.getByLabelText('Delete conversation')); + }); + + expect(onRefreshMock).not.toHaveBeenCalled(); + expect(onHistoryDeletedMock).not.toHaveBeenCalled(); + + await waitFor(async () => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(async () => { + expect(onRefreshMock).toHaveBeenCalled(); + expect(onHistoryDeletedMock).toHaveBeenCalledWith('1'); + }); + }); + + it('should display empty panel', () => { + const { renderResult } = setup({ + histories: [], + }); + + expect(renderResult.getByText('There were no results found.')).toBeInTheDocument(); + }); }); diff --git a/public/tabs/history/chat_history_list.tsx b/public/tabs/history/chat_history_list.tsx index 546aee87..2e7446ae 100644 --- a/public/tabs/history/chat_history_list.tsx +++ b/public/tabs/history/chat_history_list.tsx @@ -93,7 +93,7 @@ export const ChatHistoryListItem = ({ - {hasBottomBorder && } + {hasBottomBorder && } ); }; diff --git a/public/tabs/history/chat_history_page.tsx b/public/tabs/history/chat_history_page.tsx index 99adc4aa..ef9fe868 100644 --- a/public/tabs/history/chat_history_page.tsx +++ b/public/tabs/history/chat_history_page.tsx @@ -43,8 +43,8 @@ export const ChatHistoryPage: React.FC = React.memo((props } = useChatContext(); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); - const [searchName, setSearchName] = useState(); - const [debouncedSearchName, setDebouncedSearchName] = useState(); + const [searchName, setSearchName] = useState(''); + const [debouncedSearchName, setDebouncedSearchName] = useState(''); const bulkGetOptions = useMemo( () => ({ page: pageIndex + 1, diff --git a/public/tabs/history/chat_history_search_list.tsx b/public/tabs/history/chat_history_search_list.tsx index 979fd54f..eadf5af9 100644 --- a/public/tabs/history/chat_history_search_list.tsx +++ b/public/tabs/history/chat_history_search_list.tsx @@ -18,7 +18,7 @@ import { EditConversationNameModal } from '../../components/edit_conversation_na import { DeleteConversationConfirmModal } from './delete_conversation_confirm_modal'; import { useChatContext } from '../../contexts'; -interface ChatHistorySearchListProps +export interface ChatHistorySearchListProps extends Pick< EuiTablePaginationProps, 'activePage' | 'itemsPerPage' | 'onChangeItemsPerPage' | 'onChangePage' | 'pageCount'