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: '' } const wrapper = mount(UnsupportedField, { props: { field, modelValue: null, controlId: 'u' } }) expect(wrapper.find('img').exists()).toBe(false) expect(wrapper.find('code').text()).toBe('') }) })