From 480defcd820e27cef0fde993408ff32ae18bb475 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 24 Feb 2026 10:48:46 +0100 Subject: [PATCH] feat : ajout des tests et retour sur le composant --- .playground/pages/composant/inputText.vue | 4 + app/components/malio/Input.test.ts | 318 +++++++++++++++++++++- app/components/malio/InputText.vue | 95 +++---- pre-commit | 26 +- 4 files changed, 362 insertions(+), 81 deletions(-) diff --git a/.playground/pages/composant/inputText.vue b/.playground/pages/composant/inputText.vue index 0f501a6..e1d478f 100644 --- a/.playground/pages/composant/inputText.vue +++ b/.playground/pages/composant/inputText.vue @@ -32,6 +32,10 @@ disabled label="Champ désactivé" /> +
diff --git a/app/components/malio/Input.test.ts b/app/components/malio/Input.test.ts index aefc9fb..c067744 100644 --- a/app/components/malio/Input.test.ts +++ b/app/components/malio/Input.test.ts @@ -1,23 +1,319 @@ -import { describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' -import Input from './Input.vue' +import {describe, expect, it} from 'vitest' +import {config, mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import Input from './InputText.vue' + + +type InputProps = { + id?: string + label?: string + name?: string + autocomplete?: string + modelValue?: string | null + textSize?: string + labelClass?: string + required?: boolean + maxLength?: number | string + minLength?: number | string + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + iconName?: string + iconSize?: string | number + iconColor?: string +} + +const InputForTest = Input as DefineComponent +const iconStub = { + template: '', +} +config.global.stubs = { + ...(config.global.stubs ?? {}), + Icon: iconStub, +} describe('MalioInput', () => { - it('affiche la valeur initiale', () => { - const wrapper = mount(Input, { - props: { modelValue: 'hello' }, + // Props de base: valeur, label, name, id, autocomplete + it('renders the initial input value', () => { + const wrapper = mount(InputForTest, { + props: {modelValue: 'initialValueTest'}, }) - - expect(wrapper.get('input').element.value).toBe('hello') + expect(wrapper.get('input').element.value).toBe('initialValueTest') }) - it('emet update:modelValue au changement', async () => { - const wrapper = mount(Input, { - props: { modelValue: '' }, + it('renders the label text', () => { + const wrapper = mount(InputForTest, { + props: {label: 'labelTest'}, }) + expect(wrapper.get('label').text()).toBe('labelTest') + }) + it('applies the name attribute', () => { + const wrapper = mount(InputForTest, { + props: {name: 'nameTest'}, + }) + expect(wrapper.get('input').attributes('name')).toBe('nameTest') + }) + + it('uses provided id on input and label', () => { + const wrapper = mount(InputForTest, { + props: {id: 'custom-id', label: 'Label'}, + }) + expect(wrapper.get('input').attributes('id')).toBe('custom-id') + expect(wrapper.get('label').attributes('for')).toBe('custom-id') + }) + + it('applies a different size of rounded', () => { + const wrapper = mount(InputForTest, { + props: {rounded: 'rounded-md'}, + }) + expect(wrapper.get('input').classes()).toContain('rounded-md') + }) + + it('generates an id when missing and reuses it on label', () => { + const wrapper = mount(InputForTest, { + props: {label: 'Label'}, + }) + const inputId = wrapper.get('input').attributes('id') + expect(inputId).toBeDefined() + expect(inputId?.startsWith('malio-input-text-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(inputId) + }) + + it('applies the autocomplete attribute', () => { + const wrapper = mount(InputForTest, { + props: {autocomplete: 'autocompleteTest'}, + }) + expect(wrapper.get('input').attributes('autocomplete')).toBe('autocompleteTest') + }) + + // États HTML: required, readonly, disabled + it('does not set required when false', () => { + const wrapper = mount(InputForTest, { + props: {required: false}, + }) + expect(wrapper.get('input').attributes('required')).toBeUndefined() + }) + + it('sets required when true', () => { + const wrapper = mount(InputForTest, { + props: {required: true}, + }) + expect(wrapper.get('input').attributes('required')).toBeDefined() + }) + + it('does not set readonly when false', () => { + const wrapper = mount(InputForTest, { + props: {readonly: false}, + }) + expect(wrapper.get('input').attributes('readonly')).toBeUndefined() + }) + + it('sets readonly when true', () => { + const wrapper = mount(InputForTest, { + props: {readonly: true}, + }) + expect(wrapper.get('input').attributes('readonly')).toBeDefined() + }) + + it('does not set disabled and keeps text cursor when false', () => { + const wrapper = mount(InputForTest, { + props: {disabled: false}, + }) + expect(wrapper.get('input').attributes('disabled')).toBeUndefined() + expect(wrapper.get('input').classes()).toContain('cursor-text') + }) + + it('sets disabled styles when true', () => { + const wrapper = mount(InputForTest, { + props: {disabled: true}, + }) + expect(wrapper.get('input').attributes('disabled')).toBeDefined() + expect(wrapper.get('input').classes()).toContain('cursor-not-allowed') + expect(wrapper.get('input').classes()).toContain('text-black/60') + }) + + // Émission d'événements + it('emits update:modelValue on input change', async () => { + const wrapper = mount(InputForTest, { + props: {modelValue: ''}, + }) await wrapper.get('input').setValue('new value') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value']) }) + + // Contraintes et classes de texte + it('applies maxLength to input', () => { + const wrapper = mount(InputForTest, { + props: {maxLength: 25}, + }) + expect(wrapper.get('input').attributes('maxlength')).toBe('25') + }) + + it('applies minLength to input', () => { + const wrapper = mount(InputForTest, { + props: {minLength: 25}, + }) + expect(wrapper.get('input').attributes('minlength')).toBe('25') + }) + + it('applies textSize class on label', () => { + const wrapper = mount(InputForTest, { + props: {label: 'Label', textLabel: 'text-sm'}, + }) + expect(wrapper.get('label').classes()).toContain('text-sm') + }) + + // États visuels: erreur et succès + it('shows error message without label and icon', () => { + const wrapper = mount(InputForTest, { + props: {error: 'Error message test'}, + }) + expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('p').classes()).toContain('text-m-error') + }) + + it('shows error message with label and without icon', () => { + const wrapper = mount(InputForTest, { + props: {error: 'Error message test', label: 'Error message'}, + }) + expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('p').classes()).toContain('text-m-error') + + }) + + it('shows error message with label and icon', () => { + const wrapper = mount(InputForTest, { + props: {error: 'Error message test', label: 'Error message', iconName: 'mdi:key-outline'}, + }) + expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error') + expect(wrapper.get('p').classes()).toContain('text-m-error') + + }) + + it('shows error message with icon and without label', () => { + const wrapper = mount(InputForTest, { + props: {error: 'Error message test', iconName: 'mdi:key-outline'}, + }) + expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error') + }) + + it('shows success message without label and icon', () => { + const wrapper = mount(InputForTest, { + props: {success: 'Success message test'}, + }) + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + }) + + it('shows success message with label and without icon', () => { + const wrapper = mount(InputForTest, { + props: {success: 'Success message test', label: 'Success message'}, + }) + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + }) + + it('shows success message with label and icon', () => { + const wrapper = mount(InputForTest, { + props: {success: 'Success message test', label: 'Success message', iconName: 'mdi:key-outline'}, + }) + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success') + }) + + it('shows success message with icon and without label', () => { + const wrapper = mount(InputForTest, { + props: {success: 'Success message test', iconName: 'mdi:key-outline'}, + }) + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success') + }) + + it('prioritizes error over success when both are provided', () => { + const wrapper = mount(InputForTest, { + props: { + error: 'Error message test', + success: 'Success message test', + }, + }) + expect(wrapper.find('p.text-m-error').exists()).toBe(true) + expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') + expect(wrapper.find('p.text-m-success').exists()).toBe(false) + expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('input').classes()).not.toContain('border-m-success') + }) + + // Aide et classes de label + it('shows hint message', () => { + const wrapper = mount(InputForTest, { + props: {hint: 'Hint message test'}, + }) + expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test') + }) + + it('applies labelClass on label', () => { + const wrapper = mount(InputForTest, { + props: {label: 'Label', labelClass: 'text-red-500'}, + }) + expect(wrapper.get('label').classes()).toContain('text-red-500') + }) + + it('does not render label when label prop is missing', () => { + const wrapper = mount(InputForTest, { + props: {labelClass: 'text-red-500'}, + }) + + expect(wrapper.find('label').exists()).toBe(false) + }) + + // Icône : rendu et options + it('renders icon with default positioning and muted color', () => { + const wrapper = mount(InputForTest, { + props: {iconName: 'mdi:key-outline'}, + }) + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('pointer-events-none') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('absolute') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-2') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('top-1/2') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2') + }) + + it('passes icon size prop to icon component', () => { + const wrapper = mount(InputForTest, { + props: {iconName: 'mdi:key-outline', iconSize: 'text-2xl'}, + }) + expect(wrapper.get('[data-test="icon"]').attributes('size')).toBe('text-2xl') + }) + + it('applies icon color class', () => { + const wrapper = mount(InputForTest, { + props: {iconName: 'mdi:key-outline', iconColor: 'text-m-primary'}, + }) + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) }) diff --git a/app/components/malio/InputText.vue b/app/components/malio/InputText.vue index 3d0616a..cb9374e 100644 --- a/app/components/malio/InputText.vue +++ b/app/components/malio/InputText.vue @@ -1,6 +1,6 @@ @@ -109,8 +101,8 @@ const props = withDefaults( modelValue?: string | null | undefined minWidth?: string maxWidth?: string - text?: string - textSize?: string + textInput?: string + textLabel?: string inputClass?: string labelClass?: string required?: boolean @@ -139,12 +131,12 @@ const props = withDefaults( maxWidth: '', inputClass: '', labelClass: '', - text: 'text-lg', + textInput: 'text-lg', required: false, maxLength: undefined, minLength: undefined, readonly: false, - textSize: 'text-sm', + textLabel: 'text-sml', disabled: false, rounded: 'rounded-md', hint: '', @@ -199,17 +191,14 @@ const iconInputPaddingClass = computed(() => { } .grow-height { - transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; - transform-origin: center; + transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; } .grow-height:focus { - transform: scaleY(1.2); + padding-top: 0.625rem; + padding-bottom: 0.625rem; } - @media (prefers-reduced-motion: reduce) { - .grow-height { - transition: none; - } + .grow-height { transition: none; } } diff --git a/pre-commit b/pre-commit index b616324..cf4eeee 100644 --- a/pre-commit +++ b/pre-commit @@ -3,23 +3,14 @@ set -e echo "######### Pre-commit hook start #############" -if ! command -v npm >/dev/null 2>&1; then - if [ -f ".nvmrc" ]; then - NVM_VERSION="$(tr -d '\r\n' < .nvmrc)" - NVM_VERSION="${NVM_VERSION#v}" - NPM_BIN="$HOME/.nvm/versions/node/v$NVM_VERSION/bin" - if [ -x "$NPM_BIN/npm" ]; then - PATH="$NPM_BIN:$PATH" - export PATH - fi - fi -fi - -if ! command -v npm >/dev/null 2>&1; then - if [ -s "$HOME/.nvm/nvm.sh" ]; then - # shellcheck disable=SC1090 - . "$HOME/.nvm/nvm.sh" - nvm use >/dev/null 2>&1 || true +# Prefer the exact Node version from .nvmrc for hooks (IDE + CLI consistency). +if [ -f ".nvmrc" ]; then + NVM_VERSION="$(tr -d '\r\n' < .nvmrc)" + NVM_VERSION="${NVM_VERSION#v}" + NVM_BIN="$HOME/.nvm/versions/node/v$NVM_VERSION/bin" + if [ -x "$NVM_BIN/node" ] && [ -x "$NVM_BIN/npm" ]; then + PATH="$NVM_BIN:$PATH" + export PATH fi fi @@ -28,6 +19,7 @@ if ! command -v npm >/dev/null 2>&1; then exit 1 fi +echo "Node $(node -v) / npm $(npm -v)" echo "--- make pre-commit start ---" make pre-commit echo "--- make pre-commit finished ---"