Compare commits

...

11 Commits

Author SHA1 Message Date
d79ce06278 feat: condivisione piano pasti via QR code
Merge branch 'qrcode'
2026-03-31 10:44:54 +02:00
55257f849d fix: aggiungi permesso CAMERA all'AndroidManifest.xml generato da Capacitor 2026-03-31 10:44:09 +02:00
7dd1bce80f chore: merge main nel branch qrcode (fix vulnerabilità npm)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:20:03 +02:00
9b099dac83 fix: risolvi 6 vulnerabilità npm aggiornando vite e vitest
- Upgrade vite 5 → 6 (fix GHSA-67mh-4wv8-2f99: esbuild CORS dev server)
- Upgrade vitest 2 → 3 e @vitest/coverage-v8 2 → 3
- Rinomina vite.config.js → vite.config.mjs (richiesto da vite 6 ESM)
- Aggiorna requisito Node.js in README: 18.x → >= 20.x LTS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:15:55 +02:00
8685ea0aca feat: condivisione piano pasti via QR code
Aggiunge due pulsanti in fondo alla schermata Piano Pasti:
- "Condividi": genera un QR code con i dati della settimana
- "Ricevi": apre la fotocamera per scansionare il QR e importare il piano

Dipendenze aggiunte: qrcode, jsqr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:13:21 +02:00
7a63706af5 chore: aggiorna versione a 1.0.0 e CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:16:45 +01:00
5df7956069 fix: crea dist/ prima del docker run per evitare ownership root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:57:33 +01:00
76cf672d80 fix: APK con --head ora copiato in dist/ invece di andare perso nella tmpdir
Con --head la DIST_DIR puntava alla directory temporanea che veniva cancellata
dal trap EXIT prima che l'utente potesse recuperare l'APK. L'APK viene ora
copiato esplicitamente in PROJECT_ROOT/dist/ dopo il docker run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:50:55 +01:00
96f75df6f2 feat: supporto build APK release firmato nella pipeline Docker
- Dockerfile: CMD condizionale su BUILD_TYPE (debug default, release con
  assembleRelease + zipalign + apksigner)
- build.sh: flag --release, richiesta password interattiva, mount keystore
  come volume read-only (mai nell'immagine)
- .gitignore: aggiunto *.jks
- docker/README.md: documentazione build debug/release, keytool, flag combinabili

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:10:43 +01:00
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
22 changed files with 4121 additions and 170 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.DS_Store .DS_Store
*.jks

View File

@@ -7,6 +7,55 @@ il progetto aderisce al [Semantic Versioning](https://semver.org/lang/it/).
--- ---
## [1.0.0] — 2026-03-28
### Aggiunto
- **Build APK release firmato** — pipeline Docker aggiornata con supporto
`assembleRelease` + `zipalign` + `apksigner`; flag `--release` in `build.sh`
per attivare la firma; keystore montato come volume read-only (mai nell'immagine)
- **`build.sh --release`** — chiede interattivamente le password del keystore;
supporta override via `KEYSTORE_PASS`, `KEY_PASS`, `KEYSTORE_PATH`
- **Flag combinabili** — `--head` e `--release` ora combinabili in qualsiasi ordine
### Fix
- `build.sh --head` — APK ora copiato in `dist/` del progetto invece di andare
perso nella directory temporanea cancellata dal trap EXIT
- `build.sh``dist/` creata con `mkdir -p` prima del `docker run` per evitare
ownership root che causava "Permission denied" nelle build successive
---
## [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 ## [0.9.0] — 2026-03-27
### Aggiunto ### Aggiunto

View File

@@ -37,7 +37,7 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
| Strumento | Versione minima | Note | | Strumento | Versione minima | Note |
|---|---|---| |---|---|---|
| Node.js | 18.x | | | Node.js | >= 20.x LTS | testato con v24 |
| npm | 9.x | incluso con Node.js | | npm | 9.x | incluso con Node.js |
| Git | 2.x | | | Git | 2.x | |
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati | | Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |

View File

@@ -52,6 +52,10 @@ RUN npm install @capacitor/core @capacitor/cli @capacitor/android --save
RUN npx cap add android RUN npx cap add android
# ── Permessi Android ──────────────────────────────────────────────────────────
RUN sed -i 's|</manifest>| <uses-permission android:name="android.permission.CAMERA" />\n</manifest>|' \
android/app/src/main/AndroidManifest.xml
# ── Versione da package.json → android/app/build.gradle ────────────────────── # ── Versione da package.json → android/app/build.gradle ──────────────────────
RUN node -e "\ RUN node -e "\
const v = require('./package.json').version; \ const v = require('./package.json').version; \
@@ -76,11 +80,29 @@ RUN rm -rf android/app/src/main/res/mipmap-anydpi-v26 && \
# Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8) # Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8)
RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle
# ── Runtime: dist/ viene montato come volume dall'host ──────────────────────── # ── Runtime: dist/ e (opzionale) keystore montati come volumi dall'host ───────
# build.sh esegue: docker run -v ./dist:/app/dist ... # build.sh esegue: docker run -v ./dist:/app/dist ...
# Qui cap sync copia dist/ in android/assets, poi Gradle builda l'APK # Con --release: aggiunge -e BUILD_TYPE=release -v biteplan.jks:/app/biteplan.jks:ro
# BUILD_TYPE=release → assembleRelease + zipalign + apksigner
# BUILD_TYPE vuoto → assembleDebug (default)
CMD npx cap sync android && \ CMD npx cap sync android && \
cd android && chmod +x gradlew && ./gradlew assembleDebug --no-daemon && \ cd android && chmod +x gradlew && \
if [ "$BUILD_TYPE" = "release" ]; then \
./gradlew assembleRelease --no-daemon && \
$ANDROID_HOME/build-tools/34.0.0/zipalign -v 4 \
app/build/outputs/apk/release/app-release-unsigned.apk \
/tmp/aligned.apk && \
$ANDROID_HOME/build-tools/34.0.0/apksigner sign \
--ks /app/biteplan.jks \
--ks-key-alias biteplan \
--ks-pass pass:$KEYSTORE_PASS \
--key-pass pass:$KEY_PASS \
--out /app/dist/biteplan.apk \
/tmp/aligned.apk && \
echo "APK release firmato: /app/dist/biteplan.apk"; \
else \
./gradlew assembleDebug --no-daemon && \
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \ cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \
echo "APK generato: /app/dist/biteplan.apk" echo "APK debug: /app/dist/biteplan.apk"; \
fi

View File

@@ -1,6 +1,6 @@
# Build APK — Docker # Build APK — Docker
Genera un APK Android debug senza installare nulla sull'host oltre a Docker. Genera un APK Android senza installare nulla sull'host oltre a Docker.
## Requisiti host ## Requisiti host
@@ -11,21 +11,75 @@ Genera un APK Android debug senza installare nulla sull'host oltre a Docker.
| **Docker** | Installato e avviato | | **Docker** | Installato e avviato |
| **Node.js** | v18+ (per il build Vite locale) | | **Node.js** | v18+ (per il build Vite locale) |
> Gli Android build-tools (`aapt2`, `zipalign`, ecc.) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU. > Gli Android build-tools (`aapt2`, `zipalign`, `apksigner`) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU.
## Utilizzo ---
Dalla root del progetto: ## Build debug (default)
APK firmato con il debug keystore di Android. Adatto per test su dispositivo.
```bash ```bash
# Build dalla working directory (file attuali)
bash docker/build.sh bash docker/build.sh
# Build da HEAD (ignora modifiche non committate)
bash docker/build.sh --head
``` ```
L'APK viene generato in `dist/biteplan.apk`. ---
## Build release (distribuzione)
APK firmato con il tuo keystore personale. Necessario per distribuire l'app.
### 1. Genera il keystore (una volta sola)
```bash
keytool -genkey -v \
-keystore docker/biteplan.jks \
-alias biteplan \
-keyalg RSA \
-keysize 2048 \
-validity 10000
```
> Il file `docker/biteplan.jks` è già in `.gitignore` — non verrà mai committato.
> Conservalo in un posto sicuro: senza di esso non puoi aggiornare l'app.
### 2. Esegui la build release
```bash
bash docker/build.sh --release
# chiede interattivamente: Password keystore / Password chiave
```
Oppure passando le password come variabili d'ambiente (utile in CI):
```bash
KEYSTORE_PASS=tuapassword KEY_PASS=tuapassword bash docker/build.sh --release
```
Per usare un keystore in un percorso diverso da `docker/biteplan.jks`:
```bash
KEYSTORE_PATH=/percorso/biteplan.jks bash docker/build.sh --release
```
### 3. Verifica la firma
```bash
$ANDROID_HOME/build-tools/34.0.0/apksigner verify --verbose dist/biteplan.apk
```
---
## Flag combinabili
| Comando | Risultato |
|---------|-----------|
| `bash docker/build.sh` | APK debug dalla working directory |
| `bash docker/build.sh --head` | APK debug dall'ultimo commit git |
| `bash docker/build.sh --release` | APK release firmato dalla working directory |
| `bash docker/build.sh --head --release` | APK release firmato dall'ultimo commit git |
---
## Prima build ## Prima build
@@ -34,8 +88,6 @@ Le build successive usano la cache Docker e sono molto più rapide.
## Installazione su dispositivo ## Installazione su dispositivo
Con ADB:
```bash ```bash
adb install dist/biteplan.apk adb install dist/biteplan.apk
``` ```
@@ -43,14 +95,15 @@ adb install dist/biteplan.apk
## Pipeline ## Pipeline
``` ```
[host] npm run build → dist/ (montato come volume in sola lettura) [host] npm run build → dist/
[docker] cap sync → copia dist/ in android/assets/ [docker] cap sync → copia dist/ in android/assets/
[docker] gradlew assembleDebug [docker] gradlew assembleDebug/Release
[host] output/biteplan.apk [docker] zipalign + apksigner → solo in modalità release
[host] dist/biteplan.apk
``` ```
## Note ## Note
- APK di tipo **debug**, non firmato per la produzione
- App ID: `com.davide.biteplan` - App ID: `com.davide.biteplan`
- Android target: API 34 - Android target: API 34
- Il keystore non viene mai copiato nell'immagine Docker (montato come volume read-only)

View File

@@ -6,10 +6,31 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DIST_DIR="$PROJECT_ROOT/dist" DIST_DIR="$PROJECT_ROOT/dist"
FROM_HEAD=false FROM_HEAD=false
RELEASE=false
for arg in "$@"; do for arg in "$@"; do
[[ "$arg" == "--head" ]] && FROM_HEAD=true [[ "$arg" == "--head" ]] && FROM_HEAD=true
[[ "$arg" == "--release" ]] && RELEASE=true
done done
# ── Keystore (solo in modalità release) ───────────────────────────────────────
KEYSTORE_PATH="${KEYSTORE_PATH:-$SCRIPT_DIR/biteplan.jks}"
if $RELEASE; then
if [[ ! -f "$KEYSTORE_PATH" ]]; then
echo "Errore: keystore non trovato in $KEYSTORE_PATH"
echo "Generalo con:"
echo " keytool -genkey -v -keystore docker/biteplan.jks -alias biteplan -keyalg RSA -keysize 2048 -validity 10000"
exit 1
fi
if [[ -z "${KEYSTORE_PASS:-}" ]]; then
read -rsp "Password keystore: " KEYSTORE_PASS; echo
fi
if [[ -z "${KEY_PASS:-}" ]]; then
read -rsp "Password chiave: " KEY_PASS; echo
fi
fi
# ── Build Vite ──────────────────────────────────────────────────────────────── # ── Build Vite ────────────────────────────────────────────────────────────────
if $FROM_HEAD; then if $FROM_HEAD; then
COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD) COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD)
@@ -36,11 +57,31 @@ docker build \
-t biteplan-builder \ -t biteplan-builder \
"$PROJECT_ROOT" "$PROJECT_ROOT"
# ── Generazione APK (dist/ montato come volume) ─────────────────────────────── # ── Generazione APK ───────────────────────────────────────────────────────────
echo "==> Generazione APK..." echo "==> Generazione APK${RELEASE:+ release}..."
docker run --rm \ mkdir -p "$PROJECT_ROOT/dist"
-v "$DIST_DIR:/app/dist" \
biteplan-builder DOCKER_ARGS=(
--rm
-v "$DIST_DIR:/app/dist"
)
if $RELEASE; then
DOCKER_ARGS+=(
-e BUILD_TYPE=release
-e KEYSTORE_PASS="$KEYSTORE_PASS"
-e KEY_PASS="$KEY_PASS"
-v "$KEYSTORE_PATH:/app/biteplan.jks:ro"
)
fi
docker run "${DOCKER_ARGS[@]}" biteplan-builder
# Con --head l'APK è nella dir temporanea — copiarlo in dist/ del progetto
if $FROM_HEAD; then
mkdir -p "$PROJECT_ROOT/dist"
cp "$DIST_DIR/biteplan.apk" "$PROJECT_ROOT/dist/biteplan.apk"
fi
echo "" echo ""
echo "APK pronto in: $DIST_DIR/biteplan.apk" echo "APK pronto in: $PROJECT_ROOT/dist/biteplan.apk"

2714
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,29 @@
{ {
"name": "biteplan", "name": "biteplan",
"version": "0.9.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build", "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": { "dependencies": {
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0" "vue": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@playwright/test": "^1.58.2",
"vite": "^5.0.0" "@vitejs/plugin-vue": "^5.2.0",
"@vitest/coverage-v8": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.8.9",
"jsdom": "^29.0.1",
"vite": "^6.0.0",
"vitest": "^3.0.0"
} }
} }

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

@@ -21,13 +21,89 @@
</svg> </svg>
Genera lista della spesa Genera lista della spesa
</button> </button>
<div class="btn-share-row">
<button class="btn-share" @click="openShare">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="3" height="3" rx="0.5"/>
<rect x="19" y="14" width="2" height="2" rx="0.5"/>
<rect x="14" y="19" width="2" height="2" rx="0.5"/>
<rect x="18" y="19" width="3" height="2" rx="0.5"/>
</svg>
Condividi
</button>
<button class="btn-share btn-share--receive" @click="openScan">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
Ricevi
</button>
</div> </div>
</div>
<!-- Modal: Condividi -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showShareModal" class="qr-overlay" @click.self="closeShare">
<div class="qr-sheet" role="dialog" aria-label="Condividi piano pasti" aria-modal="true">
<div class="sheet-handle" />
<div class="qr-header">
<span class="qr-title">Condividi piano</span>
<button class="btn-x" @click="closeShare" aria-label="Chiudi">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="qr-hint">Fai scansionare questo codice dall'altro dispositivo</p>
<div class="qr-img-wrap">
<img v-if="qrDataUrl" :src="qrDataUrl" alt="QR code piano pasti" class="qr-img" />
<p v-else-if="qrError" class="qr-error">{{ qrError }}</p>
<div v-else class="qr-loading">Generazione in corso…</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Modal: Ricevi -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showScanModal" class="qr-overlay" @click.self="closeAndStopScan">
<div class="qr-sheet" role="dialog" aria-label="Ricevi piano pasti" aria-modal="true">
<div class="sheet-handle" />
<div class="qr-header">
<span class="qr-title">Scansiona QR</span>
<button class="btn-x" @click="closeAndStopScan" aria-label="Chiudi">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="qr-hint">Inquadra il codice QR dell'altro dispositivo</p>
<div class="scan-wrap">
<video ref="videoEl" class="scan-video" autoplay playsinline muted />
<canvas ref="canvasEl" class="scan-canvas" aria-hidden="true" />
<div class="scan-frame" aria-hidden="true" />
</div>
<p v-if="scanError" class="qr-error">{{ scanError }}</p>
<p v-if="scanSuccess" class="qr-success">Piano ricevuto!</p>
</div>
</div>
</Transition>
</Teleport>
</template> </template>
<script setup> <script setup>
import { reactive, watch } from 'vue' import { reactive, watch, ref, onUnmounted, nextTick } from 'vue'
import MealCard from '../components/MealCard.vue' import MealCard from '../components/MealCard.vue'
import { save, load } from '../utils/storage.js' import { save, load } from '../utils/storage.js'
import QRCode from 'qrcode'
import jsQR from 'jsqr'
const emit = defineEmits(['go-shop']) const emit = defineEmits(['go-shop'])
@@ -79,6 +155,144 @@ function generateShopping() {
save('shopping', [...existing, ...toAdd]) save('shopping', [...existing, ...toAdd])
emit('go-shop') emit('go-shop')
} }
// ── Share (QR generation) ─────────────────────────────────────────────────
const showShareModal = ref(false)
const qrDataUrl = ref('')
const qrError = ref('')
async function openShare() {
qrDataUrl.value = ''
qrError.value = ''
showShareModal.value = true
const payload = JSON.stringify({ v: 1, meals })
if (payload.length > 2953) {
qrError.value = 'Dati troppo grandi per un QR code. Riduci il numero di alimenti inseriti.'
return
}
try {
qrDataUrl.value = await QRCode.toDataURL(payload, {
errorCorrectionLevel: 'L',
margin: 1,
width: 260,
color: { dark: '#1a1a1a', light: '#ffffff' },
})
} catch {
qrError.value = 'Impossibile generare il QR code.'
}
}
function closeShare() {
showShareModal.value = false
qrDataUrl.value = ''
qrError.value = ''
}
// ── Receive (camera scan) ─────────────────────────────────────────────────
const showScanModal = ref(false)
const videoEl = ref(null)
const canvasEl = ref(null)
const scanError = ref('')
const scanSuccess = ref(false)
let stream = null
let rafId = null
async function openScan() {
scanError.value = ''
scanSuccess.value = false
showScanModal.value = true
await nextTick()
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
})
videoEl.value.srcObject = stream
videoEl.value.play()
rafId = requestAnimationFrame(scanFrame)
} catch (err) {
if (err.name === 'NotAllowedError') {
scanError.value = 'Accesso fotocamera negato. Abilita il permesso nelle impostazioni del dispositivo.'
} else {
scanError.value = 'Impossibile accedere alla fotocamera.'
}
}
}
function scanFrame() {
const video = videoEl.value
const canvas = canvasEl.value
if (!video || !canvas || video.readyState < 2) {
rafId = requestAnimationFrame(scanFrame)
return
}
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, canvas.width, canvas.height, {
inversionAttempts: 'dontInvert',
})
if (code) {
handleScannedData(code.data)
return
}
rafId = requestAnimationFrame(scanFrame)
}
function handleScannedData(raw) {
stopCamera()
try {
const parsed = JSON.parse(raw)
if (parsed?.v !== 1 || typeof parsed.meals !== 'object') {
scanError.value = 'QR non valido: dati non riconosciuti.'
return
}
const expectedDays = ['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica']
const expectedSlots = ['colazione', 'pranzo', 'cena']
for (const day of expectedDays) {
for (const slot of expectedSlots) {
if (!Array.isArray(parsed.meals[day]?.[slot])) {
scanError.value = 'QR non valido: struttura dati errata.'
return
}
}
}
for (const day of expectedDays) {
for (const slot of expectedSlots) {
meals[day][slot] = parsed.meals[day][slot]
}
}
scanSuccess.value = true
setTimeout(() => closeAndStopScan(), 1200)
} catch {
scanError.value = 'QR non valido: impossibile leggere i dati.'
}
}
function stopCamera() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
}
function closeAndStopScan() {
stopCamera()
showScanModal.value = false
scanError.value = ''
scanSuccess.value = false
}
onUnmounted(stopCamera)
</script> </script>
<style scoped> <style scoped>
@@ -100,4 +314,182 @@ function generateShopping() {
.btn-generate:active { .btn-generate:active {
opacity: 0.85; opacity: 0.85;
} }
/* ── Riga Condividi / Ricevi ─────────────────────── */
.btn-share-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-share {
flex: 1;
background: var(--color-primary-muted);
color: var(--color-primary);
font-weight: 600;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 44px;
border-radius: var(--radius);
border: 1.5px solid transparent;
}
.btn-share:active {
background: var(--color-primary);
color: #fff;
opacity: 1;
}
.btn-share--receive {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text);
}
.btn-share--receive:active {
background: var(--color-bg);
opacity: 1;
}
/* ── Overlay ─────────────────────────────────────── */
.qr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 400;
display: flex;
align-items: flex-end;
justify-content: center;
}
/* ── Bottom sheet ────────────────────────────────── */
.qr-sheet {
width: 100%;
max-width: 480px;
background: var(--color-surface);
border-radius: var(--radius) var(--radius) 0 0;
padding: 12px 20px 48px;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
}
.sheet-handle {
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: var(--radius-full);
margin: 0 auto 20px;
}
.qr-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.qr-title {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.btn-x {
background: var(--color-bg);
color: var(--color-muted);
min-height: 36px;
min-width: 36px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
}
.qr-hint {
font-size: 0.85rem;
color: var(--color-muted);
margin-bottom: 20px;
}
/* ── QR image ────────────────────────────────────── */
.qr-img-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 180px;
}
.qr-img {
width: 240px;
height: 240px;
border-radius: var(--radius-sm);
image-rendering: pixelated;
}
.qr-loading {
font-size: 0.9rem;
color: var(--color-muted);
}
/* ── Scanner ─────────────────────────────────────── */
.scan-wrap {
position: relative;
width: 100%;
max-width: 320px;
margin: 0 auto 16px;
border-radius: var(--radius);
overflow: hidden;
background: #000;
aspect-ratio: 1;
}
.scan-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.scan-canvas {
display: none;
}
.scan-frame {
position: absolute;
inset: 24px;
border: 2.5px solid var(--color-primary-light);
border-radius: var(--radius-sm);
pointer-events: none;
}
/* ── Feedback ────────────────────────────────────── */
.qr-error {
font-size: 0.85rem;
color: var(--color-danger);
background: var(--color-danger-muted);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-top: 4px;
}
.qr-success {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-primary);
background: var(--color-primary-muted);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-top: 4px;
text-align: center;
}
/* ── Transizioni ─────────────────────────────────── */
.fade-enter-active,
.fade-leave-active { transition: opacity 200ms ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style> </style>

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