Pre-existing breakage on main since WS-5b's validation_rules
canonicalisation renamed max_priorities → max_selected. Component
was migrated; the spec fixtures were not.
Four occurrences in
apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts:
- line 182, 253, 260: max_priorities used in fixture, the
component's max_selected read returned undefined → test
assertions on rendered max-cap behaviour failed (2 tests red)
- line 220: also used max_priorities; test was accidentally
passing because the value (99) was ignored and the component
fell back to HARD_CAP = 5 which happened to match the
"5 / 5" assertion. Now passes via the correct path (99 clamped
to HARD_CAP via Math.min).
No component-side changes. No new test helpers. Pure fixture
key-rename matching ARCH-CONSOLIDATION-ADDENDUM-2026-04-24
WS-5b Uitvoering: "max_priorities → rule_type = max_selected:
semantically equivalent; two enum cases for one semantic = rot."
Pre: 111/113 passing, 2 failing.
Post: 113/113 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
9.3 KiB
TypeScript
266 lines
9.3 KiB
TypeScript
import { mount } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ref } from 'vue'
|
|
|
|
const state = {
|
|
data: ref<Array<Record<string, unknown>> | 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: '<div class="draggable-stub" :data-ghost-class="ghostClass" :data-drag-class="dragClass" :data-chosen-class="chosenClass"><template v-for="(el, i) in modelValue" :key="i"><slot name="item" :element="el" :index="i"/></template></div>',
|
|
},
|
|
}))
|
|
|
|
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> = {}): 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>): 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: '<div class="v-skeleton-stub"/>' },
|
|
VAlert: {
|
|
name: 'VAlert',
|
|
props: ['type'],
|
|
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
|
},
|
|
VBtn: {
|
|
name: 'VBtn',
|
|
props: ['ariaLabel', 'icon'],
|
|
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
|
},
|
|
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: '<div class="v-card-stub"><slot/></div>',
|
|
},
|
|
VRow: { name: 'VRow', template: '<div class="v-row-stub"><slot/></div>' },
|
|
VCol: { name: 'VCol', template: '<div class="v-col-stub"><slot/></div>' },
|
|
VIcon: { name: 'VIcon', template: '<i class="v-icon-stub"/>' },
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
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<Array<Array<{ section_id: string; priority: number }>>>
|
|
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<Array<Array<{ section_id: string; priority: number }>>>
|
|
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_selected: 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_selected: 99 } as Record<string, unknown> }),
|
|
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 <draggable>', () => {
|
|
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_selected: 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_selected: 1 } }),
|
|
modelValue: [{ section_id: 'a', priority: 1 }],
|
|
})
|
|
expect(full.html()).toContain('section-priority-unranked-disabled')
|
|
})
|
|
})
|