Add portion controls and improve recipe display
This commit is contained in:
+12
-12
@@ -15,7 +15,7 @@
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
padding: 42px 52px;
|
||||
padding: 50px 58px;
|
||||
background: #11110f;
|
||||
}
|
||||
|
||||
@@ -40,18 +40,18 @@
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-recipe h1 {
|
||||
margin: 0 100px 18px 0;
|
||||
margin: 0 100px 24px 0;
|
||||
color: #fff6d8;
|
||||
font-size: 52px;
|
||||
font-size: 68px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-meta {
|
||||
display: flex;
|
||||
gap: 8px 18px;
|
||||
gap: 10px 22px;
|
||||
align-items: baseline;
|
||||
margin: 0 0 24px;
|
||||
font-size: 21px;
|
||||
margin: 0 0 32px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-meta dt {
|
||||
@@ -66,25 +66,25 @@
|
||||
.MMM-NextcloudCookbook .ncc-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.4fr);
|
||||
gap: 46px;
|
||||
gap: 52px;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns h2 {
|
||||
margin: 0 0 12px;
|
||||
margin: 0 0 18px;
|
||||
color: #d6b96b;
|
||||
font-size: 30px;
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns ul,
|
||||
.MMM-NextcloudCookbook .ncc-columns ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25em;
|
||||
font-size: 23px;
|
||||
line-height: 1.3;
|
||||
font-size: 34px;
|
||||
line-height: 1.34;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-columns li {
|
||||
margin-bottom: 9px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.MMM-NextcloudCookbook .ncc-error {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Module.register("MMM-NextcloudCookbook", {
|
||||
defaults: {
|
||||
controlPath: "/MMM-NextcloudCookbook/control",
|
||||
animationSpeed: 400
|
||||
animationSpeed: 400,
|
||||
shareUrl: "",
|
||||
sharePassword: ""
|
||||
},
|
||||
|
||||
start() {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# MMM-NextcloudCookbook
|
||||
|
||||
A MagicMirror module that displays recipes from a read-only Nextcloud public
|
||||
share. It includes a phone-friendly control page for choosing a recipe and
|
||||
entering or leaving recipe mode.
|
||||
share. It includes a phone-friendly control page for choosing a recipe,
|
||||
adjusting its number of portions, and entering or leaving recipe mode.
|
||||
|
||||
The Nextcloud share URL is read only by `node_helper.js` from
|
||||
`SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL`. An optional protected-share password can
|
||||
be supplied as `SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD`. Neither value is sent
|
||||
to the MagicMirror browser or control page.
|
||||
Create a public share for the folder configured as your Nextcloud Cookbook
|
||||
recipe directory. Read-only permission is sufficient. Copy the public share
|
||||
link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
|
||||
|
||||
## MagicMirror configuration
|
||||
|
||||
@@ -16,11 +15,32 @@ to the MagicMirror browser or control page.
|
||||
module: "MMM-NextcloudCookbook",
|
||||
position: "fullscreen_above",
|
||||
config: {
|
||||
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
|
||||
controlPath: "/MMM-NextcloudCookbook/control"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a password-protected public share, add `sharePassword`:
|
||||
|
||||
```js
|
||||
config: {
|
||||
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
|
||||
sharePassword: "PUBLIC_SHARE_PASSWORD"
|
||||
}
|
||||
```
|
||||
|
||||
MagicMirror sends module configuration to its browser clients. A public-share
|
||||
URL is normally appropriate there because the token is already a scoped share
|
||||
credential, but do not put a Nextcloud account password or app password in this
|
||||
configuration.
|
||||
|
||||
For deployments that keep even the share token out of browser-visible
|
||||
configuration, set `SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL` and, if needed,
|
||||
`SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD` in the MagicMirror process
|
||||
environment and omit the corresponding config values. Environment values take
|
||||
precedence over module configuration and are read only by `node_helper.js`.
|
||||
|
||||
Open the controller at:
|
||||
|
||||
```text
|
||||
@@ -31,6 +51,37 @@ The share must contain Nextcloud Cookbook recipe folders with a `recipe.json`
|
||||
file in each folder. The module uses Nextcloud's token-scoped public DAV API;
|
||||
protected shares authenticate as Nextcloud's `anonymous` public-share user.
|
||||
|
||||
## Data-source decision: public WebDAV instead of the Cookbook API
|
||||
|
||||
Nextcloud Cookbook provides a REST API, but its external API requires
|
||||
Nextcloud user credentials on every request. A read-only public-share token
|
||||
cannot authenticate to that API.
|
||||
|
||||
This module intentionally reads the recipe files through Nextcloud's public
|
||||
WebDAV interface because its job is limited to listing and displaying recipes.
|
||||
That gives the mirror a narrower security boundary:
|
||||
|
||||
- access is scoped to the shared recipe folder;
|
||||
- the share can be read-only;
|
||||
- the mirror receives no general Nextcloud account credential; and
|
||||
- the module cannot create, update, or delete recipes.
|
||||
|
||||
Cookbook stores recipes as ordinary `recipe.json` files, so WebDAV provides the
|
||||
data required by this read-only display without relying on Cookbook's database
|
||||
index. Index synchronization and reindexing matter when an external tool writes
|
||||
recipe files; this module does not write them.
|
||||
|
||||
The Cookbook API would become preferable if the module later needs
|
||||
Cookbook-native search, categories, keywords, imports, or recipe editing. In
|
||||
that case, use a dedicated Nextcloud service user with read-only access to the
|
||||
recipe folder and a dedicated app password rather than credentials for a
|
||||
person's normal account.
|
||||
|
||||
References:
|
||||
|
||||
- [Cookbook API documentation](https://nextcloud.github.io/cookbook/dev/api/0.1.0/index.html)
|
||||
- [Cookbook user documentation](https://nextcloud.github.io/cookbook/user/)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,8 +3,14 @@ const status = document.querySelector("#status");
|
||||
const list = document.querySelector("#recipes");
|
||||
const search = document.querySelector("#search");
|
||||
const exit = document.querySelector("#exit");
|
||||
const portionControls = document.querySelector("#portions");
|
||||
const portionCount = document.querySelector("#portion-count");
|
||||
const fewerPortions = document.querySelector("#fewer-portions");
|
||||
const morePortions = document.querySelector("#more-portions");
|
||||
let recipes = [];
|
||||
let activeRecipeId = null;
|
||||
let activeRecipeName = null;
|
||||
let portions = null;
|
||||
|
||||
async function request(path, options) {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
@@ -39,6 +45,9 @@ function render() {
|
||||
return item;
|
||||
}));
|
||||
if (!matches.length) status.textContent = "No matching recipes.";
|
||||
portionControls.hidden = !activeRecipeId;
|
||||
portionCount.textContent = portions ?? "";
|
||||
fewerPortions.disabled = portions <= 1;
|
||||
}
|
||||
|
||||
async function activate(recipe) {
|
||||
@@ -49,6 +58,8 @@ async function activate(recipe) {
|
||||
body: JSON.stringify({ recipeId: recipe.id })
|
||||
});
|
||||
activeRecipeId = state.recipeId;
|
||||
activeRecipeName = recipe.name;
|
||||
portions = state.portions;
|
||||
status.textContent = `${recipe.name} is now on the mirror.`;
|
||||
render();
|
||||
} catch (error) {
|
||||
@@ -56,11 +67,30 @@ async function activate(recipe) {
|
||||
}
|
||||
}
|
||||
|
||||
async function adjustPortions(delta) {
|
||||
try {
|
||||
const state = await request("/portions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ delta })
|
||||
});
|
||||
portions = state.portions;
|
||||
status.textContent = `${activeRecipeName} is now on the mirror.`;
|
||||
render();
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
fewerPortions.addEventListener("click", () => adjustPortions(-1));
|
||||
morePortions.addEventListener("click", () => adjustPortions(1));
|
||||
|
||||
exit.addEventListener("click", async () => {
|
||||
status.textContent = "Leaving recipe mode…";
|
||||
try {
|
||||
await request("/exit", { method: "POST", body: "{}" });
|
||||
activeRecipeId = null;
|
||||
activeRecipeName = null;
|
||||
portions = null;
|
||||
status.textContent = "The mirror is back to its normal display.";
|
||||
render();
|
||||
} catch (error) {
|
||||
@@ -73,6 +103,8 @@ search.addEventListener("input", render);
|
||||
Promise.all([request("/state"), request("/recipes")])
|
||||
.then(([state, loadedRecipes]) => {
|
||||
activeRecipeId = state.recipeId;
|
||||
activeRecipeName = state.recipe?.name || null;
|
||||
portions = state.portions;
|
||||
recipes = loadedRecipes;
|
||||
status.textContent = `${recipes.length} recipes available.`;
|
||||
render();
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
|
||||
</header>
|
||||
<p id="status" role="status">Loading recipes…</p>
|
||||
<div id="portions" class="portions" hidden>
|
||||
<button id="fewer-portions" type="button" aria-label="One fewer portion">−</button>
|
||||
<span><strong id="portion-count"></strong> portions</span>
|
||||
<button id="more-portions" type="button" aria-label="One more portion">+</button>
|
||||
</div>
|
||||
<label for="search">Find a recipe</label>
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Search recipes">
|
||||
<ul id="recipes" aria-live="polite"></ul>
|
||||
|
||||
@@ -61,6 +61,28 @@ button {
|
||||
|
||||
#status { min-height: 1.5em; color: #665a45; }
|
||||
|
||||
.portions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin: 4px 0 20px;
|
||||
}
|
||||
|
||||
.portions[hidden] { display: none; }
|
||||
|
||||
.portions button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #fff;
|
||||
background: #5a4b30;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.portions button:disabled { cursor: default; opacity: .4; }
|
||||
|
||||
.portions span { min-width: 92px; text-align: center; }
|
||||
|
||||
#recipes { display: grid; gap: 10px; padding: 0; list-style: none; }
|
||||
|
||||
.recipe {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const FRACTIONS = new Map([
|
||||
["¼", 1 / 4], ["½", 1 / 2], ["¾", 3 / 4], ["⅓", 1 / 3],
|
||||
["⅔", 2 / 3], ["⅛", 1 / 8], ["⅜", 3 / 8], ["⅝", 5 / 8], ["⅞", 7 / 8]
|
||||
]);
|
||||
|
||||
function parseYield(value) {
|
||||
const match = String(value ?? "").match(/\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const portions = Number(match[0]);
|
||||
return Number.isFinite(portions) && portions > 0 ? portions : null;
|
||||
}
|
||||
|
||||
function parseQuantity(value) {
|
||||
if (FRACTIONS.has(value)) return FRACTIONS.get(value);
|
||||
if (value.includes("/")) {
|
||||
const [numerator, denominator] = value.split("/").map(Number);
|
||||
return denominator ? numerator / denominator : null;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function formatQuantity(value) {
|
||||
const whole = Math.floor(value + 1e-8);
|
||||
const remainder = value - whole;
|
||||
const candidates = [[0, ""], [1 / 8, "⅛"], [1 / 4, "¼"], [1 / 3, "⅓"],
|
||||
[3 / 8, "⅜"], [1 / 2, "½"], [5 / 8, "⅝"], [2 / 3, "⅔"],
|
||||
[3 / 4, "¾"], [7 / 8, "⅞"], [1, ""]];
|
||||
const [fraction, symbol] = candidates.reduce((best, candidate) => (
|
||||
Math.abs(candidate[0] - remainder) < Math.abs(best[0] - remainder) ? candidate : best
|
||||
));
|
||||
if (Math.abs(fraction - remainder) <= 0.02) {
|
||||
const adjustedWhole = fraction === 1 ? whole + 1 : whole;
|
||||
return `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0";
|
||||
}
|
||||
return Number(value.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
function scaleIngredient(ingredient, factor) {
|
||||
if (typeof ingredient !== "string" || factor === 1) return ingredient;
|
||||
const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/);
|
||||
if (!match) return ingredient;
|
||||
const whole = Number(match[2] || 0);
|
||||
const quantity = parseQuantity(match[3]);
|
||||
if (!Number.isFinite(quantity)) return ingredient;
|
||||
const replacement = `${match[1]}${formatQuantity((whole + quantity) * factor)}`;
|
||||
return replacement + ingredient.slice(match[0].length);
|
||||
}
|
||||
|
||||
function scaleRecipe(recipe, portions) {
|
||||
const basePortions = parseYield(recipe?.recipeYield);
|
||||
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
|
||||
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
|
||||
return {
|
||||
...recipe,
|
||||
recipeYield: suffix ? `${portions} ${suffix}` : portions,
|
||||
recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => (
|
||||
scaleIngredient(ingredient, portions / basePortions)
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseYield, scaleIngredient, scaleRecipe };
|
||||
+54
-8
@@ -4,11 +4,12 @@ const express = require("express");
|
||||
const NodeHelper = require("node_helper");
|
||||
const QRCode = require("qrcode");
|
||||
const NextcloudPublicShare = require("./lib/nextcloud-public-share");
|
||||
const { parseYield, scaleRecipe } = require("./lib/recipe-scaler");
|
||||
|
||||
module.exports = NodeHelper.create({
|
||||
start() {
|
||||
this.config = { controlPath: "/MMM-NextcloudCookbook/control" };
|
||||
this.state = { active: false, recipeId: null, recipe: null, error: null };
|
||||
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
|
||||
this.recipeCache = { expires: 0, recipes: [] };
|
||||
this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json");
|
||||
this.configureClient();
|
||||
@@ -19,6 +20,12 @@ module.exports = NodeHelper.create({
|
||||
socketNotificationReceived(notification, payload) {
|
||||
if (notification !== "CONFIG") return;
|
||||
this.config = { ...this.config, ...payload };
|
||||
try {
|
||||
this.configureClient();
|
||||
} catch (error) {
|
||||
this.setError(error);
|
||||
return;
|
||||
}
|
||||
this.stateReady.then(async () => {
|
||||
this.sendSocketNotification("STATE", this.publicState());
|
||||
await this.restoreRecipe();
|
||||
@@ -26,13 +33,17 @@ module.exports = NodeHelper.create({
|
||||
},
|
||||
|
||||
configureClient() {
|
||||
const shareUrl = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL;
|
||||
const shareUrl = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL || this.config.shareUrl;
|
||||
const password = process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD
|
||||
|| this.config.sharePassword
|
||||
|| "";
|
||||
this.client = shareUrl
|
||||
? new NextcloudPublicShare({
|
||||
shareUrl,
|
||||
password: process.env.SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD || ""
|
||||
password
|
||||
})
|
||||
: null;
|
||||
this.recipeCache = { expires: 0, recipes: [] };
|
||||
},
|
||||
|
||||
registerRoutes() {
|
||||
@@ -69,7 +80,30 @@ module.exports = NodeHelper.create({
|
||||
return response.status(400).json({ error: "recipeId is required" });
|
||||
}
|
||||
const recipe = await this.getClient().getRecipe(recipeId);
|
||||
this.state = { active: true, recipeId, recipe, error: null };
|
||||
this.state = {
|
||||
active: true,
|
||||
recipeId,
|
||||
recipe,
|
||||
portions: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
|
||||
error: null
|
||||
};
|
||||
await this.saveState();
|
||||
this.sendSocketNotification("STATE", this.publicState());
|
||||
return response.json(this.publicState());
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => {
|
||||
try {
|
||||
const delta = request.body?.delta;
|
||||
if (!this.state.active || !this.state.recipe) {
|
||||
return response.status(409).json({ error: "No recipe is active" });
|
||||
}
|
||||
if (delta !== -1 && delta !== 1) {
|
||||
return response.status(400).json({ error: "delta must be -1 or 1" });
|
||||
}
|
||||
this.state.portions = Math.max(1, this.state.portions + delta);
|
||||
await this.saveState();
|
||||
this.sendSocketNotification("STATE", this.publicState());
|
||||
return response.json(this.publicState());
|
||||
@@ -79,7 +113,7 @@ module.exports = NodeHelper.create({
|
||||
});
|
||||
this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => {
|
||||
try {
|
||||
this.state = { active: false, recipeId: null, recipe: null, error: null };
|
||||
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null };
|
||||
await this.saveState();
|
||||
this.sendSocketNotification("STATE", this.publicState());
|
||||
response.json(this.publicState());
|
||||
@@ -109,7 +143,13 @@ module.exports = NodeHelper.create({
|
||||
try {
|
||||
const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8"));
|
||||
if (!persisted.active || !persisted.recipeId) return;
|
||||
this.state = { active: true, recipeId: persisted.recipeId, recipe: null, error: null };
|
||||
this.state = {
|
||||
active: true,
|
||||
recipeId: persisted.recipeId,
|
||||
recipe: null,
|
||||
portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
@@ -119,6 +159,7 @@ module.exports = NodeHelper.create({
|
||||
if (!this.state.active || !this.state.recipeId) return;
|
||||
try {
|
||||
this.state.recipe = await this.getClient().getRecipe(this.state.recipeId);
|
||||
this.state.portions ||= Math.max(1, Math.round(parseYield(this.state.recipe.recipeYield) || 1));
|
||||
this.state.error = null;
|
||||
} catch (error) {
|
||||
this.setError(error);
|
||||
@@ -128,7 +169,11 @@ module.exports = NodeHelper.create({
|
||||
|
||||
async saveState() {
|
||||
const temporary = `${this.stateFile}.tmp`;
|
||||
await fs.writeFile(temporary, `${JSON.stringify({ active: this.state.active, recipeId: this.state.recipeId })}\n`, {
|
||||
await fs.writeFile(temporary, `${JSON.stringify({
|
||||
active: this.state.active,
|
||||
recipeId: this.state.recipeId,
|
||||
portions: this.state.portions
|
||||
})}\n`, {
|
||||
mode: 0o600
|
||||
});
|
||||
await fs.rename(temporary, this.stateFile);
|
||||
@@ -143,7 +188,8 @@ module.exports = NodeHelper.create({
|
||||
return {
|
||||
active: this.state.active,
|
||||
recipeId: this.state.recipeId,
|
||||
recipe: this.state.recipe,
|
||||
recipe: this.state.recipe ? scaleRecipe(this.state.recipe, this.state.portions) : null,
|
||||
portions: this.state.portions,
|
||||
error: this.state.error
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"description": "Display recipes from a Nextcloud Cookbook share on MagicMirror",
|
||||
"main": "node_helper.js",
|
||||
"scripts": {
|
||||
"check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/nextcloud-public-share.js && node --check control/app.js",
|
||||
"check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-scaler.js && node --check control/app.js",
|
||||
"test": "npm run check && node --test"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { parseYield, scaleIngredient, scaleRecipe } = require("../lib/recipe-scaler");
|
||||
|
||||
test("extracts the number of portions from a recipe yield", () => {
|
||||
assert.equal(parseYield("6 servings"), 6);
|
||||
assert.equal(parseYield(4), 4);
|
||||
assert.equal(parseYield("as needed"), null);
|
||||
});
|
||||
|
||||
test("scales leading whole, fractional, and mixed quantities", () => {
|
||||
assert.equal(scaleIngredient("2 cups oats", 2), "4 cups oats");
|
||||
assert.equal(scaleIngredient("½ cup milk", 2), "1 cup milk");
|
||||
assert.equal(scaleIngredient("1 1/2 tsp salt", 2), "3 tsp salt");
|
||||
assert.equal(scaleIngredient("Salt to taste", 2), "Salt to taste");
|
||||
});
|
||||
|
||||
test("returns a scaled copy of a recipe", () => {
|
||||
const original = {
|
||||
name: "Oatmeal Cups",
|
||||
recipeYield: "6 servings",
|
||||
recipeIngredient: ["2 cups oats", "Salt to taste"]
|
||||
};
|
||||
const scaled = scaleRecipe(original, 9);
|
||||
assert.equal(scaled.recipeYield, "9 servings");
|
||||
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
|
||||
assert.equal(original.recipeYield, "6 servings");
|
||||
});
|
||||
Reference in New Issue
Block a user