From a3109d2a6cf8e21d7b84099c242b9e0a6a5e136b Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 12:32:55 +0200 Subject: [PATCH] test: setup Vitest workspace and Playwright with first nutrition tests Vitest: - Workspace at root (vitest.workspace.ts) running per-package configs - packages/shared/vitest.config.ts with v8 coverage and 70% thresholds - Root scripts: test / test:watch / test:coverage Nutrition module in @ketopath/shared: - calculateBmr (Mifflin-St Jeor) + calculateTdee with activity multipliers - ACTIVITY_MULTIPLIERS constants matching CLAUDE.md domain knowledge - 14 unit tests covering Michele/Laura PRD personas, all sex variants, every activity level, and input validation (100% coverage on src/nutrition) Playwright in apps/web: - chromium-only project, webServer auto-boot of pnpm dev - e2e/home.spec.ts smoke test asserts Italian copy and disclaimer Co-Authored-By: Claude Opus 4.7 (1M context) --- .prettierignore | 5 +++ apps/web/.gitignore | 4 +++ apps/web/e2e/home.spec.ts | 11 +++++++ apps/web/package.json | 6 ++-- apps/web/playwright.config.ts | 24 ++++++++++++++ package.json | 8 +++-- packages/shared/package.json | 4 ++- packages/shared/src/index.ts | 4 ++- packages/shared/src/nutrition/bmr.test.ts | 35 +++++++++++++++++++++ packages/shared/src/nutrition/bmr.ts | 27 ++++++++++++++++ packages/shared/src/nutrition/tdee.test.ts | 30 ++++++++++++++++++ packages/shared/src/nutrition/tdee.ts | 8 +++++ packages/shared/src/nutrition/types.ts | 10 ++++++ packages/shared/vitest.config.ts | 21 +++++++++++++ pnpm-lock.yaml | Bin 169379 -> 203185 bytes vitest.workspace.ts | 3 ++ 16 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 apps/web/e2e/home.spec.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 packages/shared/src/nutrition/bmr.test.ts create mode 100644 packages/shared/src/nutrition/bmr.ts create mode 100644 packages/shared/src/nutrition/tdee.test.ts create mode 100644 packages/shared/src/nutrition/tdee.ts create mode 100644 packages/shared/src/nutrition/types.ts create mode 100644 packages/shared/vitest.config.ts create mode 100644 vitest.workspace.ts diff --git a/.prettierignore b/.prettierignore index 1916473..653166b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,5 +10,10 @@ pnpm-lock.yaml *.docx *.pdf +# Playwright artifacts +**/test-results/ +**/playwright-report/ +**/playwright/.cache/ + # User-authored project doc — preserve exact formatting CLAUDE.md diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 2472ac6..da260b4 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,3 +3,7 @@ out/ next-env.d.ts.bak .vercel/ *.tsbuildinfo +test-results/ +playwright-report/ +playwright/.cache/ +blob-report/ diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts new file mode 100644 index 0000000..0ba446b --- /dev/null +++ b/apps/web/e2e/home.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '@playwright/test'; + +test('home page renders Italian copy and CTAs', async ({ page }) => { + await page.goto('/'); + + await expect(page).toHaveTitle(/KetoPath/); + await expect(page.getByRole('heading', { name: 'KetoPath' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Inizia gratis' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Scopri come funziona' })).toBeVisible(); + await expect(page.getByText(/non sostituiscono il parere di un medico/)).toBeVisible(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index d336931..b0a742b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start --port 3000", "typecheck": "tsc --noEmit", - "lint": "next lint --max-warnings=0" + "lint": "next lint --max-warnings=0", + "test:e2e": "playwright test" }, "dependencies": { "@ketopath/shared": "workspace:*", @@ -26,6 +27,7 @@ "autoprefixer": "^10.4.19", "eslint-config-next": "^14.2.5", "postcss": "^8.4.39", - "tailwindcss": "^3.4.4" + "tailwindcss": "^3.4.4", + "@playwright/test": "^1.45.3" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..10c1e33 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 3000; +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'pnpm dev', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/package.json b/package.json index c009aa6..8e91b24 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc -b", - "test": "pnpm --filter \"./packages/*\" --filter \"./apps/*\" -r --if-present test", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "pnpm --filter @ketopath/web test:e2e", "db:generate": "pnpm --filter @ketopath/db db:generate", "db:migrate": "pnpm --filter @ketopath/db db:migrate", @@ -40,7 +42,9 @@ "lint-staged": "^15.2.2", "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.14", - "typescript": "5.4.5" + "typescript": "5.4.5", + "vitest": "^1.6.0", + "@vitest/coverage-v8": "^1.6.0" }, "lint-staged": { "*.{ts,tsx,js,jsx,cjs,mjs}": [ diff --git a/packages/shared/package.json b/packages/shared/package.json index 5fb63ef..0db2b1a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,7 +10,9 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint src --max-warnings=0" + "lint": "eslint src --max-warnings=0", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@ketopath/eslint-config": "workspace:*", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cb0ff5c..6fac531 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,3 @@ -export {}; +export * from './nutrition/bmr.js'; +export * from './nutrition/tdee.js'; +export * from './nutrition/types.js'; diff --git a/packages/shared/src/nutrition/bmr.test.ts b/packages/shared/src/nutrition/bmr.test.ts new file mode 100644 index 0000000..3a52c42 --- /dev/null +++ b/packages/shared/src/nutrition/bmr.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateBmr } from './bmr.js'; + +describe('calculateBmr (Mifflin-St Jeor)', () => { + it('matches the male formula for the "Michele" PRD persona', () => { + // 49 anni, uomo, 76 kg, 170 cm + // 10·76 + 6.25·170 − 5·49 + 5 = 760 + 1062.5 − 245 + 5 = 1582.5 + const bmr = calculateBmr({ weightKg: 76, heightCm: 170, ageYears: 49, gender: 'MALE' }); + expect(bmr).toBeCloseTo(1582.5, 2); + }); + + it('matches the female formula for the "Laura" PRD persona', () => { + // 46 anni, donna, 70 kg, 165 cm + // 10·70 + 6.25·165 − 5·46 − 161 = 700 + 1031.25 − 230 − 161 = 1340.25 + const bmr = calculateBmr({ weightKg: 70, heightCm: 165, ageYears: 46, gender: 'FEMALE' }); + expect(bmr).toBeCloseTo(1340.25, 2); + }); + + it('uses the average sex-constant for OTHER', () => { + const male = calculateBmr({ weightKg: 70, heightCm: 170, ageYears: 30, gender: 'MALE' }); + const female = calculateBmr({ weightKg: 70, heightCm: 170, ageYears: 30, gender: 'FEMALE' }); + const other = calculateBmr({ weightKg: 70, heightCm: 170, ageYears: 30, gender: 'OTHER' }); + expect(other).toBeCloseTo((male + female) / 2, 5); + }); + + it.each([ + { weightKg: 0, heightCm: 170, ageYears: 30 }, + { weightKg: 70, heightCm: 0, ageYears: 30 }, + { weightKg: 70, heightCm: 170, ageYears: 0 }, + { weightKg: -1, heightCm: 170, ageYears: 30 }, + ])('rejects non-positive inputs %j', (input) => { + expect(() => calculateBmr({ ...input, gender: 'MALE' })).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/nutrition/bmr.ts b/packages/shared/src/nutrition/bmr.ts new file mode 100644 index 0000000..f8dc921 --- /dev/null +++ b/packages/shared/src/nutrition/bmr.ts @@ -0,0 +1,27 @@ +import type { Gender } from './types.js'; + +export interface BmrInput { + weightKg: number; + heightCm: number; + ageYears: number; + gender: Gender; +} + +// Mifflin-St Jeor equation. For Gender 'OTHER' we use the average of the +// male and female sex-specific constants (-78), as a neutral approximation. +export function calculateBmr({ weightKg, heightCm, ageYears, gender }: BmrInput): number { + if (weightKg <= 0 || heightCm <= 0 || ageYears <= 0) { + throw new RangeError('weightKg, heightCm and ageYears must be positive'); + } + + const base = 10 * weightKg + 6.25 * heightCm - 5 * ageYears; + + switch (gender) { + case 'MALE': + return base + 5; + case 'FEMALE': + return base - 161; + case 'OTHER': + return base - 78; + } +} diff --git a/packages/shared/src/nutrition/tdee.test.ts b/packages/shared/src/nutrition/tdee.test.ts new file mode 100644 index 0000000..caf82a4 --- /dev/null +++ b/packages/shared/src/nutrition/tdee.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateBmr } from './bmr.js'; +import { calculateTdee } from './tdee.js'; + +describe('calculateTdee', () => { + it('multiplies BMR by sedentary factor 1.2 for "Michele"', () => { + const bmr = calculateBmr({ weightKg: 76, heightCm: 170, ageYears: 49, gender: 'MALE' }); + expect(calculateTdee(bmr, 'SEDENTARY')).toBeCloseTo(1899, 1); + }); + + it('multiplies BMR by moderate factor 1.55 for "Laura"', () => { + const bmr = calculateBmr({ weightKg: 70, heightCm: 165, ageYears: 46, gender: 'FEMALE' }); + expect(calculateTdee(bmr, 'MODERATE')).toBeCloseTo(2077.39, 2); + }); + + it.each([ + ['SEDENTARY', 1.2], + ['LIGHT', 1.375], + ['MODERATE', 1.55], + ['INTENSE', 1.725], + ] as const)('applies the %s multiplier (%f)', (level, factor) => { + expect(calculateTdee(2000, level)).toBeCloseTo(2000 * factor, 5); + }); + + it('rejects non-positive BMR', () => { + expect(() => calculateTdee(0, 'SEDENTARY')).toThrow(RangeError); + expect(() => calculateTdee(-1, 'SEDENTARY')).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/nutrition/tdee.ts b/packages/shared/src/nutrition/tdee.ts new file mode 100644 index 0000000..30433f7 --- /dev/null +++ b/packages/shared/src/nutrition/tdee.ts @@ -0,0 +1,8 @@ +import { ACTIVITY_MULTIPLIERS, type ActivityLevel } from './types.js'; + +export function calculateTdee(bmr: number, activityLevel: ActivityLevel): number { + if (bmr <= 0) { + throw new RangeError('bmr must be positive'); + } + return bmr * ACTIVITY_MULTIPLIERS[activityLevel]; +} diff --git a/packages/shared/src/nutrition/types.ts b/packages/shared/src/nutrition/types.ts new file mode 100644 index 0000000..888d624 --- /dev/null +++ b/packages/shared/src/nutrition/types.ts @@ -0,0 +1,10 @@ +export type Gender = 'MALE' | 'FEMALE' | 'OTHER'; + +export type ActivityLevel = 'SEDENTARY' | 'LIGHT' | 'MODERATE' | 'INTENSE'; + +export const ACTIVITY_MULTIPLIERS: Record = { + SEDENTARY: 1.2, + LIGHT: 1.375, + MODERATE: 1.55, + INTENSE: 1.725, +}; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..f46f517 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: '@ketopath/shared', + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + thresholds: { + statements: 70, + branches: 70, + functions: 70, + lines: 70, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb7b3deeb0f7a5405ebdb8fa5c0a8ed4c929601..6046057d4e6ac44bdf1f8ca3c353d306e313aba2 100644 GIT binary patch delta 22231 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

Ep^8oxug_+<7el;a4LnZiJwnh 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 E020I%qW}N^ delta 1239 zcmXxje@xV690%|{pYQ$hI5>{G<2c~F!ZAjV*}Of7;n zQ`-z%PwZ1|A!RHRkROlmfrM;Hj16OeK%sz0t7K-F=IqB7J$GDxe7EO$KhO95{qFm` zzU{(rx=G&_0-_4WAVlwS6;)QgyrHPF6itN?FRrM6Qt?rJwY(DfqCQeIhrGr3h;9qd zJ!TY>q4zb|Zvwk`+&;uG$qag4;dJt-Sb_EbPsSac`~~MUcf`%$ti``0+r{Cb4qh8S z!}C_p;>t)F8rP2TzJ#m1V*Qldoxt-F&-3f<8-ejqQWw@o1-p-K>_me-ILS`wOw97Kcr^VzCR%;pFzgLRe1AqeU&_3OwXwm9nXERxB_l!9W^W6}SN?%W zoLU26yr-6ioV@lj1G6XkDSW@6;X)>!;>E5zc7D&`cCjXyKd z^7#>sm^+uF7kxe34fyJAh~vHAJH`Eh2?cdU5GJaJ3K^a+f(TLlUA{)_9q-nOftfS~ zKP!>h>MD%AfJyQ!yLdl?5j^djRl zTf!Q3WZcg-Mbd-g%&Z~tF00Yt{L^5k=Rag=;Z%HqPUx^1Tgv*YomnQY^{ct?lF~`YX4$K)%c@U zvC zf^`@k11kP&x_P;}AsTdeJsPyU)fX(1k&4%Bioxe%Ai=N1BX#I8DOTPri6mo=6z*N07$>vHAcKl1LWSyuMUJHa83dhIkZaZw(7>(}rbLutZ_zkC5!@?HWP3;xGO zoSuPb?CXb}GD*Q_zlF#AOM$p+0Nw#S;DdM?8w93BDawQwW@Sg}2>2MajKZJFvaOCu zC{m%cWulL-K@U**EKI3U0<+=aKfpo<7vQ*-B(-XWO?PF5R5q@qTYp2lsxDmlp>9dp zLvJ%B59qd{lq)D@MdK4^!Gd?%AquZFgPHoZO1>tb4W%{%E)>h=Ub8Z&(40*_& T?8{7cZ}hmmsp+oQs`UQ>G*8IA diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..9a6c8cb --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,3 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace(['./packages/shared']);