Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add unit tests for HeaderChatButton component #82

Merged
merged 1 commit into from
Dec 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/chat_flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface ChatFlyoutProps {
toggleFlyoutFullScreen: () => void;
}

export const ChatFlyout: React.FC<ChatFlyoutProps> = (props) => {
export const ChatFlyout = (props: ChatFlyoutProps) => {
const chatContext = useChatContext();
const chatHistoryPageLoadedRef = useRef(false);

Expand Down
162 changes: 162 additions & 0 deletions public/chat_header_button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { act, render, fireEvent, screen } from '@testing-library/react';

import { HeaderChatButton } from './chat_header_button';
import { applicationServiceMock } from '../../../src/core/public/mocks';
import { AssistantActions } from './types';
import { BehaviorSubject } from 'rxjs';

let mockSend: jest.Mock;
let mockLoadChat: jest.Mock;

jest.mock('./hooks/use_chat_actions', () => {
mockSend = jest.fn();
mockLoadChat = jest.fn();
return {
useChatActions: jest.fn().mockReturnValue({
send: mockSend,
loadChat: mockLoadChat,
openChatUI: jest.fn(),
executeAction: jest.fn(),
abortAction: jest.fn(),
regenerate: jest.fn(),
}),
};
});

jest.mock('./chat_flyout', () => {
return {
ChatFlyout: ({
toggleFlyoutFullScreen,
flyoutFullScreen,
}: {
toggleFlyoutFullScreen: () => void;
flyoutFullScreen: boolean;
}) => (
<div aria-label="chat flyout mock">
<button onClick={toggleFlyoutFullScreen}>toggle chat flyout fullscreen</button>
<p>{flyoutFullScreen ? 'fullscreen mode' : 'dock-right mode'}</p>
</div>
),
};
});

describe('<HeaderChatButton />', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should open chat flyout and send the initial message', () => {
const applicationStart = {
...applicationServiceMock.createStartContract(),
currentAppId$: new BehaviorSubject(''),
};
render(
<HeaderChatButton
application={applicationStart}
userHasAccess={true}
contentRenderers={{}}
actionExecutors={{}}
assistantActions={{} as AssistantActions}
currentAccount={{ username: 'test_user', tenant: 'test_tenant' }}
/>
);

act(() => applicationStart.currentAppId$.next('mock_app_id'));

screen.getByLabelText('chat input').focus();
fireEvent.change(screen.getByLabelText('chat input'), {
target: { value: 'what indices are in my cluster?' },
});
expect(screen.getByLabelText('chat input')).toHaveFocus();

fireEvent.keyPress(screen.getByLabelText('chat input'), {
key: 'Enter',
code: 'Enter',
charCode: 13,
});

// start a new chat
expect(mockLoadChat).toHaveBeenCalled();
// send chat message
expect(mockSend).toHaveBeenCalledWith({
type: 'input',
contentType: 'text',
content: 'what indices are in my cluster?',
context: { appId: 'mock_app_id' },
});
// chat flyout displayed
expect(screen.queryByLabelText('chat flyout mock')).toBeInTheDocument();
// the input value is cleared after pressing enter
expect(screen.getByLabelText('chat input')).toHaveValue('');
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
});

it('should toggle chat flyout size', () => {
render(
<HeaderChatButton
application={applicationServiceMock.createStartContract()}
userHasAccess={true}
contentRenderers={{}}
actionExecutors={{}}
assistantActions={{} as AssistantActions}
currentAccount={{ username: 'test_user', tenant: 'test_tenant' }}
/>
);
fireEvent.click(screen.getByLabelText('toggle chat flyout icon'));
expect(screen.queryByText('dock-right mode')).toBeInTheDocument();

fireEvent.click(screen.getByText('toggle chat flyout fullscreen'));
expect(screen.queryByText('fullscreen mode')).toBeInTheDocument();
});

it('should focus in chat input when click and press Escape should blur', () => {
render(
<HeaderChatButton
application={applicationServiceMock.createStartContract()}
userHasAccess={true}
contentRenderers={{}}
actionExecutors={{}}
assistantActions={{} as AssistantActions}
currentAccount={{ username: 'test_user', tenant: 'test_tenant' }}
/>
);
screen.getByLabelText('chat input').focus();
expect(screen.getByLabelText('chat input')).toHaveFocus();
expect(screen.getByTitle('press enter to chat')).toBeInTheDocument();

fireEvent.keyUp(screen.getByLabelText('chat input'), {
key: 'Escape',
code: 'Escape',
charCode: 27,
});
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
expect(screen.getByTitle('press ⌘ + / to start typing')).toBeInTheDocument();
});

it('should focus on chat input when pressing global shortcut', () => {
render(
<HeaderChatButton
application={applicationServiceMock.createStartContract()}
userHasAccess={true}
contentRenderers={{}}
actionExecutors={{}}
assistantActions={{} as AssistantActions}
currentAccount={{ username: 'test_user', tenant: 'test_tenant' }}
/>
);
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
fireEvent.keyDown(document.body, {
key: '/',
code: 'NumpadDivide',
charCode: 111,
metaKey: true,
});
expect(screen.getByLabelText('chat input')).toHaveFocus();
});
});
22 changes: 18 additions & 4 deletions public/chat_header_button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface HeaderChatButtonProps {

let flyoutLoaded = false;

export const HeaderChatButton: React.FC<HeaderChatButtonProps> = (props) => {
export const HeaderChatButton = (props: HeaderChatButtonProps) => {
const [appId, setAppId] = useState<string>();
const [sessionId, setSessionId] = useState<string>();
const [title, setTitle] = useState<string>();
Expand Down Expand Up @@ -144,6 +144,7 @@ export const HeaderChatButton: React.FC<HeaderChatButtonProps> = (props) => {
<>
<div className={classNames('llm-chat-header-icon-wrapper')}>
<EuiFieldText
aria-label="chat input"
inputRef={inputRef}
compressed
value={query}
Expand All @@ -154,16 +155,29 @@ export const HeaderChatButton: React.FC<HeaderChatButtonProps> = (props) => {
onKeyPress={onKeyPress}
onKeyUp={onKeyUp}
prepend={
<EuiIcon type={chatIcon} size="l" onClick={() => setFlyoutVisible(!flyoutVisible)} />
<EuiIcon
aria-label="toggle chat flyout icon"
type={chatIcon}
size="l"
onClick={() => setFlyoutVisible(!flyoutVisible)}
/>
}
append={
<span className="llm-chat-header-shortcut">
{inputFocus ? (
<EuiBadge className="llm-chat-header-shortcut-enter" color="hollow">
<EuiBadge
title="press enter to chat"
className="llm-chat-header-shortcut-enter"
color="hollow"
>
</EuiBadge>
) : (
<EuiBadge className="llm-chat-header-shortcut-cmd" color="hollow">
<EuiBadge
title="press ⌘ + / to start typing"
Copy link
Member

@SuZhou-Joe SuZhou-Joe Dec 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know if the text will confuse Windows user. As well as that if we need to do any special check logic on Windows browser.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we have a task to improve this

className="llm-chat-header-shortcut-cmd"
color="hollow"
>
⌘ + /
</EuiBadge>
)}
Expand Down
2 changes: 1 addition & 1 deletion public/contexts/set_context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface SetContextProps {
}

// TODO needs a better solution to expose hook
export const SetContext: React.FC<SetContextProps> = (props) => {
export const SetContext = (props: SetContextProps) => {
Object.assign(props.assistantActions, useChatActions());
return null;
};
7 changes: 2 additions & 5 deletions public/hooks/use_chat_actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,18 @@ import { SessionLoadService } from '../services/session_load_service';
import * as chatStateHookExports from './use_chat_state';
import { ASSISTANT_API } from '../../common/constants/llm';

const mockSessionsLoad = jest.fn();
const mockSessionLoad = jest.fn().mockReturnValue({ messages: [], interactions: [] });

jest.mock('../services/sessions_service', () => {
return {
SessionsService: jest.fn().mockImplementation(() => {
return { reload: mockSessionsLoad };
return { reload: jest.fn() };
}),
};
});

jest.mock('../services/session_load_service', () => {
return {
SessionLoadService: jest.fn().mockImplementation(() => {
return { load: mockSessionLoad };
return { load: jest.fn().mockReturnValue({ messages: [], interactions: [] }) };
}),
};
});
Expand Down
2 changes: 1 addition & 1 deletion public/hooks/use_chat_state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const chatStateReducer: React.Reducer<ChatState, ChatStateAction> = (state, acti
}
});

export const ChatStateProvider: React.FC = (props) => {
export const ChatStateProvider = (props: { children?: React.ReactNode }) => {
const [chatState, chatStateDispatch] = useReducer(chatStateReducer, initialState);
const contextValue: IChatStateContext = useMemo(() => ({ chatState, chatStateDispatch }), [
chatState,
Expand Down
2 changes: 1 addition & 1 deletion public/tabs/chat_window_header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ChatWindowHeaderProps {
toggleFlyoutFullScreen: () => void;
}

export const ChatWindowHeader: React.FC<ChatWindowHeaderProps> = React.memo((props) => {
export const ChatWindowHeader = React.memo((props: ChatWindowHeaderProps) => {
const chatContext = useChatContext();

const dockBottom = () => (
Expand Down
Loading