From b159fdcc2634374484cc3dc56d27375eb61ad078 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 23 Apr 2026 17:21:04 +0200 Subject: [PATCH] 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) --- .../public-form/FieldRenderer.test.ts | 102 ++++++++++++++++++ .../composables/useConditionalLogic.test.ts | 94 ++++++++++++++++ apps/portal/tests/setup.ts | 18 ++++ apps/portal/vitest.config.ts | 34 ++++++ 4 files changed, 248 insertions(+) create mode 100644 apps/portal/tests/components/public-form/FieldRenderer.test.ts create mode 100644 apps/portal/tests/composables/useConditionalLogic.test.ts create mode 100644 apps/portal/tests/setup.ts create mode 100644 apps/portal/vitest.config.ts diff --git a/apps/portal/tests/components/public-form/FieldRenderer.test.ts b/apps/portal/tests/components/public-form/FieldRenderer.test.ts new file mode 100644 index 00000000..3d96cec1 --- /dev/null +++ b/apps/portal/tests/components/public-form/FieldRenderer.test.ts @@ -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 { + 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 = {}) { + return mount(FieldRenderer, { + props: { field, modelValue: undefined, allValues }, + global: { + stubs: { + VCol: { name: 'VCol', template: '
' }, + VAlert: { name: 'VAlert', template: '
' }, + FieldText: { name: 'FieldText', template: '
' }, + FieldTextarea: { name: 'FieldTextarea', template: '
' }, + FieldEmail: { name: 'FieldEmail', template: '
' }, + FieldPhone: { name: 'FieldPhone', template: '
' }, + FieldNumber: { name: 'FieldNumber', template: '
' }, + FieldDate: { name: 'FieldDate', template: '
' }, + FieldBoolean: { name: 'FieldBoolean', template: '
' }, + FieldRadio: { name: 'FieldRadio', template: '
' }, + FieldSelect: { name: 'FieldSelect', template: '
' }, + FieldMultiselect: { name: 'FieldMultiselect', template: '
' }, + FieldCheckboxList: { name: 'FieldCheckboxList', template: '
' }, + FieldHeading: { name: 'FieldHeading', template: '
' }, + FieldParagraph: { name: 'FieldParagraph', template: '
' }, + FieldUrl: { name: 'FieldUrl', template: '
' }, + }, + }, + }) +} + +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([ + 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) + }) +}) diff --git a/apps/portal/tests/composables/useConditionalLogic.test.ts b/apps/portal/tests/composables/useConditionalLogic.test.ts new file mode 100644 index 00000000..c1305c14 --- /dev/null +++ b/apps/portal/tests/composables/useConditionalLogic.test.ts @@ -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) + }) +}) diff --git a/apps/portal/tests/setup.ts b/apps/portal/tests/setup.ts new file mode 100644 index 00000000..3e0c6611 --- /dev/null +++ b/apps/portal/tests/setup.ts @@ -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() }), +})) diff --git a/apps/portal/vitest.config.ts b/apps/portal/vitest.config.ts new file mode 100644 index 00000000..728a5aea --- /dev/null +++ b/apps/portal/vitest.config.ts @@ -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'], + }, +})