diff --git a/frontend/tests/composables/useCustomFieldInputs.test.ts b/frontend/tests/composables/useCustomFieldInputs.test.ts new file mode 100644 index 0000000..b97815c --- /dev/null +++ b/frontend/tests/composables/useCustomFieldInputs.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' + +import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs' +import { + shouldPersist, + formatValueForSave, +} from '~/shared/utils/customFields' +import { + mockCustomFieldDefs, + mockCustomFieldValues, + mockMachineCustomFieldDefs, + mockMachineCustomFieldValues, +} from '../fixtures/mockData' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockUpdateCustomFieldValue = vi.fn() +const mockUpsertCustomFieldValue = vi.fn() + +vi.mock('~/composables/useCustomFields', () => ({ + useCustomFields: () => ({ + updateCustomFieldValue: mockUpdateCustomFieldValue, + upsertCustomFieldValue: mockUpsertCustomFieldValue, + }), +})) + +vi.mock('~/composables/useToast', () => ({ + useToast: () => ({ + showSuccess: vi.fn(), + showError: vi.fn(), + showInfo: vi.fn(), + showToast: vi.fn(), + toasts: { value: [] }, + clearAll: vi.fn(), + }), +})) + +beforeEach(() => { + vi.clearAllMocks() + mockUpdateCustomFieldValue.mockResolvedValue({ success: true }) + mockUpsertCustomFieldValue.mockResolvedValue({ success: true, data: { id: 'new-cfv-id', customField: { id: 'new-cf-id' } } }) +}) + +// --------------------------------------------------------------------------- +// Field initialization +// --------------------------------------------------------------------------- + +describe('field initialization', () => { + it('merges all definitions with their values (6 defs → 6 allFields, 5 standalone fields)', () => { + const { fields, allFields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + expect(allFields.value).toHaveLength(6) + expect(fields.value).toHaveLength(5) + }) + + it('preserves value for number type', () => { + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const numberField = fields.value.find(f => f.name === 'Tension nominale') + expect(numberField?.value).toBe('220') + expect(numberField?.type).toBe('number') + }) + + it('preserves value for boolean type', () => { + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const boolField = fields.value.find(f => f.name === 'Certifié CE') + expect(boolField?.value).toBe('true') + expect(boolField?.type).toBe('boolean') + }) + + it('preserves value for select type with options', () => { + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const selectField = fields.value.find(f => f.name === 'Indice de protection') + expect(selectField?.value).toBe('IP65') + expect(selectField?.type).toBe('select') + expect(selectField?.options).toEqual(['IP54', 'IP55', 'IP65']) + }) + + it('preserves value for date type', () => { + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const dateField = fields.value.find(f => f.name === 'Date de calibration') + expect(dateField?.value).toBe('2025-06-15') + expect(dateField?.type).toBe('date') + }) + + it('preserves value for text type', () => { + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const textField = fields.value.find(f => f.name === 'Remarques techniques') + expect(textField?.value).toBe('Roulement renforcé pour environnement humide') + expect(textField?.type).toBe('text') + }) + + it('uses defaultValue when no persisted value exists', () => { + // Pass empty values array so all fields use defaultValue + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref([]), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const numberField = fields.value.find(f => f.name === 'Tension nominale') + expect(numberField?.value).toBe('220') + + const boolField = fields.value.find(f => f.name === 'Certifié CE') + expect(boolField?.value).toBe('false') + + // No defaultValue → empty string + const dateField = fields.value.find(f => f.name === 'Date de calibration') + expect(dateField?.value).toBe('') + }) + + it('filters machineContextOnly in standalone context (allFields=6, fields=5)', () => { + const { fields, allFields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + expect(allFields.value).toHaveLength(6) + expect(fields.value).toHaveLength(5) + expect(fields.value.every(f => !f.machineContextOnly)).toBe(true) + }) + + it('shows only machineContextOnly in machine context (1 field)', () => { + const { fields } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'machine', + }) + + expect(fields.value).toHaveLength(1) + expect(fields.value[0]?.name).toBe('Position sur machine') + expect(fields.value[0]?.machineContextOnly).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Boolean — the tricky case +// --------------------------------------------------------------------------- + +describe('boolean — the tricky case', () => { + it('saves "false" value via update (not ignored)', async () => { + const { fields, update } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const boolField = fields.value.find(f => f.name === 'Certifié CE')! + boolField.value = 'false' + + await update(boolField) + + expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' }) + }) + + it('persists boolean "false" in saveAll (not skipped)', async () => { + // Only provide the boolean field def + value + const boolDef = mockCustomFieldDefs[1]! + const boolVal = { ...mockCustomFieldValues[1]!, value: 'false' } + + const { fields, saveAll } = useCustomFieldInputs({ + definitions: ref([boolDef]), + values: ref([boolVal]), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + expect(fields.value[0]?.value).toBe('false') + + const failed = await saveAll() + expect(failed).toEqual([]) + expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' }) + }) +}) + +// --------------------------------------------------------------------------- +// Number zero +// --------------------------------------------------------------------------- + +describe('number zero', () => { + it('saves "0" value (not ignored)', async () => { + const { fields, update } = useCustomFieldInputs({ + definitions: ref(mockMachineCustomFieldDefs), + values: ref(mockMachineCustomFieldValues), + entityType: 'machine', + entityId: ref('cl-machine-1'), + context: 'standalone', + }) + + const numField = fields.value.find(f => f.name === 'Puissance (kW)')! + expect(numField.value).toBe('0') + + await update(numField) + + expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' }) + }) +}) + +// --------------------------------------------------------------------------- +// Text empty string +// --------------------------------------------------------------------------- + +describe('text empty string', () => { + it('shouldPersist returns false for empty trimmed string', () => { + const field = { + customFieldId: 'cf-1', + customFieldValueId: null, + name: 'Notes', + type: 'text', + required: false, + options: [], + defaultValue: null, + orderIndex: 0, + machineContextOnly: false, + value: ' ', + } + + expect(shouldPersist(field)).toBe(false) + }) + + it('persists non-empty text value', () => { + const field = { + customFieldId: 'cf-1', + customFieldValueId: null, + name: 'Notes', + type: 'text', + required: false, + options: [], + defaultValue: null, + orderIndex: 0, + machineContextOnly: false, + value: 'some text', + } + + expect(shouldPersist(field)).toBe(true) + expect(formatValueForSave(field)).toBe('some text') + }) +}) + +// --------------------------------------------------------------------------- +// Select +// --------------------------------------------------------------------------- + +describe('select', () => { + it('saves changed option value', async () => { + const { fields, update } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const selectField = fields.value.find(f => f.name === 'Indice de protection')! + selectField.value = 'IP55' + + await update(selectField) + + expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-003', { value: 'IP55' }) + }) +}) + +// --------------------------------------------------------------------------- +// Date +// --------------------------------------------------------------------------- + +describe('date', () => { + it('saves date value in correct format', async () => { + const { fields, update } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const dateField = fields.value.find(f => f.name === 'Date de calibration')! + dateField.value = '2026-01-20' + + await update(dateField) + + expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-004', { value: '2026-01-20' }) + }) +}) + +// --------------------------------------------------------------------------- +// saveAll isolation +// --------------------------------------------------------------------------- + +describe('saveAll isolation', () => { + it('saves all fields independently without losing values', async () => { + const { fields, saveAll } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + // Modify one field + const numberField = fields.value.find(f => f.name === 'Tension nominale')! + numberField.value = '380' + + const failed = await saveAll() + expect(failed).toEqual([]) + + // All persistable fields should have been saved + // 5 fields in standalone context, all have values + expect(mockUpdateCustomFieldValue.mock.calls.length).toBeGreaterThanOrEqual(4) + + // The modified field should have the new value + const numberCall = mockUpdateCustomFieldValue.mock.calls.find( + (c: any[]) => c[0] === 'cfv-001', + ) + expect(numberCall?.[1]).toEqual({ value: '380' }) + + // Another field should still have its original value + const boolCall = mockUpdateCustomFieldValue.mock.calls.find( + (c: any[]) => c[0] === 'cfv-002', + ) + expect(boolCall?.[1]).toEqual({ value: 'true' }) + }) + + it('upserts new value when no customFieldValueId exists', async () => { + // Use defs without matching values — no customFieldValueId + const defs = [mockCustomFieldDefs[0]!] + + const { saveAll } = useCustomFieldInputs({ + definitions: ref(defs), + values: ref([]), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const failed = await saveAll() + expect(failed).toEqual([]) + + // Should use upsert since no customFieldValueId + expect(mockUpsertCustomFieldValue).toHaveBeenCalledWith( + 'cf-def-001', + 'composant', + 'cl-comp-1', + '220', + undefined, + ) + }) + + it('returns failed field names on error', async () => { + mockUpdateCustomFieldValue.mockResolvedValueOnce({ success: false }) + + const defs = [mockCustomFieldDefs[0]!] + const vals = [mockCustomFieldValues[0]!] + + const { saveAll } = useCustomFieldInputs({ + definitions: ref(defs), + values: ref(vals), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + const failed = await saveAll() + expect(failed).toEqual(['Tension nominale']) + }) +}) + +// --------------------------------------------------------------------------- +// requiredFilled validation +// --------------------------------------------------------------------------- + +describe('requiredFilled validation', () => { + it('returns true when required fields have values (including defaultValue)', () => { + const { requiredFilled } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref(mockCustomFieldValues), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + // "Tension nominale" is required and has value '220' + expect(requiredFilled.value).toBe(true) + }) + + it('returns true when required field uses defaultValue', () => { + // No values provided — required field should use defaultValue '220' + const { requiredFilled } = useCustomFieldInputs({ + definitions: ref(mockCustomFieldDefs), + values: ref([]), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + expect(requiredFilled.value).toBe(true) + }) + + it('returns false when required field has no value and no default', () => { + // Create a required field with no default and no value + const defs = [{ + id: 'cf-required-no-default', + name: 'Required Field', + type: 'text', + required: true, + options: [], + defaultValue: null, + orderIndex: 0, + machineContextOnly: false, + }] + + const { requiredFilled } = useCustomFieldInputs({ + definitions: ref(defs), + values: ref([]), + entityType: 'composant', + entityId: ref('cl-comp-1'), + context: 'standalone', + }) + + expect(requiredFilled.value).toBe(false) + }) +})