test(portal): add Vitest setup and public-form tests

Introduces vitest config, jsdom setup, and first suites covering
FieldRenderer dispatch and useConditionalLogic evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 17:21:04 +02:00
parent 4074dce402
commit b159fdcc26
4 changed files with 248 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import FieldRenderer from '@/components/public-form/FieldRenderer.vue'
import { FormFieldType } from '@/types/formBuilder'
import type { PublicFormField } from '@/types/formBuilder'
function makeField(partial: Partial<PublicFormField>): PublicFormField {
return {
id: partial.id ?? 'id',
slug: partial.slug ?? 'slug',
field_type: partial.field_type ?? FormFieldType.TEXT,
label: partial.label ?? 'Label',
help_text: partial.help_text ?? null,
options: partial.options ?? null,
available_tags: partial.available_tags ?? null,
validation_rules: partial.validation_rules ?? null,
is_required: partial.is_required ?? false,
display_width: partial.display_width ?? 'full',
conditional_logic: partial.conditional_logic ?? null,
sort_order: partial.sort_order ?? 0,
form_schema_section_id: partial.form_schema_section_id ?? null,
}
}
function mountRenderer(field: PublicFormField, allValues: Record<string, unknown> = {}) {
return mount(FieldRenderer, {
props: { field, modelValue: undefined, allValues },
global: {
stubs: {
VCol: { name: 'VCol', template: '<div class="v-col-stub"><slot/></div>' },
VAlert: { name: 'VAlert', template: '<div class="v-alert-stub"><slot/></div>' },
FieldText: { name: 'FieldText', template: '<div class="field-text-stub"/>' },
FieldTextarea: { name: 'FieldTextarea', template: '<div class="field-textarea-stub"/>' },
FieldEmail: { name: 'FieldEmail', template: '<div class="field-email-stub"/>' },
FieldPhone: { name: 'FieldPhone', template: '<div class="field-phone-stub"/>' },
FieldNumber: { name: 'FieldNumber', template: '<div class="field-number-stub"/>' },
FieldDate: { name: 'FieldDate', template: '<div class="field-date-stub"/>' },
FieldBoolean: { name: 'FieldBoolean', template: '<div class="field-boolean-stub"/>' },
FieldRadio: { name: 'FieldRadio', template: '<div class="field-radio-stub"/>' },
FieldSelect: { name: 'FieldSelect', template: '<div class="field-select-stub"/>' },
FieldMultiselect: { name: 'FieldMultiselect', template: '<div class="field-multiselect-stub"/>' },
FieldCheckboxList: { name: 'FieldCheckboxList', template: '<div class="field-checkboxlist-stub"/>' },
FieldHeading: { name: 'FieldHeading', template: '<div class="field-heading-stub"/>' },
FieldParagraph: { name: 'FieldParagraph', template: '<div class="field-paragraph-stub"/>' },
FieldUrl: { name: 'FieldUrl', template: '<div class="field-url-stub"/>' },
},
},
})
}
describe('FieldRenderer', () => {
it.each<[string, string]>([
[FormFieldType.TEXT, 'field-text-stub'],
[FormFieldType.TEXTAREA, 'field-textarea-stub'],
[FormFieldType.EMAIL, 'field-email-stub'],
[FormFieldType.PHONE, 'field-phone-stub'],
[FormFieldType.NUMBER, 'field-number-stub'],
[FormFieldType.DATE, 'field-date-stub'],
[FormFieldType.BOOLEAN, 'field-boolean-stub'],
[FormFieldType.RADIO, 'field-radio-stub'],
[FormFieldType.SELECT, 'field-select-stub'],
[FormFieldType.MULTISELECT, 'field-multiselect-stub'],
[FormFieldType.CHECKBOX_LIST, 'field-checkboxlist-stub'],
[FormFieldType.HEADING, 'field-heading-stub'],
[FormFieldType.PARAGRAPH, 'field-paragraph-stub'],
[FormFieldType.URL, 'field-url-stub'],
])('dispatches to the right component for %s', (fieldType, className) => {
const wrapper = mountRenderer(makeField({ field_type: fieldType as typeof FormFieldType[keyof typeof FormFieldType] }))
expect(wrapper.find(`.${className}`).exists()).toBe(true)
})
it.each<string>([
FormFieldType.TAG_PICKER,
FormFieldType.AVAILABILITY_PICKER,
FormFieldType.SECTION_PRIORITY,
FormFieldType.FILE_UPLOAD,
FormFieldType.IMAGE_UPLOAD,
FormFieldType.SIGNATURE,
FormFieldType.TABLE_ROWS,
FormFieldType.DATETIME,
])('renders placeholder alert for out-of-scope type %s', fieldType => {
const wrapper = mountRenderer(makeField({ field_type: fieldType as typeof FormFieldType[keyof typeof FormFieldType] }))
expect(wrapper.find('.v-alert-stub').exists()).toBe(true)
expect(wrapper.text()).toContain('binnenkort ondersteund')
})
it('hides the field when conditional logic evaluates to false', () => {
const field = makeField({
field_type: FormFieldType.TEXT,
conditional_logic: {
show_when: { all: [{ field_slug: 'gate', operator: 'equals', value: 'yes' }] },
},
})
const hidden = mountRenderer(field, { gate: 'no' })
expect(hidden.find('.field-text-stub').exists()).toBe(false)
expect(hidden.find('.v-col-stub').exists()).toBe(false)
const shown = mountRenderer(field, { gate: 'yes' })
expect(shown.find('.field-text-stub').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { evaluateConditionalLogic } from '@/composables/useConditionalLogic'
import type { ConditionalLogic } from '@/types/formBuilder'
describe('evaluateConditionalLogic', () => {
it('returns true when logic is null or undefined', () => {
expect(evaluateConditionalLogic(null, {})).toBe(true)
expect(evaluateConditionalLogic(undefined, {})).toBe(true)
expect(evaluateConditionalLogic({}, {})).toBe(true)
})
it('handles simple equals / not_equals', () => {
const logic: ConditionalLogic = {
show_when: { all: [{ field_slug: 'foo', operator: 'equals', value: 'bar' }] },
}
expect(evaluateConditionalLogic(logic, { foo: 'bar' })).toBe(true)
expect(evaluateConditionalLogic(logic, { foo: 'baz' })).toBe(false)
const negative: ConditionalLogic = {
show_when: { all: [{ field_slug: 'foo', operator: 'not_equals', value: 'bar' }] },
}
expect(evaluateConditionalLogic(negative, { foo: 'baz' })).toBe(true)
expect(evaluateConditionalLogic(negative, { foo: 'bar' })).toBe(false)
})
it('handles in / not_in over single and array values', () => {
const logic: ConditionalLogic = {
show_when: { all: [{ field_slug: 'tags', operator: 'in', value: ['a', 'b'] }] },
}
expect(evaluateConditionalLogic(logic, { tags: 'a' })).toBe(true)
expect(evaluateConditionalLogic(logic, { tags: 'c' })).toBe(false)
expect(evaluateConditionalLogic(logic, { tags: ['c', 'a'] })).toBe(true)
const negative: ConditionalLogic = {
show_when: { all: [{ field_slug: 'tags', operator: 'not_in', value: ['a'] }] },
}
expect(evaluateConditionalLogic(negative, { tags: 'a' })).toBe(false)
expect(evaluateConditionalLogic(negative, { tags: ['b', 'c'] })).toBe(true)
})
it('handles empty / not_empty across string, array, null', () => {
const empty: ConditionalLogic = { show_when: { all: [{ field_slug: 'x', operator: 'empty' }] } }
expect(evaluateConditionalLogic(empty, {})).toBe(true)
expect(evaluateConditionalLogic(empty, { x: null })).toBe(true)
expect(evaluateConditionalLogic(empty, { x: '' })).toBe(true)
expect(evaluateConditionalLogic(empty, { x: [] })).toBe(true)
expect(evaluateConditionalLogic(empty, { x: 'hi' })).toBe(false)
const notEmpty: ConditionalLogic = { show_when: { all: [{ field_slug: 'x', operator: 'not_empty' }] } }
expect(evaluateConditionalLogic(notEmpty, { x: 'hi' })).toBe(true)
expect(evaluateConditionalLogic(notEmpty, {})).toBe(false)
})
it('evaluates nested all/any groups', () => {
const logic: ConditionalLogic = {
show_when: {
all: [
{ field_slug: 'role', operator: 'equals', value: 'admin' },
{
any: [
{ field_slug: 'region', operator: 'equals', value: 'NL' },
{ field_slug: 'region', operator: 'equals', value: 'BE' },
],
},
],
},
}
expect(evaluateConditionalLogic(logic, { role: 'admin', region: 'BE' })).toBe(true)
expect(evaluateConditionalLogic(logic, { role: 'admin', region: 'DE' })).toBe(false)
expect(evaluateConditionalLogic(logic, { role: 'guest', region: 'NL' })).toBe(false)
})
it('does not throw on missing field values', () => {
const eq: ConditionalLogic = {
show_when: { all: [{ field_slug: 'missing', operator: 'equals', value: 'x' }] },
}
expect(() => evaluateConditionalLogic(eq, {})).not.toThrow()
expect(evaluateConditionalLogic(eq, {})).toBe(false)
const emptyCheck: ConditionalLogic = {
show_when: { all: [{ field_slug: 'missing', operator: 'empty' }] },
}
expect(evaluateConditionalLogic(emptyCheck, {})).toBe(true)
})
it('handles greater_than / less_than with non-numeric fallback', () => {
const gt: ConditionalLogic = {
show_when: { all: [{ field_slug: 'age', operator: 'greater_than', value: 18 }] },
}
expect(evaluateConditionalLogic(gt, { age: 20 })).toBe(true)
expect(evaluateConditionalLogic(gt, { age: 17 })).toBe(false)
expect(evaluateConditionalLogic(gt, { age: 'not-a-number' })).toBe(false)
})
})

View File

@@ -0,0 +1,18 @@
import { vi } from 'vitest'
// Deterministic idempotency-key generation for useFormDraft tests.
if (!globalThis.crypto) {
;(globalThis as { crypto: Crypto }).crypto = {
randomUUID: () => '00000000-0000-4000-8000-000000000000',
getRandomValues: (buf: Uint8Array) => {
for (let i = 0; i < buf.length; i++) buf[i] = 0
return buf
},
} as unknown as Crypto
}
// Suppress Vue-router usage in isolated composable tests.
vi.mock('vue-router', () => ({
useRoute: () => ({ params: {}, query: {} }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))

View File

@@ -0,0 +1,34 @@
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import { defineConfig } from 'vitest/config'
// Dedicated Vitest config — intentionally trimmed down from vite.config.ts.
// We skip Vuetify / MetaLayouts / VueRouter plugins so unit tests run fast
// in jsdom without loading the full Vuexy bundle.
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', '@vueuse/core'],
dirs: ['./src/@core/utils', './src/@core/composable/', './src/composables/', './src/utils/'],
vueTemplate: true,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@core': fileURLToPath(new URL('./src/@core', import.meta.url)),
'@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/assets/styles/', import.meta.url)),
'@validators': fileURLToPath(new URL('./src/@core/utils/validators', import.meta.url)),
},
},
test: {
environment: 'jsdom',
globals: true,
include: ['tests/**/*.{test,spec}.ts'],
setupFiles: ['./tests/setup.ts'],
},
})