feat: add API optimizations, cache invalidation and comprehensive test suite
- Add abort controllers and request deduplication to composables - Add entity type cache invalidation on create/update/delete flows - Add 179 new tests (utilities, services, composables, components) - Fix Vue runtime warnings in structure editors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
361
tests/components/ModelTypeForm.test.ts
Normal file
361
tests/components/ModelTypeForm.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — modelUtils (structure functions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('~/shared/modelUtils', () => ({
|
||||
normalizeStructureForEditor: (s: any) => s ?? { customFields: [] },
|
||||
normalizeStructureForSave: (s: any) => s ?? { customFields: [] },
|
||||
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
|
||||
normalizeProductStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
|
||||
formatStructurePreview: () => 'Component preview',
|
||||
formatPieceStructurePreview: () => 'Piece preview',
|
||||
formatProductStructurePreview: () => 'Product preview',
|
||||
defaultStructure: () => ({ customFields: [] }),
|
||||
defaultPieceStructure: () => ({ customFields: [], products: [] }),
|
||||
defaultProductStructure: () => ({ customFields: [], products: [] }),
|
||||
cloneStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [] })),
|
||||
clonePieceStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
|
||||
cloneProductStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const globalStubs = {
|
||||
ComponentModelStructureEditor: { template: '<div data-testid="comp-editor" />' },
|
||||
PieceModelStructureEditor: { template: '<div data-testid="piece-editor" />' },
|
||||
}
|
||||
|
||||
function mountForm(props: Record<string, any> = {}) {
|
||||
return shallowMount(ModelTypeForm, {
|
||||
props: {
|
||||
mode: 'create',
|
||||
initialCategory: 'COMPONENT',
|
||||
lockCategory: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: globalStubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getNameInput(wrapper: VueWrapper) {
|
||||
return wrapper.find('input[name="name"]')
|
||||
}
|
||||
|
||||
function getCategorySelect(wrapper: VueWrapper) {
|
||||
return wrapper.find('select[name="category"]')
|
||||
}
|
||||
|
||||
function getNotesTextarea(wrapper: VueWrapper) {
|
||||
return wrapper.find('textarea[name="notes"]')
|
||||
}
|
||||
|
||||
function getSubmitButton(wrapper: VueWrapper) {
|
||||
return wrapper.find('button[type="submit"]')
|
||||
}
|
||||
|
||||
function getCancelButton(wrapper: VueWrapper) {
|
||||
return wrapper.find('button[type="button"]')
|
||||
}
|
||||
|
||||
async function submitForm(wrapper: VueWrapper) {
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('rendering', () => {
|
||||
it('renders form with name, category, and notes fields', () => {
|
||||
const wrapper = mountForm()
|
||||
expect(getNameInput(wrapper).exists()).toBe(true)
|
||||
expect(getCategorySelect(wrapper).exists()).toBe(true)
|
||||
expect(getNotesTextarea(wrapper).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows "Créer" button in create mode', () => {
|
||||
const wrapper = mountForm({ mode: 'create' })
|
||||
expect(getSubmitButton(wrapper).text()).toBe('Créer')
|
||||
})
|
||||
|
||||
it('shows "Enregistrer" button in edit mode', () => {
|
||||
const wrapper = mountForm({ mode: 'edit' })
|
||||
expect(getSubmitButton(wrapper).text()).toBe('Enregistrer')
|
||||
})
|
||||
|
||||
it('populates form from initialData in edit mode', async () => {
|
||||
const wrapper = mountForm({
|
||||
mode: 'edit',
|
||||
initialData: {
|
||||
name: 'Existing Type',
|
||||
code: 'existing-type',
|
||||
category: 'COMPONENT',
|
||||
notes: 'Some notes',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect((getNameInput(wrapper).element as HTMLInputElement).value).toBe('Existing Type')
|
||||
expect((getNotesTextarea(wrapper).element as HTMLTextAreaElement).value).toBe('Some notes')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('validation', () => {
|
||||
it('shows error for name shorter than 2 characters', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('A')
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain('au moins 2 caractères')
|
||||
expect(wrapper.emitted('submit')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows error for name longer than 120 characters', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('A'.repeat(121))
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain('ne peut pas dépasser 120')
|
||||
expect(wrapper.emitted('submit')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('submits with valid name', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Mon Type')
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')![0]).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show error with valid name', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Valid Name')
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.text()).not.toContain('au moins 2')
|
||||
expect(wrapper.text()).not.toContain('ne peut pas dépasser')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code generation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('code generation', () => {
|
||||
it('auto-generates code from name in create mode', async () => {
|
||||
const wrapper = mountForm({ mode: 'create' })
|
||||
await getNameInput(wrapper).setValue('Ma Catégorie')
|
||||
await nextTick()
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.code).toBe('ma-categorie')
|
||||
})
|
||||
|
||||
it('preserves initialData code in edit mode', async () => {
|
||||
const wrapper = mountForm({
|
||||
mode: 'edit',
|
||||
initialData: {
|
||||
name: 'Type',
|
||||
code: 'custom-code',
|
||||
category: 'COMPONENT',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.code).toBe('custom-code')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category-based structure editor rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('structure editor rendering', () => {
|
||||
it('renders ComponentModelStructureEditor for COMPONENT', () => {
|
||||
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
|
||||
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders PieceModelStructureEditor for PIECE', () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PIECE' })
|
||||
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders PieceModelStructureEditor for PRODUCT', () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
|
||||
// PRODUCT reuses PieceModelStructureEditor
|
||||
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category lock
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('category lock', () => {
|
||||
it('disables category select when lockCategory is true', () => {
|
||||
const wrapper = mountForm({ lockCategory: true })
|
||||
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables category select when lockCategory is false', () => {
|
||||
const wrapper = mountForm({ lockCategory: false })
|
||||
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restricted mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('restricted mode', () => {
|
||||
it('shows restricted mode message', () => {
|
||||
const wrapper = mountForm({
|
||||
restrictedMode: true,
|
||||
restrictedModeMessage: 'Mode restreint actif',
|
||||
})
|
||||
expect(wrapper.text()).toContain('Mode restreint actif')
|
||||
expect(wrapper.find('.alert-info').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show restricted mode message when not restricted', () => {
|
||||
const wrapper = mountForm({
|
||||
restrictedMode: false,
|
||||
})
|
||||
expect(wrapper.find('.alert-info').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables name input in restricted mode', () => {
|
||||
const wrapper = mountForm({ restrictedMode: true })
|
||||
expect((getNameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit disabled
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('submit disabled', () => {
|
||||
it('disables submit button when disableSubmit is true', () => {
|
||||
const wrapper = mountForm({ disableSubmit: true })
|
||||
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows warning alert when disableSubmit is true', () => {
|
||||
const wrapper = mountForm({
|
||||
disableSubmit: true,
|
||||
disableSubmitMessage: 'Cannot save now',
|
||||
})
|
||||
expect(wrapper.find('.alert-warning').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Cannot save now')
|
||||
})
|
||||
|
||||
it('does not show warning when disableSubmit is false', () => {
|
||||
const wrapper = mountForm({ disableSubmit: false })
|
||||
expect(wrapper.find('.alert-warning').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saving state
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('saving state', () => {
|
||||
it('disables submit button when saving', () => {
|
||||
const wrapper = mountForm({ saving: true })
|
||||
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables cancel button when saving', () => {
|
||||
const wrapper = mountForm({ saving: true })
|
||||
expect((getCancelButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows spinner when saving', () => {
|
||||
const wrapper = mountForm({ saving: true })
|
||||
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('cancel', () => {
|
||||
it('emits cancel on cancel button click', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getCancelButton(wrapper).trigger('click')
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit payload
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('submit payload', () => {
|
||||
it('emits payload with COMPONENT category and structure', async () => {
|
||||
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
|
||||
await getNameInput(wrapper).setValue('Test Component')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.category).toBe('COMPONENT')
|
||||
expect(payload.name).toBe('Test Component')
|
||||
expect(payload.structure).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits payload with PIECE category', async () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PIECE' })
|
||||
await getNameInput(wrapper).setValue('Test Piece')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.category).toBe('PIECE')
|
||||
})
|
||||
|
||||
it('emits payload with PRODUCT category', async () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
|
||||
await getNameInput(wrapper).setValue('Test Product')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.category).toBe('PRODUCT')
|
||||
})
|
||||
|
||||
it('includes trimmed notes in payload', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Test')
|
||||
await getNotesTextarea(wrapper).setValue(' Some notes ')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.notes).toBe('Some notes')
|
||||
})
|
||||
|
||||
it('omits empty notes from payload', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Test')
|
||||
await getNotesTextarea(wrapper).setValue('')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.notes).toBeUndefined()
|
||||
})
|
||||
})
|
||||
396
tests/components/PieceModelStructureEditor.test.ts
Normal file
396
tests/components/PieceModelStructureEditor.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
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<string, any> = {}) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user