Files
summercms/admin/tests/form/fields.test.ts
Jakub Zych 1c2a66df45 test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
2026-09-27 17:53:57 +02:00

216 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
import NumberField from '../../src/components/form/fields/NumberField.vue'
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
import TextField from '../../src/components/form/fields/TextField.vue'
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
import { setBundle } from '../../src/app/i18n'
import type { FormField } from '../../src/api/types'
import { formField } from '../fixtures/typed'
import { resetState } from '../helpers'
const common = { controlId: 'c', describedBy: 'c-comment' }
beforeEach(() => {
resetState()
})
describe('text field', () => {
it('is a required-aware text input that emits its value', async () => {
const field = { ...formField('name'), attributes: { placeholder: 'Type', onclick: 'x' } }
const wrapper = mount(TextField, { props: { field, modelValue: 'Blue', invalid: true, ...common } })
const input = wrapper.find('input')
expect(input.attributes()).toMatchObject({
id: 'c',
name: 'name',
type: 'text',
'aria-required': 'true',
'aria-invalid': 'true',
'aria-describedby': 'c-comment',
placeholder: 'Type',
})
expect(input.attributes('required')).toBeDefined()
expect(input.attributes('onclick')).toBeUndefined()
expect(input.classes()).toContain('border-danger')
await input.setValue('Red')
expect(wrapper.emitted('update:modelValue')).toEqual([['Red']])
})
it('shows an empty input for null and stringifies numbers', () => {
const optional = formField('code')
expect((mount(TextField, { props: { field: optional, modelValue: null, controlId: 'c' } }).find('input').element as HTMLInputElement).value).toBe('')
const input = mount(TextField, { props: { field: optional, modelValue: 12, controlId: 'c' } }).find('input')
expect((input.element as HTMLInputElement).value).toBe('12')
expect(input.attributes('aria-required')).toBeUndefined()
expect(input.attributes('required')).toBeUndefined()
expect(input.attributes('aria-describedby')).toBeUndefined()
})
})
describe('textarea field', () => {
it.each([
['tiny', '2'],
['small', '4'],
['large', '8'],
['huge', '12'],
['giant', '18'],
[undefined, '5'],
['odd', '5'],
])('maps size %s to %s rows', (size, rows) => {
const field: FormField = { ...formField('notes'), size }
expect(mount(TextareaField, { props: { field, modelValue: 'x', controlId: 't' } }).find('textarea').attributes('rows')).toBe(rows)
})
it('emits its text', async () => {
const wrapper = mount(TextareaField, { props: { field: formField('notes'), modelValue: undefined, controlId: 't' } })
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
await wrapper.find('textarea').setValue('Long text')
expect(wrapper.emitted('update:modelValue')).toEqual([['Long text']])
})
})
describe('number field', () => {
it('emits numbers and null for an empty input', async () => {
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: 12, controlId: 'n' } })
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('number')
expect(input.attributes('inputmode')).toBe('decimal')
expect((input.element as HTMLInputElement).value).toBe('12')
await input.setValue('3.5')
await input.setValue('')
await input.setValue(' ')
expect(wrapper.emitted('update:modelValue')).toEqual([[3.5], [null], [null]])
})
it('emits null for a value that is not a finite number', () => {
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: null, controlId: 'n' } })
const input = wrapper.find('input').element as HTMLInputElement
Object.defineProperty(input, 'value', { configurable: true, get: () => 'Infinity' })
input.dispatchEvent(new Event('input'))
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
})
})
describe('dropdown field', () => {
const typed: FormField = {
...formField('size'),
options: [
{ value: 's', label: 'Small' },
{ value: 2, label: 'Two' },
{ value: true, label: 'Yes' },
],
}
it('offers the emptyOption first and sends null for it', async () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: null, controlId: 'd' } })
const select = wrapper.find('select')
expect(select.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Two', 'Yes'])
expect((select.element as HTMLSelectElement).value).toBe('empty')
expect(select.classes()).toContain('text-muted')
await select.setValue('empty')
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
})
it('keeps the JSON type of the chosen option value', async () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 's', controlId: 'd' } })
const select = wrapper.find('select')
expect((select.element as HTMLSelectElement).value).toBe('0')
expect(select.classes()).not.toContain('text-muted')
await select.setValue('1')
await select.setValue('2')
expect(wrapper.emitted('update:modelValue')).toEqual([[2], [true]])
})
it('matches a stored value by its string form', () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: '2', controlId: 'd' } })
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('1')
})
it('shows a disabled placeholder without an emptyOption or a matching value', () => {
const field: FormField = { ...typed, emptyOption: undefined, required: true }
const wrapper = mount(DropdownField, { props: { field, modelValue: 'unknown', controlId: 'd', invalid: true } })
const placeholder = wrapper.find('option[value="placeholder"]')
expect(placeholder.text()).toBe('Wybierz…')
expect(placeholder.attributes('disabled')).toBeDefined()
expect(wrapper.find('select').attributes('aria-required')).toBe('true')
expect(wrapper.find('select').attributes('aria-invalid')).toBe('true')
expect(mount(DropdownField, { props: { field, modelValue: 's', controlId: 'd' } }).find('option[value="placeholder"]').exists()).toBe(false)
})
it('shows the placeholder, not the empty option, for a stored value it does not know', () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 'zzz', controlId: 'd' } })
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('placeholder')
})
})
describe('toggle cards', () => {
it('renders a switch with role=switch, aria-checked, its own label and comment', async () => {
const wrapper = mount(SwitchField, { props: { field: formField('active'), modelValue: true, controlId: 's', describedBy: 's-comment' } })
expect(wrapper.find('[data-toggle-card]').exists()).toBe(true)
expect(wrapper.find('label').attributes('for')).toBe('s')
expect(wrapper.find('label').text()).toBe('Active')
expect(wrapper.find('#s-comment').text()).toBe('Shown in the catalogue')
const control = wrapper.find('[role="switch"]')
expect(control.attributes('id')).toBe('s')
expect(control.attributes('type')).toBe('button')
expect(control.attributes('aria-checked')).toBe('true')
expect(control.attributes('aria-describedby')).toBe('s-comment')
await control.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('keeps a numeric switch value numeric and marks it invalid', async () => {
const field = { ...formField('active'), required: true, label: '' }
const wrapper = mount(SwitchField, { props: { field, modelValue: 0, controlId: 's', invalid: true } })
expect(wrapper.find('[role="switch"]').attributes('aria-checked')).toBe('false')
expect(wrapper.find('[role="switch"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.find('[data-toggle-card]').classes()).toContain('border-danger')
expect(wrapper.find('label').text()).toBe('active *')
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
})
it('renders a checkbox card that emits its checked state', async () => {
const wrapper = mount(CheckboxField, { props: { field: formField('featured'), modelValue: '1', controlId: 'k' } })
const box = wrapper.find('input[type="checkbox"]')
expect((box.element as HTMLInputElement).checked).toBe(true)
expect(wrapper.find('label').attributes('for')).toBe('k')
expect(wrapper.find('#k-comment').text()).toBe('Pinned to the top')
await box.setValue(false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
const numeric = mount(CheckboxField, { props: { field: formField('featured'), modelValue: 0, controlId: 'k', invalid: true } })
expect(numeric.find('input').attributes('aria-invalid')).toBe('true')
await numeric.find('input').setValue(true)
expect(numeric.emitted('update:modelValue')).toEqual([[1]])
})
})
describe('unsupported field (D-05)', () => {
it('names the type as code inside the translated sentence', () => {
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: '#fff', controlId: 'u', describedBy: 'u-error' } })
const box = wrapper.find('[data-unsupported-field]')
expect(box.attributes('id')).toBe('u')
expect(box.attributes('aria-describedby')).toBe('u-error')
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
expect(box.find('code').text()).toBe('colorpicker')
})
it('appends the type when the sentence has no :type placeholder', () => {
setBundle({ 'backend::lang.form.unsupported_field': { other: 'Unsupported field' } }, 'en')
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: null, controlId: 'u' } })
expect(wrapper.text()).toBe('Unsupported field colorpicker')
expect(wrapper.find('code').text()).toBe('colorpicker')
})
it('renders a hostile type name as text', () => {
const field = { ...formField('color'), type: '<img src=x onerror=alert(1)>' }
const wrapper = mount(UnsupportedField, { props: { field, modelValue: null, controlId: 'u' } })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.find('code').text()).toBe('<img src=x onerror=alert(1)>')
})
})