import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' import { nextTick, ref } from 'vue' import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue' // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- vi.mock('~/composables/useProductTypes', () => ({ useProductTypes: () => ({ productTypes: ref([]), loadingProductTypes: ref(false), loadProductTypes: vi.fn().mockResolvedValue({ success: true }), createProductType: vi.fn(), updateProductType: vi.fn(), deleteProductType: vi.fn(), getProductTypes: () => [], isProductTypeLoading: () => false, }), })) vi.mock('~/shared/modelUtils', () => ({ normalizePieceStructureForSave: (s: any) => s ?? { customFields: [] }, })) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function mountEditor(props: Record = {}) { return mount(PieceModelStructureEditor, { props: { modelValue: { customFields: [], products: [] }, ...props, }, }) } function getAddFieldButton(wrapper: VueWrapper) { // The second "Ajouter" button is for custom fields const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter')) return buttons[buttons.length - 1] } function getAddProductButton(wrapper: VueWrapper) { // The first "Ajouter" button is for products const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter')) return buttons[0] } beforeEach(() => { vi.clearAllMocks() }) // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- describe('empty state', () => { it('renders with no fields and shows empty message', () => { const wrapper = mountEditor() expect(wrapper.text()).toContain('Aucun champ personnalisé') }) it('renders with no products and shows empty message', () => { const wrapper = mountEditor() expect(wrapper.text()).toContain('Aucun produit défini') }) it('shows add field button', () => { const wrapper = mountEditor() expect(getAddFieldButton(wrapper).exists()).toBe(true) }) }) // --------------------------------------------------------------------------- // Add custom field // --------------------------------------------------------------------------- describe('add custom field', () => { it('adds a new empty field on button click', async () => { const wrapper = mountEditor() await getAddFieldButton(wrapper).trigger('click') await nextTick() const nameInputs = wrapper.findAll('input[type="text"]') expect(nameInputs.length).toBeGreaterThanOrEqual(1) expect(wrapper.text()).not.toContain('Aucun champ personnalisé') }) it('new field has type "text" by default', async () => { const wrapper = mountEditor() await getAddFieldButton(wrapper).trigger('click') await nextTick() const selects = wrapper.findAll('select') // The last select should be the type select for the new field const typeSelect = selects[selects.length - 1] expect((typeSelect.element as HTMLSelectElement).value).toBe('text') }) it('emits update:modelValue after adding field', async () => { const wrapper = mountEditor() await getAddFieldButton(wrapper).trigger('click') await nextTick() expect(wrapper.emitted('update:modelValue')).toBeTruthy() }) }) // --------------------------------------------------------------------------- // Remove custom field // --------------------------------------------------------------------------- describe('remove custom field', () => { it('removes field on delete button click', async () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }], products: [], }, }) // Find the delete button (btn-error) const deleteBtn = wrapper.find('button.btn-error') expect(deleteBtn.exists()).toBe(true) await deleteBtn.trigger('click') await nextTick() expect(wrapper.text()).toContain('Aucun champ personnalisé') expect(wrapper.emitted('update:modelValue')).toBeTruthy() }) }) // --------------------------------------------------------------------------- // Field name editing // --------------------------------------------------------------------------- describe('field name editing', () => { it('updates field name on input', async () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Old Name', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) const nameInput = wrapper.find('input[type="text"]') await nameInput.setValue('New Name') await nextTick() const events = wrapper.emitted('update:modelValue') expect(events).toBeTruthy() const lastPayload = events![events!.length - 1][0] as any expect(lastPayload.customFields[0].name).toBe('New Name') }) }) // --------------------------------------------------------------------------- // Field type change // --------------------------------------------------------------------------- describe('field type change', () => { it('changes field type via select', async () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Size', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) // Find the type select (not the product type select) const selects = wrapper.findAll('select') const typeSelect = selects[selects.length - 1] await typeSelect.setValue('number') await nextTick() const events = wrapper.emitted('update:modelValue') expect(events).toBeTruthy() const lastPayload = events![events!.length - 1][0] as any expect(lastPayload.customFields[0].type).toBe('number') }) it('shows options textarea for select type', async () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: 'Red\nBlue' }], products: [], }, }) const textarea = wrapper.find('textarea') expect(textarea.exists()).toBe(true) }) it('hides options textarea for non-select types', () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }], products: [], }, }) const textarea = wrapper.find('textarea') expect(textarea.exists()).toBe(false) }) }) // --------------------------------------------------------------------------- // Required checkbox // --------------------------------------------------------------------------- describe('required checkbox', () => { it('toggles required on checkbox change', async () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Test', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) const checkbox = wrapper.find('input[type="checkbox"]') await checkbox.setValue(true) await nextTick() const events = wrapper.emitted('update:modelValue') expect(events).toBeTruthy() const lastPayload = events![events!.length - 1][0] as any expect(lastPayload.customFields[0].required).toBe(true) }) }) // --------------------------------------------------------------------------- // Restricted mode // --------------------------------------------------------------------------- describe('restricted mode', () => { it('allows editing name of pre-existing field', () => { const wrapper = mountEditor({ restrictedMode: true, modelValue: { customFields: [{ name: 'Locked Field', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) const nameInput = wrapper.find('input[type="text"]') expect((nameInput.element as HTMLInputElement).disabled).toBe(false) }) it('disables type select for pre-existing field', () => { const wrapper = mountEditor({ restrictedMode: true, modelValue: { customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) const selects = wrapper.findAll('select') const typeSelect = selects[selects.length - 1] expect((typeSelect.element as HTMLSelectElement).disabled).toBe(true) }) it('disables required checkbox for pre-existing field', () => { const wrapper = mountEditor({ restrictedMode: true, modelValue: { customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) const checkbox = wrapper.find('input[type="checkbox"]') expect((checkbox.element as HTMLInputElement).disabled).toBe(true) }) it('hides delete button for pre-existing field', () => { const wrapper = mountEditor({ restrictedMode: true, modelValue: { customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }], products: [], }, }) // btn-error should not exist for locked fields const deleteBtn = wrapper.find('button.btn-error') expect(deleteBtn.exists()).toBe(false) }) it('allows full editing of newly added field', async () => { const wrapper = mountEditor({ restrictedMode: true, modelValue: { customFields: [], products: [], }, }) await getAddFieldButton(wrapper).trigger('click') await nextTick() // New field should have an editable type select (not disabled) const selects = wrapper.findAll('select') const typeSelect = selects[selects.length - 1] expect((typeSelect.element as HTMLSelectElement).disabled).toBe(false) // Delete button should exist for new field const deleteBtn = wrapper.find('button.btn-error') expect(deleteBtn.exists()).toBe(true) }) it('hides product add button in restricted mode', () => { const wrapper = mountEditor({ restrictedMode: true, modelValue: { customFields: [], products: [] }, }) const addButtons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter')) // Only the "add field" button should be visible, not the product one expect(addButtons.length).toBe(1) }) }) // --------------------------------------------------------------------------- // Add product // --------------------------------------------------------------------------- describe('add product', () => { it('adds a product entry on button click', async () => { const wrapper = mountEditor() await getAddProductButton(wrapper).trigger('click') await nextTick() expect(wrapper.text()).not.toContain('Aucun produit défini') // Should have a product type select expect(wrapper.findAll('select').length).toBeGreaterThanOrEqual(1) }) }) // --------------------------------------------------------------------------- // Options parsing // --------------------------------------------------------------------------- describe('options parsing', () => { it('parses multiline options text into array', async () => { const wrapper = mountEditor({ modelValue: { customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: '' }], products: [], }, }) const textarea = wrapper.find('textarea') await textarea.setValue('Red\nGreen\nBlue') await nextTick() const events = wrapper.emitted('update:modelValue') expect(events).toBeTruthy() const lastPayload = events![events!.length - 1][0] as any expect(lastPayload.customFields[0].options).toEqual(['Red', 'Green', 'Blue']) }) }) // --------------------------------------------------------------------------- // Hydration from modelValue // --------------------------------------------------------------------------- describe('hydration', () => { it('renders existing fields from modelValue', () => { const wrapper = mountEditor({ modelValue: { customFields: [ { name: 'Weight', type: 'number', required: true, orderIndex: 0 }, { name: 'Color', type: 'text', required: false, orderIndex: 1 }, ], products: [], }, }) const nameInputs = wrapper.findAll('input[type="text"]') expect(nameInputs.length).toBe(2) expect((nameInputs[0].element as HTMLInputElement).value).toBe('Weight') expect((nameInputs[1].element as HTMLInputElement).value).toBe('Color') }) it('sorts fields by orderIndex', () => { const wrapper = mountEditor({ modelValue: { customFields: [ { name: 'Second', type: 'text', required: false, orderIndex: 1 }, { name: 'First', type: 'text', required: false, orderIndex: 0 }, ], products: [], }, }) const nameInputs = wrapper.findAll('input[type="text"]') expect((nameInputs[0].element as HTMLInputElement).value).toBe('First') expect((nameInputs[1].element as HTMLInputElement).value).toBe('Second') }) })