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