Improve unit tests & fix tag adding

This commit is contained in:
Oleg Ivaniv 2024-12-06 17:07:19 +01:00
parent a6b49f2dbd
commit 19d575cc30
No known key found for this signature in database
9 changed files with 583 additions and 265 deletions

View file

@ -1,5 +1,6 @@
import type { IRestApiContext } from '@/Interface'; import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
export interface TestDefinitionRecord { export interface TestDefinitionRecord {
id: string; id: string;
name: string; name: string;
@ -9,7 +10,9 @@ export interface TestDefinitionRecord {
description?: string | null; description?: string | null;
updatedAt?: string; updatedAt?: string;
createdAt?: string; createdAt?: string;
annotationTag: string | null;
} }
interface CreateTestDefinitionParams { interface CreateTestDefinitionParams {
name: string; name: string;
workflowId: string; workflowId: string;
@ -22,16 +25,17 @@ export interface UpdateTestDefinitionParams {
annotationTagId?: string | null; annotationTagId?: string | null;
description?: string | null; description?: string | null;
} }
export interface UpdateTestResponse { export interface UpdateTestResponse {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
id: string; id: string;
name: string; name: string;
workflowId: string; workflowId: string;
description: string | null; description?: string | null;
annotationTag: string | null; annotationTag?: string | null;
evaluationWorkflowId: string | null; evaluationWorkflowId?: string | null;
annotationTagId: string | null; annotationTagId?: string | null;
} }
const endpoint = '/evaluation/test-definitions'; const endpoint = '/evaluation/test-definitions';

View file

@ -1,18 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { EditableField } from '../types';
export interface EvaluationHeaderProps { export interface EvaluationHeaderProps {
modelValue: { modelValue: EditableField<string>;
value: string; startEditing: (field: 'name') => void;
isEditing: boolean; saveChanges: (field: 'name') => void;
tempValue: string; handleKeydown: (e: KeyboardEvent, field: 'name') => void;
};
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
handleKeydown: (e: KeyboardEvent, field: string) => void;
} }
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>(); defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
defineProps<EvaluationHeaderProps>(); defineProps<EvaluationHeaderProps>();
const locale = useI18n(); const locale = useI18n();

View file

@ -3,25 +3,24 @@ import { useI18n } from '@/composables/useI18n';
import type { ITag } from '@/Interface'; import type { ITag } from '@/Interface';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import { computed } from 'vue'; import { computed } from 'vue';
import type { EditableField } from '../types';
export interface TagsInputProps { export interface TagsInputProps {
modelValue?: { modelValue: EditableField<string[]>;
isEditing: boolean;
appliedTagIds: string[];
};
allTags: ITag[]; allTags: ITag[];
tagsById: Record<string, ITag>; tagsById: Record<string, ITag>;
isLoading: boolean; isLoading: boolean;
startEditing: (field: string) => void; startEditing: (field: 'tags') => void;
saveChanges: (field: string) => void; saveChanges: (field: 'tags') => void;
cancelEditing: (field: string) => void; cancelEditing: (field: 'tags') => void;
createTag?: (name: string) => Promise<ITag>; createTag?: (name: string) => Promise<ITag>;
} }
const props = withDefaults(defineProps<TagsInputProps>(), { const props = withDefaults(defineProps<TagsInputProps>(), {
modelValue: () => ({ modelValue: () => ({
isEditing: false, isEditing: false,
appliedTagIds: [], value: [],
tempValue: [],
}), }),
createTag: undefined, createTag: undefined,
}); });
@ -30,15 +29,22 @@ const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelVal
const locale = useI18n(); const locale = useI18n();
const tagsEventBus = createEventBus(); const tagsEventBus = createEventBus();
/**
* Compute the tag name by ID
*/
const getTagName = computed(() => (tagId: string) => { const getTagName = computed(() => (tagId: string) => {
return props.tagsById[tagId]?.name ?? ''; return props.tagsById[tagId]?.name ?? '';
}); });
/**
* Update the tempValue of the tags when the dropdown changes.
* This does not finalize the changes; that happens on blur or hitting enter.
*/
function updateTags(tags: string[]) { function updateTags(tags: string[]) {
const newTags = tags[0] ? [tags[0]] : [];
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
appliedTagIds: newTags, tempValue: tags,
}); });
} }
</script> </script>
@ -50,12 +56,13 @@ function updateTags(tags: string[]) {
:bold="false" :bold="false"
size="small" size="small"
> >
<!-- Read-only view -->
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')"> <div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
<n8n-text v-if="modelValue.appliedTagIds.length === 0" size="small"> <n8n-text v-if="modelValue.value.length === 0" size="small">
{{ locale.baseText('testDefinition.edit.selectTag') }} {{ locale.baseText('testDefinition.edit.selectTag') }}
</n8n-text> </n8n-text>
<n8n-tag <n8n-tag
v-for="tagId in modelValue.appliedTagIds" v-for="tagId in modelValue.value"
:key="tagId" :key="tagId"
:text="getTagName(tagId)" :text="getTagName(tagId)"
data-test-id="evaluation-tag-field" data-test-id="evaluation-tag-field"
@ -68,11 +75,13 @@ function updateTags(tags: string[]) {
transparent transparent
/> />
</div> </div>
<!-- Editing view -->
<TagsDropdown <TagsDropdown
v-else v-else
:model-value="modelValue.appliedTagIds" :model-value="modelValue.tempValue"
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')" :placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="modelValue.appliedTagIds.length === 0" :create-enabled="modelValue.tempValue.length === 0"
:all-tags="allTags" :all-tags="allTags"
:is-loading="isLoading" :is-loading="isLoading"
:tags-by-id="tagsById" :tags-by-id="tagsById"

View file

@ -1,30 +1,10 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import type { ComponentPublicInstance } from 'vue'; import type { ComponentPublicInstance, ComputedRef } from 'vue';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue'; import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
import type { N8nInput } from 'n8n-design-system'; import type { N8nInput } from 'n8n-design-system';
import type { TestMetricRecord, UpdateTestDefinitionParams } from '@/api/testDefinition.ee'; import type { UpdateTestDefinitionParams } from '@/api/testDefinition.ee';
import type { ITag } from '@/Interface'; import type { EditableField, EditableFormState, EvaluationFormState } from '../types';
import { useAnnotationTagsStore } from '@/stores/tags.store';
// import type { TestMetricRecord } from '@/api/testMetric.ee';
interface EditableField {
value: string;
isEditing: boolean;
tempValue: string;
}
export interface IEvaluationFormState {
name: EditableField;
description: string;
tags: {
isEditing: boolean;
appliedTagIds: string[];
};
evaluationWorkflow: INodeParameterResourceLocator;
metrics: TestMetricRecord[];
}
type FormRefs = { type FormRefs = {
nameInput: ComponentPublicInstance<typeof N8nInput>; nameInput: ComponentPublicInstance<typeof N8nInput>;
@ -32,22 +12,21 @@ type FormRefs = {
}; };
export function useTestDefinitionForm() { export function useTestDefinitionForm() {
// Stores
const evaluationsStore = useTestDefinitionStore(); const evaluationsStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore();
// Form state // State initialization
const state = ref<IEvaluationFormState>({ const state = ref<EvaluationFormState>({
description: '',
name: { name: {
value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`, value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`,
isEditing: false,
tempValue: '', tempValue: '',
isEditing: false,
}, },
tags: { tags: {
value: [],
tempValue: [],
isEditing: false, isEditing: false,
appliedTagIds: [],
}, },
description: '',
evaluationWorkflow: { evaluationWorkflow: {
mode: 'list', mode: 'list',
value: '', value: '',
@ -56,45 +35,48 @@ export function useTestDefinitionForm() {
metrics: [], metrics: [],
}); });
// Loading states
const isSaving = ref(false); const isSaving = ref(false);
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]); const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
// Field refs
const fields = ref<FormRefs>({} as FormRefs); const fields = ref<FormRefs>({} as FormRefs);
const tagIdToITag = (tagId: string) => { // A computed mapping of editable fields to their states
return tagsStore.tagsById[tagId]; // This ensures TS knows the exact type of each field.
}; const editableFields: ComputedRef<{
// Methods name: EditableField<string>;
tags: EditableField<string[]>;
}> = computed(() => ({
name: state.value.name,
tags: state.value.tags,
}));
/**
* Load test data including metrics.
*/
const loadTestData = async (testId: string) => { const loadTestData = async (testId: string) => {
try { try {
await evaluationsStore.fetchAll({ force: true }); await evaluationsStore.fetchAll({ force: true });
const testDefinition = evaluationsStore.testDefinitionsById[testId]; const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) { if (testDefinition) {
// Fetch metrics for this test definition
const metrics = await evaluationsStore.fetchMetrics(testId); const metrics = await evaluationsStore.fetchMetrics(testId);
console.log('Loaded metrics:', metrics);
state.value = { state.value.description = testDefinition.description ?? '';
description: testDefinition.description ?? '', state.value.name = {
name: { value: testDefinition.name ?? '',
value: testDefinition.name ?? '', isEditing: false,
isEditing: false, tempValue: '',
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
},
evaluationWorkflow: {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
},
metrics, // Use the fetched metrics
}; };
state.value.tags = {
isEditing: false,
value: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
tempValue: [],
};
state.value.evaluationWorkflow = {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
};
state.value.metrics = metrics;
} }
} catch (error) { } catch (error) {
console.error('Failed to load test data', error); console.error('Failed to load test data', error);
@ -108,17 +90,12 @@ export function useTestDefinitionForm() {
fieldsIssues.value = []; fieldsIssues.value = [];
try { try {
// Prepare parameters for creating a new test
const params = { const params = {
name: state.value.name.value, name: state.value.name.value,
workflowId, workflowId,
description: state.value.description, description: state.value.description,
}; };
return await evaluationsStore.create(params);
const newTest = await evaluationsStore.create(params);
return newTest;
} catch (error) {
throw error;
} finally { } finally {
isSaving.value = false; isSaving.value = false;
} }
@ -130,9 +107,8 @@ export function useTestDefinitionForm() {
}; };
const updateMetrics = async (testId: string) => { const updateMetrics = async (testId: string) => {
const updatePromises = state.value.metrics.map(async (metric) => { const promises = state.value.metrics.map(async (metric) => {
if (!metric.name) return; if (!metric.name) return;
if (!metric.id) { if (!metric.id) {
const createdMetric = await evaluationsStore.createMetric({ const createdMetric = await evaluationsStore.createMetric({
name: metric.name, name: metric.name,
@ -148,7 +124,7 @@ export function useTestDefinitionForm() {
} }
}); });
await Promise.all(updatePromises); await Promise.all(promises);
}; };
const updateTest = async (testId: string) => { const updateTest = async (testId: string) => {
@ -158,64 +134,82 @@ export function useTestDefinitionForm() {
fieldsIssues.value = []; fieldsIssues.value = [];
try { try {
// Check if the test ID is provided
if (!testId) { if (!testId) {
throw new Error('Test ID is required for updating a test'); throw new Error('Test ID is required for updating a test');
} }
// Prepare parameters for updating the existing test
const params: UpdateTestDefinitionParams = { const params: UpdateTestDefinitionParams = {
name: state.value.name.value, name: state.value.name.value,
description: state.value.description, description: state.value.description,
}; };
if (state.value.evaluationWorkflow.value) { if (state.value.evaluationWorkflow.value) {
params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString(); params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString();
} }
const annotationTagId = state.value.tags?.[0]?.id; const annotationTagId = state.value.tags.value[0];
if (annotationTagId) { if (annotationTagId) {
params.annotationTagId = annotationTagId; params.annotationTagId = annotationTagId;
} }
// Update the existing test
const updatedTest = await evaluationsStore.update({ ...params, id: testId });
return updatedTest; return await evaluationsStore.update({ ...params, id: testId });
} catch (error) {
throw error;
} finally { } finally {
isSaving.value = false; isSaving.value = false;
} }
}; };
const startEditing = async (field: string) => { /**
if (field === 'name') { * Start editing an editable field by copying `value` to `tempValue`.
state.value.name.tempValue = state.value.name.value; */
state.value.name.isEditing = true; function startEditing<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
if (fieldObj.isEditing) {
// Already editing, do nothing
return;
} }
};
const saveChanges = (field: string) => { if (Array.isArray(fieldObj.value)) {
if (field === 'name') { fieldObj.tempValue = [...fieldObj.value];
state.value.name.value = state.value.name.tempValue; } else {
state.value.name.isEditing = false; fieldObj.tempValue = fieldObj.value;
} }
}; fieldObj.isEditing = true;
}
/**
* Save changes by copying `tempValue` back into `value`.
*/
function saveChanges<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
fieldObj.value = Array.isArray(fieldObj.tempValue)
? [...fieldObj.tempValue]
: fieldObj.tempValue;
fieldObj.isEditing = false;
}
const cancelEditing = (field: string) => { /**
if (field === 'name') { * Cancel editing and revert `tempValue` from `value`.
state.value.name.isEditing = false; */
state.value.name.tempValue = ''; function cancelEditing<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
if (Array.isArray(fieldObj.value)) {
fieldObj.tempValue = [...fieldObj.value];
} else {
fieldObj.tempValue = fieldObj.value;
} }
}; fieldObj.isEditing = false;
}
const handleKeydown = (event: KeyboardEvent, field: string) => { /**
* Handle keyboard events during editing.
*/
function handleKeydown<T extends keyof EditableFormState>(event: KeyboardEvent, field: T) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
cancelEditing(field); cancelEditing(field);
} else if (event.key === 'Enter' && !event.shiftKey) { } else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
saveChanges(field); saveChanges(field);
} }
}; }
return { return {
state, state,

View file

@ -6,11 +6,11 @@ import userEvent from '@testing-library/user-event';
const renderComponent = createComponentRenderer(MetricsInput); const renderComponent = createComponentRenderer(MetricsInput);
describe('MetricsInput', () => { describe('MetricsInput', () => {
let props: { modelValue: string[] }; let props: { modelValue: Array<{ name: string }> };
beforeEach(() => { beforeEach(() => {
props = { props = {
modelValue: ['Metric 1', 'Metric 2'], modelValue: [{ name: 'Metric 1' }, { name: 'Metric 2' }],
}; };
}); });
@ -25,14 +25,18 @@ describe('MetricsInput', () => {
it('should update a metric when typing in the input', async () => { it('should update a metric when typing in the input', async () => {
const { getAllByPlaceholderText, emitted } = renderComponent({ const { getAllByPlaceholderText, emitted } = renderComponent({
props: { props: {
modelValue: [''], modelValue: [{ name: '' }],
}, },
}); });
const inputs = getAllByPlaceholderText('Enter metric name'); const inputs = getAllByPlaceholderText('Enter metric name');
await userEvent.type(inputs[0], 'Updated Metric 1'); await userEvent.type(inputs[0], 'Updated Metric 1');
expect(emitted('update:modelValue')).toBeTruthy(); // Every character typed triggers an update event. Let's check the last emission.
expect(emitted('update:modelValue')).toEqual('Updated Metric 1'.split('').map((c) => [[c]])); const allEmits = emitted('update:modelValue');
expect(allEmits).toBeTruthy();
// The last emission should contain the fully updated name
const lastEmission = allEmits[allEmits.length - 1];
expect(lastEmission).toEqual([[{ name: 'Updated Metric 1' }]]);
}); });
it('should render correctly with no initial metrics', () => { it('should render correctly with no initial metrics', () => {
@ -47,10 +51,95 @@ describe('MetricsInput', () => {
const { getByText, emitted } = renderComponent({ props }); const { getByText, emitted } = renderComponent({ props });
const addButton = getByText('New metric'); const addButton = getByText('New metric');
addButton.click(); await userEvent.click(addButton);
addButton.click(); await userEvent.click(addButton);
addButton.click(); await userEvent.click(addButton);
expect(emitted('update:modelValue')).toHaveProperty('length', 3); // Each click adds a new metric
const updateEvents = emitted('update:modelValue');
expect(updateEvents).toHaveLength(3);
// Check the structure of one of the emissions
// Initial: [{ name: 'Metric 1' }, { name: 'Metric 2' }]
// After first click: [{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]
expect(updateEvents[0]).toEqual([[{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]]);
});
it('should emit "deleteMetric" event when a delete button is clicked', async () => {
const { getAllByRole, emitted } = renderComponent({ props });
// Each metric row has a delete button, identified by "button"
const deleteButtons = getAllByRole('button', { name: '' });
// Since these are icon buttons, if you have trouble querying by role/name,
// consider adding a test-id or using `getAllByTestId('evaluation-metric-item')`
// and navigate to its sibling button.
expect(deleteButtons).toHaveLength(props.modelValue.length);
// Click on the delete button for the second metric
await userEvent.click(deleteButtons[1]);
expect(emitted('deleteMetric')).toBeTruthy();
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 2' }]);
});
it('should emit multiple update events as the user types and reflect the final name correctly', async () => {
const { getAllByPlaceholderText, emitted } = renderComponent({
props: {
modelValue: [{ name: '' }],
},
});
const inputs = getAllByPlaceholderText('Enter metric name');
await userEvent.type(inputs[0], 'ABC');
const allEmits = emitted('update:modelValue');
expect(allEmits).toBeTruthy();
// Each character typed should emit a new value
expect(allEmits.length).toBe(3);
expect(allEmits[2]).toEqual([[{ name: 'ABC' }]]);
});
it('should not break if metrics are empty and still allow adding a new metric', async () => {
props.modelValue = [];
const { queryAllByRole, getByText, emitted } = renderComponent({ props });
// No metrics initially
const inputs = queryAllByRole('textbox');
expect(inputs).toHaveLength(0);
const addButton = getByText('New metric');
await userEvent.click(addButton);
const updates = emitted('update:modelValue');
expect(updates).toBeTruthy();
expect(updates[0]).toEqual([[{ name: '' }]]);
// After adding one metric, we should now have an input
const { getAllByPlaceholderText } = renderComponent({
props: { modelValue: [{ name: '' }] },
});
const updatedInputs = getAllByPlaceholderText('Enter metric name');
expect(updatedInputs).toHaveLength(1);
});
it('should handle deleting the first metric and still display remaining metrics correctly', async () => {
const { getAllByPlaceholderText, getAllByRole, rerender, emitted } = renderComponent({
props,
});
const inputs = getAllByPlaceholderText('Enter metric name');
expect(inputs).toHaveLength(2);
const deleteButtons = getAllByRole('button', { name: '' });
await userEvent.click(deleteButtons[0]);
// Verify the "deleteMetric" event
expect(emitted('deleteMetric')).toBeTruthy();
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 1' }]);
// Re-render with the updated props (simulating parent update)
await rerender({ modelValue: [{ name: 'Metric 2' }] });
const updatedInputs = getAllByPlaceholderText('Enter metric name');
expect(updatedInputs).toHaveLength(1);
expect(updatedInputs[0]).toHaveValue('Metric 2');
}); });
}); });

View file

@ -12,18 +12,21 @@ const TEST_DEF_A: TestDefinitionRecord = {
evaluationWorkflowId: '456', evaluationWorkflowId: '456',
workflowId: '123', workflowId: '123',
annotationTagId: '789', annotationTagId: '789',
annotationTag: null,
}; };
const TEST_DEF_B: TestDefinitionRecord = { const TEST_DEF_B: TestDefinitionRecord = {
id: '2', id: '2',
name: 'Test Definition B', name: 'Test Definition B',
workflowId: '123', workflowId: '123',
description: 'Description B', description: 'Description B',
annotationTag: null,
}; };
const TEST_DEF_NEW: TestDefinitionRecord = { const TEST_DEF_NEW: TestDefinitionRecord = {
id: '3', id: '3',
workflowId: '123', workflowId: '123',
name: 'New Test Definition', name: 'New Test Definition',
description: 'New Description', description: 'New Description',
annotationTag: null,
}; };
beforeEach(() => { beforeEach(() => {
@ -35,44 +38,79 @@ afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('useTestDefinitionForm', async () => { describe('useTestDefinitionForm', () => {
it('should initialize with default props', async () => { it('should initialize with default props', () => {
const { state } = useTestDefinitionForm(); const { state } = useTestDefinitionForm();
expect(state.value.description).toEqual(''); expect(state.value.description).toBe('');
expect(state.value.name.value).toContain('My Test'); expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.appliedTagIds).toEqual([]); expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual(['']); expect(state.value.metrics).toEqual([]);
expect(state.value.evaluationWorkflow.value).toEqual(''); expect(state.value.evaluationWorkflow.value).toBe('');
}); });
it('should load test data', async () => { it('should load test data', async () => {
const { loadTestData, state } = useTestDefinitionForm(); const { loadTestData, state } = useTestDefinitionForm();
const fetchSpy = vi.fn(); const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
const fetchMetricsSpy = vi.spyOn(useTestDefinitionStore(), 'fetchMetrics').mockResolvedValue([
{
id: 'metric1',
name: 'Metric 1',
testDefinitionId: TEST_DEF_A.id,
},
]);
const evaluationsStore = mockedStore(useTestDefinitionStore); const evaluationsStore = mockedStore(useTestDefinitionStore);
expect(state.value.description).toEqual('');
expect(state.value.name.value).toContain('My Test');
evaluationsStore.testDefinitionsById = { evaluationsStore.testDefinitionsById = {
[TEST_DEF_A.id]: TEST_DEF_A, [TEST_DEF_A.id]: TEST_DEF_A,
[TEST_DEF_B.id]: TEST_DEF_B, [TEST_DEF_B.id]: TEST_DEF_B,
}; };
evaluationsStore.fetchAll = fetchSpy;
await loadTestData(TEST_DEF_A.id); await loadTestData(TEST_DEF_A.id);
expect(fetchSpy).toBeCalled(); expect(fetchSpy).toBeCalled();
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
expect(state.value.name.value).toEqual(TEST_DEF_A.name); expect(state.value.name.value).toEqual(TEST_DEF_A.name);
expect(state.value.description).toEqual(TEST_DEF_A.description); expect(state.value.description).toEqual(TEST_DEF_A.description);
expect(state.value.tags.appliedTagIds).toEqual([TEST_DEF_A.annotationTagId]); expect(state.value.tags.value).toEqual([TEST_DEF_A.annotationTagId]);
expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId); expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId);
expect(state.value.metrics).toEqual([
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
]);
});
it('should gracefully handle loadTestData when no test definition found', async () => {
const { loadTestData, state } = useTestDefinitionForm();
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.testDefinitionsById = {};
await loadTestData('unknown-id');
expect(fetchSpy).toBeCalled();
// Should remain unchanged since no definition found
expect(state.value.description).toBe('');
expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual([]);
});
it('should handle errors while loading test data', async () => {
const { loadTestData } = useTestDefinitionForm();
// const fetchSpy = vi.fn().mockRejectedValue(new Error('Fetch Failed'));
const fetchSpy = vi
.spyOn(useTestDefinitionStore(), 'fetchAll')
.mockRejectedValue(new Error('Fetch Failed'));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await loadTestData(TEST_DEF_A.id);
expect(fetchSpy).toBeCalled();
expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error));
consoleErrorSpy.mockRestore();
}); });
it('should save a new test', async () => { it('should save a new test', async () => {
const { createTest, state } = useTestDefinitionForm(); const { createTest, state } = useTestDefinitionForm();
const createSpy = vi.fn().mockResolvedValue(TEST_DEF_NEW); const createSpy = vi.spyOn(useTestDefinitionStore(), 'create').mockResolvedValue(TEST_DEF_NEW);
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.create = createSpy;
state.value.name.value = TEST_DEF_NEW.name; state.value.name.value = TEST_DEF_NEW.name;
state.value.description = TEST_DEF_NEW.description ?? ''; state.value.description = TEST_DEF_NEW.description ?? '';
@ -86,12 +124,24 @@ describe('useTestDefinitionForm', async () => {
expect(newTest).toEqual(TEST_DEF_NEW); expect(newTest).toEqual(TEST_DEF_NEW);
}); });
it('should handle errors when creating a new test', async () => {
const { createTest } = useTestDefinitionForm();
const createSpy = vi
.spyOn(useTestDefinitionStore(), 'create')
.mockRejectedValue(new Error('Create Failed'));
await expect(createTest('123')).rejects.toThrow('Create Failed');
expect(createSpy).toBeCalled();
});
it('should update an existing test', async () => { it('should update an existing test', async () => {
const { updateTest, state } = useTestDefinitionForm(); const { updateTest, state } = useTestDefinitionForm();
const updateSpy = vi.fn().mockResolvedValue(TEST_DEF_B); const updatedBTest = {
const evaluationsStore = mockedStore(useTestDefinitionStore); ...TEST_DEF_B,
updatedAt: '2022-01-01T00:00:00.000Z',
evaluationsStore.update = updateSpy; createdAt: '2022-01-01T00:00:00.000Z',
};
const updateSpy = vi.spyOn(useTestDefinitionStore(), 'update').mockResolvedValue(updatedBTest);
state.value.name.value = TEST_DEF_B.name; state.value.name.value = TEST_DEF_B.name;
state.value.description = TEST_DEF_B.description ?? ''; state.value.description = TEST_DEF_B.description ?? '';
@ -102,75 +152,183 @@ describe('useTestDefinitionForm', async () => {
name: TEST_DEF_B.name, name: TEST_DEF_B.name,
description: TEST_DEF_B.description, description: TEST_DEF_B.description,
}); });
expect(updatedTest).toEqual(TEST_DEF_B); expect(updatedTest).toEqual(updatedBTest);
}); });
it('should start editing a field', async () => { it('should throw an error if no testId is provided when updating a test', async () => {
const { updateTest } = useTestDefinitionForm();
await expect(updateTest('')).rejects.toThrow('Test ID is required for updating a test');
});
it('should handle errors when updating a test', async () => {
const { updateTest, state } = useTestDefinitionForm();
const updateSpy = vi
.spyOn(useTestDefinitionStore(), 'update')
.mockRejectedValue(new Error('Update Failed'));
state.value.name.value = 'Test';
state.value.description = 'Some description';
await expect(updateTest(TEST_DEF_A.id)).rejects.toThrow('Update Failed');
expect(updateSpy).toBeCalled();
});
it('should delete a metric', async () => {
const { state, deleteMetric } = useTestDefinitionForm();
const evaluationsStore = mockedStore(useTestDefinitionStore);
const deleteMetricSpy = vi.spyOn(evaluationsStore, 'deleteMetric');
state.value.metrics = [
{
id: 'metric1',
name: 'Metric 1',
testDefinitionId: '1',
},
{
id: 'metric2',
name: 'Metric 2',
testDefinitionId: '1',
},
];
await deleteMetric('metric1', TEST_DEF_A.id);
expect(deleteMetricSpy).toBeCalledWith({ id: 'metric1', testDefinitionId: TEST_DEF_A.id });
expect(state.value.metrics).toEqual([
{ id: 'metric2', name: 'Metric 2', testDefinitionId: '1' },
]);
});
it('should update metrics', async () => {
const { state, updateMetrics } = useTestDefinitionForm();
const evaluationsStore = mockedStore(useTestDefinitionStore);
const updateMetricSpy = vi.spyOn(evaluationsStore, 'updateMetric');
const createMetricSpy = vi
.spyOn(evaluationsStore, 'createMetric')
.mockResolvedValue({ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id });
state.value.metrics = [
{
id: 'metric1',
name: 'Metric 1',
testDefinitionId: TEST_DEF_A.id,
},
{
id: '',
name: 'Metric 2',
testDefinitionId: TEST_DEF_A.id,
}, // New metric that needs creation
];
await updateMetrics(TEST_DEF_A.id);
expect(createMetricSpy).toHaveBeenCalledWith({
name: 'Metric 2',
testDefinitionId: TEST_DEF_A.id,
});
expect(updateMetricSpy).toHaveBeenCalledWith({
name: 'Metric 1',
id: 'metric1',
testDefinitionId: TEST_DEF_A.id,
});
expect(state.value.metrics).toEqual([
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
{ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id },
]);
});
it('should start editing a field', () => {
const { state, startEditing } = useTestDefinitionForm(); const { state, startEditing } = useTestDefinitionForm();
await startEditing('name'); startEditing('name');
expect(state.value.name.isEditing).toBe(true); expect(state.value.name.isEditing).toBe(true);
expect(state.value.name.tempValue).toBe(state.value.name.value); expect(state.value.name.tempValue).toBe(state.value.name.value);
await startEditing('tags'); startEditing('tags');
expect(state.value.tags.isEditing).toBe(true); expect(state.value.tags.isEditing).toBe(true);
expect(state.value.tags.tempValue).toEqual(state.value.tags.value);
}); });
it('should save changes to a field', async () => { it('should do nothing if startEditing is called while already editing', () => {
const { state, startEditing } = useTestDefinitionForm();
state.value.name.isEditing = true;
state.value.name.tempValue = 'Original Name';
startEditing('name');
// Should remain unchanged because it was already editing
expect(state.value.name.isEditing).toBe(true);
expect(state.value.name.tempValue).toBe('Original Name');
});
it('should save changes to a field', () => {
const { state, startEditing, saveChanges } = useTestDefinitionForm(); const { state, startEditing, saveChanges } = useTestDefinitionForm();
await startEditing('name'); // Name
startEditing('name');
state.value.name.tempValue = 'New Name'; state.value.name.tempValue = 'New Name';
saveChanges('name'); saveChanges('name');
expect(state.value.name.isEditing).toBe(false); expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.value).toBe('New Name'); expect(state.value.name.value).toBe('New Name');
await startEditing('tags'); // Tags
state.value.tags.appliedTagIds = ['123']; startEditing('tags');
state.value.tags.tempValue = ['123'];
saveChanges('tags'); saveChanges('tags');
expect(state.value.tags.isEditing).toBe(false); expect(state.value.tags.isEditing).toBe(false);
expect(state.value.tags.appliedTagIds).toEqual(['123']); expect(state.value.tags.value).toEqual(['123']);
}); });
it('should cancel editing a field', async () => { it('should cancel editing a field', () => {
const { state, startEditing, cancelEditing } = useTestDefinitionForm(); const { state, startEditing, cancelEditing } = useTestDefinitionForm();
await startEditing('name'); const originalName = state.value.name.value;
startEditing('name');
state.value.name.tempValue = 'New Name'; state.value.name.tempValue = 'New Name';
cancelEditing('name'); cancelEditing('name');
expect(state.value.name.isEditing).toBe(false); expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.tempValue).toBe(''); expect(state.value.name.tempValue).toBe(originalName);
await startEditing('tags'); const originalTags = [...state.value.tags.value];
state.value.tags.appliedTagIds = ['123']; startEditing('tags');
state.value.tags.tempValue = ['123'];
cancelEditing('tags'); cancelEditing('tags');
expect(state.value.tags.isEditing).toBe(false); expect(state.value.tags.isEditing).toBe(false);
expect(state.value.tags.tempValue).toEqual(originalTags);
}); });
it('should handle keydown - Escape', async () => { it('should handle keydown - Escape', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm(); const { state, startEditing, handleKeydown } = useTestDefinitionForm();
await startEditing('name'); startEditing('name');
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name'); handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name');
expect(state.value.name.isEditing).toBe(false); expect(state.value.name.isEditing).toBe(false);
await startEditing('tags'); startEditing('tags');
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags'); handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags');
expect(state.value.tags.isEditing).toBe(false); expect(state.value.tags.isEditing).toBe(false);
}); });
it('should handle keydown - Enter', async () => { it('should handle keydown - Enter', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm(); const { state, startEditing, handleKeydown } = useTestDefinitionForm();
await startEditing('name'); startEditing('name');
state.value.name.tempValue = 'New Name'; state.value.name.tempValue = 'New Name';
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name'); handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name');
expect(state.value.name.isEditing).toBe(false); expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.value).toBe('New Name'); expect(state.value.name.value).toBe('New Name');
await startEditing('tags'); startEditing('tags');
state.value.tags.appliedTagIds = ['123']; state.value.tags.tempValue = ['123'];
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags'); handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags');
expect(state.value.tags.isEditing).toBe(false); expect(state.value.tags.isEditing).toBe(false);
expect(state.value.tags.value).toEqual(['123']);
});
it('should not save changes when shift+Enter is pressed', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
startEditing('name');
state.value.name.tempValue = 'New Name With Shift';
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }), 'name');
expect(state.value.name.isEditing).toBe(true);
expect(state.value.name.value).not.toBe('New Name With Shift');
}); });
}); });

View file

@ -1,3 +1,23 @@
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
export interface EditableField<T = string> {
value: T;
tempValue: T;
isEditing: boolean;
}
export interface EditableFormState {
name: EditableField<string>;
tags: EditableField<string[]>;
}
export interface EvaluationFormState extends EditableFormState {
description: string;
evaluationWorkflow: INodeParameterResourceLocator;
metrics: TestMetricRecord[];
}
export interface TestExecution { export interface TestExecution {
lastRun: string | null; lastRun: string | null;
errorRate: number | null; errorRate: number | null;

View file

@ -63,7 +63,8 @@ onMounted(async () => {
await tagsStore.fetchAll(); await tagsStore.fetchAll();
if (testId.value) { if (testId.value) {
await loadTestData(testId.value); await loadTestData(testId.value);
if (state.value.tags.appliedTagIds.length > 0) { // Now tags are in state.tags.value instead of appliedTagIds
if (state.value.tags.value.length > 0) {
await fetchSelectedExecutions(); await fetchSelectedExecutions();
} }
} else { } else {
@ -105,23 +106,24 @@ async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
} }
async function fetchSelectedExecutions() { async function fetchSelectedExecutions() {
// Use state.tags.value for the annotationTags
const executionsForTags = await fetchExecutions({ const executionsForTags = await fetchExecutions({
annotationTags: state.value.tags.appliedTagIds, annotationTags: state.value.tags.value,
}); });
matchedExecutions.value = executionsForTags.results; matchedExecutions.value = executionsForTags.results;
} }
watch( // Debounced watchers for auto-saving
[() => state.value.name, () => state.value.description, () => state.value.evaluationWorkflow], watch([() => state.value.evaluationWorkflow], debounce(onSaveTest, { debounceTime: 400 }), {
debounce(onSaveTest, { debounceTime: 400 }), deep: true,
{ deep: true }, });
);
watch( watch(
[() => state.value.metrics], () => state.value.metrics,
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }), debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }),
{ deep: true }, { deep: true },
); );
async function handleCreateTag(tagName: string) { async function handleCreateTag(tagName: string) {
try { try {
const newTag = await tagsStore.create(tagName); const newTag = await tagsStore.create(tagName);
@ -266,7 +268,6 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
.panelBlock { .panelBlock {
max-width: var(--evaluation-edit-panel-width, 24rem); max-width: var(--evaluation-edit-panel-width, 24rem);
display: grid; display: grid;
justify-items: end; justify-items: end;
} }
.panelIntro { .panelIntro {

View file

@ -1,3 +1,4 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
@ -8,57 +9,73 @@ import { useToast } from '@/composables/useToast';
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm'; import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useAnnotationTagsStore } from '@/stores/tags.store'; import { useAnnotationTagsStore } from '@/stores/tags.store';
import { ref, nextTick } from 'vue'; import { ref, nextTick } from 'vue';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
vi.mock('vue-router'); vi.mock('vue-router');
vi.mock('@/composables/useToast'); vi.mock('@/composables/useToast');
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm'); vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm');
vi.mock('@/stores/tags.store');
vi.mock('@/stores/projects.store'); vi.mock('@/stores/projects.store');
describe('TestDefinitionEditView', () => { describe('TestDefinitionEditView', () => {
const renderComponent = createComponentRenderer(TestDefinitionEditView); const renderComponent = createComponentRenderer(TestDefinitionEditView);
let createTestMock: Mock;
let updateTestMock: Mock;
let loadTestDataMock: Mock;
let deleteMetricMock: Mock;
let updateMetricsMock: Mock;
let showMessageMock: Mock;
let showErrorMock: Mock;
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); setActivePinia(createPinia());
// Default route mock: no testId
vi.mocked(useRoute).mockReturnValue({ vi.mocked(useRoute).mockReturnValue({
params: {}, params: {},
path: '/test-path', name: VIEWS.NEW_TEST_DEFINITION,
name: 'test-route',
} as ReturnType<typeof useRoute>); } as ReturnType<typeof useRoute>);
vi.mocked(useRouter).mockReturnValue({ vi.mocked(useRouter).mockReturnValue({
push: vi.fn(), push: vi.fn(),
replace: vi.fn(),
resolve: vi.fn().mockReturnValue({ href: '/test-href' }), resolve: vi.fn().mockReturnValue({ href: '/test-href' }),
} as unknown as ReturnType<typeof useRouter>); } as unknown as ReturnType<typeof useRouter>);
createTestMock = vi.fn().mockResolvedValue({ id: 'newTestId' });
updateTestMock = vi.fn().mockResolvedValue({});
loadTestDataMock = vi.fn();
deleteMetricMock = vi.fn();
updateMetricsMock = vi.fn();
showMessageMock = vi.fn();
showErrorMock = vi.fn();
vi.mocked(useToast).mockReturnValue({ vi.mocked(useToast).mockReturnValue({
showMessage: vi.fn(), showMessage: showMessageMock,
showError: vi.fn(), showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>); } as unknown as ReturnType<typeof useToast>);
vi.mocked(useTestDefinitionForm).mockReturnValue({ vi.mocked(useTestDefinitionForm).mockReturnValue({
state: ref({ state: ref({
name: { value: '', isEditing: false, tempValue: '' }, name: { value: '', isEditing: false, tempValue: '' },
description: '', description: '',
tags: { appliedTagIds: [], isEditing: false }, tags: { value: [], tempValue: [], isEditing: false },
evaluationWorkflow: { id: '1', name: 'Test Workflow' }, evaluationWorkflow: { mode: 'list', value: '', __rl: true },
metrics: [], metrics: [],
}), }),
fieldsIssues: ref([]), fieldsIssues: ref([]),
isSaving: ref(false), isSaving: ref(false),
loadTestData: vi.fn(), loadTestData: loadTestDataMock,
saveTest: vi.fn(), createTest: createTestMock,
updateTest: updateTestMock,
startEditing: vi.fn(), startEditing: vi.fn(),
saveChanges: vi.fn(), saveChanges: vi.fn(),
cancelEditing: vi.fn(), cancelEditing: vi.fn(),
handleKeydown: vi.fn(), handleKeydown: vi.fn(),
deleteMetric: deleteMetricMock,
updateMetrics: updateMetricsMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>); } as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mocked(useAnnotationTagsStore).mockReturnValue({
isLoading: ref(false),
allTags: ref([]),
tagsById: ref({}),
fetchAll: vi.fn(),
} as unknown as ReturnType<typeof useAnnotationTagsStore>);
vi.mock('@/stores/projects.store', () => ({ vi.mock('@/stores/projects.store', () => ({
useProjectsStore: vi.fn().mockReturnValue({ useProjectsStore: vi.fn().mockReturnValue({
@ -76,133 +93,162 @@ describe('TestDefinitionEditView', () => {
it('should load test data when testId is provided', async () => { it('should load test data when testId is provided', async () => {
vi.mocked(useRoute).mockReturnValue({ vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' }, params: { testId: '1' },
path: '/test-path', name: VIEWS.TEST_DEFINITION_EDIT,
name: 'test-route',
} as unknown as ReturnType<typeof useRoute>); } as unknown as ReturnType<typeof useRoute>);
const loadTestDataMock = vi.fn();
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
loadTestData: loadTestDataMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
renderComponent({ const pinia = createTestingPinia();
pinia: createTestingPinia(), setActivePinia(pinia);
}); mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { getByTestId } = renderComponent({ pinia });
await nextTick(); await nextTick();
expect(loadTestDataMock).toHaveBeenCalledWith('1'); expect(loadTestDataMock).toHaveBeenCalledWith('1');
}); });
it('should not load test data when testId is not provided', async () => { it('should not load test data when testId is not provided', async () => {
const loadTestDataMock = vi.fn(); // Here route returns no testId
vi.mocked(useTestDefinitionForm).mockReturnValue({ vi.mocked(useRoute).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(), params: {},
loadTestData: loadTestDataMock, name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useTestDefinitionForm>); } as unknown as ReturnType<typeof useRoute>);
renderComponent({ const pinia = createTestingPinia();
pinia: createTestingPinia(), setActivePinia(pinia);
}); mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
renderComponent({ pinia });
await nextTick(); await nextTick();
expect(loadTestDataMock).not.toHaveBeenCalled(); expect(loadTestDataMock).not.toHaveBeenCalled();
}); });
it('should save test and show success message on successful save', async () => { it('should create a new test and show success message on save if no testId is present', async () => {
const saveTestMock = vi.fn().mockResolvedValue({}); vi.mocked(useRoute).mockReturnValue({
const routerPushMock = vi.fn(); params: {},
const routerResolveMock = vi.fn().mockReturnValue({ href: '/test-href' }); name: VIEWS.NEW_TEST_DEFINITION,
vi.mocked(useTestDefinitionForm).mockReturnValue({ } as ReturnType<typeof useRoute>);
...vi.mocked(useTestDefinitionForm)(),
createTest: saveTestMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mocked(useRouter).mockReturnValue({ const pinia = createTestingPinia();
push: routerPushMock, setActivePinia(pinia);
resolve: routerResolveMock, mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
} as unknown as ReturnType<typeof useRouter>);
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({ pinia });
pinia: createTestingPinia(),
});
await nextTick(); await nextTick();
const saveButton = getByTestId('run-test-button'); const saveButton = getByTestId('run-test-button');
saveButton.click(); saveButton.click();
await nextTick(); await nextTick();
expect(saveTestMock).toHaveBeenCalled(); expect(createTestMock).toHaveBeenCalled();
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
}); });
it('should show error message on failed save', async () => { it('should update test and show success message on save if testId is present', async () => {
const saveTestMock = vi.fn().mockRejectedValue(new Error('Save failed')); vi.mocked(useRoute).mockReturnValue({
const showErrorMock = vi.fn(); params: { testId: '1' },
vi.mocked(useTestDefinitionForm).mockReturnValue({ name: VIEWS.TEST_DEFINITION_EDIT,
...vi.mocked(useTestDefinitionForm)(), } as unknown as ReturnType<typeof useRoute>);
createTest: saveTestMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mocked(useToast).mockReturnValue({ showError: showErrorMock } as unknown as ReturnType<
typeof useToast
>);
const { getByTestId } = renderComponent({ const pinia = createTestingPinia();
pinia: createTestingPinia(), setActivePinia(pinia);
}); mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { getByTestId } = renderComponent({ pinia });
await nextTick(); await nextTick();
const saveButton = getByTestId('run-test-button'); const saveButton = getByTestId('run-test-button');
saveButton.click(); saveButton.click();
await nextTick(); await nextTick();
expect(saveTestMock).toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalled(); expect(updateTestMock).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
});
it('should show error message on failed test creation', async () => {
createTestMock.mockRejectedValue(new Error('Save failed'));
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
const pinia = createTestingPinia();
setActivePinia(pinia);
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { getByTestId } = renderComponent({ pinia });
await nextTick();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
expect(createTestMock).toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
}); });
it('should display "Update Test" button when editing existing test', async () => { it('should display "Update Test" button when editing existing test', async () => {
vi.mocked(useRoute).mockReturnValue({ vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' }, params: { testId: '1' },
path: '/test-path', name: VIEWS.TEST_DEFINITION_EDIT,
name: 'test-route',
} as unknown as ReturnType<typeof useRoute>); } as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponent({
pinia: createTestingPinia(), const pinia = createTestingPinia();
}); setActivePinia(pinia);
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { getByTestId } = renderComponent({ pinia });
await nextTick(); await nextTick();
const updateButton = getByTestId('run-test-button'); const updateButton = getByTestId('run-test-button');
expect(updateButton.textContent).toContain('Update test'); expect(updateButton.textContent?.toLowerCase()).toContain('update');
}); });
it('should display "Run Test" button when creating new test', async () => { it('should display "Save Test" button when creating new test', async () => {
const { getByTestId } = renderComponent({ vi.mocked(useRoute).mockReturnValue({
pinia: createTestingPinia(), params: {},
}); name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
const pinia = createTestingPinia();
setActivePinia(pinia);
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { getByTestId } = renderComponent({ pinia });
await nextTick(); await nextTick();
const saveButton = getByTestId('run-test-button'); const saveButton = getByTestId('run-test-button');
expect(saveButton).toBeTruthy(); expect(saveButton.textContent?.toLowerCase()).toContain('run test');
}); });
it('should apply "has-issues" class to inputs with issues', async () => { it('should apply "has-issues" class to inputs with issues', async () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({ vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(), ...vi.mocked(useTestDefinitionForm)(),
fieldsIssues: ref([{ field: 'name' }, { field: 'tags' }]), fieldsIssues: ref([
{ field: 'name', message: 'Name is required' },
{ field: 'tags', message: 'Tag is required' },
]),
} as unknown as ReturnType<typeof useTestDefinitionForm>); } as unknown as ReturnType<typeof useTestDefinitionForm>);
const { container } = renderComponent({ const pinia = createTestingPinia();
pinia: createTestingPinia(), setActivePinia(pinia);
}); mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { container } = renderComponent({ pinia });
await nextTick(); await nextTick();
expect(container.querySelector('.has-issues')).toBeTruthy(); const issueElements = container.querySelectorAll('.has-issues');
expect(issueElements.length).toBeGreaterThan(0);
}); });
it('should fetch all tags on mount', async () => { it('should fetch all tags on mount', async () => {
const fetchAllMock = vi.fn(); const pinia = createTestingPinia();
vi.mocked(useAnnotationTagsStore).mockReturnValue({ setActivePinia(pinia);
...vi.mocked(useAnnotationTagsStore)(), mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
fetchAll: fetchAllMock,
} as unknown as ReturnType<typeof useAnnotationTagsStore>);
renderComponent({
pinia: createTestingPinia(),
});
renderComponent({ pinia });
await nextTick(); await nextTick();
expect(fetchAllMock).toHaveBeenCalled(); expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
}); });
}); });