Compare commits

...

6 Commits

Author SHA1 Message Date
0c2a227be2 chore: aggiorna versione a 0.9.1 e CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:47:57 +01:00
e10bb2df4c 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>
2026-03-27 14:45:31 +01:00
09c9706938 docs: aggiungi requisiti di sviluppo al README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:35:11 +01:00
ecbae836b3 feat: guida utente in-app e fix grafici Convertitore
- DocsPanel: pannello documentazione slide-from-right con nav a pill,
  IntersectionObserver per sezione attiva, card con step numerati e callout tip
- InfoPanel: bottone "Guida" emette open-docs invece di aprire link esterno
- App: integra DocsPanel con v-model showDocs
- Converter: fix capitalize food names (capFirst invece di text-transform),
  simmetria visiva input/output (stesso underline e font-size 1.6rem),
  rimosso CSS morto (doppio align-items, doppio background, visibility hack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:32:36 +01:00
f24e591ca0 docs: riscrivi guida utente allineata allo stato attuale dell'app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:10:00 +01:00
eef38a74cc Merge branch 'new-logo' 2026-03-27 14:07:32 +01:00
21 changed files with 3719 additions and 59 deletions

View File

@@ -7,6 +7,35 @@ il progetto aderisce al [Semantic Versioning](https://semver.org/lang/it/).
---
## [0.9.1] — 2026-03-27
### Aggiunto
- **Guida utente in-app** — pannello documentazione integrato (DocsPanel): slide-from-right, navigazione a pill per sezione (Pasti, Converti, Spesa), IntersectionObserver per pill attiva durante lo scroll, card con step numerati e callout tip
- **Suite di test** — 64 test automatici con Vitest + Playwright:
- Unit: `conversion.js` e `storage.js` con edge case
- Integration: Converter, MealPlanner, ShoppingList con Vue Test Utils
- E2E: navigazione, piano pasti, convertitore, lista della spesa con Playwright
- **Script npm**: `test`, `test:coverage`, `test:e2e`, `test:e2e:ui`
### Modificato
- **InfoPanel** — il link esterno alla documentazione è sostituito dal pulsante "Guida" che apre DocsPanel in-app
- **Convertitore** — nomi degli alimenti in sentence case (`capFirst`) anche nella lista risultati e nell'header della card (non solo i metodi)
- **Convertitore** — simmetria visiva input/output: stesso underline `border-bottom` e stessa dimensione `1.6rem` per entrambe le colonne
- **README** — aggiunta sezione "Requisiti per lo sviluppo" con versioni minime di Node.js, npm, Git e browser
- **Icona app** — favicon e icona Android (round launcher) aggiornate a forma circolare con sfondo trasparente
### Fix
- **Convertitore** — rimosso CSS morto: doppio `align-items` in `.calc-output`, doppio `background` in `.btn-reset`, hack visibility globale su `.calc-unit`
### Documentazione
- **docs/guida-utente.md** — riscritta e allineata allo stato attuale dell'app (tab corrette, funzionalità aggiornate, friggitrice ad aria inclusa)
---
## [0.9.0] — 2026-03-27
### Aggiunto

View File

@@ -33,6 +33,17 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
| Mobile | Capacitor Android |
| Build APK | Docker |
## Requisiti per lo sviluppo
| Strumento | Versione minima | Note |
|---|---|---|
| Node.js | 18.x | |
| npm | 9.x | incluso con Node.js |
| Git | 2.x | |
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |
Per la build APK Android sono necessari requisiti aggiuntivi — vedi [docker/README.md](docker/README.md).
## Avvio in sviluppo
```bash

View File

@@ -4,21 +4,21 @@
BitePlan è organizzata in tre sezioni accessibili dalla barra in basso:
| Icona | Sezione | Funzione |
| Icona | Tab | Funzione |
|---|---|---|
| Calendario | Piano Pasti | Pianifica i pasti della settimana |
| Bilancia | Convertitore | Converti peso crudo/cotto |
| Lista | Lista della spesa | Gestisci la spesa |
| Calendario | Pasti | Pianifica i pasti della settimana |
| Frecce | Converti | Converti peso crudo/cotto |
| Carrello | Spesa | Gestisci la lista della spesa |
---
## Piano Pasti
## Pasti
La sezione mostra i sette giorni della settimana. Il giorno corrente è aperto di default.
La sezione mostra i sette giorni della settimana. Il giorno corrente è espanso di default.
### Aggiungere un alimento
1. Tocca il giorno per espandere la card
1. Tocca il giorno per espandere la card (se non è già aperta)
2. Vai al pasto desiderato (Colazione, Pranzo o Cena)
3. Scrivi il nome dell'alimento nel campo di testo
4. Premi **+** o il tasto Invio per aggiungerlo
@@ -27,43 +27,60 @@ La sezione mostra i sette giorni della settimana. Il giorno corrente è aperto d
Tocca il pulsante **×** a destra dell'elemento.
### Generare la lista della spesa
Premi il pulsante **Genera lista della spesa** in fondo alla pagina. Tutti gli alimenti pianificati vengono aggiunti alla lista della spesa, senza duplicati rispetto a quelli già presenti. L'app passa automaticamente alla tab Spesa.
### Persistenza
I dati vengono salvati automaticamente sul dispositivo (LocalStorage). Non è necessario premere nessun pulsante di salvataggio.
I dati vengono salvati automaticamente sul dispositivo. Non è necessario premere nessun pulsante di salvataggio.
---
## Convertitore crudo/cotto
## Converti
Calcola il peso cotto a partire dal crudo, o viceversa, in base ai coefficienti di resa di ogni alimento.
Calcola il peso cotto a partire dal crudo, o viceversa, in base ai coefficienti di resa di ogni alimento e metodo di cottura.
### Utilizzo
1. Cerca l'alimento nel campo di ricerca (es. `pollo`, `riso`)
2. Seleziona il metodo di cottura dall'elenco risultati
3. Scegli la direzione: **Crudo → Cotto** o **Cotto → Crudo** (pulsante swap)
4. Inserisci il peso in grammi
5. Il risultato appare in tempo reale
1. Cerca l'alimento nel campo di ricerca (es. `pollo`, `riso`, `zucchine`)
2. Seleziona dall'elenco la combinazione alimento + metodo di cottura
3. Inserisci il peso in grammi
4. Il risultato appare in tempo reale
5. Per invertire la direzione (cotto → crudo), premi il pulsante **⇄** al centro
### Alimenti disponibili
### Alimenti e metodi disponibili
Proteine: pollo, manzo, maiale, salmone, tonno, uova
Carboidrati: riso, pasta, lenticchie
Verdure: zucchine, carote, patate, spinaci, broccoli
Il database copre oltre 50 voci suddivise per categoria:
Per l'elenco completo con metodi di cottura e coefficienti, vedi [conversioni.md](conversioni.md).
| Categoria | Esempi |
|---|---|
| Cereali e pasta | Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous |
| Legumi secchi | Ceci, fagioli, lenticchie |
| Verdure | Carote, zucchine, patate, spinaci, broccoli, cavolfiore, asparagi, carciofi, finocchi e altri |
| Carni | Pollo petto, tacchino fesa, hamburger, vitello |
| Pesce | Tonno, merluzzo, spigola, sogliola |
| Uova | Uovo al tegamino, frittata |
I metodi di cottura disponibili variano per alimento: **bollitura**, **padella**, **forno**, **friggitrice ad aria**.
Per l'elenco completo con coefficienti e fonti, vedi [conversioni.md](conversioni.md).
---
## Lista della spesa
## Spesa
### Aggiungere un elemento
Scrivi il nome nel campo in alto e premi **+** o Invio.
### Importare dal piano pasti
Vai alla tab **Pasti** e premi **Genera lista della spesa** — gli alimenti pianificati vengono aggiunti automaticamente.
### Spuntare un elemento
Tocca la casella a sinistra dell'elemento. Gli elementi completati vengono spostati in fondo con testo barrato.
Tocca la casella a sinistra dell'elemento. Gli elementi completati vengono spostati in una sezione separata con testo barrato.
### Rimuovere un elemento

2144
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,27 @@
{
"name": "biteplan",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"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
View 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'] } },
],
})

View File

@@ -14,7 +14,8 @@
</svg>
</button>
<InfoPanel v-model="showInfo" />
<InfoPanel v-model="showInfo" @open-docs="showDocs = true" />
<DocsPanel v-model="showDocs" />
</div>
</template>
@@ -22,12 +23,14 @@
import { ref } from 'vue'
import BottomNav from './components/BottomNav.vue'
import InfoPanel from './components/InfoPanel.vue'
import DocsPanel from './components/DocsPanel.vue'
import MealPlanner from './pages/MealPlanner.vue'
import Converter from './pages/Converter.vue'
import ShoppingList from './pages/ShoppingList.vue'
const page = ref('meal')
const page = ref('meal')
const showInfo = ref(false)
const showDocs = ref(false)
</script>
<style scoped>

View File

@@ -0,0 +1,505 @@
<template>
<Transition name="slide-right">
<div v-if="modelValue" class="docs-panel" role="dialog" aria-label="Guida utente">
<!-- Header sticky: back arrow + titolo centrato + spacer bilanciante -->
<div class="docs-header">
<button class="btn-back" @click="$emit('update:modelValue', false)" aria-label="Torna indietro">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="docs-title">Guida</span>
<!-- spacer identico al btn-back per centrare il titolo -->
<div aria-hidden="true" class="header-spacer" />
</div>
<!-- Pill nav scrollabile IntersectionObserver aggiorna la pill attiva -->
<div class="section-nav" role="tablist" aria-label="Sezioni">
<button
v-for="s in sections"
:key="s.id"
:class="['nav-pill', { active: activeSection === s.id }]"
role="tab"
:aria-selected="activeSection === s.id"
@click="scrollToSection(s.id)"
>{{ s.label }}</button>
</div>
<!-- Contenuto scorrevole -->
<div class="docs-scroll" ref="scrollEl">
<!-- Pasti -->
<section data-section="pasti">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</span>
<h2 class="section-heading">Pasti</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Aggiungere un alimento</h3>
<ol class="steps">
<li>Tocca il giorno per espandere la card</li>
<li>Scegli il pasto: <strong>Colazione</strong>, <strong>Pranzo</strong> o <strong>Cena</strong></li>
<li>Scrivi il nome nel campo di testo</li>
<li>Premi <kbd>+</kbd> o Invio per aggiungerlo</li>
</ol>
</div>
<div class="doc-card tip">
<p class="tip-label" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Suggerimento
</p>
<p>Premi <strong>Genera lista della spesa</strong> in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Rimuovere un alimento</h3>
<p>Tocca il pulsante <strong>×</strong> a destra dell'elemento.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Salvataggio automatico</h3>
<p>I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.</p>
</div>
</section>
<!-- ── Converti ───────────────────────────── -->
<section data-section="converti">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/>
<polyline points="7 23 3 19 7 15"/>
<path d="M21 13v2a4 4 0 01-4 4H3"/>
</svg>
</span>
<h2 class="section-heading">Converti</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Come usarlo</h3>
<ol class="steps">
<li>Cerca l'alimento nel campo (es. <em>pollo</em>, <em>riso</em>)</li>
<li>Seleziona il metodo di cottura dall'elenco</li>
<li>Inserisci il peso in grammi</li>
<li>Il risultato appare in tempo reale</li>
<li>Premi <strong>⇄</strong> per invertire crudo ↔ cotto</li>
</ol>
</div>
<div class="doc-card">
<h3 class="card-title">Alimenti disponibili</h3>
<div class="category-table" role="list">
<div class="cat-row" v-for="cat in categories" :key="cat.label" role="listitem">
<span class="cat-label">{{ cat.label }}</span>
<span class="cat-items">{{ cat.items }}</span>
</div>
</div>
<div class="methods-row" aria-label="Metodi di cottura disponibili">
<span class="method-chip" v-for="m in cookingMethods" :key="m">{{ m }}</span>
</div>
</div>
</section>
<!-- ── Spesa ──────────────────────────────── -->
<section data-section="spesa">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
</span>
<h2 class="section-heading">Spesa</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Aggiungere un elemento</h3>
<p>Scrivi il nome nel campo in alto e premi <kbd>+</kbd> o Invio.</p>
</div>
<div class="doc-card tip">
<p class="tip-label" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Suggerimento
</p>
<p>Vai alla tab <strong>Pasti</strong> e premi <strong>Genera lista della spesa</strong> per importare automaticamente gli alimenti pianificati.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Spuntare un elemento</h3>
<p>Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Rimuovere / svuotare</h3>
<p>Tocca <strong>×</strong> per rimuovere un elemento singolo, oppure <strong>Svuota lista</strong> in fondo per eliminare tutto (richiede conferma).</p>
</div>
</section>
<div class="docs-footer">BitePlan · v{{ version }} · Davide Grilli</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue'
import pkg from '../../package.json'
const props = defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue'])
const version = pkg.version
const scrollEl = ref(null)
const activeSection = ref('pasti')
let observer = null
const sections = [
{ id: 'pasti', label: 'Pasti' },
{ id: 'converti', label: 'Converti' },
{ id: 'spesa', label: 'Spesa' },
]
const categories = [
{ label: 'Cereali e pasta', items: 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous' },
{ label: 'Legumi secchi', items: 'Ceci, fagioli, lenticchie' },
{ label: 'Verdure', items: 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri' },
{ label: 'Carni', items: 'Pollo petto, tacchino fesa, hamburger, vitello' },
{ label: 'Pesce', items: 'Tonno, merluzzo, spigola, sogliola' },
{ label: 'Uova', items: 'Uovo al tegamino, frittata' },
]
const cookingMethods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria']
watch(() => props.modelValue, async (open) => {
if (open) {
await nextTick()
setupObserver()
activeSection.value = 'pasti'
scrollEl.value?.scrollTo({ top: 0 })
} else {
observer?.disconnect()
observer = null
}
})
function setupObserver() {
if (!scrollEl.value) return
observer?.disconnect()
// rootMargin: entra in zona attiva quando la sezione raggiunge il 20% dall'alto del container
observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter(e => e.isIntersecting)
if (visible.length) activeSection.value = visible[0].target.dataset.section
},
{ root: scrollEl.value, rootMargin: '0px 0px -65% 0px', threshold: 0 }
)
scrollEl.value.querySelectorAll('[data-section]').forEach(el => observer.observe(el))
}
function scrollToSection(id) {
const el = scrollEl.value?.querySelector(`[data-section="${id}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onUnmounted(() => observer?.disconnect())
</script>
<style scoped>
/* ── Panel container ─────────────────────────── */
.docs-panel {
position: fixed;
inset: 0;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg);
z-index: 300;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Animazione slide da destra ──────────────── */
/* enter ease-out: risposta immediata, decelera all'arrivo (feel nativo) */
.slide-right-enter-active { transition: transform 320ms cubic-bezier(0.25, 1, 0.5, 1); }
/* leave ease-in: parte lentamente, accelera — chiusura decisa */
.slide-right-leave-active { transition: transform 240ms cubic-bezier(0.55, 0, 1, 0.45); }
.slide-right-enter-from,
.slide-right-leave-to { transform: translateX(100%); }
/* ── Header ──────────────────────────────────── */
.docs-header {
display: flex;
align-items: center;
justify-content: space-between;
/* safe-area-inset-top: evita sovrapposizione con status bar su iOS */
padding: calc(env(safe-area-inset-top, 0px) + 10px) 8px 10px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.btn-back {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
background: none;
color: var(--color-primary);
border-radius: var(--radius-sm);
padding: 0;
transition: background var(--transition);
}
.btn-back:active {
background: var(--color-primary-muted);
opacity: 1;
}
.docs-title {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
/* spacer uguale al btn-back per centrare il titolo otticamente */
.header-spacer { width: 44px; }
/* ── Navigazione sezioni (pill) ──────────────── */
.section-nav {
display: flex;
gap: 6px;
padding: 10px 16px;
overflow-x: auto;
scrollbar-width: none;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.section-nav::-webkit-scrollbar { display: none; }
.nav-pill {
flex-shrink: 0;
font-size: 0.85rem;
font-weight: 600;
padding: 0 14px;
min-height: 32px;
border-radius: var(--radius-full);
background: var(--color-bg);
color: var(--color-muted);
border: 1px solid var(--color-border);
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.nav-pill.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
/* ── Scroll container ────────────────────────── */
.docs-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px);
}
/* ── Section header ──────────────────────────── */
section { padding-top: 4px; }
section + section { margin-top: 8px; }
.section-head {
display: flex;
align-items: center;
gap: 10px;
padding: 24px 16px 10px;
}
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
/* quadratino con sfondo primario muted — identifica visivamente la sezione */
background: var(--color-primary-muted);
border-radius: var(--radius-sm);
color: var(--color-primary);
flex-shrink: 0;
}
.section-heading {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-text);
}
/* ── Card contenuto ──────────────────────────── */
.doc-card {
margin: 0 16px 8px;
background: var(--color-surface);
border-radius: var(--radius);
padding: 14px 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
font-size: 0.9rem;
line-height: 1.55;
color: var(--color-text);
}
.card-title {
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-bottom: 8px;
}
/* ── Card tip / suggerimento ─────────────────── */
/* border-left accent: segnala contenuto accessorio senza competere con le card */
.doc-card.tip {
background: var(--color-primary-muted);
border-color: transparent;
border-left: 3px solid var(--color-primary);
box-shadow: none;
}
.tip-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-primary);
margin-bottom: 6px;
}
/* ── Lista numerata con badge ────────────────── */
/* counter CSS invece di <span> inline: mantiene semantica <ol> accessibile */
.steps {
list-style: none;
counter-reset: steps;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 8px;
}
.steps li {
counter-increment: steps;
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 0.9rem;
line-height: 1.5;
}
.steps li::before {
content: counter(steps);
display: flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
background: var(--color-primary);
color: #fff;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
margin-top: 2px;
}
/* ── Kbd inline ──────────────────────────────── */
kbd {
display: inline-flex;
align-items: center;
padding: 1px 7px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 5px;
font-family: inherit;
font-size: 0.85em;
font-weight: 600;
color: var(--color-text);
}
/* ── Tabella categorie alimenti ──────────────── */
.category-table {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
margin-top: 10px;
}
.cat-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 0.86rem;
}
.cat-row:last-child { border-bottom: none; }
.cat-label {
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
min-width: 110px;
}
.cat-items { color: var(--color-muted); font-size: 0.82rem; }
/* ── Chip metodi cottura ─────────────────────── */
.methods-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.method-chip {
font-size: 0.78rem;
font-weight: 600;
padding: 3px 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
}
/* ── Footer ──────────────────────────────────── */
.docs-footer {
margin: 24px 16px 0;
text-align: center;
font-size: 0.78rem;
color: var(--color-muted);
opacity: 0.6;
}
</style>

View File

@@ -32,16 +32,15 @@
<span class="info-label">Licenza</span>
<span class="info-value">EUPL v1.2</span>
</div>
<div class="info-row">
<span class="info-label">Documentazione</span>
<a class="info-link" href="https://docs.biteplan.example.com" target="_blank" rel="noopener">
<button class="info-row info-row--btn" @click="$emit('open-docs')" aria-label="Apri guida">
<span class="info-label">Guida</span>
<span class="info-link">
Apri
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"/>
</svg>
</a>
</div>
</span>
</button>
</div>
</div>
@@ -52,7 +51,7 @@
import pkg from '../../package.json'
import appIcon from '../../assets/icon-only.png'
defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue'])
defineEmits(['update:modelValue', 'open-docs'])
const version = pkg.version
</script>
@@ -150,6 +149,16 @@ const version = pkg.version
.info-row:last-child { border-bottom: none; }
/* riga cliccabile: reset button, mantiene layout identico alle righe statiche */
.info-row--btn {
width: 100%;
min-height: unset;
border-radius: 0;
background: var(--color-surface);
text-align: left;
}
.info-row--btn:active { background: var(--color-bg); opacity: 1; }
.info-label {
font-size: 0.9rem;
color: var(--color-text);

View File

@@ -27,7 +27,7 @@
class="result-item"
@click="selectItem(r)"
>
<span class="result-food">{{ r.food }}</span>
<span class="result-food">{{ capFirst(r.food) }}</span>
<span class="result-method">{{ capFirst(r.method) }}</span>
</li>
</ul>
@@ -37,7 +37,7 @@
<div v-if="selected" class="converter-card">
<div class="card-top">
<div class="food-info">
<span class="food-name">{{ selected.food }}</span>
<span class="food-name">{{ capFirst(selected.food) }}</span>
<span class="food-sep">·</span>
<span class="food-method">{{ capFirst(selected.method) }}</span>
</div>
@@ -64,7 +64,7 @@
</div>
</div>
<button class="btn-swap" @click="swapDirection" :title="direction === 'rawToCooked' ? 'Inverti direzione' : 'Inverti direzione'">
<button class="btn-swap" @click="swapDirection" aria-label="Inverti direzione">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/>
@@ -73,12 +73,12 @@
</svg>
</button>
<div class="calc-side output-side" :class="{ 'has-result': result !== null }">
<div class="calc-side output-side">
<div class="calc-label">{{ direction === 'rawToCooked' ? 'cotto' : 'crudo' }}</div>
<div class="calc-output">
<span v-if="result !== null" class="output-value">{{ result }}</span>
<span v-else class="output-placeholder"></span>
<span class="calc-unit" :class="{ visible: result !== null }">g</span>
<span v-if="result !== null" class="calc-unit">g</span>
</div>
</div>
</div>
@@ -206,7 +206,7 @@ function swapDirection() {
.result-item:last-child { border-bottom: none; }
.result-item:active { background: var(--color-bg); }
.result-food { font-weight: 600; text-transform: capitalize; }
.result-food { font-weight: 600; }
.result-method { font-size: 0.85rem; color: var(--color-muted); }
/* ── converter card ───────────────────────────────── */
@@ -231,12 +231,11 @@ function swapDirection() {
gap: 6px;
}
.food-name { font-weight: 700; font-size: 1rem; text-transform: capitalize; }
.food-name { font-weight: 700; font-size: 1rem; }
.food-sep { color: var(--color-border); font-size: 1.1rem; }
.food-method { font-size: 0.9rem; color: var(--color-muted); }
.btn-reset {
background: none;
color: var(--color-primary);
font-size: 0.85rem;
font-weight: 600;
@@ -284,7 +283,7 @@ function swapDirection() {
/* override globale per input dentro la card */
.calc-input {
font-size: 1.5rem;
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
border: none;
@@ -308,6 +307,8 @@ function swapDirection() {
font-size: 1rem;
font-weight: 600;
color: var(--color-muted);
align-self: flex-end;
padding-bottom: 6px;
}
/* bottone swap centrale */
@@ -332,33 +333,31 @@ function swapDirection() {
opacity: 1;
}
/* colonna output */
.output-side .calc-output {
/* colonna output: underline visivo per simmetria con il lato input */
.calc-output {
display: flex;
align-items: baseline;
align-items: center;
gap: 4px;
min-height: 44px;
align-items: center;
border-bottom: 2px solid var(--color-border);
padding-bottom: 4px;
}
.output-value {
font-size: 2rem;
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-primary);
line-height: 1;
}
.output-placeholder {
font-size: 2rem;
font-size: 1.6rem;
font-weight: 300;
color: var(--color-border);
line-height: 1;
}
/* nasconde "g" finché non c'è un risultato */
.calc-unit { visibility: hidden; }
.calc-input-wrap .calc-unit,
.has-result .calc-unit { visibility: visible; }
/* ── footer card ─────────────────────────────────── */
.card-footer {
padding: 10px 16px 14px;

View 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')
})
})

View 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()
})
})

View 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()
})
})

View 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()
})
})

View 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()
})
})

View 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)
})
})

View 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
View 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())

View 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)
})
})

View 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
View 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/**'],
},
},
})