Files
crewli/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts
bert.hausmans 198f6f2d3b fix(portal): align FieldSectionPriority spec with WS-5b max_selected
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>
2026-04-25 03:37:09 +02:00

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')
})
})