230 lines
8.7 KiB
JavaScript
230 lines
8.7 KiB
JavaScript
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
|
import { mount } from '@vue/test-utils';
|
||
|
|
|
||
|
|
vi.mock('@inertiajs/vue3', async () => import('../stubs/inertia.js'));
|
||
|
|
|
||
|
|
import PromptForm from '../../../resources/js/Components/PromptForm.vue';
|
||
|
|
import { inertiaMock, resetInertiaMock, resolvePendingSubmissions } from '../stubs/inertia.js';
|
||
|
|
|
||
|
|
const baseProps = () => ({
|
||
|
|
initial: { name: 'Ada', description: 'Greeter', body: 'Hi {{ name }}', tags: ['greeting'] },
|
||
|
|
suggestedTags: ['greeting', 'chat', 'code'],
|
||
|
|
method: 'post',
|
||
|
|
action: '/prompts',
|
||
|
|
submitLabel: 'Create prompt',
|
||
|
|
cancelHref: '/prompts',
|
||
|
|
});
|
||
|
|
|
||
|
|
const removeButtons = (wrapper) => wrapper.findAll('[aria-label^="Remove tag"]');
|
||
|
|
|
||
|
|
describe('PromptForm', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
resetInertiaMock();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('pre-fills name, description, body, and tags from initial', () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
expect(wrapper.find('#prompt-name').element.value).toBe('Ada');
|
||
|
|
expect(wrapper.find('#prompt-description').element.value).toBe('Greeter');
|
||
|
|
expect(wrapper.find('#prompt-body').element.value).toBe('Hi {{ name }}');
|
||
|
|
|
||
|
|
const buttons = removeButtons(wrapper);
|
||
|
|
expect(buttons).toHaveLength(1);
|
||
|
|
expect(buttons[0].attributes('aria-label')).toBe('Remove tag greeting');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('treats missing initial values as empty', () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: { ...baseProps(), initial: {} } });
|
||
|
|
|
||
|
|
expect(wrapper.find('#prompt-name').element.value).toBe('');
|
||
|
|
expect(wrapper.find('#prompt-description').element.value).toBe('');
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('suggests only tags not already added', () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
const suggestions = wrapper
|
||
|
|
.findAll('button')
|
||
|
|
.filter((button) => button.text().startsWith('+ '))
|
||
|
|
.map((button) => button.text());
|
||
|
|
|
||
|
|
expect(suggestions).toEqual(['+ chat', '+ code']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('adds a trimmed, lowercase tag on Enter and clears the input', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
const input = wrapper.find('#prompt-tags');
|
||
|
|
|
||
|
|
await input.setValue(' Code Review ');
|
||
|
|
await input.trigger('keydown', { key: 'Enter' });
|
||
|
|
|
||
|
|
expect(input.element.value).toBe('');
|
||
|
|
expect(removeButtons(wrapper).map((b) => b.attributes('aria-label'))).toContain(
|
||
|
|
'Remove tag code review'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('adds a tag on comma and commits a pending tag on blur', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
const input = wrapper.find('#prompt-tags');
|
||
|
|
|
||
|
|
await input.setValue('notes');
|
||
|
|
await input.trigger('keydown', { key: ',' });
|
||
|
|
expect(input.element.value).toBe('');
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(2);
|
||
|
|
|
||
|
|
await input.setValue('solo');
|
||
|
|
await input.trigger('blur');
|
||
|
|
expect(removeButtons(wrapper).map((b) => b.attributes('aria-label'))).toContain('Remove tag solo');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('ignores empty and duplicate tags', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
const input = wrapper.find('#prompt-tags');
|
||
|
|
|
||
|
|
await input.setValue(' ');
|
||
|
|
await input.trigger('keydown', { key: 'Enter' });
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(1);
|
||
|
|
|
||
|
|
const suggestion = wrapper
|
||
|
|
.findAll('button')
|
||
|
|
.find((button) => button.text() === '+ chat');
|
||
|
|
await suggestion.trigger('click');
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(2);
|
||
|
|
|
||
|
|
await suggestion.trigger('click');
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('removes the last tag on Backspace with an empty input', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
const input = wrapper.find('#prompt-tags');
|
||
|
|
|
||
|
|
await input.setValue('draft');
|
||
|
|
await input.trigger('keydown', { key: 'Backspace' });
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(1);
|
||
|
|
|
||
|
|
await input.setValue('');
|
||
|
|
await input.trigger('keydown', { key: 'Backspace' });
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('removes a tag via its remove button', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
await removeButtons(wrapper)[0].trigger('click');
|
||
|
|
|
||
|
|
expect(removeButtons(wrapper)).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows server errors from the Inertia page (array and string shapes)', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
inertiaMock.page.props.errors = {
|
||
|
|
name: 'The name is required.',
|
||
|
|
description: 'Too long.',
|
||
|
|
body: 'The body is required.',
|
||
|
|
tags: ['Each tag may be up to 40 characters.'],
|
||
|
|
};
|
||
|
|
await wrapper.vm.$nextTick();
|
||
|
|
|
||
|
|
expect(wrapper.text()).toContain('The name is required.');
|
||
|
|
expect(wrapper.text()).toContain('Too long.');
|
||
|
|
expect(wrapper.text()).toContain('The body is required.');
|
||
|
|
expect(wrapper.text()).toContain('Each tag may be up to 40 characters.');
|
||
|
|
|
||
|
|
inertiaMock.page.props.errors = { tags: 'Tags must be strings.' };
|
||
|
|
await wrapper.vm.$nextTick();
|
||
|
|
expect(wrapper.text()).toContain('Tags must be strings.');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('submits via POST with the transformed payload (trimmed/null description, pending tag)', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
await wrapper.find('#prompt-description').setValue(' ');
|
||
|
|
await wrapper.find('#prompt-tags').setValue('new one');
|
||
|
|
await wrapper.find('form').trigger('submit');
|
||
|
|
|
||
|
|
const submission = inertiaMock.formSubmissions[0];
|
||
|
|
expect(submission.method).toBe('post');
|
||
|
|
expect(submission.url).toBe('/prompts');
|
||
|
|
expect(submission.data).toEqual({
|
||
|
|
name: 'Ada',
|
||
|
|
description: null,
|
||
|
|
body: 'Hi {{ name }}',
|
||
|
|
tags: ['greeting', 'new one'],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps a non-empty description trimmed when submitting', async () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
await wrapper.find('#prompt-description').setValue(' Trimmed ');
|
||
|
|
await wrapper.find('form').trigger('submit');
|
||
|
|
|
||
|
|
expect(inertiaMock.formSubmissions[0].data.description).toBe('Trimmed');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('submits via PUT when method is "put"', async () => {
|
||
|
|
const wrapper = mount(PromptForm, {
|
||
|
|
props: {
|
||
|
|
...baseProps(),
|
||
|
|
method: 'put',
|
||
|
|
action: '/prompts/7',
|
||
|
|
submitLabel: 'Save changes',
|
||
|
|
cancelHref: '/prompts/7',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await wrapper.find('form').trigger('submit');
|
||
|
|
|
||
|
|
const submission = inertiaMock.formSubmissions[0];
|
||
|
|
expect(submission.method).toBe('put');
|
||
|
|
expect(submission.url).toBe('/prompts/7');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows "Saving…" and disables the button while processing', async () => {
|
||
|
|
inertiaMock.deferSubmissions = true;
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
const button = () => wrapper.find('button[type="submit"]');
|
||
|
|
|
||
|
|
await wrapper.find('form').trigger('submit');
|
||
|
|
expect(inertiaMock.forms[0].processing).toBe(true);
|
||
|
|
await wrapper.vm.$nextTick();
|
||
|
|
|
||
|
|
expect(button().text()).toBe('Saving…');
|
||
|
|
expect(button().attributes('disabled')).toBeDefined();
|
||
|
|
|
||
|
|
resolvePendingSubmissions();
|
||
|
|
await wrapper.vm.$nextTick();
|
||
|
|
|
||
|
|
expect(inertiaMock.forms[0].processing).toBe(false);
|
||
|
|
expect(button().text()).toBe('Create prompt');
|
||
|
|
expect(button().attributes('disabled')).toBeUndefined();
|
||
|
|
expect(inertiaMock.formSubmissions).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clears form errors via onError when the server rejects the submission', async () => {
|
||
|
|
inertiaMock.failNext = true;
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
await wrapper.find('form').trigger('submit');
|
||
|
|
|
||
|
|
const form = inertiaMock.forms[0];
|
||
|
|
expect(form.errors).toEqual({}); // stub seeded errors; onError cleared them
|
||
|
|
expect(form.processing).toBe(false);
|
||
|
|
expect(inertiaMock.page.props.errors.name).toEqual(['The server rejected the submission.']);
|
||
|
|
expect(wrapper.text()).toContain('The server rejected the submission.');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders a cancel link to cancelHref', () => {
|
||
|
|
const wrapper = mount(PromptForm, { props: baseProps() });
|
||
|
|
|
||
|
|
const link = wrapper.find('a[data-inertia]');
|
||
|
|
expect(link.attributes('href')).toBe('/prompts');
|
||
|
|
expect(link.text()).toBe('Cancel');
|
||
|
|
});
|
||
|
|
});
|