import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' const state = { data: ref> | undefined>(undefined), isLoading: ref(false), isError: ref(false), refetch: vi.fn(), } vi.mock('@/composables/api/usePublicFormSections', () => ({ usePublicFormSections: () => state, })) vi.mock('@/composables/publicFormInjection', () => ({ usePublicFormToken: () => ref('TKN'), providePublicFormToken: () => {}, })) // Vuetify's useDisplay touches window.matchMedia which jsdom doesn't // implement; stub to always return desktop for consistent behaviour. vi.mock('vuetify', () => ({ useDisplay: () => ({ mobile: ref(false) }), })) // vuedraggable depends on SortableJS which pokes the DOM on mount; a // minimal slot-passthrough stub is enough for the tap-based behaviour // under test. vi.mock('vuedraggable', () => ({ default: { name: 'draggable', props: ['modelValue', 'ghostClass', 'dragClass', 'chosenClass', 'itemKey', 'handle', 'animation'], template: '
', }, })) import FieldSectionPriority from '@/components/public-form/FieldSectionPriority.vue' import { FormFieldType } from '@form-schema/types/formBuilder' import type { PublicFormField, PublicFormSectionOption } from '@form-schema/types/formBuilder' function field(partial: Partial = {}): PublicFormField { return { id: 'f_1', slug: 'sectie_voorkeur', field_type: FormFieldType.SECTION_PRIORITY, label: 'Bij welke sectie wil je werken?', help_text: null, options: null, available_tags: null, validation_rules: null, is_required: false, display_width: 'full', conditional_logic: null, sort_order: 1, form_schema_section_id: null, ...partial, } } function section(partial: Partial): PublicFormSectionOption { return { id: partial.id ?? '01A', name: partial.name ?? 'Bar', category: partial.category ?? null, icon: partial.icon ?? null, registration_description: partial.registration_description ?? null, } } function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) { return mount(FieldSectionPriority, { props, global: { stubs: { VSkeletonLoader: { name: 'VSkeletonLoader', template: '
' }, VAlert: { name: 'VAlert', props: ['type'], template: '
', }, VBtn: { name: 'VBtn', props: ['ariaLabel', 'icon'], template: '', }, VCard: { name: 'VCard', // Do not re-emit click/keydown — the parent's @click listener // falls through to the root element, and emitting + fallthrough // would wire the handler twice. inheritAttrs: true, template: '
', }, VRow: { name: 'VRow', template: '
' }, VCol: { name: 'VCol', template: '
' }, VIcon: { name: 'VIcon', template: '' }, }, }, }) } describe('FieldSectionPriority', () => { beforeEach(() => { state.data.value = undefined state.isLoading.value = false state.isError.value = false state.refetch = vi.fn() }) afterEach(() => { vi.clearAllMocks() }) it('renders the skeleton while loading', () => { state.isLoading.value = true const w = mountPicker({ field: field(), modelValue: [] }) expect(w.find('.v-skeleton-stub').exists()).toBe(true) }) it('renders the error alert with retry wiring', async () => { state.isError.value = true const w = mountPicker({ field: field(), modelValue: [] }) expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error') await w.find('.v-btn-stub').trigger('click') expect(state.refetch).toHaveBeenCalled() }) it('renders the info empty-state when no sections are published', () => { state.data.value = [] const w = mountPicker({ field: field(), modelValue: [] }) expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info') expect(w.text()).toContain('Er zijn nog geen secties gepubliceerd voor registratie.') }) it('renders all sections in the unranked pool initially', () => { state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })] const w = mountPicker({ field: field(), modelValue: [] }) expect(w.text()).toContain('Bar') expect(w.text()).toContain('Hospitality') }) it('tap-to-rank moves a section to the ranked list at priority 1', async () => { state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })] const w = mountPicker({ field: field(), modelValue: [] }) // First unranked card tap const cards = w.findAll('.v-card-stub') await cards[0].trigger('click') const emits = w.emitted('update:modelValue') as unknown as Array>> const last = emits[emits.length - 1][0] expect(last).toEqual([{ section_id: 'a', priority: 1 }]) }) it('tapping a second section lands it at priority 2', async () => { state.data.value = [section({ id: 'a' }), section({ id: 'b' })] const w = mountPicker({ field: field(), modelValue: [{ section_id: 'a', priority: 1 }], }) // Only section b is still in the pool — it renders as a card. const poolCards = w.findAll('.v-card-stub').filter(c => c.text().length > 0) await poolCards[poolCards.length - 1].trigger('click') const emits = w.emitted('update:modelValue') as unknown as Array>> const last = emits[emits.length - 1][0] expect(last).toEqual([ { section_id: 'a', priority: 1 }, { section_id: 'b', priority: 2 }, ]) }) it('respects validation_rules.max_priorities when present', async () => { state.data.value = [section({ id: 'a' }), section({ id: 'b' })] const w = mountPicker({ field: field({ validation_rules: { max_priorities: 1 } }), modelValue: [{ section_id: 'a', priority: 1 }], }) // Cap is 1; the only unranked card should be disabled per the // rendered "Maximaal" hint. expect(w.text()).toContain('Maximaal 1 voorkeuren') }) it('self-heals an incoming string[] modelValue to []', () => { state.data.value = [section({ id: 'a' })] const w = mountPicker({ field: field(), modelValue: ['a', 'b'], // wrong shape (string[]) }) // Still renders fine — no crash, empty ranked list, both sections // in the pool. expect(w.find('.v-alert-stub').exists()).toBe(false) expect(w.text()).toContain('Bar') }) it('clamps max_priorities to the hard cap of 5 when the rule is too high', () => { state.data.value = [ section({ id: 'a' }), section({ id: 'b' }), section({ id: 'c' }), section({ id: 'd' }), section({ id: 'e' }), ] const ranked = [ { section_id: 'a', priority: 1 }, { section_id: 'b', priority: 2 }, { section_id: 'c', priority: 3 }, { section_id: 'd', priority: 4 }, { section_id: 'e', priority: 5 }, ] const w = mountPicker({ field: field({ validation_rules: { max_priorities: 99 } as Record }), modelValue: ranked, }) // Cap falls back to 5 — counter reads "5 / 5" rather than "5 / 99". expect(w.text()).toContain('5 / 5') }) it('exposes the ranked counter in the UI copy', () => { state.data.value = [section({ id: 'a' })] const w = mountPicker({ field: field(), modelValue: [{ section_id: 'a', priority: 1 }], }) expect(w.text()).toContain('1 / 5') }) it('wires ghost-class / drag-class / chosen-class through to ', () => { state.data.value = [section({ id: 'a' })] const w = mountPicker({ field: field(), modelValue: [] }) const d = w.find('.draggable-stub') expect(d.attributes('data-ghost-class')).toBe('section-priority-ghost') expect(d.attributes('data-drag-class')).toBe('section-priority-drag') expect(d.attributes('data-chosen-class')).toBe('section-priority-chosen') }) it('toggles the disabled class on unranked cards when max is reached', () => { state.data.value = [section({ id: 'a' }), section({ id: 'b' })] // Not at cap — unranked cards must not carry the disabled marker. const notFull = mountPicker({ field: field({ validation_rules: { max_priorities: 3 } }), modelValue: [{ section_id: 'a', priority: 1 }], }) expect(notFull.html()).not.toContain('section-priority-unranked-disabled') // Hit the cap — remaining unranked cards switch to the disabled class. const full = mountPicker({ field: field({ validation_rules: { max_priorities: 1 } }), modelValue: [{ section_id: 'a', priority: 1 }], }) expect(full.html()).toContain('section-priority-unranked-disabled') }) })