Коммит 317dcdef создал по автору Stanislav Lashmanov's avatar Stanislav Lashmanov
Просмотр файлов

Refactor AI Genie components

Add more tests
владелец 4a9997ac
/* eslint-disable jest/no-disabled-tests */
<script>
import { debounce } from 'lodash';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
......@@ -13,12 +12,11 @@ export const i18n = {
};
export const AI_GENIE_DEBOUNCE = 300;
const regex = /^\d+$\n/gm;
let lastSelectedText = '';
const linesWithDigitsOnly = /^\d+$\n/gm;
export default {
name: 'AiGenie',
i18n,
components: {
GlButton,
AiGenieChat,
......@@ -44,78 +42,64 @@ export default {
codeExplanationLoading: false,
codeExplanationError: '',
selectedText: '',
snippetLanguage: 'text',
snippetLanguage: undefined,
shouldShowButton: false,
container: null,
top: null,
};
},
computed: {
i18n() {
return i18n;
},
shouldShowChat() {
return this.codeExplanation || this.codeExplanationLoading || this.codeExplanationError;
},
rootStyle() {
if (!this.top) return null;
return { top: `${this.top}px` };
},
},
created() {
this.debouncedSelectionChangeHandler = debounce(this.handleSelectionChange, AI_GENIE_DEBOUNCE);
},
mounted() {
this.container = document.getElementById(this.containerId);
this.addSelectionChangeListener();
if (!this.container) {
throw new Error(s__("AI|The container element wasn't found, stopping AI Genie."));
}
this.snippetLanguage = this.container.querySelector('[lang]')?.lang;
document.addEventListener('selectionchange', this.debouncedSelectionChangeHandler);
},
beforeDestroy() {
this.removeSelectionChangeListener();
document.removeEventListener('selectionchange', this.debouncedSelectionChangeHandler);
},
methods: {
addSelectionChangeListener() {
document.addEventListener('selectionchange', this.debouncedSelectionChangeHandler);
},
removeSelectionChangeListener() {
document.removeEventListener('selectionchange', this.debouncedSelectionChangeHandler);
},
// eslint-disable-next-line func-names
debouncedSelectionChangeHandler: debounce(function () {
if (this.container.contains(window.getSelection().anchorNode)) {
lastSelectedText =
this.selectedText || window.getSelection().toString().replace(regex, '').trim();
this.positionCodeGenieInContainer();
handleSelectionChange() {
const selection = window.getSelection();
if (this.isWithinContainer(selection)) {
this.setPosition(selection);
this.shouldShowButton = true;
} else {
this.shouldShowButton = false;
}
}, AI_GENIE_DEBOUNCE),
updateSelectedText() {
this.selectedText = window.getSelection().toString().replace(regex, '').trim();
},
positionCodeGenieInContainer() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
this.shouldShowButton = false;
return;
}
if (
!this.container ||
!this.container.contains(selection.anchorNode) ||
!this.container.contains(selection.focusNode)
) {
this.shouldShowButton = false;
return;
}
isWithinContainer(selection) {
return (
!selection.isCollapsed &&
this.container.contains(selection.anchorNode) &&
this.container.contains(selection.focusNode)
);
},
setPosition(selection) {
const { top: startSelectionTop } = selection.getRangeAt(0).getBoundingClientRect();
const { top: finishSelectionTop } = selection
.getRangeAt(selection.rangeCount - 1)
.getBoundingClientRect();
const { top: containerTop } = this.container.getBoundingClientRect();
const top = Math.min(startSelectionTop, finishSelectionTop) - containerTop;
this.$el.style.top = `${top}px`;
this.shouldShowButton = true;
this.selectedText = lastSelectedText;
this.snippetLanguage = this.container.querySelector('[lang]')?.lang;
this.top = Math.min(startSelectionTop, finishSelectionTop) - containerTop;
},
async requestCodeExplanation() {
this.codeExplanationLoading = true;
this.updateSelectedText();
this.selectedText = window.getSelection().toString().replace(linesWithDigitsOnly, '').trim();
try {
const aiResponse = await explainCode(this.selectedText, this.filePath);
const {
......@@ -132,11 +116,11 @@ export default {
};
</script>
<template>
<div class="gl-absolute gl-z-index-9999 gl-mx-n3">
<div class="gl-absolute gl-z-index-9999 gl-mx-n3" :style="rootStyle">
<gl-button
v-show="shouldShowButton"
v-gl-tooltip
:title="i18n.GENIE_TOOLTIP"
:title="$options.i18n.GENIE_TOOLTIP"
category="tertiary"
variant="default"
icon="question"
......
......@@ -11,6 +11,7 @@ const i18n = {
export default {
name: 'AiGenieChat',
i18n,
components: {
GlButton,
GlAlert,
......@@ -52,11 +53,6 @@ export default {
forceHiddenCodeExplanation: false,
};
},
computed: {
i18n() {
return i18n;
},
},
watch: {
isLoading(newVal) {
if (newVal) {
......@@ -80,14 +76,14 @@ export default {
data-testid="chat-component"
>
<header class="gl-p-5 gl-display-flex gl-justify-content-space-between gl-align-items-center">
<h3 class="gl-font-base gl-m-0">{{ i18n.GENIE_TITLE }}</h3>
<h3 class="gl-font-base gl-m-0">{{ $options.i18n.GENIE_TITLE }}</h3>
<gl-button
category="tertiary"
variant="default"
icon="close"
size="small"
class="gl-p-0! gl-ml-2"
:aria-label="i18n.GENIE_CLOSE_LABEL"
:aria-label="$options.i18n.GENIE_CLOSE_LABEL"
@click="closeCodeExplanation"
/>
</header>
......
/* eslint-disable jest/no-disabled-tests */
import { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui';
import AiGenie, { i18n } from 'ee/ai/components/ai_genie.vue';
......@@ -12,11 +11,17 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
jest.mock('ee/ai/utils');
jest.mock('~/notes/utils');
const SELECTION_START_POSITION = 50;
const SELECTION_END_POSITION = 150;
const CONTAINER_TOP = 20;
const SELECTED_TEXT = 'Foo';
describe('AiGenie', () => {
let wrapper;
const containerId = 'container';
const language = 'vue';
const getContainer = () => document.getElementById(containerId);
const createComponent = (propsData = {}) => {
wrapper = shallowMountExtended(AiGenie, {
propsData,
......@@ -27,29 +32,30 @@ describe('AiGenie', () => {
};
const findButton = () => wrapper.findComponent(GlButton);
const findGenieChat = () => wrapper.findComponent(AiGenieChat);
const getSelectionMock = () => {
const getRangeAtMock = (top = () => 0) => {
return jest.fn((rangePosition) => {
return {
getBoundingClientRect: jest.fn(() => {
return {
top: top(rangePosition),
left: 0,
right: 0,
bottom: 0,
};
}),
};
});
};
const getSelectionMock = ({ getRangeAt = getRangeAtMock(), toString = () => {} } = {}) => {
return {
anchorNode: document.getElementById('first-paragraph'),
isCollapsed: false,
getRangeAt: jest.fn().mockImplementation(() => {
return {
getBoundingClientRect: jest.fn().mockImplementation(() => {
return {
top: 0,
left: 0,
right: 0,
bottom: 0,
};
}),
};
}),
rangeCount: 1,
toString: function toString() {
return 'Foo';
},
getRangeAt,
rangeCount: 10,
toString,
};
};
const simulateSelectionEvent = () => {
const selectionChangeEvent = new Event('selectionchange');
document.dispatchEvent(selectionChangeEvent);
......@@ -60,9 +66,12 @@ describe('AiGenie', () => {
jest.runOnlyPendingTimers();
};
const simulateSelectText = async () => {
jest.spyOn(window, 'getSelection').mockImplementation(() => getSelectionMock());
jest.spyOn(document.getElementById(containerId), 'contains').mockImplementation(() => true);
const simulateSelectText = async ({
contains = true,
getSelection = getSelectionMock(),
} = {}) => {
jest.spyOn(window, 'getSelection').mockImplementation(() => getSelection);
jest.spyOn(document.getElementById(containerId), 'contains').mockImplementation(() => contains);
simulateSelectionEvent();
await waitForDebounce();
};
......@@ -76,9 +85,6 @@ describe('AiGenie', () => {
setHTMLFixture(
`<div id="${containerId}" style="height:1000px; width: 800px"><p lang=${language} id="first-paragraph">Foo</p></div>`,
);
createComponent({
containerId,
});
});
afterEach(() => {
......@@ -86,16 +92,27 @@ describe('AiGenie', () => {
});
it('correctly renders the component by default', () => {
createComponent({ containerId });
expect(findButton().exists()).toBe(true);
expect(findGenieChat().exists()).toBe(false);
});
it('correctly finds the container with the passed ID', () => {
expect(wrapper.vm.container).toBeDefined();
expect(wrapper.vm.container).toBe(document.getElementById(containerId));
it('throws when container is not found', () => {
const spy = jest.spyOn(console, 'error').mockImplementation();
try {
createComponent({ containerId: 'xxx' });
} catch (error) {
expect(error.message).toBe("The container element wasn't found, stopping AI Genie.");
} finally {
spy.mockRestore();
}
});
describe('the toggle button', () => {
beforeEach(() => {
createComponent({ containerId });
});
it('is hidden by default, yet gets the correct props', () => {
const btnWrapper = findButton();
expect(btnWrapper.isVisible()).toBe(false);
......@@ -107,7 +124,31 @@ describe('AiGenie', () => {
expect(findButton().isVisible()).toBe(true);
});
it.skip('is positioned correctly at the top of the selection', () => {});
describe('toggle position', () => {
beforeEach(() => {
jest.spyOn(getContainer(), 'getBoundingClientRect').mockImplementation(() => {
return { top: CONTAINER_TOP };
});
});
it('is positioned correctly at the start of the selection', async () => {
const getRangeAt = getRangeAtMock((position) => {
return position === 0 ? SELECTION_START_POSITION : SELECTION_END_POSITION;
});
const getSelection = getSelectionMock({ getRangeAt });
await simulateSelectText({ getSelection });
expect(wrapper.element.style.top).toBe(`${SELECTION_START_POSITION - CONTAINER_TOP}px`);
});
it('is positioned correctly at the end of the selection', async () => {
const getRangeAt = getRangeAtMock((position) => {
return position === 0 ? SELECTION_END_POSITION : SELECTION_START_POSITION;
});
const getSelection = getSelectionMock({ getRangeAt });
await simulateSelectText({ getSelection });
expect(wrapper.element.style.top).toBe(`${SELECTION_START_POSITION - CONTAINER_TOP}px`);
});
});
});
describe('selectionchange event listener', () => {
......@@ -117,9 +158,7 @@ describe('AiGenie', () => {
beforeEach(() => {
addEventListenerSpy = jest.spyOn(document, 'addEventListener');
removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
createComponent({
containerId,
});
createComponent({ containerId });
});
afterEach(() => {
......@@ -128,23 +167,21 @@ describe('AiGenie', () => {
});
it('sets up the `selectionchange` event listener', () => {
expect(addEventListenerSpy).toHaveBeenCalledWith(
'selectionchange',
wrapper.vm.debouncedSelectionChangeHandler,
);
expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', expect.any(Function));
expect(removeEventListenerSpy).not.toHaveBeenCalled();
});
it('removes the event listener when destroyed', () => {
wrapper.destroy();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'selectionchange',
wrapper.vm.debouncedSelectionChangeHandler,
);
expect(removeEventListenerSpy).toHaveBeenCalledWith('selectionchange', expect.any(Function));
});
});
describe('interaction', () => {
beforeEach(() => {
createComponent({ containerId });
});
it('toggles genie when the button is clicked', async () => {
findButton().vm.$emit('click');
await nextTick();
......@@ -169,9 +206,11 @@ describe('AiGenie', () => {
});
it('when a snippet is selected, :selected-text gets the same content', async () => {
await simulateSelectText();
const toString = () => SELECTED_TEXT;
const getSelection = getSelectionMock({ toString });
await simulateSelectText({ getSelection });
await requestExplanation();
expect(findGenieChat().props().selectedText).toBe('Foo');
expect(findGenieChat().props().selectedText).toBe(SELECTED_TEXT);
});
it('sets the snippet language', async () => {
......
......@@ -1851,6 +1851,21 @@ msgstr ""
msgid "AI|What does the selected code mean?"
msgstr ""
 
msgid "AI|Close the Code Explanation"
msgstr ""
msgid "AI|Code Explanation"
msgstr ""
msgid "AI|Explain the code from %{filePath} in human understandable language presented in Markdown format. In the response add neither original code snippet nor any title. `%{text}`"
msgstr ""
msgid "AI|The container element wasn't found, stopping AI Genie."
msgstr ""
msgid "AI|What does the selected code mean?"
msgstr ""
msgid "API"
msgstr ""
 
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать