import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { flushPromises } from '@vue/test-utils' import { interpolate, loadStrings, message, setBundle, t, tc } from '../../src/app/i18n' import { mapWinterUrl } from '../../src/app/winterUrl' import { clearUser } from '../../src/state/useAuth' import { setNavigation } from '../../src/state/useNavigation' import { clearToasts } from '../../src/state/useToasts' import lang from '../fixtures/lang.json' import formSchema from '../fixtures/widgets.form-schema.json' import record from '../fixtures/widgets.record.json' import listSchema from '../fixtures/widgets.list-schema.json' import listRows from '../fixtures/widgets.list.json' import { API, mockApi, mountApp, pathOf, requestsTo, type Route } from '../helpers' const RECORD = `${API}/acme/demo/widgets/1` const formRoutes: Record = { [`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema }, [`GET ${RECORD}`]: { body: record }, } beforeEach(() => { clearUser() setNavigation([]) clearToasts() setBundle(lang.data, 'pl') }) afterEach(() => { document.body.innerHTML = '' }) describe('strings', () => { it('loads the backend::lang bundle and sets the document language', async () => { setBundle({}, 'en') const calls = mockApi({ [`GET ${API}/lang`]: { body: lang } }) await loadStrings() expect(calls.map(pathOf)).toEqual([`${API}/lang`]) expect(document.documentElement.lang).toBe('pl') expect(t('backend::lang.form.save')).toBe('Zapisz') expect(t('backend::lang.form.missing_key')).toBe('backend::lang.form.missing_key') }) it('interpolates :name, :Name and :NAME like phrasebook.interpolate', () => { expect(interpolate('Hello :name, :Name, :NAME', { name: 'ala' })).toBe('Hello ala, Ala, ALA') // Longer placeholders win, so :names is not eaten by :name. expect(interpolate(':names and :name', { name: 'x', names: 'y' })).toBe('y and x') expect(interpolate('Item :name', { ':name': 'żaba' })).toBe('Item żaba') expect(interpolate(':Name', { name: 'żaba' })).toBe('Żaba') expect(interpolate(':count items', { count: 3 })).toBe('3 items') expect(interpolate('no placeholders', {})).toBe('no placeholders') }) it('picks the CLDR plural form for the locale and falls back to other', () => { expect(tc('backend::lang.form.error_fields', 1)).toBe('Popraw 1 pole oznaczone poniżej.') expect(tc('backend::lang.form.error_fields', 2)).toBe('Popraw 2 pola oznaczone poniżej.') expect(tc('backend::lang.form.error_fields', 5)).toBe('Popraw 5 pól oznaczonych poniżej.') expect(message({ other: ':count things' }, 1)).toBe('1 things') }) }) describe('winter URLs', () => { it('maps recordUrl and redirects onto the controller routes only', () => { const id = 'acme.demo.widgets' expect(mapWinterUrl('acme/demo/widgets', id)).toBe('/acme/demo/widgets') expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create') expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42') expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets') expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets') expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets') expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets') expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets') }) }) describe('edit a record', () => { it('opens the record route from a list row', async () => { const { wrapper, router } = await mountApp('/acme/demo/widgets', { [`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema }, [`GET ${API}/acme/demo/widgets`]: { body: listRows }, ...formRoutes, }) const firstRow = wrapper.findAll('tbody tr')[0]! expect(firstRow.find('td a').attributes('href')).toBe('/acme/demo/widgets/1') expect(firstRow.findAll('td').at(-2)!.classes()).not.toContain('font-semibold') await firstRow.findAll('td').at(-1)!.trigger('click') await flushPromises() expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1') expect(wrapper.find('h1').text()).toBe('Blue widget') expect(wrapper.text()).toContain('Editing widget') }) it('renders the schema fields with their values and marks required fields', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes) const name = wrapper.find('#field-name') expect((name.element as HTMLInputElement).value).toBe('Blue widget') expect(name.attributes('aria-required')).toBe('true') expect(wrapper.find('label[for="field-name"] [aria-hidden="true"]').text()).toBe('*') expect((wrapper.find('#field-weight').element as HTMLInputElement).value).toBe('12') expect(wrapper.find('#field-notes').attributes('rows')).toBe('4') const size = wrapper.find('#field-size') expect(size.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Medium', 'Large']) expect((size.element as HTMLSelectElement).value).toBe('1') expect(wrapper.find('#field-code').attributes('aria-describedby')).toBe('field-code-comment') expect(wrapper.find('#field-code-comment').text()).toBe('Unique widget code') }) it('PUTs the typed body keyed by field name and shows the saved toast', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', { ...formRoutes, [`PUT ${RECORD}`]: async (request) => ({ body: { data: { ...record.data, ...((await request.json()) as object) }, meta: { labels: {} } }, }), }) await wrapper.find('#field-name').setValue('Red widget') await wrapper.find('#field-size').setValue('2') await wrapper.find('#field-weight').setValue('') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const [put] = requestsTo(calls, 'PUT', RECORD) expect(put).toBeDefined() expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') // Every editable, context-allowed field; not the unsupported colorpicker, // the read-only owner or the relation manager. expect(await put!.json()).toEqual({ name: 'Red widget', code: 'W-01', size: 'l', weight: null, notes: 'Round', active: true, featured: false, maker: 2, tags: [3, 1], serial: 'SN-1', }) const toast = wrapper.find('[role="status"]') expect(toast.text()).toContain('Widget „Red widget” saved') expect(toast.find('button').attributes('aria-label')).toBe('Zamknij') }) it('maps a 422 onto fields, focuses the first invalid one and clears on change', async () => { const { wrapper } = await mountApp( '/acme/demo/widgets/1', { ...formRoutes, [`PUT ${RECORD}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'The given data was invalid.', details: { weight: ['The weight must be positive.'], name: ['The name field is required.'] }, }, }, }, }, { attach: true }, ) await wrapper.find('#field-name').setValue('') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const banner = wrapper.find('[data-error-banner]') expect(banner.attributes('role')).toBe('alert') expect(banner.text()).toContain('Nie udało się zapisać.') expect(banner.text()).toContain('Popraw 2 pola oznaczone poniżej.') const name = wrapper.find('#field-name') expect(name.attributes('aria-invalid')).toBe('true') expect(name.attributes('aria-describedby')).toBe('field-name-error') expect(wrapper.find('#field-name-error').text()).toBe('The name field is required.') expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true') // Schema order decides which invalid field is first, not the details order. expect(document.activeElement?.id).toBe('field-name') await name.setValue('Fixed') expect(wrapper.find('#field-name').attributes('aria-invalid')).toBeUndefined() expect(wrapper.find('#field-name-error').exists()).toBe(false) expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true') expect(wrapper.find('[data-error-banner]').text()).toContain('Popraw 1 pole oznaczone poniżej.') }) it('shows a danger toast with the envelope message for other errors', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', { ...formRoutes, [`PUT ${RECORD}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden.', details: {} } } }, }) await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const toast = wrapper.find('[data-tone="danger"]') expect(toast.attributes('role')).toBe('alert') expect(toast.text()).toContain('Forbidden.') expect(wrapper.find('[data-error-banner]').exists()).toBe(false) }) it('renders an unknown field type as the unsupported box instead of breaking the form', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes) const box = wrapper.find('[data-unsupported-field]') expect(box.exists()).toBe(true) expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker') expect(box.find('code').text()).toBe('colorpicker') expect(box.find('code').classes()).toContain('font-mono') expect(box.find('svg').exists()).toBe(true) expect(wrapper.find('label[for="field-color"]').text()).toBe('Colour') }) })