batchCooking/apps/api/test/planning-portions.test.ts
Nicolas eba0ea70bd
All checks were successful
CI / lint (push) Successful in 2m37s
CI / intent-service-test (push) Successful in 4m26s
CI / test (push) Successful in 15m8s
CI / build (push) Successful in 2m45s
CI / e2e (push) Successful in 8m6s
feat(planning): sépare portions demandées et portions préparées
Première PR du chantier « gestion des restes » (issue #34) — les
fondations, sans encore d'UI de panier.

À l'ajout d'une recette au planning on ne saisit plus qu'un seul nombre,
`portionsRequested` (« combien ce créneau doit nourrir »). Le serveur en
dérive `portions` (ce qui est réellement préparé) via
`computeBatchedPortions` : arrondi au multiple supérieur du rendement de
la recette (on cuisine des lots entiers, jamais une fraction de plat).
L'écart est le futur pool de restes de l'item.

Modèle (`PlanningItem`, purement additif) :
- `portionsRequested` NOT NULL — migration nullable → backfill (`= portions`
  pour les lignes existantes, sans restes à l'époque) → verrou, même
  discipline que `ingredient_unit_catalog`.
- `sourcePlanningItemId` (auto-FK nullable, `onDelete: Cascade`) et
  `discardedPortions` (défaut 0) — posés maintenant, utilisés plus tard.

Partagé (`@batch-cooking/shared/logic/planning-portions.ts`) :
- `computeBatchedPortions(recipePortions, requested)` → `{ portions, leftover }`
- `computeAvailableLeftover(item)` → pool encore disponible (posé pour les
  PRs suivantes : validation d'écriture côté API, affichage du panier
  côté web — une seule formule, deux appelants)

API : `addPlanningItemSchema` prend `portionsRequested` ; `addPlanningItem`
calcule et stocke les deux valeurs + l'évènement analytics ; `PlanningItemView`
et `getPlanningForDate` exposent `portionsRequested`/`discardedPortions`/
`sourceItem`.

Front : `RecipePickerDialog` — champ « portions demandées » + indice en
direct « → N préparées, M en restes » quand l'arrondi crée un reste ;
envoie `portionsRequested`. `RecipeImportForm` — envoie `portionsRequested`
(un import atterrit à son propre rendement, donc reste nul).

Tests : `planning-portions.test.ts` (les deux exemples de #34 + bornes) ;
nouveau bloc `POST /planning/items` dans `planning.test.ts` (arrondi au
lot, plusieurs lots, borne exacte, 400 sur non-positif) ; fixtures
`planningItem.create` des suites existantes mises à jour ; scénario
Cypress planning ajusté (`portionsRequested` dans le corps).

Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
`frontend-architecture.md`.

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:29:58 +02:00

73 lines
2.7 KiB
TypeScript

import { computeAvailableLeftover, computeBatchedPortions } from "@batch-cooking/shared";
import { expect } from "chai";
/**
* Pure portion arithmetic from `@batch-cooking/shared` — no DB, so this
* suite skips `resetDatabase`. The API (`addPlanningItem`, and later the
* leftover-assignment endpoints) and the web app both call these; the
* numbers below are the two worked examples from issue #34 plus the
* boundary cases.
*/
describe("planning portion arithmetic", () => {
describe("computeBatchedPortions", () => {
it("rounds up to one batch when fewer portions are requested than the recipe yields", () => {
expect(computeBatchedPortions(4, 3)).to.deep.equal({ portions: 4, leftover: 1 });
});
it("cooks several batches when more portions are requested than one batch yields", () => {
expect(computeBatchedPortions(4, 10)).to.deep.equal({ portions: 12, leftover: 2 });
});
it("leaves no leftover on an exact batch boundary", () => {
expect(computeBatchedPortions(4, 4)).to.deep.equal({ portions: 4, leftover: 0 });
expect(computeBatchedPortions(4, 8)).to.deep.equal({ portions: 8, leftover: 0 });
});
it("still prepares one batch when a single portion is requested", () => {
expect(computeBatchedPortions(6, 1)).to.deep.equal({ portions: 6, leftover: 5 });
});
it("handles a one-portion recipe (every request is an exact number of batches)", () => {
expect(computeBatchedPortions(1, 5)).to.deep.equal({ portions: 5, leftover: 0 });
});
});
describe("computeAvailableLeftover", () => {
const base = { portions: 12, portionsRequested: 10, discardedPortions: 0, leftoverUses: [] };
it("is the prepared-minus-requested pool when nothing has been discarded or served", () => {
expect(computeAvailableLeftover(base)).to.equal(2);
});
it("subtracts portions already served to other slots", () => {
expect(
computeAvailableLeftover({ ...base, leftoverUses: [{ portions: 1 }, { portions: 1 }] }),
).to.equal(0);
});
it("subtracts explicitly discarded portions", () => {
expect(computeAvailableLeftover({ ...base, discardedPortions: 2 })).to.equal(0);
});
it("never goes negative", () => {
expect(
computeAvailableLeftover({
...base,
discardedPortions: 5,
leftoverUses: [{ portions: 3 }],
}),
).to.equal(0);
});
it("is 0 for an item whose request landed exactly on a batch (no pool at all)", () => {
expect(
computeAvailableLeftover({
portions: 8,
portionsRequested: 8,
discardedPortions: 0,
leftoverUses: [],
}),
).to.equal(0);
});
});
});