PromptVault/tests/js/Components/PromptForm.test.js
2026-10-26 15:12:16 -05:00

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');
});
});