Merge pull request #1 from evandrodevbr/codex/review-2026-09-30-049051

fix: normalize nutrition units and retain zero-valued nutrients
This commit is contained in:
Evandro Fonseca Junior authored and GitHub committed 2026-09-30 23:02:14 -03:00
commit fc7243e5f8
8 files changed
+253 -149

No files matched your search

+10 -2
View File
@@ -193,7 +193,7 @@ nixpacks.toml, dokploy.yaml configuração de deploy
## Verificação
Não há testes automatizados neste repositório (nenhum framework de teste nas dependências) e não há CI configurada. O que existe hoje:
Há testes offline com o runner nativo do Node.js (sem framework adicional) e não há CI configurada. O que existe hoje:
- `pnpm lint` roda o ESLint (via `next lint`);
- `pnpm build` roda `prisma generate` e o `next build` com checagem de tipos;
@@ -226,7 +226,7 @@ Medições feitas nesta auditoria, em 14/09/2026, com Node 24.20.0 e pnpm 12.4.1
## Estado atual e limitações
- A busca textual depende do endpoint legado `cgi/search.pl` do Open Food Facts, que respondeu HTTP 503 durante a verificação. Na prática, buscar por nome retorna lista vazia, com a rota respondendo 200 e `count: 0`, sem derrubar a aplicação. A consulta por código de barras continua funcionando pelo endpoint `api/v2/product`.
- Sem testes automatizados e sem CI.
- Testes offline de normalização e ordenação nutricional; ainda sem CI.
- O painel de filtros avançados da página de resultados guarda as escolhas na chave do cache, mas a busca enviada para `/api/search` leva apenas texto, país e paginação. Hoje o que realmente filtra a lista são os atalhos de classificação Nutri-Score e o botão de produtos sem alérgenos.
- A ordenação implementada em `lib/openFoodFactsApi.ts` cobre quatro critérios (relevância, Nutri-Score, energia e nome). As demais opções do seletor caem no caso padrão da função e não reordenam o resultado.
- O servidor grava em `data/products-cache.json`, que é versionado. Usar a aplicação altera o conteúdo do arquivo no diretório de trabalho.
@@ -251,3 +251,11 @@ Medições feitas nesta auditoria, em 14/09/2026, com Node 24.20.0 e pnpm 12.4.1
Licença de uso pessoal, definida em [`LICENSE`](LICENSE): é permitido usar em máquina local, estudar o código e rodar para desenvolvimento. Não é permitido deploy público, uso comercial, redistribuição nem obras derivadas para distribuição. A instância em `nutriscan.evandro.dev.br` é do próprio autor.
Autor: Evandro, <https://evandro.dev.br> e [@evandrodevbr](https://github.com/evandrodevbr).
## Validação de dados nutricionais (2026-09-30)
A exibição e a ordenação de energia priorizam `energy-kcal_100g`; na ausência dele, convertem o campo em kJ para kcal usando `1 kcal = 4,184 kJ`. A página do produto usa a mesma normalização. Zero é preservado como dado válido, e valores ausentes, negativos ou não finitos não entram no cálculo. Produtos sem energia ficam no fim da ordenação nas duas direções. A linha baseada em `salt_100g` é identificada como sal.
Os exemplos de campos vêm da [documentação oficial do Open Food Facts](https://openfoodfacts.github.io/openfoodfacts-server/api/tutorial-off-api/). Valores por 100 g e referências existentes da interface são mantidos; o Nutri-Score continua vindo da API.
Com Node.js 22.7+ (verificado em 24.19.0), `pnpm test` executa 6 regressões offline usando o runner nativo, sem adicionar um framework. `pnpm lint`, `pnpm exec tsc --noEmit` e `pnpm build` completam a validação. O build foi verificado com SQLite temporário, sem conexão com um banco de produção. Next.js e eslint-config-next foram atualizados para 15.5.24.
+6 -5
View File
@@ -12,7 +12,8 @@ import {
Share2,
} from "lucide-react";
import Image from "next/image";
import { searchByBarcode, Product, formatNutritionData } from "@/lib/openFoodFactsApi";
import { searchByBarcode, Product } from "@/lib/openFoodFactsApi";
import { getEnergyKcal, getNutrientValue } from "@/lib/nutrition";
import { ProductSkeleton } from "@/app/components/ProductSkeleton";
/* ── Nutri-Score letter circle ─────────────────────────────────────────── */
@@ -305,7 +306,7 @@ export default function ProdutoPage() {
/* ── Nutrition bars — % of daily reference ─────────────────────────── */
const dailyRef: Record<string, number> = {
energy_100g: 2000,
"energy-kcal_100g": 2000,
fat_100g: 70,
saturated_fat_100g: 20,
sugars_100g: 90,
@@ -315,11 +316,11 @@ export default function ProdutoPage() {
};
const nutriments = product.nutriments || {};
const nutritionRows = [
{ key: "energy_100g", label: "Energia", unit: "kcal" },
{ key: "energy-kcal_100g", label: "Energia", unit: "kcal" },
{ key: "fat_100g", label: "Gorduras totais", unit: "g" },
{ key: "saturated_fat_100g",label: "Gorduras saturadas", unit: "g" },
{ key: "sugars_100g", label: "Açúcares", unit: "g" },
{ key: "salt_100g", label: "Sódio", unit: "g" },
{ key: "salt_100g", label: "Sal", unit: "g" },
{ key: "fiber_100g", label: "Fibras", unit: "g" },
{ key: "proteins_100g", label: "Proteínas", unit: "g" },
];
@@ -701,7 +702,7 @@ export default function ProdutoPage() {
>
{nutritionRows.map((row) => {
const raw = nutriments[row.key as keyof typeof nutriments];
const v = raw != null ? Number(raw) : null;
const v = row.key === "energy-kcal_100g" ? getEnergyKcal(nutriments) : getNutrientValue(raw);
if (v == null) return null;
const ref = dailyRef[row.key] || 100;
const pct = Math.min(100, Math.round((v / ref) * 100));
+24
View File
@@ -0,0 +1,24 @@
/** Normalização dos valores nutricionais recebidos do Open Food Facts. */
export function getNutrientValue(value: unknown): number | null {
if (typeof value !== "number" && typeof value !== "string") return null;
if (typeof value === "string" && value.trim() === "") return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : null;
}
/**
* Prefere o campo explícito em kcal. Os campos energy/energy-kj usam kJ;
* 1 kcal = 4.184 kJ. Zero é um valor válido, distinto de dado ausente.
* https://openfoodfacts.github.io/openfoodfacts-server/api/tutorial-off-api/
*/
export function getEnergyKcal(nutriments?: {
"energy-kcal_100g"?: unknown;
"energy-kj_100g"?: unknown;
energy_100g?: unknown;
}): number | null {
const kcal = getNutrientValue(nutriments?.["energy-kcal_100g"]);
if (kcal !== null) return kcal;
const kj = getNutrientValue(nutriments?.["energy-kj_100g"])
?? getNutrientValue(nutriments?.energy_100g);
return kj === null ? null : kj / 4.184;
}
+21 -14
View File
@@ -3,6 +3,8 @@
* Melhorias: Memoização de Scores, Rate Limiting Robusto e Tipagem Estrita.
*/
import { getEnergyKcal, getNutrientValue } from "./nutrition.ts";
export interface Product {
code: string;
product_name: string;
@@ -11,6 +13,8 @@ export interface Product {
ingredients_text?: string;
nutriments?: {
energy_100g?: number;
"energy-kcal_100g"?: number;
"energy-kj_100g"?: number;
fat_100g?: number;
carbohydrates_100g?: number;
proteins_100g?: number;
@@ -185,6 +189,7 @@ export function sortProducts(
value: p,
score: calculateCompletenessScore(p),
nutritionValue: getGradeValue(p.nutrition_grades),
energyKcal: getEnergyKcal(p.nutriments),
}));
// 2. Sort: Ordenação sobre valores em memória (O(n log n))
@@ -199,7 +204,9 @@ export function sortProducts(
diff = a.nutritionValue - b.nutritionValue;
break;
case "energy":
diff = (a.value.nutriments?.energy_100g || 9999) - (b.value.nutriments?.energy_100g || 9999);
if (a.energyKcal === null) return b.energyKcal === null ? 0 : 1;
if (b.energyKcal === null) return -1;
diff = a.energyKcal - b.energyKcal;
break;
case "name":
diff = (a.value.product_name || "").localeCompare(b.value.product_name || "");
@@ -240,21 +247,21 @@ export function formatNutritionData(p: Product) {
const n = p.nutriments;
if (!n) return null;
const fmt = (v: unknown, d: number = 1) => {
const num =
typeof v === "number"
? v
: parseFloat(typeof v === "string" ? v : String(v));
return isNaN(num) ? "N/A" : num.toFixed(d);
const fmt = (v: unknown, unit: string, d: number = 1) => {
const num = getNutrientValue(v);
return num === null ? "N/A" : `${num.toFixed(d)}${unit}`;
};
const energy = getEnergyKcal(n);
return {
energy: n.energy_100g ? `${Math.round(n.energy_100g)} kcal` : "N/A",
fat: n.fat_100g ? `${fmt(n.fat_100g)}g` : "N/A",
carbs: n.carbohydrates_100g ? `${fmt(n.carbohydrates_100g)}g` : "N/A",
proteins: n.proteins_100g ? `${fmt(n.proteins_100g)}g` : "N/A",
sugars: n.sugars_100g ? `${fmt(n.sugars_100g)}g` : "N/A",
sodium: n.sodium_100g ? `${fmt(n.sodium_100g, 2)}g` : "N/A",
energy: energy === null ? "N/A" : `${Math.round(energy)} kcal`,
fat: fmt(n.fat_100g, "g"),
carbs: fmt(n.carbohydrates_100g, "g"),
carbohydrates: fmt(n.carbohydrates_100g, "g"),
proteins: fmt(n.proteins_100g, "g"),
sugars: fmt(n.sugars_100g, "g"),
fiber: fmt(n.fiber_100g, "g"),
sodium: fmt(n.sodium_100g, "g", 2),
};
}
@@ -269,4 +276,4 @@ export function detectSearchType(input: string): "barcode" | "name" {
export function isValidBarcode(barcode: string): boolean {
const cleaned = barcode.replace(/\D/g, "");
return cleaned.length >= 8 && cleaned.length <= 13 && /^\d+$/.test(cleaned);
}
}
+3 -2
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"test": "node --experimental-transform-types --test tests/*.test.ts",
"dev": "next dev",
"lint": "next lint",
"postinstall": "prisma generate",
@@ -30,7 +31,7 @@
"clsx": "^2.1.1",
"framer-motion": "^11.18.2",
"lucide-react": "^0.460.0",
"next": "^15.5.10",
"next": "^15.5.24",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -43,7 +44,7 @@
"@types/react-dom": "^19.0.0",
"depcheck": "^1.4.7",
"eslint": "^8.57.1",
"eslint-config-next": "^15.5.10",
"eslint-config-next": "^15.5.24",
"postcss": "^8.5.10",
"prisma": "^6.19.3",
"tailwindcss": "^3.4.19",
+130 -126
View File
@@ -60,8 +60,8 @@ importers:
specifier: ^0.460.0
version: 0.460.0([email protected])
next:
specifier: ^15.5.10
version: 15.5.20([email protected]([email protected]))([email protected])
specifier: ^15.5.24
version: 15.5.24([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
@@ -89,13 +89,13 @@ importers:
version: 19.2.3(@types/[email protected])
depcheck:
specifier: ^1.4.7
version: 1.4.7
version: 1.4.7([email protected])
eslint:
specifier: ^8.57.1
version: 8.57.1
version: 8.57.1([email protected])
eslint-config-next:
specifier: ^15.5.10
version: 15.5.20([email protected])([email protected])
specifier: ^15.5.24
version: 15.5.24([email protected]([email protected]))([email protected])([email protected])
postcss:
specifier: ^8.5.10
version: 8.5.19
@@ -382,60 +382,60 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@next/[email protected]0':
resolution: {integrity: sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==}
'@next/[email protected]4':
resolution: {integrity: sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==}
'@next/[email protected]0':
resolution: {integrity: sha512-MZUgFpVd9rGSZpb8bNceUWvkAZe6aQw/6h2SSqHQuYzKfaiEUEVPIO6mXqaBmiBEBSN1f1sOc1uV8GCM90oegg==}
'@next/[email protected]4':
resolution: {integrity: sha512-K6jcYhwMY73qpeJ5SP+rrXs7Fuvv94HEvOCEen/zjwG7aq83utZY8n+8YdKHZR1lv0n5cB4z9C6QqwpE9acN+A==}
'@next/[email protected]0':
resolution: {integrity: sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==}
'@next/[email protected]4':
resolution: {integrity: sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/[email protected]0':
resolution: {integrity: sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==}
'@next/[email protected]4':
resolution: {integrity: sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/[email protected]0':
resolution: {integrity: sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==}
'@next/[email protected]4':
resolution: {integrity: sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/[email protected]0':
resolution: {integrity: sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==}
'@next/[email protected]4':
resolution: {integrity: sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/[email protected]0':
resolution: {integrity: sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==}
'@next/[email protected]4':
resolution: {integrity: sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/[email protected]0':
resolution: {integrity: sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==}
'@next/[email protected]4':
resolution: {integrity: sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/[email protected]0':
resolution: {integrity: sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==}
'@next/[email protected]4':
resolution: {integrity: sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/[email protected]0':
resolution: {integrity: sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==}
'@next/[email protected]4':
resolution: {integrity: sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1549,8 +1549,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
[email protected]0:
resolution: {integrity: sha512-Pl/I5544gmkcVVWcnaOMfhJSBK8lZ1NCJ+mGBkd2qvbGt2Gi7sEpgHF06OR13a2p6THODlncpvGsZzY2vUqwxw==}
[email protected]4:
resolution: {integrity: sha512-RXne8azyj9DKV9N15elyRsnh98BtKINYHCG7bmVhpIhaOtaYvrNJ37XwWpjY9Z09f5RHYOf9vn5uU0MgV1iLrw==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@@ -2184,8 +2184,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
[email protected]0:
resolution: {integrity: sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==}
[email protected]4:
resolution: {integrity: sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -2882,7 +2882,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/[email protected]':
'@babel/[email protected]([email protected])':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
@@ -2890,7 +2890,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3
debug: 4.4.3([email protected])
transitivePeerDependencies:
- supports-color
@@ -2920,17 +2920,17 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/[email protected]([email protected])':
'@eslint-community/[email protected]([email protected]([email protected]))':
dependencies:
eslint: 8.57.1
eslint: 8.57.1([email protected])
eslint-visitor-keys: 3.4.3
'@eslint-community/[email protected]': {}
'@eslint/[email protected]':
'@eslint/[email protected]([email protected])':
dependencies:
ajv: 6.15.0
debug: 4.4.3
debug: 4.4.3([email protected])
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@@ -2960,10 +2960,10 @@ snapshots:
'@floating-ui/[email protected]': {}
'@humanwhocodes/[email protected]':
'@humanwhocodes/[email protected]([email protected])':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3
debug: 4.4.3([email protected])
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -3090,34 +3090,34 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@next/[email protected]0': {}
'@next/[email protected]4': {}
'@next/[email protected]0':
'@next/[email protected]4':
dependencies:
fast-glob: 3.3.1
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@next/[email protected]0':
'@next/[email protected]4':
optional: true
'@nodelib/[email protected]':
@@ -3643,15 +3643,15 @@ snapshots:
dependencies:
csstype: 3.2.3
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.64.0([email protected])([email protected])
'@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/type-utils': 8.64.0([email protected])([email protected])
'@typescript-eslint/utils': 8.64.0([email protected])([email protected])
'@typescript-eslint/type-utils': 8.64.0([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.64.0([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.64.0
eslint: 8.57.1
eslint: 8.57.1([email protected])
ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0([email protected])
@@ -3659,23 +3659,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected])':
dependencies:
'@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0([email protected])
'@typescript-eslint/typescript-estree': 8.64.0([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.64.0
debug: 4.4.3
eslint: 8.57.1
debug: 4.4.3([email protected])
eslint: 8.57.1([email protected])
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected])':
'@typescript-eslint/[email protected]([email protected])([email protected])':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.64.0([email protected])
'@typescript-eslint/types': 8.64.0
debug: 4.4.3
debug: 4.4.3([email protected])
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3689,13 +3689,13 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/[email protected]([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected])':
dependencies:
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0([email protected])
'@typescript-eslint/utils': 8.64.0([email protected])([email protected])
debug: 4.4.3
eslint: 8.57.1
'@typescript-eslint/typescript-estree': 8.64.0([email protected])([email protected])
'@typescript-eslint/utils': 8.64.0([email protected]([email protected]))([email protected])([email protected])
debug: 4.4.3([email protected])
eslint: 8.57.1([email protected])
ts-api-utils: 2.5.0([email protected])
typescript: 5.9.3
transitivePeerDependencies:
@@ -3703,13 +3703,13 @@ snapshots:
'@typescript-eslint/[email protected]': {}
'@typescript-eslint/[email protected]([email protected])':
'@typescript-eslint/[email protected]([email protected])([email protected])':
dependencies:
'@typescript-eslint/project-service': 8.64.0([email protected])
'@typescript-eslint/project-service': 8.64.0([email protected])([email protected])
'@typescript-eslint/tsconfig-utils': 8.64.0([email protected])
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/visitor-keys': 8.64.0
debug: 4.4.3
debug: 4.4.3([email protected])
minimatch: 10.2.5
semver: 7.8.5
tinyglobby: 0.2.17
@@ -3718,13 +3718,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/eslint-utils': 4.9.1([email protected])
'@eslint-community/eslint-utils': 4.9.1([email protected]([email protected]))
'@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0([email protected])
eslint: 8.57.1
'@typescript-eslint/typescript-estree': 8.64.0([email protected])([email protected])
eslint: 8.57.1([email protected])
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -4121,13 +4121,17 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
[email protected]:
[email protected]([email protected]):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
[email protected]:
[email protected]([email protected]):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
[email protected]: {}
@@ -4147,15 +4151,15 @@ snapshots:
[email protected]: {}
[email protected]:
[email protected]([email protected]):
dependencies:
'@babel/parser': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7([email protected])
'@vue/compiler-sfc': 3.5.39
callsite: 1.0.0
camelcase: 6.3.0
cosmiconfig: 7.1.0
debug: 4.4.3
debug: 4.4.3([email protected])
deps-regex: 0.2.0
findup-sync: 5.0.0
ignore: 5.3.2
@@ -4338,19 +4342,19 @@ snapshots:
[email protected]: {}
[email protected]0([email protected])([email protected]):
[email protected]4([email protected]([email protected]))([email protected])([email protected]):
dependencies:
'@next/eslint-plugin-next': 15.5.20
'@next/eslint-plugin-next': 15.5.24
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.64.0([email protected])([email protected])
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected])
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])
eslint-plugin-jsx-a11y: 6.10.2([email protected])
eslint-plugin-react: 7.37.5([email protected])
eslint-plugin-react-hooks: 5.2.0([email protected])
'@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
eslint: 8.57.1([email protected])
eslint-import-resolver-node: 0.3.10([email protected])
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))([email protected])
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])
eslint-plugin-jsx-a11y: 6.10.2([email protected]([email protected]))
eslint-plugin-react: 7.37.5([email protected]([email protected]))
eslint-plugin-react-hooks: 5.2.0([email protected]([email protected]))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -4358,52 +4362,52 @@ snapshots:
- eslint-plugin-import-x
- supports-color
[email protected]:
[email protected]([email protected]):
dependencies:
debug: 3.2.7
debug: 3.2.7([email protected])
is-core-module: 2.16.2
resolve: 2.0.0-next.7
transitivePeerDependencies:
- supports-color
[email protected]([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]):
[email protected]([email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
eslint: 8.57.1
debug: 4.4.3([email protected])
eslint: 8.57.1([email protected])
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])
transitivePeerDependencies:
- supports-color
[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])(eslint-import-resolver-[email protected]([email protected](@typescript-eslint/[email protected]([email protected])(typescript@5.9.3))([email protected]))([email protected]))([email protected]):
[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))(eslint-import-resolver-typescript@3.10.1)([email protected]([email protected]))([email protected]):
dependencies:
debug: 3.2.7
debug: 3.2.7([email protected])
optionalDependencies:
'@typescript-eslint/parser': 8.64.0([email protected])([email protected])
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected])
'@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
eslint: 8.57.1([email protected])
eslint-import-resolver-node: 0.3.10([email protected])
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))([email protected])
transitivePeerDependencies:
- supports-color
[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected]):
[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7
debug: 3.2.7([email protected])
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.14.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])(eslint-import-resolver-[email protected]([email protected](@typescript-eslint/[email protected]([email protected])(typescript@5.9.3))([email protected]))([email protected]))([email protected])
eslint: 8.57.1([email protected])
eslint-import-resolver-node: 0.3.10([email protected])
eslint-module-utils: 2.14.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))(eslint-import-resolver-typescript@3.10.1)([email protected]([email protected]))([email protected])
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -4415,13 +4419,13 @@ snapshots:
string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.64.0([email protected])([email protected])
'@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
[email protected]([email protected]):
[email protected]([email protected]([email protected])):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -4431,7 +4435,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 8.57.1
eslint: 8.57.1([email protected])
hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -4440,11 +4444,11 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
[email protected]([email protected]):
[email protected]([email protected]([email protected])):
dependencies:
eslint: 8.57.1
eslint: 8.57.1([email protected])
[email protected]([email protected]):
[email protected]([email protected]([email protected])):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -4452,7 +4456,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.4.0
eslint: 8.57.1
eslint: 8.57.1([email protected])
estraverse: 5.3.0
hasown: 2.0.4
jsx-ast-utils: 3.3.5
@@ -4475,20 +4479,20 @@ snapshots:
[email protected]: {}
[email protected]:
[email protected]([email protected]):
dependencies:
'@eslint-community/eslint-utils': 4.9.1([email protected])
'@eslint-community/eslint-utils': 4.9.1([email protected]([email protected]))
'@eslint-community/regexpp': 4.12.2
'@eslint/eslintrc': 2.1.4
'@eslint/eslintrc': 2.1.4([email protected])
'@eslint/js': 8.57.1
'@humanwhocodes/config-array': 0.13.0
'@humanwhocodes/config-array': 0.13.0([email protected])
'@humanwhocodes/module-importer': 1.0.1
'@nodelib/fs.walk': 1.2.8
'@ungap/structured-clone': 1.3.3
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3([email protected])
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -5055,9 +5059,9 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7([email protected])
[email protected]0([email protected]([email protected]))([email protected]):
[email protected]4([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 15.5.20
'@next/env': 15.5.24
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001805
postcss: 8.4.31
@@ -5065,14 +5069,14 @@ snapshots:
react-dom: 19.2.7([email protected])
styled-jsx: 5.1.6([email protected])
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.20
'@next/swc-darwin-x64': 15.5.20
'@next/swc-linux-arm64-gnu': 15.5.20
'@next/swc-linux-arm64-musl': 15.5.20
'@next/swc-linux-x64-gnu': 15.5.20
'@next/swc-linux-x64-musl': 15.5.20
'@next/swc-win32-arm64-msvc': 15.5.20
'@next/swc-win32-x64-msvc': 15.5.20
'@next/swc-darwin-arm64': 15.5.24
'@next/swc-darwin-x64': 15.5.24
'@next/swc-linux-arm64-gnu': 15.5.24
'@next/swc-linux-arm64-musl': 15.5.24
'@next/swc-linux-x64-gnu': 15.5.24
'@next/swc-linux-x64-musl': 15.5.24
'@next/swc-win32-arm64-msvc': 15.5.24
'@next/swc-win32-x64-msvc': 15.5.24
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getEnergyKcal, getNutrientValue } from "../lib/nutrition.ts";
import { formatNutritionData, sortProducts, type Product } from "../lib/openFoodFactsApi.ts";
test("prefere energia explícita em kcal e preserva zero", () => {
assert.equal(getEnergyKcal({ "energy-kcal_100g": 539, energy_100g: 2255 }), 539);
assert.equal(getEnergyKcal({ "energy-kcal_100g": 0, energy_100g: 418.4 }), 0);
assert.equal(getNutrientValue(0), 0);
assert.equal(getNutrientValue("0"), 0);
});
test("converte kJ para kcal quando o campo kcal está ausente", () => {
assert.ok(Math.abs(getEnergyKcal({ energy_100g: 418.4 })! - 100) < 1e-10);
assert.ok(Math.abs(getEnergyKcal({ "energy-kj_100g": 418.4 })! - 100) < 1e-10);
assert.equal(getEnergyKcal(), null);
assert.equal(getEnergyKcal({}), null);
});
test("valores ausentes, negativos e não finitos não entram nos cálculos", () => {
for (const value of [null, undefined, "", " ", "garbage", "12g", true, -1, NaN, Infinity, "Infinity"]) {
assert.equal(getNutrientValue(value), null);
}
assert.equal(getEnergyKcal({ "energy-kcal_100g": NaN, energy_100g: Infinity }), null);
assert.equal(getNutrientValue("12.5"), 12.5);
});
test("formata energia e nutrientes com zero sem confundir com dados ausentes", () => {
const formatted = formatNutritionData({ code: "zero", product_name: "Zero", nutriments: {
"energy-kcal_100g": 0, energy_100g: 100, fat_100g: 0,
carbohydrates_100g: 0, sodium_100g: 0,
} });
assert.equal(formatted?.energy, "0 kcal");
assert.equal(formatted?.fat, "0.0g");
assert.equal(formatted?.carbs, "0.0g");
assert.equal(formatted?.carbohydrates, "0.0g");
assert.equal(formatted?.sodium, "0.00g");
assert.equal(formatted?.proteins, "N/A");
});
test("formata kcal explícitas e converte o fallback em kJ", () => {
const base = { code: "a", product_name: "A" };
assert.equal(formatNutritionData({ ...base, nutriments: { "energy-kcal_100g": 539, energy_100g: 2255 } })?.energy, "539 kcal");
assert.equal(formatNutritionData({ ...base, nutriments: { energy_100g: 418.4 } })?.energy, "100 kcal");
assert.equal(formatNutritionData({ ...base, nutriments: { fat_100g: Infinity } })?.fat, "N/A");
});
test("ordena por kcal, preserva zero e deixa dados ausentes no fim nas duas direções", () => {
const products: Product[] = [
{ code: "unknown", product_name: "Unknown" },
{ code: "kj", product_name: "kJ", nutriments: { energy_100g: 418.4 } },
{ code: "zero", product_name: "Zero", nutriments: { "energy-kcal_100g": 0 } },
{ code: "kcal", product_name: "kcal", nutriments: { "energy-kcal_100g": 50 } },
];
assert.deepEqual(sortProducts(products, "energy").map(p => p.code), ["zero", "kcal", "kj", "unknown"]);
assert.deepEqual(sortProducts(products, "energy", "desc").map(p => p.code), ["kj", "kcal", "zero", "unknown"]);
assert.equal(products[0].code, "unknown");
});
+1
View File
@@ -9,6 +9,7 @@
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",