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 { makeRestApiRequest } from '@/utils/apiUtils';
export interface TestDefinitionRecord {
id: string;
name: string;
@ -9,7 +10,9 @@ export interface TestDefinitionRecord {
description?: string | null;
updatedAt?: string;
createdAt?: string;
annotationTag: string | null;
}
interface CreateTestDefinitionParams {
name: string;
workflowId: string;
@ -22,16 +25,17 @@ export interface UpdateTestDefinitionParams {
annotationTagId?: string | null;
description?: string | null;
}
export interface UpdateTestResponse {
createdAt: string;
updatedAt: string;
id: string;
name: string;
workflowId: string;
description: string | null;
annotationTag: string | null;
evaluationWorkflowId: string | null;
annotationTagId: string | null;
description?: string | null;
annotationTag?: string | null;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
}
const endpoint = '/evaluation/test-definitions';

View file

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

View file

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

View file

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

View file

@ -6,11 +6,11 @@ import userEvent from '@testing-library/user-event';
const renderComponent = createComponentRenderer(MetricsInput);
describe('MetricsInput', () => {
let props: { modelValue: string[] };
let props: { modelValue: Array<{ name: string }> };
beforeEach(() => {
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 () => {
const { getAllByPlaceholderText, emitted } = renderComponent({
props: {
modelValue: [''],
modelValue: [{ name: '' }],
},
});
const inputs = getAllByPlaceholderText('Enter metric name');
await userEvent.type(inputs[0], 'Updated Metric 1');
expect(emitted('update:modelValue')).toBeTruthy();
expect(emitted('update:modelValue')).toEqual('Updated Metric 1'.split('').map((c) => [[c]]));
// Every character typed triggers an update event. Let's check the last emission.
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', () => {
@ -47,10 +51,95 @@ describe('MetricsInput', () => {
const { getByText, emitted } = renderComponent({ props });
const addButton = getByText('New metric');
addButton.click();
addButton.click();
addButton.click();
await userEvent.click(addButton);
await userEvent.click(addButton);
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',
workflowId: '123',
annotationTagId: '789',
annotationTag: null,
};
const TEST_DEF_B: TestDefinitionRecord = {
id: '2',
name: 'Test Definition B',
workflowId: '123',
description: 'Description B',
annotationTag: null,
};
const TEST_DEF_NEW: TestDefinitionRecord = {
id: '3',
workflowId: '123',
name: 'New Test Definition',
description: 'New Description',
annotationTag: null,
};
beforeEach(() => {
@ -35,44 +38,79 @@ afterEach(() => {
vi.clearAllMocks();
});
describe('useTestDefinitionForm', async () => {
it('should initialize with default props', async () => {
describe('useTestDefinitionForm', () => {
it('should initialize with default props', () => {
const { state } = useTestDefinitionForm();
expect(state.value.description).toEqual('');
expect(state.value.description).toBe('');
expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.appliedTagIds).toEqual([]);
expect(state.value.metrics).toEqual(['']);
expect(state.value.evaluationWorkflow.value).toEqual('');
expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual([]);
expect(state.value.evaluationWorkflow.value).toBe('');
});
it('should load test data', async () => {
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);
expect(state.value.description).toEqual('');
expect(state.value.name.value).toContain('My Test');
evaluationsStore.testDefinitionsById = {
[TEST_DEF_A.id]: TEST_DEF_A,
[TEST_DEF_B.id]: TEST_DEF_B,
};
evaluationsStore.fetchAll = fetchSpy;
await loadTestData(TEST_DEF_A.id);
expect(fetchSpy).toBeCalled();
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
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.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 () => {
const { createTest, state } = useTestDefinitionForm();
const createSpy = vi.fn().mockResolvedValue(TEST_DEF_NEW);
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.create = createSpy;
const createSpy = vi.spyOn(useTestDefinitionStore(), 'create').mockResolvedValue(TEST_DEF_NEW);
state.value.name.value = TEST_DEF_NEW.name;
state.value.description = TEST_DEF_NEW.description ?? '';
@ -86,12 +124,24 @@ describe('useTestDefinitionForm', async () => {
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 () => {
const { updateTest, state } = useTestDefinitionForm();
const updateSpy = vi.fn().mockResolvedValue(TEST_DEF_B);
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.update = updateSpy;
const updatedBTest = {
...TEST_DEF_B,
updatedAt: '2022-01-01T00:00:00.000Z',
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.description = TEST_DEF_B.description ?? '';
@ -102,75 +152,183 @@ describe('useTestDefinitionForm', async () => {
name: TEST_DEF_B.name,
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();
await startEditing('name');
startEditing('name');
expect(state.value.name.isEditing).toBe(true);
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.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();
await startEditing('name');
// Name
startEditing('name');
state.value.name.tempValue = 'New Name';
saveChanges('name');
expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.value).toBe('New Name');
await startEditing('tags');
state.value.tags.appliedTagIds = ['123'];
// Tags
startEditing('tags');
state.value.tags.tempValue = ['123'];
saveChanges('tags');
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();
await startEditing('name');
const originalName = state.value.name.value;
startEditing('name');
state.value.name.tempValue = 'New Name';
cancelEditing('name');
expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.tempValue).toBe('');
expect(state.value.name.tempValue).toBe(originalName);
await startEditing('tags');
state.value.tags.appliedTagIds = ['123'];
const originalTags = [...state.value.tags.value];
startEditing('tags');
state.value.tags.tempValue = ['123'];
cancelEditing('tags');
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();
await startEditing('name');
startEditing('name');
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name');
expect(state.value.name.isEditing).toBe(false);
await startEditing('tags');
startEditing('tags');
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags');
expect(state.value.tags.isEditing).toBe(false);
});
it('should handle keydown - Enter', async () => {
it('should handle keydown - Enter', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
await startEditing('name');
startEditing('name');
state.value.name.tempValue = 'New Name';
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name');
expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.value).toBe('New Name');
await startEditing('tags');
state.value.tags.appliedTagIds = ['123'];
startEditing('tags');
state.value.tags.tempValue = ['123'];
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags');
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 {
lastRun: string | null;
errorRate: number | null;

View file

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

View file

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