test: aggiungi suite completa unit, integration ed e2e
- Unit (12+9): conversion.js (rawToCooked/cookedToRaw, edge case, inversa) e storage.js (save/load, round-trip, default fallback) - Integration (17+12+14): Converter (ricerca, selezione, calcolo, swap, reset), MealPlanner (rendering, add/remove, generateShopping, deduplicazione), ShoppingList (add, toggle, remove, clearAll, contatore) - E2E Playwright (6+6+7+10): navigation, meal-planner, converter, shopping-list - Configurazione: vitest.config.js + playwright.config.js + tests/setup.js - Script: test, test:coverage, test:e2e, test:e2e:ui Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2144
package-lock.json
generated
2144
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -5,13 +5,23 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"happy-dom": "^20.8.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
19
playwright.config.js
Normal file
19
playwright.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
// Simula iPhone 14 Pro — dimensioni target dell'app
|
||||
viewport: { width: 393, height: 852 },
|
||||
locale: 'it-IT',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
],
|
||||
})
|
||||
73
tests/e2e/converter.spec.js
Normal file
73
tests/e2e/converter.spec.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Convertitore', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
|
||||
})
|
||||
|
||||
test('mostra il messaggio iniziale prima di cercare', async ({ page }) => {
|
||||
await expect(page.locator('.hint-state')).toBeVisible()
|
||||
await expect(page.locator('.converter-card')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cerca un alimento e mostra i risultati', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso')
|
||||
await expect(page.locator('.result-item').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('i nomi nella lista hanno solo l\'iniziale maiuscola', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('pollo')
|
||||
const firstFood = await page.locator('.result-food').first().textContent()
|
||||
// "Pollo petto" → solo la prima lettera maiuscola
|
||||
expect(firstFood[0]).toBe(firstFood[0].toUpperCase())
|
||||
if (firstFood.includes(' ')) {
|
||||
const secondWord = firstFood.split(' ')[1]
|
||||
expect(secondWord[0]).toBe(secondWord[0].toLowerCase())
|
||||
}
|
||||
})
|
||||
|
||||
test('seleziona un alimento e mostra la converter card', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso basmati')
|
||||
await page.locator('.result-item').first().click()
|
||||
await expect(page.locator('.converter-card')).toBeVisible()
|
||||
await expect(page.locator('.result-item')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('calcola il peso cotto inserendo i grammi', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso basmati')
|
||||
await page.locator('.result-item').first().click()
|
||||
await page.locator('.calc-input').fill('100')
|
||||
// Riso basmati ha fattore 3.0 → 300g cotto
|
||||
await expect(page.locator('.output-value')).toBeVisible()
|
||||
const result = await page.locator('.output-value').textContent()
|
||||
expect(parseFloat(result)).toBeCloseTo(300, 0)
|
||||
})
|
||||
|
||||
test('il pulsante ⇄ inverte la direzione crudo↔cotto', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('pasta')
|
||||
await page.locator('.result-item').first().click()
|
||||
|
||||
const labelBefore = await page.locator('.calc-label').first().textContent()
|
||||
await page.locator('.btn-swap').click()
|
||||
const labelAfter = await page.locator('.calc-label').first().textContent()
|
||||
|
||||
expect(labelBefore).not.toBe(labelAfter)
|
||||
})
|
||||
|
||||
test('il pulsante Cambia torna alla ricerca', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso')
|
||||
await page.locator('.result-item').first().click()
|
||||
await page.locator('.btn-reset').click()
|
||||
|
||||
await expect(page.locator('.converter-card')).not.toBeVisible()
|
||||
await expect(page.locator('input[type="text"]')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test('mostra il footer con fattore di resa quando c\'è un risultato', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso')
|
||||
await page.locator('.result-item').first().click()
|
||||
await page.locator('.calc-input').fill('100')
|
||||
await expect(page.locator('.card-footer')).toContainText('fattore di resa')
|
||||
})
|
||||
})
|
||||
78
tests/e2e/meal-planner.spec.js
Normal file
78
tests/e2e/meal-planner.spec.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Piano Pasti', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Pulisce localStorage per partire da uno stato noto
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await page.reload()
|
||||
})
|
||||
|
||||
test('mostra 7 card giornaliere', async ({ page }) => {
|
||||
const cards = page.locator('.meal-card')
|
||||
await expect(cards).toHaveCount(7)
|
||||
})
|
||||
|
||||
test('il giorno corrente è espanso di default', async ({ page }) => {
|
||||
// Almeno una card deve essere aperta (class "open")
|
||||
await expect(page.locator('.meal-card.open')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('si può espandere e chiudere una card con tap', async ({ page }) => {
|
||||
const firstHeader = page.locator('.card-header').first()
|
||||
const firstCard = page.locator('.meal-card').first()
|
||||
|
||||
// Se la prima card è già aperta, chiudila prima
|
||||
const isOpen = await firstCard.evaluate(el => el.classList.contains('open'))
|
||||
await firstHeader.click()
|
||||
if (isOpen) {
|
||||
await expect(firstCard).not.toHaveClass(/open/)
|
||||
} else {
|
||||
await expect(firstCard).toHaveClass(/open/)
|
||||
}
|
||||
})
|
||||
|
||||
test('aggiunge un alimento al pranzo del giorno corrente', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
|
||||
await pranzoInput.fill('pasta al pomodoro')
|
||||
await pranzoInput.press('Enter')
|
||||
|
||||
await expect(openCard.locator('.item-text', { hasText: 'pasta al pomodoro' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('rimuove un alimento con il pulsante ×', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
|
||||
await pranzoInput.fill('riso')
|
||||
await pranzoInput.press('Enter')
|
||||
|
||||
const itemRow = openCard.locator('.item-row', { hasText: 'riso' })
|
||||
await expect(itemRow).toBeVisible()
|
||||
await itemRow.locator('.btn-remove').click()
|
||||
await expect(itemRow).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('genera la lista della spesa e passa alla tab Spesa', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const cenahInput = openCard.locator('.meal-slot').nth(2).locator('input[type="text"]')
|
||||
await cenahInput.fill('pollo')
|
||||
await cenahInput.press('Enter')
|
||||
|
||||
await page.locator('.btn-generate').click()
|
||||
|
||||
// Deve essere passato alla tab Spesa
|
||||
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
|
||||
await expect(page.locator('.item-name', { hasText: 'pollo' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('i dati persistono dopo il reload', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const colazioneInput = openCard.locator('.meal-slot').first().locator('input[type="text"]')
|
||||
await colazioneInput.fill('caffè')
|
||||
await colazioneInput.press('Enter')
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator('.meal-card.open .item-text', { hasText: 'caffè' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
42
tests/e2e/navigation.spec.js
Normal file
42
tests/e2e/navigation.spec.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Navigazione tra tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('la tab Pasti è attiva al caricamento', async ({ page }) => {
|
||||
await expect(page.locator('.nav-btn.active')).toContainText('Pasti')
|
||||
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
|
||||
})
|
||||
|
||||
test('la tab Converti mostra il convertitore', async ({ page }) => {
|
||||
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
|
||||
await expect(page.locator('.page-title')).toContainText('Convertitore')
|
||||
await expect(page.locator('.nav-btn.active')).toContainText('Converti')
|
||||
})
|
||||
|
||||
test('la tab Spesa mostra la lista della spesa', async ({ page }) => {
|
||||
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
|
||||
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
|
||||
await expect(page.locator('.nav-btn.active')).toContainText('Spesa')
|
||||
})
|
||||
|
||||
test('si può tornare a Pasti da un\'altra tab', async ({ page }) => {
|
||||
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
|
||||
await page.locator('.nav-btn', { hasText: 'Pasti' }).click()
|
||||
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
|
||||
})
|
||||
|
||||
test('il pulsante info apre il pannello informazioni', async ({ page }) => {
|
||||
await page.locator('.btn-info').click()
|
||||
await expect(page.locator('.sheet')).toBeVisible()
|
||||
await expect(page.locator('.app-name')).toContainText('BitePlan')
|
||||
})
|
||||
|
||||
test('il pannello info si chiude con la X', async ({ page }) => {
|
||||
await page.locator('.btn-info').click()
|
||||
await page.locator('.btn-x').click()
|
||||
await expect(page.locator('.sheet')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
89
tests/e2e/shopping-list.spec.js
Normal file
89
tests/e2e/shopping-list.spec.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Lista della spesa', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
|
||||
})
|
||||
|
||||
test('mostra stato vuoto con lista vuota', async ({ page }) => {
|
||||
await expect(page.locator('.empty-state')).toBeVisible()
|
||||
})
|
||||
|
||||
test('aggiunge un elemento tramite il pulsante +', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('latte')
|
||||
await page.locator('.btn-add').click()
|
||||
await expect(page.locator('.item-name', { hasText: 'latte' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('aggiunge un elemento con il tasto Invio', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('burro')
|
||||
await page.locator('input[type="text"]').press('Enter')
|
||||
await expect(page.locator('.item-name', { hasText: 'burro' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('svuota il campo dopo l\'aggiunta', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('olio')
|
||||
await page.locator('.btn-add').click()
|
||||
await expect(page.locator('input[type="text"]')).toHaveValue('')
|
||||
})
|
||||
|
||||
test('spunta un elemento e lo sposta nei completati', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('pasta')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
|
||||
await expect(page.locator('.section-divider')).toBeVisible()
|
||||
await expect(page.locator('.muted .item-name', { hasText: 'pasta' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('rimuove un singolo elemento con ×', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('farina')
|
||||
await page.locator('.btn-add').click()
|
||||
await page.locator('.checkbox-item').first().locator('.btn-remove').click()
|
||||
await expect(page.locator('.item-name', { hasText: 'farina' })).not.toBeVisible()
|
||||
await expect(page.locator('.empty-state')).toBeVisible()
|
||||
})
|
||||
|
||||
test('svuota lista con conferma del dialog', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('test')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
page.once('dialog', dialog => dialog.accept())
|
||||
await page.locator('.btn-clear').click()
|
||||
|
||||
await expect(page.locator('.empty-state')).toBeVisible()
|
||||
})
|
||||
|
||||
test('non svuota lista se si annulla il dialog', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('test')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
page.once('dialog', dialog => dialog.dismiss())
|
||||
await page.locator('.btn-clear').click()
|
||||
|
||||
await expect(page.locator('.item-name', { hasText: 'test' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('il contatore mostra elementi completati / totale', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('a')
|
||||
await page.locator('.btn-add').click()
|
||||
await page.locator('input[type="text"]').fill('b')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
|
||||
const subtitle = await page.locator('.page-subtitle').textContent()
|
||||
expect(subtitle).toMatch(/1/)
|
||||
expect(subtitle).toMatch(/2/)
|
||||
})
|
||||
|
||||
test('i dati persistono dopo il reload', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('yogurt')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
await page.reload()
|
||||
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
|
||||
await expect(page.locator('.item-name', { hasText: 'yogurt' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
173
tests/integration/Converter.test.js
Normal file
173
tests/integration/Converter.test.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Converter from '../../src/pages/Converter.vue'
|
||||
|
||||
function mountConverter() {
|
||||
return mount(Converter, { attachTo: document.body })
|
||||
}
|
||||
|
||||
describe('Converter — stato iniziale', () => {
|
||||
it('mostra il messaggio hint quando non c\'è nessuna ricerca', () => {
|
||||
const w = mountConverter()
|
||||
expect(w.find('.hint-state').exists()).toBe(true)
|
||||
expect(w.find('.converter-card').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('non mostra risultati senza input', () => {
|
||||
const w = mountConverter()
|
||||
expect(w.findAll('.result-item')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — ricerca', () => {
|
||||
it('mostra risultati corrispondenti alla query', async () => {
|
||||
const w = mountConverter()
|
||||
const input = w.find('input[type="text"]')
|
||||
await input.setValue('riso')
|
||||
await input.trigger('input')
|
||||
expect(w.findAll('.result-item').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('ogni risultato ha nome alimento e metodo di cottura', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pollo')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
const first = w.find('.result-item')
|
||||
expect(first.find('.result-food').text()).toBeTruthy()
|
||||
expect(first.find('.result-method').text()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('nasconde l\'hint state durante la ricerca', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pasta')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
expect(w.find('.hint-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('svuota i risultati per query vuota', async () => {
|
||||
const w = mountConverter()
|
||||
const input = w.find('input[type="text"]')
|
||||
await input.setValue('riso')
|
||||
await input.trigger('input')
|
||||
await input.setValue('')
|
||||
await input.trigger('input')
|
||||
expect(w.findAll('.result-item')).toHaveLength(0)
|
||||
expect(w.find('.hint-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('i nomi degli alimenti hanno solo l\'iniziale maiuscola', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('riso')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
const foods = w.findAll('.result-food').map(el => el.text())
|
||||
// Nessun testo deve avere lettere maiuscole dopo la prima (test multi-word)
|
||||
foods.forEach(f => {
|
||||
if (f.includes(' ')) {
|
||||
// "Riso basmati" → la seconda parola non deve essere capitalizzata
|
||||
const words = f.split(' ')
|
||||
words.slice(1).forEach(w => expect(w[0]).toBe(w[0].toLowerCase()))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — selezione alimento', () => {
|
||||
async function selectFirstResult(w, query = 'riso') {
|
||||
await w.find('input[type="text"]').setValue(query)
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
}
|
||||
|
||||
it('mostra la converter card dopo la selezione', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.find('.converter-card').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('nasconde i risultati dopo la selezione', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.findAll('.result-item')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('mostra nome alimento e metodo nella card', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.find('.food-name').text()).toBeTruthy()
|
||||
expect(w.find('.food-method').text()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disabilita il campo di ricerca dopo la selezione', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.find('input[type="text"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — calcolo', () => {
|
||||
async function mountWithSelection(query = 'riso basmati') {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue(query)
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
return w
|
||||
}
|
||||
|
||||
it('mostra il risultato dopo aver inserito i grammi', async () => {
|
||||
const w = await mountWithSelection()
|
||||
await w.find('.calc-input').setValue('100')
|
||||
await w.find('.calc-input').trigger('input')
|
||||
expect(w.find('.output-value').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('non mostra risultato per input 0', async () => {
|
||||
const w = await mountWithSelection()
|
||||
await w.find('.calc-input').setValue('0')
|
||||
await w.find('.calc-input').trigger('input')
|
||||
expect(w.find('.output-value').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('mostra il footer con fattore di resa quando c\'è un risultato', async () => {
|
||||
const w = await mountWithSelection()
|
||||
await w.find('.calc-input').setValue('100')
|
||||
await w.find('.calc-input').trigger('input')
|
||||
expect(w.find('.card-footer').exists()).toBe(true)
|
||||
expect(w.find('.card-footer').text()).toContain('fattore di resa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — swap direzione', () => {
|
||||
it('il pulsante swap inverte le label crudo/cotto', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pasta')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
|
||||
expect(w.find('.calc-label').text()).toMatch(/crudo/i)
|
||||
await w.find('.btn-swap').trigger('click')
|
||||
expect(w.find('.calc-label').text()).toMatch(/cotto/i)
|
||||
})
|
||||
|
||||
it('swap azzera il campo grammi', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pasta')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
await w.find('.calc-input').setValue('200')
|
||||
await w.find('.btn-swap').trigger('click')
|
||||
// Dopo lo swap il campo deve essere vuoto (grams = null)
|
||||
expect(w.find('.calc-input').element.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — reset', () => {
|
||||
it('il pulsante Cambia riporta allo stato di ricerca', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('riso')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
await w.find('.btn-reset').trigger('click')
|
||||
expect(w.find('.converter-card').exists()).toBe(false)
|
||||
expect(w.find('input[type="text"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
152
tests/integration/MealPlanner.test.js
Normal file
152
tests/integration/MealPlanner.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import MealPlanner from '../../src/pages/MealPlanner.vue'
|
||||
import { load } from '../../src/utils/storage.js'
|
||||
|
||||
// Stub di MealCard: espone props e può emettere add/remove
|
||||
const MealCardStub = {
|
||||
name: 'MealCard',
|
||||
template: '<div class="meal-card-stub" />',
|
||||
props: ['dayName', 'meals', 'defaultOpen'],
|
||||
emits: ['add', 'remove'],
|
||||
}
|
||||
|
||||
function mountPlanner() {
|
||||
return mount(MealPlanner, {
|
||||
global: { stubs: { MealCard: MealCardStub } },
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-popola il localStorage con un piano pasti completo
|
||||
function seedMeals(overrides = {}) {
|
||||
const base = Object.fromEntries(
|
||||
['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica'].map(
|
||||
d => [d, { colazione: [], pranzo: [], cena: [] }]
|
||||
)
|
||||
)
|
||||
const merged = { ...base, ...overrides }
|
||||
localStorage.setItem('meals', JSON.stringify(merged))
|
||||
return merged
|
||||
}
|
||||
|
||||
describe('MealPlanner — rendering', () => {
|
||||
it('rende 7 card giornaliere', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.findAll('.meal-card-stub')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('mostra il pulsante "Genera lista della spesa"', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.btn-generate').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('mostra la data corrente nel sottotitolo', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.page-subtitle').text()).toMatch(/oggi/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MealPlanner — aggiunta e rimozione voci', () => {
|
||||
it('aggiunge un alimento al pranzo di lunedì', async () => {
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
// lunedì è il primo giorno (indice 0)
|
||||
await cards[0].vm.$emit('add', 'pranzo', 'pasta')
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.pranzo).toContain('pasta')
|
||||
})
|
||||
|
||||
it('non aggiunge stringhe vuote', async () => {
|
||||
// Seed necessario: il watcher non scatta se nulla cambia,
|
||||
// quindi localStorage rimarrebbe vuoto e load() returnerebbe {}
|
||||
seedMeals()
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
await cards[0].vm.$emit('add', 'pranzo', ' ')
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.pranzo).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rimuove un alimento tramite indice', async () => {
|
||||
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta', 'insalata'], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
await cards[0].vm.$emit('remove', 'pranzo', 0) // rimuovi "pasta"
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.pranzo).toEqual(['insalata'])
|
||||
})
|
||||
|
||||
it('persiste le modifiche in localStorage', async () => {
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
await cards[1].vm.$emit('add', 'cena', 'pollo')
|
||||
await nextTick()
|
||||
|
||||
expect(load('meals', {}).martedi.cena).toContain('pollo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MealPlanner — genera lista della spesa', () => {
|
||||
it('emette go-shop al click del pulsante', async () => {
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
expect(w.emitted('go-shop')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('salva gli alimenti del piano in localStorage come spesa', async () => {
|
||||
seedMeals({
|
||||
lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: ['pollo'] },
|
||||
martedi: { colazione: [], pranzo: ['riso'], cena: [] },
|
||||
})
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
|
||||
const shopping = load('shopping', [])
|
||||
const names = shopping.map(i => i.name)
|
||||
expect(names).toContain('caffè')
|
||||
expect(names).toContain('pasta')
|
||||
expect(names).toContain('pollo')
|
||||
expect(names).toContain('riso')
|
||||
})
|
||||
|
||||
it('non aggiunge duplicati rispetto agli elementi già in lista', async () => {
|
||||
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta'], cena: [] } })
|
||||
// Pasta già presente nella lista della spesa
|
||||
localStorage.setItem('shopping', JSON.stringify([
|
||||
{ id: 1, name: 'pasta', checked: false },
|
||||
]))
|
||||
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
|
||||
const shopping = load('shopping', [])
|
||||
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
|
||||
expect(pastaCount).toBe(1)
|
||||
})
|
||||
|
||||
it('non aggiunge duplicati tra i giorni del piano', async () => {
|
||||
seedMeals({
|
||||
lunedi: { colazione: [], pranzo: ['pasta'], cena: [] },
|
||||
martedi: { colazione: [], pranzo: ['pasta'], cena: [] }, // stesso alimento
|
||||
})
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
|
||||
const shopping = load('shopping', [])
|
||||
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
|
||||
expect(pastaCount).toBe(1)
|
||||
})
|
||||
|
||||
it('non aggiunge nulla se il piano è vuoto', async () => {
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
expect(load('shopping', [])).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
157
tests/integration/ShoppingList.test.js
Normal file
157
tests/integration/ShoppingList.test.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import ShoppingList from '../../src/pages/ShoppingList.vue'
|
||||
import { load } from '../../src/utils/storage.js'
|
||||
|
||||
const CheckboxItemStub = {
|
||||
name: 'CheckboxItem',
|
||||
template: '<li class="checkbox-stub">{{ item.name }}</li>',
|
||||
props: ['item'],
|
||||
emits: ['toggle', 'remove'],
|
||||
}
|
||||
|
||||
function seedShopping(items) {
|
||||
localStorage.setItem('shopping', JSON.stringify(items))
|
||||
}
|
||||
|
||||
function mountShoppingList() {
|
||||
return mount(ShoppingList, {
|
||||
global: { stubs: { CheckboxItem: CheckboxItemStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('ShoppingList — stato iniziale', () => {
|
||||
it('mostra lo stato vuoto se non ci sono elementi', () => {
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('non mostra il pulsante svuota lista se la lista è vuota', () => {
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.btn-clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('carica gli elementi dal localStorage all\'avvio', () => {
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.empty-state').exists()).toBe(false)
|
||||
expect(w.findAll('.checkbox-stub')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — aggiunta elementi', () => {
|
||||
it('aggiunge un elemento tramite click su +', async () => {
|
||||
const w = mountShoppingList()
|
||||
await w.find('input[type="text"]').setValue('latte')
|
||||
await w.find('.btn-add').trigger('click')
|
||||
|
||||
const items = load('shopping', [])
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].name).toBe('latte')
|
||||
expect(items[0].checked).toBe(false)
|
||||
})
|
||||
|
||||
it('aggiunge un elemento tramite tasto Invio', async () => {
|
||||
const w = mountShoppingList()
|
||||
const input = w.find('input[type="text"]')
|
||||
await input.setValue('burro')
|
||||
await input.trigger('keyup.enter')
|
||||
|
||||
expect(load('shopping', []).map(i => i.name)).toContain('burro')
|
||||
})
|
||||
|
||||
it('svuota il campo input dopo l\'aggiunta', async () => {
|
||||
const w = mountShoppingList()
|
||||
await w.find('input[type="text"]').setValue('olio')
|
||||
await w.find('.btn-add').trigger('click')
|
||||
expect(w.find('input[type="text"]').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('non aggiunge stringhe vuote', async () => {
|
||||
const w = mountShoppingList()
|
||||
await w.find('input[type="text"]').setValue(' ')
|
||||
await w.find('.btn-add').trigger('click')
|
||||
expect(load('shopping', [])).toHaveLength(0)
|
||||
expect(w.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — toggle e rimozione', () => {
|
||||
it('sposta un elemento nei completati dopo toggle', async () => {
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
|
||||
await nextTick()
|
||||
|
||||
const items = load('shopping', [])
|
||||
expect(items[0].checked).toBe(true)
|
||||
})
|
||||
|
||||
it('ripristina un elemento già spuntato dopo un secondo toggle', async () => {
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: true }])
|
||||
const w = mountShoppingList()
|
||||
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
|
||||
await nextTick()
|
||||
|
||||
expect(load('shopping', [])[0].checked).toBe(false)
|
||||
})
|
||||
|
||||
it('rimuove un singolo elemento', async () => {
|
||||
seedShopping([
|
||||
{ id: 1, name: 'pasta', checked: false },
|
||||
{ id: 2, name: 'riso', checked: false },
|
||||
])
|
||||
const w = mountShoppingList()
|
||||
await w.findAllComponents(CheckboxItemStub)[0].vm.$emit('remove')
|
||||
await nextTick()
|
||||
|
||||
const items = load('shopping', [])
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].name).toBe('riso')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — svuota lista', () => {
|
||||
it('svuota la lista dopo conferma', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(w.find('.empty-state').exists()).toBe(true)
|
||||
expect(load('shopping', [])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('non svuota se l\'utente annulla', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(load('shopping', [])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — contatore completati', () => {
|
||||
it('mostra N/totale nel sottotitolo', () => {
|
||||
seedShopping([
|
||||
{ id: 1, name: 'pasta', checked: true },
|
||||
{ id: 2, name: 'riso', checked: false },
|
||||
{ id: 3, name: 'patate', checked: false },
|
||||
])
|
||||
const w = mountShoppingList()
|
||||
const subtitle = w.find('.page-subtitle').text()
|
||||
expect(subtitle).toMatch(/1/)
|
||||
expect(subtitle).toMatch(/3/)
|
||||
})
|
||||
|
||||
it('non mostra il sottotitolo se la lista è vuota', () => {
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.page-subtitle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
5
tests/setup.js
Normal file
5
tests/setup.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Svuota localStorage prima e dopo ogni test per garantire isolamento
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => localStorage.clear())
|
||||
75
tests/unit/conversion.test.js
Normal file
75
tests/unit/conversion.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { rawToCooked, cookedToRaw } from '../../src/utils/conversion.js'
|
||||
|
||||
// DB minimale — isola i test dalle modifiche al JSON reale
|
||||
const db = {
|
||||
'riso basmati': { bollitura: { yield: 3.00 } },
|
||||
'pasta semola': { bollitura: { yield: 1.88 } },
|
||||
'pollo petto': { padella: { yield: 0.75 }, forno: { yield: 0.70 } },
|
||||
'zucchine': { bollitura: { yield: 0.90 }, padella: { yield: 0.82 } },
|
||||
'ceci secchi': { bollitura: { yield: 2.90 } },
|
||||
}
|
||||
|
||||
describe('rawToCooked', () => {
|
||||
it('calcola il peso cotto con fattore > 1 (cereali)', () => {
|
||||
expect(rawToCooked('riso basmati', 'bollitura', 100, db)).toBe(300)
|
||||
})
|
||||
|
||||
it('calcola il peso cotto con fattore non intero', () => {
|
||||
expect(rawToCooked('pasta semola', 'bollitura', 100, db)).toBeCloseTo(188)
|
||||
})
|
||||
|
||||
it('calcola il peso cotto con fattore < 1 (verdure)', () => {
|
||||
expect(rawToCooked('zucchine', 'bollitura', 200, db)).toBeCloseTo(180)
|
||||
})
|
||||
|
||||
it('restituisce 0 per peso 0', () => {
|
||||
expect(rawToCooked('riso basmati', 'bollitura', 0, db)).toBe(0)
|
||||
})
|
||||
|
||||
it('scala linearmente con la quantità', () => {
|
||||
const single = rawToCooked('riso basmati', 'bollitura', 100, db)
|
||||
const double = rawToCooked('riso basmati', 'bollitura', 200, db)
|
||||
expect(double).toBeCloseTo(single * 2)
|
||||
})
|
||||
|
||||
it('usa il metodo di cottura corretto', () => {
|
||||
const padella = rawToCooked('pollo petto', 'padella', 100, db)
|
||||
const forno = rawToCooked('pollo petto', 'forno', 100, db)
|
||||
expect(padella).not.toBe(forno)
|
||||
expect(padella).toBe(75)
|
||||
expect(forno).toBe(70)
|
||||
})
|
||||
|
||||
it('funziona con legumi secchi (fattore molto > 1)', () => {
|
||||
expect(rawToCooked('ceci secchi', 'bollitura', 100, db)).toBeCloseTo(290)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cookedToRaw', () => {
|
||||
it('calcola il peso crudo da un peso cotto', () => {
|
||||
expect(cookedToRaw('riso basmati', 'bollitura', 300, db)).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('è l\'inverso esatto di rawToCooked', () => {
|
||||
const rawIn = 150
|
||||
const cooked = rawToCooked('pollo petto', 'padella', rawIn, db)
|
||||
const rawOut = cookedToRaw('pollo petto', 'padella', cooked, db)
|
||||
expect(rawOut).toBeCloseTo(rawIn)
|
||||
})
|
||||
|
||||
it('funziona con verdure (fattore < 1 → crudo > cotto)', () => {
|
||||
const crudo = cookedToRaw('zucchine', 'padella', 82, db)
|
||||
expect(crudo).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('restituisce 0 per peso cotto 0', () => {
|
||||
expect(cookedToRaw('riso basmati', 'bollitura', 0, db)).toBe(0)
|
||||
})
|
||||
|
||||
it('scala linearmente', () => {
|
||||
const base = cookedToRaw('riso basmati', 'bollitura', 300, db)
|
||||
const doppio = cookedToRaw('riso basmati', 'bollitura', 600, db)
|
||||
expect(doppio).toBeCloseTo(base * 2)
|
||||
})
|
||||
})
|
||||
59
tests/unit/storage.test.js
Normal file
59
tests/unit/storage.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { save, load } from '../../src/utils/storage.js'
|
||||
|
||||
// localStorage viene svuotato in tests/setup.js — qui garantiamo isolamento locale
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('save', () => {
|
||||
it('serializza un oggetto in JSON', () => {
|
||||
save('test', { a: 1, b: 'due' })
|
||||
expect(localStorage.getItem('test')).toBe('{"a":1,"b":"due"}')
|
||||
})
|
||||
|
||||
it('serializza un array', () => {
|
||||
save('list', [1, 2, 3])
|
||||
expect(localStorage.getItem('list')).toBe('[1,2,3]')
|
||||
})
|
||||
|
||||
it('sovrascrive un valore esistente', () => {
|
||||
save('key', 'primo')
|
||||
save('key', 'secondo')
|
||||
expect(localStorage.getItem('key')).toBe('"secondo"')
|
||||
})
|
||||
|
||||
it('gestisce valori primitivi (numero, stringa, booleano)', () => {
|
||||
save('n', 42)
|
||||
save('s', 'ciao')
|
||||
save('b', false)
|
||||
expect(JSON.parse(localStorage.getItem('n'))).toBe(42)
|
||||
expect(JSON.parse(localStorage.getItem('s'))).toBe('ciao')
|
||||
expect(JSON.parse(localStorage.getItem('b'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('restituisce il valore parsato se la chiave esiste', () => {
|
||||
localStorage.setItem('obj', '{"a":1}')
|
||||
expect(load('obj', null)).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it('restituisce il default se la chiave non esiste', () => {
|
||||
expect(load('nonexistent', 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('restituisce il default anche per array vuoto come fallback', () => {
|
||||
expect(load('missing', [])).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trip: save → load restituisce il valore originale', () => {
|
||||
const data = [{ id: 1, name: 'pasta', checked: false }]
|
||||
save('shopping', data)
|
||||
expect(load('shopping', [])).toEqual(data)
|
||||
})
|
||||
|
||||
it('round-trip su struttura pasti annidata', () => {
|
||||
const meals = { lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } }
|
||||
save('meals', meals)
|
||||
expect(load('meals', {})).toEqual(meals)
|
||||
})
|
||||
})
|
||||
17
vitest.config.js
Normal file
17
vitest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
include: ['tests/unit/**/*.test.js', 'tests/integration/**/*.test.js'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/utils/**', 'src/pages/**', 'src/components/**'],
|
||||
exclude: ['src/data/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user