Munchable's recipe screens show you only the recipes that suit you. No list of warnings, no "3 of 12 recipes may not be suitable", no greyed-out cards. That is the right design, and it moves the failure mode: a case with nothing behind it is not a list of cautions, it is a blank screen, and a blank screen reads as a broken app rather than as a gap in a library.
So the library's own test file has a comment about it:
Coverage is now load-bearing in a way it was not when the list showed everything and labelled it. The app shows a reader only the recipes that suit them, so a condition with nothing behind it is not a list of cautions, it is an empty screen.
There are 38 recipes, shared by the app and the website (munchable.app/recipes is the same data the phone reads). There are eight conditions. The test suite asserts coverage over all of it.
8 + 28 + 1
Three assertions, in order of how much they taught me.
Every condition, with a floor. At least five recipes have to suit each of the eight on its own. Five rather than one, because one is a library that cannot offer a choice, and a floor set at the number you happen to have today is not a floor.
Every pair. Eight conditions make 28 unordered pairs, and each pair needs at least three recipes. This is the assertion that actually finds things, for the reason the test writes down: conditions co-occur, and combinations are where a library quietly runs out. Two conditions can each have twenty recipes behind them and share none, and nothing in a per-condition count would show you that.
All eight at once. The last one is a single line and sounds absurd until you consider that somebody out there has four diagnoses and is the exact person who downloads a scanner:
test('there is something to cook even with every condition at once', () => { assert.ok(fittingFor([...CONDITIONS]) >= MINIMUM_PER_PAIR); }); It passes. Chicken and rice soup is one of the recipes that gets through all eight, which the page says in those words, and it is a poached chicken breast with rice, a carrot and some parsley. The intersection of eight gut conditions is not an exciting dinner, and that is fine: the test is that the screen is never empty, not that the screen is interesting.
Every one of these runs the profile at the strictest setting the app offers, so coverage is counted against the harshest reading rather than the average one.
Coverage that does not depend on a gram
The assertion I would not have thought to write is the fourth one. Counts can be met by a library that clears every line by a hair. Every recipe passes, every floor is satisfied, and the day somebody adjusts one of the engine's numbers, most of a condition's recipes disappear at once.
So the floors are checked a second time against deliberately tightened numbers, read from the engine's own exported constants rather than copied into the test:
const TIGHTENED = 0.8; Make the lines a fifth stricter and the counts still have to hold. The comment is careful about what this does and does not claim:
This does not model what the engine would do with a different constant. It asserts the simpler and stronger thing: enough recipes sit far enough below each line that the line has room to move.
That is a margin test, and it is the only kind of test I know of that defends against your own future edits to a constant. Reading the constant from the engine instead of hardcoding it matters too: move the number and this check re-runs against the new one, rather than silently going on asserting something about a value nobody uses any more.
The numbers on the page are computed
That whole structure rests on per-serving figures, and the first plan was for recipe authors to type them in. The module that replaced that plan explains why:
They are computed here instead, because a hand-typed per-100 g figure is a number nobody can check and one an author silently invalidates the moment they change a quantity.
What exists instead is a table of fat and fibre per 100 g of raw ingredient, keyed by the ingredient's own identifier. Measured today:
- 68 entries in the table.
- 229 ingredient lines across the 38 recipes, 18,212 grams of food in total.
- 62 distinct ingredients used, so 6 rows are dead: bay leaf, cumin, oregano, paprika, red bell pepper and vanilla extract are in the table and in no recipe.
- 0 ingredient lines with no table entry, which is not luck.
The zero is enforced, and the way it is enforced is the detail worth stealing:
const entry = NUTRITION[tag]; if (!entry) { throw new Error( `No nutrition entry for ${tag} ("${ingredient.name}" in ${recipe.slug}). Add it to nutrition.ts.`, ); } A missing entry throws. It does not default to zero. Defaulting to zero would be the friendlier line to write and it would quietly invent a fat-free ingredient, which is the one direction an error must never go in a tool people rely on. Every recipe runs through this in CI, so an unknown ingredient is a failed build rather than a wrong number on a page.
The conventions around that table are where the care is. Values are per 100 g as weighed into the recipe, dry for rice and oats, raw for meat, because a serving is defined as total raw grams over servings and the dish is allowed to lose whatever it likes to steam. Cooking water is never an ingredient line, it lives in the method, so it cannot dilute anything. The herbs and spices are in the table at all only because every line needs an entry, with a comment saying so, and the oils are the rows to get right: being a gram out on parsley changes nothing, being wrong about oil changes every verdict a recipe gets.
And the result is printed on the page, which is the part that makes this checkable from outside. The live chicken and rice soup page says "About 231 g a serving, with 8 g of fat and 2 g of fibre in it". Add up the ingredient weights listed underneath, divide by two, and you get the first number. Multiply each line by the table and you get the other two. Nobody typed any of them.
The shape of it
Thirty-seven coverage assertions, one margin assertion, one table of 68 rows, and a build that fails if an ingredient enters a recipe without a number. The six dead rows are the only slack in the whole arrangement, and I am leaving them: a table that only holds what is currently used is one edit away from throwing in CI the moment somebody adds a pepper to a recipe.
If you want to see the coverage rather than read about it, /recipes lists all 38, each page names the conditions it clears, and the app filters the same 38 on the phone without telling the server which conditions you picked.


