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:
102
apps/portal/tests/components/public-form/FieldRenderer.test.ts
Normal file
102
apps/portal/tests/components/public-form/FieldRenderer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
94
apps/portal/tests/composables/useConditionalLogic.test.ts
Normal file
94
apps/portal/tests/composables/useConditionalLogic.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
apps/portal/tests/setup.ts
Normal file
18
apps/portal/tests/setup.ts
Normal 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() }),
|
||||
}))
|
||||
34
apps/portal/vitest.config.ts
Normal file
34
apps/portal/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user