refactor : merge Inventory_frontend submodule into frontend/ directory
Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
frontend/tests/__mocks__/iconStub.ts
Normal file
12
frontend/tests/__mocks__/iconStub.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Stub for ~icons/* imports (Unplugin Icons).
|
||||
* Returns a minimal Vue component that renders a <span>.
|
||||
*/
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'IconStub',
|
||||
render() {
|
||||
return h('span', { class: 'icon-stub' })
|
||||
},
|
||||
})
|
||||
35
frontend/tests/__mocks__/imports.ts
Normal file
35
frontend/tests/__mocks__/imports.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Minimal mock for Nuxt's #imports auto-import.
|
||||
* Add stubs here as tests require them.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useRuntimeConfig = () => ({
|
||||
public: {
|
||||
apiBaseUrl: 'http://localhost:8081/api',
|
||||
appVersion: '0.0.0-test',
|
||||
},
|
||||
})
|
||||
|
||||
export const useRoute = () => ({
|
||||
path: '/',
|
||||
params: {},
|
||||
query: {},
|
||||
})
|
||||
|
||||
export const useRouter = () => ({
|
||||
push: () => Promise.resolve(),
|
||||
replace: () => Promise.resolve(),
|
||||
})
|
||||
|
||||
export const navigateTo = () => Promise.resolve()
|
||||
|
||||
export const useRequestFetch = () => fetch
|
||||
|
||||
export const useFetch = () => ({
|
||||
data: ref(null),
|
||||
error: ref(null),
|
||||
pending: ref(false),
|
||||
refresh: () => Promise.resolve(),
|
||||
})
|
||||
311
frontend/tests/components/ModelTypeForm.test.ts
Normal file
311
frontend/tests/components/ModelTypeForm.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
304
frontend/tests/components/PieceModelStructureEditor.test.ts
Normal file
304
frontend/tests/components/PieceModelStructureEditor.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
81
frontend/tests/composables/useConfirm.test.ts
Normal file
81
frontend/tests/composables/useConfirm.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
describe('useConfirm', () => {
|
||||
it('returns confirm function and state', () => {
|
||||
const { confirm, confirmState, handleConfirm, handleCancel } = useConfirm()
|
||||
expect(typeof confirm).toBe('function')
|
||||
expect(typeof handleConfirm).toBe('function')
|
||||
expect(typeof handleCancel).toBe('function')
|
||||
expect(confirmState.open).toBe(false)
|
||||
})
|
||||
|
||||
it('opens modal with correct options', () => {
|
||||
const { confirm, confirmState } = useConfirm()
|
||||
// Don't await — we'll manually resolve
|
||||
confirm({ message: 'Delete this item?' })
|
||||
expect(confirmState.open).toBe(true)
|
||||
expect(confirmState.message).toBe('Delete this item?')
|
||||
expect(confirmState.title).toBe('Confirmation')
|
||||
expect(confirmState.confirmText).toBe('Supprimer')
|
||||
expect(confirmState.cancelText).toBe('Annuler')
|
||||
expect(confirmState.dangerous).toBe(true)
|
||||
// Clean up by canceling
|
||||
const { handleCancel } = useConfirm()
|
||||
handleCancel()
|
||||
})
|
||||
|
||||
it('resolves true on confirm', async () => {
|
||||
const { confirm, handleConfirm } = useConfirm()
|
||||
const promise = confirm({ message: 'Confirm?' })
|
||||
handleConfirm()
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves false on cancel', async () => {
|
||||
const { confirm, handleCancel } = useConfirm()
|
||||
const promise = confirm({ message: 'Cancel?' })
|
||||
handleCancel()
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('closes modal after confirm', async () => {
|
||||
const { confirm, confirmState, handleConfirm } = useConfirm()
|
||||
confirm({ message: 'Test' })
|
||||
expect(confirmState.open).toBe(true)
|
||||
handleConfirm()
|
||||
expect(confirmState.open).toBe(false)
|
||||
})
|
||||
|
||||
it('closes modal after cancel', async () => {
|
||||
const { confirm, confirmState, handleCancel } = useConfirm()
|
||||
confirm({ message: 'Test' })
|
||||
expect(confirmState.open).toBe(true)
|
||||
handleCancel()
|
||||
expect(confirmState.open).toBe(false)
|
||||
})
|
||||
|
||||
it('supports custom options', () => {
|
||||
const { confirm, confirmState, handleCancel } = useConfirm()
|
||||
confirm({
|
||||
title: 'Custom Title',
|
||||
message: 'Custom message',
|
||||
confirmText: 'Yes',
|
||||
cancelText: 'No',
|
||||
dangerous: false,
|
||||
})
|
||||
expect(confirmState.title).toBe('Custom Title')
|
||||
expect(confirmState.confirmText).toBe('Yes')
|
||||
expect(confirmState.cancelText).toBe('No')
|
||||
expect(confirmState.dangerous).toBe(false)
|
||||
handleCancel()
|
||||
})
|
||||
|
||||
it('shares state across calls (singleton)', () => {
|
||||
const a = useConfirm()
|
||||
const b = useConfirm()
|
||||
expect(a.confirmState).toBe(b.confirmState)
|
||||
})
|
||||
})
|
||||
412
frontend/tests/composables/useEntityTypes.test.ts
Normal file
412
frontend/tests/composables/useEntityTypes.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { useEntityTypes, invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('~/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
showInfo: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
toasts: { value: [] },
|
||||
clearAll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockListModelTypes = vi.fn()
|
||||
const mockCreateModelType = vi.fn()
|
||||
const mockUpdateModelType = vi.fn()
|
||||
const mockDeleteModelType = vi.fn()
|
||||
|
||||
vi.mock('~/services/modelTypes', () => ({
|
||||
listModelTypes: (...args: any[]) => mockListModelTypes(...args),
|
||||
createModelType: (...args: any[]) => mockCreateModelType(...args),
|
||||
updateModelType: (...args: any[]) => mockUpdateModelType(...args),
|
||||
deleteModelType: (...args: any[]) => mockDeleteModelType(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fakeItem = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
code: name.toLowerCase(),
|
||||
category: 'COMPONENT' as const,
|
||||
structure: null,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
notes: null,
|
||||
description: null,
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
// Reset singleton state via public APIs
|
||||
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
comp.types.value = []
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
|
||||
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
|
||||
piece.types.value = []
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
|
||||
const product = useEntityTypes({ category: 'PRODUCT', label: 'produit' })
|
||||
product.types.value = []
|
||||
invalidateEntityTypeCache('PRODUCT')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetState()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadTypes
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('loadTypes', () => {
|
||||
it('fetches from API and stores in types', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Type A'), fakeItem('2', 'Type B')],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await loadTypes()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(2)
|
||||
expect(types.value[0].name).toBe('Type A')
|
||||
expect(mockListModelTypes).toHaveBeenCalledOnce()
|
||||
expect(mockListModelTypes).toHaveBeenCalledWith({
|
||||
category: 'COMPONENT',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns cached data on second call without force', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Cached')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call — should return cache
|
||||
const result = await loadTypes()
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(1) // NOT called again
|
||||
})
|
||||
|
||||
it('refetches with force: true', async () => {
|
||||
mockListModelTypes
|
||||
.mockResolvedValueOnce({ items: [fakeItem('1', 'Old')], total: 1, offset: 0, limit: 200 })
|
||||
.mockResolvedValueOnce({ items: [fakeItem('1', 'New')], total: 1, offset: 0, limit: 200 })
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(types.value[0].name).toBe('Old')
|
||||
|
||||
await loadTypes({ force: true })
|
||||
expect(types.value[0].name).toBe('New')
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('sets loading during fetch', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
mockListModelTypes.mockReturnValue(new Promise((resolve) => { resolvePromise = resolve }))
|
||||
|
||||
const { loadTypes, loading } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
expect(loading.value).toBe(false)
|
||||
|
||||
const promise = loadTypes()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolvePromise!({ items: [], total: 0, offset: 0, limit: 200 })
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error on API failure', async () => {
|
||||
mockListModelTypes.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await loadTypes()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Network error')
|
||||
expect(types.value).toEqual([])
|
||||
expect(mockShowError).toHaveBeenCalledWith(
|
||||
'Impossible de charger les types de composant.',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes items with description fallback', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [{ ...fakeItem('1', 'Test'), notes: 'From notes', description: null }],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
expect(types.value[0].description).toBe('From notes')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('createType', () => {
|
||||
it('creates and pushes to types array', async () => {
|
||||
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Created'))
|
||||
|
||||
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await createType({ name: 'Created' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(types.value[0].name).toBe('Created')
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Created'))
|
||||
})
|
||||
|
||||
it('sends correct payload with auto-generated code', async () => {
|
||||
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Ma Catégorie'))
|
||||
|
||||
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await createType({ name: 'Ma Catégorie' })
|
||||
|
||||
expect(mockCreateModelType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Ma Catégorie',
|
||||
code: 'ma-categorie',
|
||||
category: 'COMPONENT',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses provided code if available', async () => {
|
||||
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Test'))
|
||||
|
||||
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await createType({ name: 'Test', code: 'custom-code' })
|
||||
|
||||
expect(mockCreateModelType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'custom-code' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error on API failure without modifying types', async () => {
|
||||
mockCreateModelType.mockRejectedValue(new Error('Create failed'))
|
||||
|
||||
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await createType({ name: 'Fail' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(types.value).toEqual([])
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('updateType', () => {
|
||||
it('updates the existing item in types array', async () => {
|
||||
// Pre-populate
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Original')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
mockUpdateModelType.mockResolvedValue(fakeItem('1', 'Updated'))
|
||||
const result = await updateType('1', { name: 'Updated' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(types.value[0].name).toBe('Updated')
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Updated'))
|
||||
})
|
||||
|
||||
it('shows error on API failure without modifying types', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Original')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
mockUpdateModelType.mockRejectedValue(new Error('Update failed'))
|
||||
const result = await updateType('1', { name: 'Bad' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(types.value[0].name).toBe('Original')
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('deleteType', () => {
|
||||
it('removes item from types array', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'A'), fakeItem('2', 'B')],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(types.value).toHaveLength(2)
|
||||
|
||||
mockDeleteModelType.mockResolvedValue(undefined)
|
||||
const result = await deleteType('1')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(types.value[0].id).toBe('2')
|
||||
expect(mockShowSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error on API failure without removing item', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Keep')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
mockDeleteModelType.mockRejectedValue(new Error('Delete failed'))
|
||||
const result = await deleteType('1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// invalidateEntityTypeCache
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('invalidateEntityTypeCache', () => {
|
||||
it('forces next loadTypes to refetch', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Cached')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Without invalidation, wouldn't refetch
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Fresh')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes: loadAgain, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadAgain()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
|
||||
expect(types.value[0].name).toBe('Fresh')
|
||||
})
|
||||
|
||||
it('does not crash for unknown category', () => {
|
||||
expect(() => invalidateEntityTypeCache('COMPONENT')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton per category
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('singleton per category', () => {
|
||||
it('shares state between same category calls', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Shared')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const a = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await a.loadTypes()
|
||||
|
||||
const b = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
expect(b.types.value).toHaveLength(1)
|
||||
expect(b.types.value[0].name).toBe('Shared')
|
||||
})
|
||||
|
||||
it('isolates state between different categories', async () => {
|
||||
mockListModelTypes
|
||||
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Component')], total: 1, offset: 0, limit: 200 })
|
||||
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
|
||||
|
||||
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
|
||||
|
||||
await comp.loadTypes()
|
||||
await piece.loadTypes()
|
||||
|
||||
expect(comp.types.value).toHaveLength(1)
|
||||
expect(comp.types.value[0].name).toBe('Component')
|
||||
expect(piece.types.value).toHaveLength(1)
|
||||
expect(piece.types.value[0].name).toBe('Piece')
|
||||
})
|
||||
|
||||
it('invalidateEntityTypeCache only affects target category', async () => {
|
||||
mockListModelTypes
|
||||
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Comp')], total: 1, offset: 0, limit: 200 })
|
||||
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
|
||||
|
||||
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
|
||||
await comp.loadTypes()
|
||||
await piece.loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Invalidate only COMPONENT
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
|
||||
// PIECE should still use cache
|
||||
await piece.loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2) // No extra call
|
||||
|
||||
// COMPONENT should refetch
|
||||
mockListModelTypes.mockResolvedValueOnce({ items: [fakeItem('c1', 'Refreshed')], total: 1, offset: 0, limit: 200 })
|
||||
await comp.loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
83
frontend/tests/composables/useToast.test.ts
Normal file
83
frontend/tests/composables/useToast.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
describe('useToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
const { clearAll } = useToast()
|
||||
clearAll()
|
||||
})
|
||||
|
||||
it('returns all expected functions', () => {
|
||||
const toast = useToast()
|
||||
expect(typeof toast.showToast).toBe('function')
|
||||
expect(typeof toast.showSuccess).toBe('function')
|
||||
expect(typeof toast.showError).toBe('function')
|
||||
expect(typeof toast.showWarning).toBe('function')
|
||||
expect(typeof toast.showInfo).toBe('function')
|
||||
expect(typeof toast.removeToast).toBe('function')
|
||||
expect(typeof toast.clearAll).toBe('function')
|
||||
})
|
||||
|
||||
it('adds a toast with correct properties', () => {
|
||||
const { showToast, toasts } = useToast()
|
||||
const id = showToast('Hello', 'info')
|
||||
expect(toasts.value).toHaveLength(1)
|
||||
expect(toasts.value[0].message).toBe('Hello')
|
||||
expect(toasts.value[0].type).toBe('info')
|
||||
expect(toasts.value[0].visible).toBe(true)
|
||||
expect(toasts.value[0].id).toBe(id)
|
||||
})
|
||||
|
||||
it('showSuccess creates a success toast', () => {
|
||||
const { showSuccess, toasts } = useToast()
|
||||
showSuccess('Saved!')
|
||||
expect(toasts.value[0].type).toBe('success')
|
||||
expect(toasts.value[0].message).toBe('Saved!')
|
||||
})
|
||||
|
||||
it('showError creates an error toast', () => {
|
||||
const { showError, toasts } = useToast()
|
||||
showError('Failed!')
|
||||
expect(toasts.value[0].type).toBe('error')
|
||||
})
|
||||
|
||||
it('showWarning creates a warning toast', () => {
|
||||
const { showWarning, toasts } = useToast()
|
||||
showWarning('Caution!')
|
||||
expect(toasts.value[0].type).toBe('warning')
|
||||
})
|
||||
|
||||
it('limits to MAX_TOASTS (3)', () => {
|
||||
const { showToast, toasts } = useToast()
|
||||
showToast('A', 'info')
|
||||
showToast('B', 'info')
|
||||
showToast('C', 'info')
|
||||
showToast('D', 'info')
|
||||
expect(toasts.value).toHaveLength(3)
|
||||
expect(toasts.value[0].message).toBe('B')
|
||||
expect(toasts.value[2].message).toBe('D')
|
||||
})
|
||||
|
||||
it('clearAll removes all toasts', () => {
|
||||
const { showToast, toasts, clearAll } = useToast()
|
||||
showToast('A', 'info')
|
||||
showToast('B', 'info')
|
||||
expect(toasts.value.length).toBeGreaterThan(0)
|
||||
clearAll()
|
||||
expect(toasts.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shares state across calls (singleton)', () => {
|
||||
const a = useToast()
|
||||
const b = useToast()
|
||||
expect(a.toasts).toBe(b.toasts)
|
||||
})
|
||||
|
||||
it('removeToast sets visible to false', () => {
|
||||
const { showToast, toasts, removeToast } = useToast()
|
||||
const id = showToast('Test', 'info')
|
||||
removeToast(id)
|
||||
expect(toasts.value[0].visible).toBe(false)
|
||||
})
|
||||
})
|
||||
221
frontend/tests/services/modelTypes.test.ts
Normal file
221
frontend/tests/services/modelTypes.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Import AFTER mocking
|
||||
import {
|
||||
createModelType,
|
||||
updateModelType,
|
||||
deleteModelType,
|
||||
getModelType,
|
||||
type ModelType,
|
||||
type ModelTypePayload,
|
||||
} from '~/services/modelTypes'
|
||||
|
||||
// The service uses useRequestFetch from #imports (explicit import)
|
||||
// AND useRuntimeConfig as a Nuxt auto-import (bare global).
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
// Mock the explicit #imports module
|
||||
vi.mock('#imports', () => ({
|
||||
useRuntimeConfig: () => ({
|
||||
public: { apiBaseUrl: 'http://test-api:8081/api' },
|
||||
}),
|
||||
useRequestFetch: () => mockFetch,
|
||||
}))
|
||||
|
||||
// Also stub the global auto-import (Nuxt makes these globally available)
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
public: { apiBaseUrl: 'http://test-api:8081/api' },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fakeModelType = (overrides: Partial<ModelType> = {}): ModelType => ({
|
||||
id: 'mt-1',
|
||||
name: 'Test Type',
|
||||
code: 'test-type',
|
||||
category: 'COMPONENT',
|
||||
structure: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeModelType (via getModelType)', () => {
|
||||
it('returns structure as-is for COMPONENT', async () => {
|
||||
const structure = { customFields: [{ name: 'Weight' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'COMPONENT',
|
||||
structure: structure as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(structure)
|
||||
})
|
||||
|
||||
it('returns structure as-is for PIECE', async () => {
|
||||
const structure = { customFields: [{ name: 'Size' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'PIECE',
|
||||
structure: structure as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(structure)
|
||||
})
|
||||
|
||||
it('returns structure as-is for PRODUCT', async () => {
|
||||
const structure = { customFields: [{ name: 'Brand' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'PRODUCT',
|
||||
structure: structure as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(structure)
|
||||
})
|
||||
|
||||
it('preserves null structure', async () => {
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'COMPONENT',
|
||||
structure: null,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createModelType — sends structure directly
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('createModelType', () => {
|
||||
it('sends POST with structure for COMPONENT', async () => {
|
||||
const structure = { customFields: [] }
|
||||
mockFetch.mockResolvedValue(fakeModelType())
|
||||
|
||||
const payload: ModelTypePayload = {
|
||||
name: 'New Type',
|
||||
code: 'new-type',
|
||||
category: 'COMPONENT',
|
||||
structure: structure as any,
|
||||
}
|
||||
|
||||
await createModelType(payload)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.body.structure).toEqual(structure)
|
||||
})
|
||||
|
||||
it('sends POST with structure for PIECE', async () => {
|
||||
const structure = { customFields: [], products: [] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
|
||||
|
||||
await createModelType({
|
||||
name: 'Piece Type',
|
||||
code: 'piece-type',
|
||||
category: 'PIECE',
|
||||
structure: structure as any,
|
||||
})
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.body.structure).toEqual(structure)
|
||||
})
|
||||
|
||||
it('sends POST with structure for PRODUCT', async () => {
|
||||
const structure = { customFields: [] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
|
||||
|
||||
await createModelType({
|
||||
name: 'Product Type',
|
||||
code: 'product-type',
|
||||
category: 'PRODUCT',
|
||||
structure: structure as any,
|
||||
})
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.body.structure).toEqual(structure)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateModelType — sends structure directly
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('updateModelType', () => {
|
||||
it('sends PATCH with correct endpoint and structure', async () => {
|
||||
const structure = { customFields: [{ name: 'Updated' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType())
|
||||
|
||||
await updateModelType('mt-1', {
|
||||
name: 'Updated',
|
||||
code: 'updated',
|
||||
category: 'COMPONENT',
|
||||
structure: structure as any,
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types/mt-1')
|
||||
expect(options.method).toBe('PATCH')
|
||||
expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
|
||||
expect(options.body.structure).toEqual(structure)
|
||||
})
|
||||
|
||||
it('sends payload without structure when not provided', async () => {
|
||||
mockFetch.mockResolvedValue(fakeModelType())
|
||||
|
||||
await updateModelType('mt-1', {
|
||||
name: 'Just Name',
|
||||
code: 'just-name',
|
||||
category: 'COMPONENT',
|
||||
})
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.body.structure).toBeUndefined()
|
||||
expect(options.body.name).toBe('Just Name')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteModelType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('deleteModelType', () => {
|
||||
it('sends DELETE to correct endpoint', async () => {
|
||||
mockFetch.mockResolvedValue(undefined)
|
||||
|
||||
await deleteModelType('mt-42')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types/mt-42')
|
||||
expect(options.method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getModelType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getModelType', () => {
|
||||
it('sends GET to correct endpoint', async () => {
|
||||
mockFetch.mockResolvedValue(fakeModelType({ id: 'mt-99' }))
|
||||
|
||||
const result = await getModelType('mt-99')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types/mt-99')
|
||||
expect(options.method).toBe('GET')
|
||||
expect(result.id).toBe('mt-99')
|
||||
})
|
||||
})
|
||||
50
frontend/tests/shared/apiHelpers.test.ts
Normal file
50
frontend/tests/shared/apiHelpers.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
describe('extractCollection', () => {
|
||||
it('returns the input if it is already an array', () => {
|
||||
const items = [{ id: 1 }, { id: 2 }]
|
||||
expect(extractCollection(items)).toEqual(items)
|
||||
})
|
||||
|
||||
it('extracts from hydra:member', () => {
|
||||
const payload = { 'hydra:member': [{ id: 1 }], 'hydra:totalItems': 1 }
|
||||
expect(extractCollection(payload)).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('extracts from member', () => {
|
||||
const payload = { member: [{ id: 1 }, { id: 2 }] }
|
||||
expect(extractCollection(payload)).toEqual([{ id: 1 }, { id: 2 }])
|
||||
})
|
||||
|
||||
it('extracts from items', () => {
|
||||
const payload = { items: [{ id: 1 }] }
|
||||
expect(extractCollection(payload)).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('extracts from data', () => {
|
||||
const payload = { data: [{ id: 1 }] }
|
||||
expect(extractCollection(payload)).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('prefers member over hydra:member', () => {
|
||||
const payload = { member: [{ id: 'member' }], 'hydra:member': [{ id: 'hydra' }] }
|
||||
expect(extractCollection(payload)).toEqual([{ id: 'member' }])
|
||||
})
|
||||
|
||||
it('returns empty array for null', () => {
|
||||
expect(extractCollection(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
expect(extractCollection(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty object', () => {
|
||||
expect(extractCollection({})).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for string', () => {
|
||||
expect(extractCollection('not an object')).toEqual([])
|
||||
})
|
||||
})
|
||||
508
frontend/tests/shared/customFieldFormUtils.test.ts
Normal file
508
frontend/tests/shared/customFieldFormUtils.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toFieldString,
|
||||
fieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveRequiredFlag,
|
||||
resolveOptions,
|
||||
resolveDefaultValue,
|
||||
formatDefaultValue,
|
||||
normalizeCustomField,
|
||||
normalizeCustomFieldInputs,
|
||||
extractStoredCustomFieldValue,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
shouldPersistField,
|
||||
formatValueForPersistence,
|
||||
buildCustomFieldMetadata,
|
||||
type CustomFieldInput,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toFieldString
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('toFieldString', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(toFieldString(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(toFieldString(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('returns string as-is', () => {
|
||||
expect(toFieldString('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('converts number to string', () => {
|
||||
expect(toFieldString(42)).toBe('42')
|
||||
})
|
||||
|
||||
it('converts boolean to string', () => {
|
||||
expect(toFieldString(true)).toBe('true')
|
||||
expect(toFieldString(false)).toBe('false')
|
||||
})
|
||||
|
||||
it('returns empty string for objects', () => {
|
||||
expect(toFieldString({ foo: 'bar' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fieldKey
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('fieldKey', () => {
|
||||
it('prefers customFieldValueId', () => {
|
||||
const field = { customFieldValueId: 'cfv-1', id: 'id-1', name: 'field' } as CustomFieldInput
|
||||
expect(fieldKey(field, 0)).toBe('cfv-1')
|
||||
})
|
||||
|
||||
it('falls back to id', () => {
|
||||
const field = { customFieldValueId: null, id: 'id-1', name: 'field' } as CustomFieldInput
|
||||
expect(fieldKey(field, 0)).toBe('id-1')
|
||||
})
|
||||
|
||||
it('falls back to name-index', () => {
|
||||
const field = { customFieldValueId: null, id: null, name: 'weight' } as CustomFieldInput
|
||||
expect(fieldKey(field, 3)).toBe('weight-3')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFieldName
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveFieldName', () => {
|
||||
it('resolves from name property', () => {
|
||||
expect(resolveFieldName({ name: 'Poids' })).toBe('Poids')
|
||||
})
|
||||
|
||||
it('falls back to key', () => {
|
||||
expect(resolveFieldName({ key: 'poids' })).toBe('poids')
|
||||
})
|
||||
|
||||
it('falls back to label', () => {
|
||||
expect(resolveFieldName({ label: 'Poids' })).toBe('Poids')
|
||||
})
|
||||
|
||||
it('returns empty string for empty object', () => {
|
||||
expect(resolveFieldName({})).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(resolveFieldName(null)).toBe('')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(resolveFieldName({ name: ' Poids ' })).toBe('Poids')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFieldType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveFieldType', () => {
|
||||
it('resolves valid type', () => {
|
||||
expect(resolveFieldType({ type: 'number' })).toBe('number')
|
||||
})
|
||||
|
||||
it('resolves case-insensitive', () => {
|
||||
expect(resolveFieldType({ type: 'SELECT' })).toBe('select')
|
||||
})
|
||||
|
||||
it('falls back to text for unknown type', () => {
|
||||
expect(resolveFieldType({ type: 'blob' })).toBe('text')
|
||||
})
|
||||
|
||||
it('resolves nested value.type', () => {
|
||||
expect(resolveFieldType({ value: { type: 'date' } })).toBe('date')
|
||||
})
|
||||
|
||||
it('returns text for missing type', () => {
|
||||
expect(resolveFieldType({})).toBe('text')
|
||||
})
|
||||
|
||||
it('handles all allowed types', () => {
|
||||
for (const type of ['text', 'number', 'select', 'boolean', 'date']) {
|
||||
expect(resolveFieldType({ type })).toBe(type)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveRequiredFlag
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveRequiredFlag', () => {
|
||||
it('resolves boolean true', () => {
|
||||
expect(resolveRequiredFlag({ required: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves boolean false', () => {
|
||||
expect(resolveRequiredFlag({ required: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves nested value.required', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: true } })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves string "true"', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: 'true' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves string "1"', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: '1' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false', () => {
|
||||
expect(resolveRequiredFlag({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveOptions', () => {
|
||||
it('resolves array of strings', () => {
|
||||
expect(resolveOptions({ options: ['A', 'B'] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('resolves array of objects with value key', () => {
|
||||
expect(resolveOptions({ options: [{ value: 'A' }, { value: 'B' }] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('resolves array of objects with label key', () => {
|
||||
expect(resolveOptions({ options: [{ label: 'Foo' }] })).toEqual(['Foo'])
|
||||
})
|
||||
|
||||
it('falls back to value.options', () => {
|
||||
expect(resolveOptions({ value: { options: ['X'] } })).toEqual(['X'])
|
||||
})
|
||||
|
||||
it('falls back to value.choices', () => {
|
||||
expect(resolveOptions({ value: { choices: ['Y'] } })).toEqual(['Y'])
|
||||
})
|
||||
|
||||
it('returns empty array for no options', () => {
|
||||
expect(resolveOptions({})).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out empty strings', () => {
|
||||
expect(resolveOptions({ options: ['A', '', 'B'] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('filters out null values', () => {
|
||||
expect(resolveOptions({ options: [null, 'A'] })).toEqual(['A'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveDefaultValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveDefaultValue', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(resolveDefaultValue(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves defaultValue', () => {
|
||||
expect(resolveDefaultValue({ defaultValue: 'hello' })).toBe('hello')
|
||||
})
|
||||
|
||||
it('resolves value (non-object)', () => {
|
||||
expect(resolveDefaultValue({ value: 42 })).toBe(42)
|
||||
})
|
||||
|
||||
it('resolves nested value.defaultValue', () => {
|
||||
expect(resolveDefaultValue({ value: { defaultValue: 'nested' } })).toBe('nested')
|
||||
})
|
||||
|
||||
it('returns null when nothing found', () => {
|
||||
expect(resolveDefaultValue({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDefaultValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDefaultValue', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatDefaultValue('text', null)).toBe('')
|
||||
})
|
||||
|
||||
it('converts number to string', () => {
|
||||
expect(formatDefaultValue('number', 42)).toBe('42')
|
||||
})
|
||||
|
||||
it('handles boolean type with true', () => {
|
||||
expect(formatDefaultValue('boolean', 'true')).toBe('true')
|
||||
expect(formatDefaultValue('boolean', true)).toBe('true')
|
||||
expect(formatDefaultValue('boolean', '1')).toBe('true')
|
||||
})
|
||||
|
||||
it('handles boolean type with false', () => {
|
||||
expect(formatDefaultValue('boolean', 'false')).toBe('false')
|
||||
expect(formatDefaultValue('boolean', false)).toBe('false')
|
||||
expect(formatDefaultValue('boolean', '0')).toBe('false')
|
||||
})
|
||||
|
||||
it('unwraps nested defaultValue object', () => {
|
||||
expect(formatDefaultValue('text', { defaultValue: 'inner' })).toBe('inner')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCustomField
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeCustomField', () => {
|
||||
it('normalizes a complete field', () => {
|
||||
const result = normalizeCustomField({
|
||||
id: 'cf-1',
|
||||
name: 'Weight',
|
||||
type: 'number',
|
||||
required: true,
|
||||
options: [],
|
||||
orderIndex: 2,
|
||||
})
|
||||
expect(result).toEqual({
|
||||
id: 'cf-1',
|
||||
name: 'Weight',
|
||||
type: 'number',
|
||||
required: true,
|
||||
options: [],
|
||||
value: '',
|
||||
customFieldId: 'cf-1',
|
||||
customFieldValueId: null,
|
||||
orderIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(normalizeCustomField(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for field without name', () => {
|
||||
expect(normalizeCustomField({ type: 'text' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses fallback index', () => {
|
||||
const result = normalizeCustomField({ name: 'Test' }, 5)
|
||||
expect(result?.orderIndex).toBe(5)
|
||||
})
|
||||
|
||||
it('defaults type to text', () => {
|
||||
const result = normalizeCustomField({ name: 'Field' })
|
||||
expect(result?.type).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCustomFieldInputs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeCustomFieldInputs', () => {
|
||||
it('returns empty array for null structure', () => {
|
||||
expect(normalizeCustomFieldInputs(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for structure without customFields', () => {
|
||||
expect(normalizeCustomFieldInputs({})).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes and sorts fields by orderIndex', () => {
|
||||
const result = normalizeCustomFieldInputs({
|
||||
customFields: [
|
||||
{ name: 'B', orderIndex: 2 },
|
||||
{ name: 'A', orderIndex: 1 },
|
||||
],
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe('A')
|
||||
expect(result[1].name).toBe('B')
|
||||
})
|
||||
|
||||
it('filters out invalid fields', () => {
|
||||
const result = normalizeCustomFieldInputs({
|
||||
customFields: [{ name: 'Valid' }, null, { type: 'text' }],
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('Valid')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractStoredCustomFieldValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('extractStoredCustomFieldValue', () => {
|
||||
it('returns string directly', () => {
|
||||
expect(extractStoredCustomFieldValue('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns number directly', () => {
|
||||
expect(extractStoredCustomFieldValue(42)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(extractStoredCustomFieldValue(null)).toBe('')
|
||||
})
|
||||
|
||||
it('extracts .value from object', () => {
|
||||
expect(extractStoredCustomFieldValue({ value: 'test' })).toBe('test')
|
||||
})
|
||||
|
||||
it('extracts nested .value.value', () => {
|
||||
expect(extractStoredCustomFieldValue({ value: { value: 'deep' } })).toBe('deep')
|
||||
})
|
||||
|
||||
it('extracts customFieldValue.value', () => {
|
||||
expect(extractStoredCustomFieldValue({ customFieldValue: { value: 'cfv' } })).toBe('cfv')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCustomFieldInputs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildCustomFieldInputs', () => {
|
||||
const definitions = {
|
||||
customFields: [
|
||||
{ name: 'Weight', type: 'number', required: true, id: 'cf-1', orderIndex: 0 },
|
||||
{ name: 'Color', type: 'select', options: ['Red', 'Blue'], id: 'cf-2', orderIndex: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
it('builds inputs from definitions without values', () => {
|
||||
const result = buildCustomFieldInputs(definitions, null)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe('Weight')
|
||||
expect(result[0].value).toBe('')
|
||||
expect(result[1].name).toBe('Color')
|
||||
})
|
||||
|
||||
it('merges stored values by id', () => {
|
||||
const values = [
|
||||
{ customField: { id: 'cf-1', name: 'Weight' }, id: 'cfv-1', value: '42' },
|
||||
]
|
||||
const result = buildCustomFieldInputs(definitions, values)
|
||||
expect(result[0].value).toBe('42')
|
||||
expect(result[0].customFieldValueId).toBe('cfv-1')
|
||||
})
|
||||
|
||||
it('merges stored values by name fallback', () => {
|
||||
const values = [
|
||||
{ name: 'Color', id: 'cfv-2', value: 'Blue' },
|
||||
]
|
||||
const result = buildCustomFieldInputs(definitions, values)
|
||||
expect(result[1].value).toBe('Blue')
|
||||
})
|
||||
|
||||
it('returns empty array for null structure', () => {
|
||||
expect(buildCustomFieldInputs(null, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// requiredCustomFieldsFilled
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('requiredCustomFieldsFilled', () => {
|
||||
it('returns true when all required fields are filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: 'hello', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when required field is empty', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for non-required empty field', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: false, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean "false" as filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'false', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean "true" as filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'true', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean empty as not filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for empty array', () => {
|
||||
expect(requiredCustomFieldsFilled([])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldPersistField & formatValueForPersistence
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('shouldPersistField', () => {
|
||||
it('returns true for non-empty text field', () => {
|
||||
expect(shouldPersistField({ value: 'hello' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for empty text field', () => {
|
||||
expect(shouldPersistField({ value: '', type: 'text' } as CustomFieldInput)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for boolean "true"', () => {
|
||||
expect(shouldPersistField({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for boolean "false"', () => {
|
||||
expect(shouldPersistField({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for boolean empty', () => {
|
||||
expect(shouldPersistField({ value: '', type: 'boolean' } as CustomFieldInput)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForPersistence', () => {
|
||||
it('trims text value', () => {
|
||||
expect(formatValueForPersistence({ value: ' hello ', type: 'text' } as CustomFieldInput)).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns "true" for boolean true', () => {
|
||||
expect(formatValueForPersistence({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe('true')
|
||||
})
|
||||
|
||||
it('returns "false" for boolean non-true', () => {
|
||||
expect(formatValueForPersistence({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
||||
expect(formatValueForPersistence({ value: '', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCustomFieldMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildCustomFieldMetadata', () => {
|
||||
it('builds metadata from field', () => {
|
||||
const field: CustomFieldInput = {
|
||||
id: null, name: 'Color', type: 'select', required: true,
|
||||
options: ['Red', 'Blue'], value: 'Red',
|
||||
customFieldId: null, customFieldValueId: null, orderIndex: 0,
|
||||
}
|
||||
expect(buildCustomFieldMetadata(field)).toEqual({
|
||||
customFieldName: 'Color',
|
||||
customFieldType: 'select',
|
||||
customFieldRequired: true,
|
||||
customFieldOptions: ['Red', 'Blue'],
|
||||
})
|
||||
})
|
||||
})
|
||||
118
frontend/tests/shared/inventory-types.test.ts
Normal file
118
frontend/tests/shared/inventory-types.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
componentModelStructureValidator,
|
||||
createEmptyComponentModelStructure,
|
||||
createEmptyPieceModelStructure,
|
||||
createEmptyProductModelStructure,
|
||||
} from '~/shared/types/inventory'
|
||||
|
||||
describe('createEmptyComponentModelStructure', () => {
|
||||
it('returns a valid empty structure', () => {
|
||||
const result = createEmptyComponentModelStructure()
|
||||
expect(result).toEqual({
|
||||
customFields: [],
|
||||
pieces: [],
|
||||
products: [],
|
||||
subcomponents: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEmptyPieceModelStructure', () => {
|
||||
it('returns a valid empty piece structure', () => {
|
||||
const result = createEmptyPieceModelStructure()
|
||||
expect(result).toEqual({
|
||||
customFields: [],
|
||||
products: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEmptyProductModelStructure', () => {
|
||||
it('returns a valid empty product structure', () => {
|
||||
const result = createEmptyProductModelStructure()
|
||||
expect(result).toEqual({
|
||||
customFields: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('componentModelStructureValidator', () => {
|
||||
it('parses a minimal valid structure', () => {
|
||||
const input = {
|
||||
customFields: [],
|
||||
pieces: [],
|
||||
subcomponents: [],
|
||||
}
|
||||
const result = componentModelStructureValidator.safeParse(input)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.customFields).toEqual([])
|
||||
expect(result.data.pieces).toEqual([])
|
||||
expect(result.data.subcomponents).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-object input', () => {
|
||||
const result = componentModelStructureValidator.safeParse('not an object')
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.issues.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates custom fields with name and type', () => {
|
||||
const input = {
|
||||
customFields: [
|
||||
{ name: 'Color', type: 'text', required: true },
|
||||
{ name: 'Size', type: 'select', required: false, options: ['S', 'M', 'L'] },
|
||||
],
|
||||
pieces: [],
|
||||
subcomponents: [],
|
||||
}
|
||||
const result = componentModelStructureValidator.safeParse(input)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.customFields).toHaveLength(2)
|
||||
expect(result.data.customFields[0].name).toBe('Color')
|
||||
expect(result.data.customFields[1].options).toEqual(['S', 'M', 'L'])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects custom fields without name', () => {
|
||||
const input = {
|
||||
customFields: [{ type: 'text', required: false }],
|
||||
pieces: [],
|
||||
subcomponents: [],
|
||||
}
|
||||
const result = componentModelStructureValidator.safeParse(input)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.issues.some((i) => i.includes('name'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates nested subcomponents', () => {
|
||||
const input = {
|
||||
customFields: [],
|
||||
pieces: [],
|
||||
subcomponents: [
|
||||
{
|
||||
typeComposantId: 'abc-123',
|
||||
alias: 'Motor',
|
||||
subcomponents: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = componentModelStructureValidator.safeParse(input)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.subcomponents).toHaveLength(1)
|
||||
expect(result.data.subcomponents[0].alias).toBe('Motor')
|
||||
}
|
||||
})
|
||||
|
||||
it('parse throws on invalid input', () => {
|
||||
expect(() => componentModelStructureValidator.parse(null)).toThrow()
|
||||
})
|
||||
})
|
||||
169
frontend/tests/shared/modelUtils.test.ts
Normal file
169
frontend/tests/shared/modelUtils.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
isPlainObject,
|
||||
defaultStructure,
|
||||
cloneStructure,
|
||||
computeStructureStats,
|
||||
formatStructurePreview,
|
||||
} from '~/shared/model/componentStructure'
|
||||
import {
|
||||
defaultPieceStructure,
|
||||
defaultProductStructure,
|
||||
clonePieceStructure,
|
||||
cloneProductStructure,
|
||||
} from '~/shared/model/pieceProductStructure'
|
||||
|
||||
describe('isPlainObject', () => {
|
||||
it('returns true for plain objects', () => {
|
||||
expect(isPlainObject({})).toBe(true)
|
||||
expect(isPlainObject({ key: 'value' })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for arrays', () => {
|
||||
expect(isPlainObject([])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isPlainObject(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for primitives', () => {
|
||||
expect(isPlainObject('string')).toBe(false)
|
||||
expect(isPlainObject(42)).toBe(false)
|
||||
expect(isPlainObject(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultStructure', () => {
|
||||
it('returns a fresh empty structure each time', () => {
|
||||
const a = defaultStructure()
|
||||
const b = defaultStructure()
|
||||
expect(a).toEqual(b)
|
||||
expect(a).not.toBe(b)
|
||||
expect(a.customFields).toEqual([])
|
||||
expect(a.pieces).toEqual([])
|
||||
expect(a.products).toEqual([])
|
||||
expect(a.subcomponents).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneStructure', () => {
|
||||
it('deep clones a structure', () => {
|
||||
const original = defaultStructure()
|
||||
original.customFields.push({ name: 'test', type: 'text', required: false })
|
||||
const cloned = cloneStructure(original)
|
||||
expect(cloned.customFields).toHaveLength(1)
|
||||
expect(cloned.customFields[0].name).toBe('test')
|
||||
// Ensure deep clone — mutating original doesn't affect clone
|
||||
original.customFields[0].name = 'mutated'
|
||||
expect(cloned.customFields[0].name).toBe('test')
|
||||
})
|
||||
|
||||
it('returns default structure for null input', () => {
|
||||
const result = cloneStructure(null)
|
||||
expect(result).toEqual(defaultStructure())
|
||||
})
|
||||
|
||||
it('returns default structure for undefined input', () => {
|
||||
const result = cloneStructure(undefined)
|
||||
expect(result).toEqual(defaultStructure())
|
||||
})
|
||||
|
||||
it('preserves typeComposantId and alias', () => {
|
||||
const input = {
|
||||
...defaultStructure(),
|
||||
typeComposantId: 'abc-123',
|
||||
alias: 'Motor',
|
||||
}
|
||||
const result = cloneStructure(input)
|
||||
expect(result.typeComposantId).toBe('abc-123')
|
||||
expect(result.alias).toBe('Motor')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeStructureStats', () => {
|
||||
it('counts elements in a structure', () => {
|
||||
const structure = {
|
||||
...defaultStructure(),
|
||||
customFields: [
|
||||
{ name: 'A', type: 'text' as const, required: false },
|
||||
{ name: 'B', type: 'number' as const, required: true },
|
||||
],
|
||||
pieces: [{ typePieceId: 'p1' }],
|
||||
products: [{ typeProductId: 'pr1' }],
|
||||
subcomponents: [{ subcomponents: [] }],
|
||||
}
|
||||
const stats = computeStructureStats(structure)
|
||||
expect(stats.customFields).toBe(2)
|
||||
expect(stats.pieces).toBe(1)
|
||||
expect(stats.products).toBe(1)
|
||||
expect(stats.subcomponents).toBe(1)
|
||||
})
|
||||
|
||||
it('returns zeros for empty structure', () => {
|
||||
const stats = computeStructureStats(defaultStructure())
|
||||
expect(stats).toEqual({
|
||||
customFields: 0,
|
||||
pieces: 0,
|
||||
products: 0,
|
||||
subcomponents: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStructurePreview', () => {
|
||||
it('returns "Structure vide" for empty structure', () => {
|
||||
const result = formatStructurePreview(defaultStructure())
|
||||
expect(result).toBe('Structure vide')
|
||||
})
|
||||
|
||||
it('formats a non-empty structure', () => {
|
||||
const structure = {
|
||||
...defaultStructure(),
|
||||
customFields: [{ name: 'A', type: 'text' as const, required: false }],
|
||||
pieces: [{ typePieceId: 'p1' }, { typePieceId: 'p2' }],
|
||||
}
|
||||
const result = formatStructurePreview(structure)
|
||||
expect(result).toContain('1 champ')
|
||||
expect(result).toContain('2 pièce')
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultPieceStructure', () => {
|
||||
it('returns a valid empty piece structure', () => {
|
||||
const result = defaultPieceStructure()
|
||||
expect(result.customFields).toEqual([])
|
||||
expect(result.products).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultProductStructure', () => {
|
||||
it('returns a valid empty product structure', () => {
|
||||
const result = defaultProductStructure()
|
||||
expect(result.customFields).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clonePieceStructure', () => {
|
||||
it('deep clones a piece structure', () => {
|
||||
const original = defaultPieceStructure()
|
||||
original.customFields.push({ name: 'Weight', type: 'number', required: true })
|
||||
const cloned = clonePieceStructure(original)
|
||||
expect(cloned.customFields).toHaveLength(1)
|
||||
original.customFields[0].name = 'mutated'
|
||||
expect(cloned.customFields[0].name).toBe('Weight')
|
||||
})
|
||||
|
||||
it('returns default for null', () => {
|
||||
expect(clonePieceStructure(null)).toEqual(defaultPieceStructure())
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneProductStructure', () => {
|
||||
it('deep clones a product structure', () => {
|
||||
const original = defaultProductStructure()
|
||||
original.customFields.push({ name: 'Color', type: 'text', required: false })
|
||||
const cloned = cloneProductStructure(original)
|
||||
expect(cloned.customFields).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user