From 6689c867b1b6eb2fef9ada978b2d660e0eb9adad Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sat, 13 Jan 2024 12:41:52 +0100
Subject: [PATCH 001/188] refactor: Migrate, cleanup, and document `helper.js`,
`logging.js`, and `title-rename.js` to typescript
Signed-off-by: Sebastian Fey
---
.eslintrc.yml | 10 +-
package-lock.json | 20 +-
package.json | 2 +
src/js/helper.js | 179 ------------------
src/js/helper.ts | 134 +++++++++++++
src/js/logging.js | 73 -------
src/js/logging.ts | 90 +++++++++
src/js/title-rename.js | 85 ---------
src/js/title-rename.ts | 129 +++++++++++++
src/tests/.eslintrc.yml | 2 -
...test.js => RecipeCategoriesFilter.test.ts} | 0
...r.test.js => RecipeKeywordsFilter.test.ts} | 0
...lter.test.js => RecipeNamesFilter.test.ts} | 0
tsconfig.json | 2 +-
14 files changed, 379 insertions(+), 347 deletions(-)
delete mode 100644 src/js/helper.js
create mode 100644 src/js/helper.ts
delete mode 100644 src/js/logging.js
create mode 100644 src/js/logging.ts
delete mode 100644 src/js/title-rename.js
create mode 100644 src/js/title-rename.ts
delete mode 100644 src/tests/.eslintrc.yml
rename src/tests/unit/RecipeFilters/{RecipeCategoriesFilter.test.js => RecipeCategoriesFilter.test.ts} (100%)
rename src/tests/unit/RecipeFilters/{RecipeKeywordsFilter.test.js => RecipeKeywordsFilter.test.ts} (100%)
rename src/tests/unit/RecipeFilters/{RecipeNamesFilter.test.js => RecipeNamesFilter.test.ts} (100%)
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 07f3546d1..6847e7ffd 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -15,9 +15,8 @@ extends:
parser: "vue-eslint-parser"
parserOptions:
- parser: "@typescript-eslint/parser"
- project: './tsconfig.json'
- tsconfigRootDir: './'
+ parser: "@typescript-eslint/parser"
+ project: './tsconfig.json'
plugins:
- vue
@@ -25,6 +24,7 @@ plugins:
env:
browser: true
+# jest: true
globals:
OC: readonly
@@ -74,9 +74,13 @@ rules:
# While we are still on Vue2, we need this. Remove once on Vue3
vue/no-deprecated-v-bind-sync: off
+
overrides:
- files: [ "src/composables/**/*.js" ]
rules:
import/prefer-default-export: off
+ - files: [ "./src/tests/**/*.test.{js,ts}"]
+ env:
+ jest: true
diff --git a/package-lock.json b/package-lock.json
index 36fdea970..636de6fb1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@nextcloud/moment": "^1.2.2",
"@nextcloud/router": "^2.2.0",
"@nextcloud/vue": "^8.3.0",
+ "axios": "^1.6.5",
"caret-pos": "^2.0.0",
"fuse.js": "^7.0.0",
"linkifyjs": "^4.1.1",
@@ -36,6 +37,7 @@
"@nextcloud/browserslist-config": "^3.0.0",
"@nextcloud/stylelint-config": "^2.3.1",
"@nextcloud/webpack-vue-config": "^6.0.0",
+ "@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vue/cli-plugin-typescript": "~5.0.8",
@@ -3907,6 +3909,16 @@
"@types/istanbul-lib-report": "*"
}
},
+ "node_modules/@types/jest": {
+ "version": "29.5.11",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz",
+ "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==",
+ "dev": true,
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
"node_modules/@types/jquery": {
"version": "3.5.16",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz",
@@ -6328,11 +6340,11 @@
}
},
"node_modules/axios": {
- "version": "1.6.2",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
- "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
+ "version": "1.6.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz",
+ "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==",
"dependencies": {
- "follow-redirects": "^1.15.0",
+ "follow-redirects": "^1.15.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
diff --git a/package.json b/package.json
index 7eec35f5c..3b02a8547 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@nextcloud/moment": "^1.2.2",
"@nextcloud/router": "^2.2.0",
"@nextcloud/vue": "^8.3.0",
+ "axios": "^1.6.5",
"caret-pos": "^2.0.0",
"fuse.js": "^7.0.0",
"linkifyjs": "^4.1.1",
@@ -57,6 +58,7 @@
"@nextcloud/browserslist-config": "^3.0.0",
"@nextcloud/stylelint-config": "^2.3.1",
"@nextcloud/webpack-vue-config": "^6.0.0",
+ "@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vue/cli-plugin-typescript": "~5.0.8",
diff --git a/src/js/helper.js b/src/js/helper.js
deleted file mode 100644
index 1ddf57974..000000000
--- a/src/js/helper.js
+++ /dev/null
@@ -1,179 +0,0 @@
-import { showSimpleAlertModal } from 'cookbook/js/modals';
-
-/**
- * Clamps val between the minimum min and maximum max value.
- * @param val The value to be clamped between min and max
- * @param min The upper limit
- * @param max The lower limit
- * @returns {number} min if val is <= min, max if val is >= max and val if min <= val <= max.
- */
-function clamp(val, min, max) {
- return Math.min(max, Math.max(min, val));
-}
-
-// Check if two routes point to the same component but have different content
-function shouldReloadContent(url1, url2) {
- if (url1 === url2) {
- return false; // Obviously should not if both routes are the same
- }
-
- const comps1 = url1.split('/');
- const comps2 = url2.split('/');
-
- if (comps1.length < 2 || comps2.length < 2) {
- return false; // Just a failsafe, this should never happen
- }
-
- // The route structure is as follows:
- // - /{item}/:id View
- // - /{item}/:id/edit Edit
- // - /{item}/create Create
- // If the items are different, then the router automatically handles
- // component loading: do not manually reload
- if (comps1[1] !== comps2[1]) {
- return false;
- }
-
- // If one of the routes is edit and the other is not
- if (comps1.length !== comps2.length) {
- // Only reload if changing from edit to create
- return comps1.pop() === 'create' || comps2.pop() === 'create';
- }
- if (comps1.pop() === 'create') {
- // But, if we are moving from create to view, do not reload
- // the create component
- return false;
- }
-
- // Only options left are that both of the routes are edit or view,
- // but not identical, or that we're moving from view to create
- // -> reload view
- return true;
-}
-
-// Check if the two urls point to the same item instance
-function isSameItemInstance(url1, url2) {
- if (url1 === url2) {
- return true; // Obviously true if the routes are the same
- }
- const comps1 = url1.split('/');
- const comps2 = url2.split('/');
- if (comps1.length < 2 || comps2.length < 2) {
- return false; // Just a failsafe, this should never happen
- }
- // If the items are different, then the item instance cannot be
- // the same either
- if (comps1[1] !== comps2[1]) {
- return false;
- }
- if (comps1.length < 3 || comps2.length < 3) {
- // ID is the third url component, so can't be the same instance if
- // either of the urls have less than three components
- return false;
- }
- return comps1[2] === comps2[2];
-}
-
-/**
- * A simple function to sanitize HTML tags.
- * @param {string} text Input string
- * @returns {string}
- */
-
-function escapeHTML(text) {
- return text.replace(
- /["&'<>]/g,
- (a) =>
- ({
- '&': '&',
- '"': '"',
- "'": ''',
- '<': '<',
- '>': '>',
- })[a],
- );
-}
-
-// Fix the decimal separator for languages that use a comma instead of dot
-// deprecated
-function fixDecimalSeparator(value, io) {
- // value is the string value of the number to process
- // io is either 'i' as in input or 'o' as in output
- if (!value) {
- return '';
- }
- if (io === 'i') {
- // Check if it's an American number where a comma precedes a dot
- // e.g. 12,500.25
- if (value.indexOf('.') > value.indexOf(',')) {
- return value.replace(',', '');
- }
- return value.replace(',', '.');
- }
- if (io === 'o') {
- return value.toString().replace('.', ',');
- }
- return '';
-}
-
-// This will replace the PHP function nl2br in Vue components
-// deprecated
-function nl2br(text) {
- return text.replace(/\n/g, '
');
-}
-
-// A simple function that converts a MySQL datetime into a timestamp.
-// deprecated
-function getTimestamp(date) {
- if (date) {
- return new Date(date);
- }
- return null;
-}
-
-let router;
-
-function useRouter(_router) {
- router = _router;
-}
-// Push a new URL to the router, essentially navigating to that page.
-function goTo(url) {
- router.push(url);
-}
-
-// Notify the user if notifications are allowed
-// deprecated
-function notify(title, options) {
- if (!('Notification' in window)) {
- return;
- }
- if (Notification.permission === 'granted') {
- // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
- const notification = new Notification(title, options);
- } else if (Notification.permission !== 'denied') {
- Notification.requestPermission((permission) => {
- if (!('permission' in Notification)) {
- Notification.permission = permission;
- }
- if (permission === 'granted') {
- // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
- const notification = new Notification(title, options);
- } else {
- showSimpleAlertModal(title);
- }
- });
- }
-}
-
-export default {
- clamp,
- shouldReloadContent,
- isSameItemInstance,
- escapeHTML,
- fixDecimalSeparator,
- nl2br,
- getTimestamp,
- useRouter,
- goTo,
- notify,
-};
diff --git a/src/js/helper.ts b/src/js/helper.ts
new file mode 100644
index 000000000..43267e380
--- /dev/null
+++ b/src/js/helper.ts
@@ -0,0 +1,134 @@
+import VueRouter, { Route } from 'vue-router';
+
+/**
+ * Clamps val between the minimum min and maximum max value.
+ * @param val The value to be clamped between min and max
+ * @param min The upper limit
+ * @param max The lower limit
+ * @returns {number} min if val is <= min, max if val is >= max and val if min <= val <= max.
+ */
+function clamp(val: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, val));
+}
+
+// Check if two routes point to the same component but have different content
+function shouldReloadContent(url1: string, url2: string): boolean {
+ if (url1 === url2) {
+ return false; // Obviously should not if both routes are the same
+ }
+
+ const comps1 = url1.split('/');
+ const comps2 = url2.split('/');
+
+ if (comps1.length < 2 || comps2.length < 2) {
+ return false; // Just a failsafe, this should never happen
+ }
+
+ // The route structure is as follows:
+ // - /{item}/:id View
+ // - /{item}/:id/edit Edit
+ // - /{item}/create Create
+ // If the items are different, then the router automatically handles
+ // component loading: do not manually reload
+ if (comps1[1] !== comps2[1]) {
+ return false;
+ }
+
+ // If one of the routes is "edit" and the other is not
+ if (comps1.length !== comps2.length) {
+ // Only reload if changing from "edit" to "create"
+ return comps1.pop() === 'create' || comps2.pop() === 'create';
+ }
+ if (comps1.pop() === 'create') {
+ // But, if we are moving from create to view, do not reload
+ // the "create" component
+ return false;
+ }
+
+ // Only options left are that both of the routes are edit or view,
+ // but not identical, or that we're moving from "view" to "create"
+ // -> reload view
+ return true;
+}
+
+/**
+ * Check if the two urls point to the same item instance
+ */
+function isSameItemInstance(url1: string, url2: string): boolean {
+ if (url1 === url2) {
+ return true; // Obviously true if the routes are the same
+ }
+ const comps1 = url1.split('/');
+ const comps2 = url2.split('/');
+ if (comps1.length < 2 || comps2.length < 2) {
+ return false; // Just a failsafe, this should never happen
+ }
+ // If the items are different, then the item instance cannot be
+ // the same either
+ if (comps1[1] !== comps2[1]) {
+ return false;
+ }
+ if (comps1.length < 3 || comps2.length < 3) {
+ // ID is the third url component, so can't be the same instance if
+ // either of the urls have less than three components
+ return false;
+ }
+ return comps1[2] === comps2[2];
+}
+
+/**
+ * A simple function to sanitize HTML tags.
+ * @param {string} text Input string
+ * @returns {string}
+ */
+function escapeHTML(text: string): string {
+ const replacementChars = {
+ '&': '&',
+ '"': '"',
+ "'": ''',
+ '<': '<',
+ '>': '>',
+ };
+ return text.replace(/["&'<>]/g, (c) => replacementChars[c]);
+}
+
+/**
+ * `VueRouter` instance to be used for navigation functions like `goTo(url)`.
+ */
+let router: VueRouter;
+
+/**
+ * Set the router to be used for navigation functions like `goTo(url)`.
+ * @param _router `VueRouter` to be used.
+ */
+function useRouter(_router: VueRouter): void {
+ router = _router;
+}
+
+/**
+ * Push a new URL to the router, effectively navigating to that page.
+ * @param url URL to navigate to.
+ */
+function goTo(url: string): Promise {
+ return router.push(url);
+}
+
+/**
+ * Ensures that item is an array. If not, wraps it in an array.
+ * @template T
+ * @param {T|T[]} item Item to be wrapped in an array if it isn't an array itself.
+ * @returns {T[]}
+ */
+export function asArray(item: T | T[]): T[] {
+ return Array.isArray(item) ? item : [item];
+}
+
+export default {
+ asArray,
+ clamp,
+ shouldReloadContent,
+ isSameItemInstance,
+ escapeHTML,
+ useRouter,
+ goTo,
+};
diff --git a/src/js/logging.js b/src/js/logging.js
deleted file mode 100644
index 0491ad004..000000000
--- a/src/js/logging.js
+++ /dev/null
@@ -1,73 +0,0 @@
-// TODO: Switch to vuejs3-logger when we switch to Vue 3
-import VueLogger from 'vuejs-logger';
-import moment from '@nextcloud/moment';
-
-const DEFAULT_LOG_LEVEL = 'info';
-// How many minutes the logging configuration is valid for
-const EXPIRY_MINUTES = 30;
-// localStorage keys
-const KEY_ENABLED = 'COOKBOOK_LOGGING_ENABLED';
-const KEY_EXPIRY = 'COOKBOOK_LOGGING_EXPIRY';
-const KEY_LOG_LEVEL = 'COOKBOOK_LOGGING_LEVEL';
-
-// Check if the logging configuration in local storage has expired
-//
-// Since the expiry entry is added by us after the first run where
-// the enabled entry is detected, this only checks if it has been EXPIRY_MINUTES
-// since the first run, not EXPIRY_MINUTES since the user added the entry
-// This is a reasonable comprimise to simplify what the user has to do to enable
-// logging. We don't want them to have to setup the expiry as well
-const isExpired = (timestamp) => {
- if (timestamp === null) {
- return false;
- }
-
- return moment().isAfter(parseInt(timestamp, 10));
-};
-
-const isEnabled = () => {
- const DEFAULT = false;
- const userValue = localStorage.getItem(KEY_ENABLED);
- const expiry = localStorage.getItem(KEY_EXPIRY);
-
- // Detect the first load after the user has enabled logging
- // Set the expiry so the logging isn't enabled forever
- if (userValue !== null && expiry === null) {
- localStorage.setItem(
- KEY_EXPIRY,
- moment().add(EXPIRY_MINUTES, 'm').valueOf(),
- );
- }
-
- if (isExpired(expiry)) {
- localStorage.removeItem(KEY_ENABLED);
- localStorage.removeItem(KEY_EXPIRY);
-
- return DEFAULT;
- }
-
- // Local storage converts everything to string
- // Use JSON.parse to transform "false" -> false
- return JSON.parse(userValue) ?? DEFAULT;
-};
-
-export default function setupLogging(Vue) {
- const logLevel = localStorage.getItem(KEY_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL;
-
- Vue.use(VueLogger, {
- isEnabled: isEnabled(),
- logLevel,
- stringifyArguments: false,
- showLogLevel: true,
- showMethodName: true,
- separator: '|',
- showConsoleColors: true,
- });
-
- Vue.$log.info(`Setting up logging with log level ${logLevel}`);
-}
-
-export function enableLogging() {
- localStorage.setItem(KEY_ENABLED, true);
- localStorage.setItem(KEY_LOG_LEVEL, 'debug');
-}
diff --git a/src/js/logging.ts b/src/js/logging.ts
new file mode 100644
index 000000000..d844017e1
--- /dev/null
+++ b/src/js/logging.ts
@@ -0,0 +1,90 @@
+// TODO: Switch to vuejs3-logger when we switch to Vue 3
+import VueLogger from 'vuejs-logger';
+import moment from '@nextcloud/moment';
+
+const DEFAULT_LOG_LEVEL = 'info';
+/**
+ * For how many minutes the logging configuration is valid.
+ */
+const EXPIRY_MINUTES: number = 30;
+// localStorage keys
+const KEY_ENABLED = 'COOKBOOK_LOGGING_ENABLED';
+const KEY_EXPIRY = 'COOKBOOK_LOGGING_EXPIRY';
+const KEY_LOG_LEVEL = 'COOKBOOK_LOGGING_LEVEL';
+
+/**
+ * Checks if the logging configuration in local storage has expired.
+ *
+ * Since the expiry entry is added by us after the first run where
+ * the enabled entry is detected, this only checks if it has been EXPIRY_MINUTES
+ * since the first run, not EXPIRY_MINUTES since the user added the entry
+ * This is a reasonable compromise to simplify what the user has to do to enable
+ * logging. We don't want them to have to set up the expiry as well.
+ * @param timestamp
+ * @returns {string} True if the logging configuration has expired. False, otherwise
+ */
+
+const isExpired = (timestamp: string): boolean => {
+ if (timestamp === null) {
+ return false;
+ }
+
+ return moment().isAfter(parseInt(timestamp, 10));
+};
+
+/**
+ * True, if logging is enabled. False, otherwise.
+ */
+const isEnabled = (): boolean => {
+ const DEFAULT = false;
+ const userValue = localStorage.getItem(KEY_ENABLED);
+ let expiry = localStorage.getItem(KEY_EXPIRY);
+
+ if (expiry && isExpired(expiry)) {
+ localStorage.removeItem(KEY_ENABLED);
+ localStorage.removeItem(KEY_EXPIRY);
+
+ return DEFAULT;
+ }
+
+ if (!userValue) return false;
+
+ // Detect the first load after the user has enabled logging
+ // Set the expiry so the logging isn't enabled forever
+ if (userValue !== null && expiry === null) {
+ expiry = moment().add(EXPIRY_MINUTES, 'm').valueOf().toString();
+ localStorage.setItem(KEY_EXPIRY, expiry);
+ }
+
+ // Local storage converts everything to string
+ // Use JSON.parse to transform "false" -> false
+ return JSON.parse(userValue) ?? DEFAULT;
+};
+
+/**
+ * Runs the initial logging setup.
+ * @param Vue
+ */
+export default function setupLogging(Vue) {
+ const logLevel = localStorage.getItem(KEY_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL;
+
+ Vue.use(VueLogger, {
+ isEnabled: isEnabled(),
+ logLevel,
+ stringifyArguments: false,
+ showLogLevel: true,
+ showMethodName: true,
+ separator: '|',
+ showConsoleColors: true,
+ });
+
+ Vue.$log.info(`Setting up logging with log level ${logLevel}`);
+}
+
+/**
+ * Enables logging and sets log level to "DEBUG".
+ */
+export function enableLogging(): void {
+ localStorage.setItem(KEY_ENABLED, 'true');
+ localStorage.setItem(KEY_LOG_LEVEL, 'debug');
+}
diff --git a/src/js/title-rename.js b/src/js/title-rename.js
deleted file mode 100644
index 97171592f..000000000
--- a/src/js/title-rename.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import api from 'cookbook/js/api-interface';
-
-import { generateUrl } from '@nextcloud/router';
-
-const baseUrl = generateUrl('apps/cookbook');
-
-function extractAllRecipeLinkIds(content) {
- const re = /(?:^|[^#])#r\/([0-9]+)/g;
- let ret = [];
- let matches;
- for (
- matches = re.exec(content);
- matches !== null;
- matches = re.exec(content)
- ) {
- ret.push(matches[1]);
- }
-
- // Make the ids unique, see https://stackoverflow.com/a/14438954/882756
- function onlyUnique(value, index, self) {
- return self.indexOf(value) === index;
- }
- ret = ret.filter(onlyUnique);
-
- return ret;
-}
-
-async function getRecipesFromLinks(linkIds) {
- return Promise.all(
- linkIds.map(async (x) => {
- let recipe;
- try {
- recipe = await api.recipes.get(x);
- } catch (ex) {
- recipe = null;
- }
- return recipe;
- }),
- );
-}
-
-function cleanUpRecipeList(recipes) {
- return recipes.filter((r) => r !== null).map((x) => x.data);
-}
-
-function getRecipeUrl(id) {
- return `${baseUrl}/#/recipe/${id}`;
-}
-
-function insertMarkdownLinks(content, recipes) {
- let ret = content;
- recipes.forEach((r) => {
- const { id } = r;
-
- // Replace link urls in dedicated links (like [this example](#r/123))
- ret = ret.replace(`](${id})`, `](${getRecipeUrl(id)})`);
-
- // Replace plain references with recipe name
- const rePlain = RegExp(
- `(^|\\s|[,._+&?!-])#r/${id}($|\\s|[,._+&?!-])`,
- 'g',
- );
- // const re = /(^|\s|[,._+&?!-])#r\/(\d+)(?=$|\s|[.,_+&?!-])/g
- ret = ret.replace(
- rePlain,
- `$1[${r.name} (\\#r/${id})](${getRecipeUrl(id)})$2`,
- );
- });
- return ret;
-}
-
-async function normalizeNamesMarkdown(content) {
- // console.log(`Content: ${content}`)
- const linkIds = extractAllRecipeLinkIds(content);
- let recipes = await getRecipesFromLinks(linkIds);
- recipes = cleanUpRecipeList(recipes);
- // console.log("List of recipes", recipes)
-
- const markdown = insertMarkdownLinks(content, recipes);
- // console.log("Formatted markdown:", markdown)
-
- return markdown;
-}
-
-export default normalizeNamesMarkdown;
diff --git a/src/js/title-rename.ts b/src/js/title-rename.ts
new file mode 100644
index 000000000..3a91449b3
--- /dev/null
+++ b/src/js/title-rename.ts
@@ -0,0 +1,129 @@
+import api from 'cookbook/js/api-interface';
+
+import { generateUrl } from '@nextcloud/router';
+import { AxiosResponse } from 'axios/index';
+
+interface Recipe {
+ id: string;
+ name: string;
+}
+
+/**
+ * Relative base URL of the cookbook app.
+ */
+const baseUrl = generateUrl('apps/cookbook');
+
+/**
+ * Extracts a list of unique recipe ids from recipe references in `content`.
+ * @param content The text to search for recipe references.
+ * @returns List of unique recipe ids.
+ */
+function extractAllRecipeLinkIds(content: string): string[] {
+ const re = /(?:^|[^#])#r\/([0-9]+)/g;
+ let ret: string[] = [];
+ let matches: RegExpExecArray | null;
+ for (
+ matches = re.exec(content);
+ matches !== null;
+ matches = re.exec(content)
+ ) {
+ ret.push(matches[1]);
+ }
+
+ // Make the ids unique, see https://stackoverflow.com/a/14438954/882756
+ function onlyUnique(value: string, index: number, self: string[]): boolean {
+ return self.indexOf(value) === index;
+ }
+ ret = ret.filter(onlyUnique);
+
+ return ret;
+}
+
+/**
+ * Loads recipe data from the server for all recipes with ids in `linkIds`.
+ * @param linkIds List of recipe ids.
+ * @returns List of API responses with recipe data.
+ */
+async function getRecipesFromLinks(
+ linkIds: string[],
+): Promise<(AxiosResponse | null)[]> {
+ return Promise.all(
+ linkIds.map(async (x): Promise | null> => {
+ let recipeResponse: AxiosResponse | null;
+ try {
+ recipeResponse = await api.recipes.get(x);
+ } catch (ex) {
+ recipeResponse = null;
+ }
+ return recipeResponse;
+ }),
+ );
+}
+
+/**
+ * Takes list of response objects from the API call, removes all null objects and extracts only the response data.
+ * @param recipes List of response objects.
+ * @returns The response data without null objects.
+ */
+function cleanUpRecipeList(
+ recipes: (AxiosResponse | null)[],
+): Recipe[] {
+ return recipes.filter((r) => r !== null).map((x) => x!.data);
+}
+
+/**
+ * Constructs the relative URL to the recipe with identifier `id`.
+ * @param id Recipe id.
+ * @returns URL to recipe.
+ */
+function getRecipeUrl(id: string): string {
+ return `${baseUrl}/#/recipe/${id}`;
+}
+
+/**
+ * Replaces all recipe references in `content` by the Markdown link to the recipes.
+ * @param content Text containing recipe references.
+ * @param recipes List of recipe objects used to extract names and create links.
+ * @returns Text with inserted links.
+ */
+function insertMarkdownLinks(content: string, recipes: Recipe[]) {
+ let ret = content;
+ recipes.forEach((r) => {
+ const { id, name } = r;
+
+ // Replace link urls in dedicated links (like [this example](#r/123))
+ ret = ret.replace(`](${id})`, `](${getRecipeUrl(id)})`);
+
+ // Replace plain references with recipe name
+ const rePlain = RegExp(
+ `(^|\\s|[,._+&?!-])#r/${id}($|\\s|[,._+&?!-])`,
+ 'g',
+ );
+ // const re = /(^|\s|[,._+&?!-])#r\/(\d+)(?=$|\s|[.,_+&?!-])/g
+ ret = ret.replace(
+ rePlain,
+ `$1[${name} (\\#r/${id})](${getRecipeUrl(id)})$2`,
+ );
+ });
+ return ret;
+}
+
+/**
+ * Checks the `content` for reference to recipes and replaces them by Markdown links with the recipes' titles.
+ * @param content The text to search for and replace recipe references.
+ * @returns `content` with replaced references.
+ */
+async function normalizeNamesMarkdown(content: string): Promise {
+ // console.log(`Content: ${content}`)
+ const linkIds = extractAllRecipeLinkIds(content);
+ const recipeResponses = await getRecipesFromLinks(linkIds);
+ const recipes = cleanUpRecipeList(recipeResponses);
+ // console.log("List of recipes", recipes)
+
+ const markdown = insertMarkdownLinks(content, recipes);
+ // console.log("Formatted markdown:", markdown)
+
+ return markdown;
+}
+
+export default normalizeNamesMarkdown;
diff --git a/src/tests/.eslintrc.yml b/src/tests/.eslintrc.yml
deleted file mode 100644
index e19b2cfa8..000000000
--- a/src/tests/.eslintrc.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-env:
- jest: true
diff --git a/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.js b/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.ts
similarity index 100%
rename from src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.js
rename to src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.ts
diff --git a/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.js b/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.ts
similarity index 100%
rename from src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.js
rename to src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.ts
diff --git a/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.js b/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.ts
similarity index 100%
rename from src/tests/unit/RecipeFilters/RecipeNamesFilter.test.js
rename to src/tests/unit/RecipeFilters/RecipeNamesFilter.test.ts
diff --git a/tsconfig.json b/tsconfig.json
index aca88db00..62221bd84 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.vue"],
- "exclude": ["node_modules"],
+ "exclude": ["node_modules", "js"],
"compilerOptions": {
"lib": [
"dom",
From 88050c6d54cd2e7602688edf13999be2315b8810 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Fri, 19 Jan 2024 10:49:01 +0100
Subject: [PATCH 002/188] refactor: Add multiple TS classes for representing
recipe-related schema.org objects
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/HowToDirection.ts | 71 +++++++++
src/js/Models/schema/HowToSection.ts | 63 ++++++++
src/js/Models/schema/HowToSupply.ts | 58 ++++++++
src/js/Models/schema/HowToTool.ts | 58 ++++++++
src/js/Models/schema/NutritionInformation.ts | 95 ++++++++++++
src/js/Models/schema/QuantitativeValue.ts | 43 ++++++
src/js/Models/schema/Recipe.ts | 149 +++++++++++++++++++
src/js/Models/schema/index.ts | 15 ++
src/js/helper.ts | 15 +-
9 files changed, 565 insertions(+), 2 deletions(-)
create mode 100644 src/js/Models/schema/HowToDirection.ts
create mode 100644 src/js/Models/schema/HowToSection.ts
create mode 100644 src/js/Models/schema/HowToSupply.ts
create mode 100644 src/js/Models/schema/HowToTool.ts
create mode 100644 src/js/Models/schema/NutritionInformation.ts
create mode 100644 src/js/Models/schema/QuantitativeValue.ts
create mode 100644 src/js/Models/schema/Recipe.ts
create mode 100644 src/js/Models/schema/index.ts
diff --git a/src/js/Models/schema/HowToDirection.ts b/src/js/Models/schema/HowToDirection.ts
new file mode 100644
index 000000000..96bd2f459
--- /dev/null
+++ b/src/js/Models/schema/HowToDirection.ts
@@ -0,0 +1,71 @@
+import HowToSupply from './HowToSupply';
+import HowToTool from './HowToTool';
+import { asCleanedArray } from '../../helper';
+
+/**
+ * Interface representing the options for constructing a HowToDirection instance.
+ * @interface
+ */
+interface HowToDirectionOptions {
+ /** The position of the direction in the sequence. */
+ position?: number;
+
+ /** The images associated with the direction. */
+ image?: string | string[];
+
+ /** The thumbnail URLs for the images. */
+ thumbnailUrl?: string | string[];
+
+ /** The time required for the direction. */
+ timeRequired?: string;
+
+ /** The list of supplies needed for the direction. */
+ supply?: HowToSupply | HowToSupply[];
+
+ /** The list of tools needed for the direction. */
+ tool?: HowToTool | HowToTool[];
+}
+
+/**
+ * Represents a step or direction in the recipe instructions.
+ * @class
+ */
+export default class HowToDirection {
+ /** The text content of the direction. */
+ public text: string;
+
+ /** The position of the direction in the sequence. */
+ public position?: number;
+
+ /** The images associated with the direction. */
+ public image: string[];
+
+ /** The thumbnail URLs for the images. */
+ public thumbnailUrl: string[];
+
+ /** The time required for the direction. */
+ public timeRequired?: string;
+
+ /** The list of supplies needed for the direction. */
+ public supply: HowToSupply[];
+
+ /** The list of tools needed for the direction. */
+ public tool: HowToTool[];
+
+ /**
+ * Creates a `HowToDirection` instance.
+ * @constructor
+ * @param {string} text - The text content of the direction.
+ * @param {HowToDirectionOptions} options - An options object containing additional properties.
+ */
+ public constructor(text: string, options: HowToDirectionOptions = {}) {
+ this['@type'] = 'HowToDirection';
+ this.text = text;
+ this.position = options.position;
+ this.image = asCleanedArray(options.image);
+ this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
+ this.timeRequired = options.timeRequired;
+ this.supply = asCleanedArray(options.supply);
+ this.tool = asCleanedArray(options.tool);
+ }
+}
diff --git a/src/js/Models/schema/HowToSection.ts b/src/js/Models/schema/HowToSection.ts
new file mode 100644
index 000000000..c8fdf76cf
--- /dev/null
+++ b/src/js/Models/schema/HowToSection.ts
@@ -0,0 +1,63 @@
+import HowToDirection from './HowToDirection';
+import { asArray, asCleanedArray } from '../../helper';
+
+/**
+ * Interface representing the options for constructing a HowToSection instance.
+ * @interface
+ */
+interface HowToSectionOptions {
+ /** The description of the section. */
+ description?: string;
+
+ /** The position of the section in the sequence. */
+ position?: number;
+
+ /** The images associated with the section. */
+ image?: string | string[];
+
+ /** The thumbnail URLs for the images defined in `image`. */
+ thumbnailUrl?: string | string[];
+
+ /** The list of directions within the section. */
+ itemListElement?: HowToDirection | HowToDirection[];
+}
+
+/**
+ * Represents a section in the recipe instructions.
+ * @class
+ */
+export default class HowToSection {
+ /** The name of the section. */
+ public name: string;
+
+ /** The position of the section in the sequence. */
+ public position?: number;
+
+ /** The description of the section. */
+ public description?: string;
+
+ /** The images associated with the section. */
+ public image: string[];
+
+ /** The thumbnail URLs for the images defined in `image`. */
+ public thumbnailUrl: string[];
+
+ /** The list of directions within the section. */
+ public itemListElement: HowToDirection[];
+
+ /**
+ * Creates a HowToSection instance.
+ * @constructor
+ * @param {string} name - The name of the section.
+ * @param {HowToSectionOptions} options - An options object containing additional properties.
+ */
+ public constructor(name: string, options: HowToSectionOptions = {}) {
+ this['@type'] = 'HowToSection';
+ this.name = name;
+ this.description = options.description;
+ this.position = options.position;
+ this.image = asCleanedArray(options.image);
+ this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
+ this.itemListElement = asCleanedArray(options.itemListElement);
+ }
+}
diff --git a/src/js/Models/schema/HowToSupply.ts b/src/js/Models/schema/HowToSupply.ts
new file mode 100644
index 000000000..b40b6997d
--- /dev/null
+++ b/src/js/Models/schema/HowToSupply.ts
@@ -0,0 +1,58 @@
+import QuantitativeValue from './QuantitativeValue';
+
+/**
+ * Represents a supply item in the `HowToSupply` section.
+ * @class
+ */
+export default class HowToSupply {
+ /** The name of the supply item. */
+ public name: string;
+
+ /** The identifier of the supply item. */
+ public identifier?: string;
+
+ /** The description of the supply item. */
+ public description?: string;
+
+ /** The required quantity of the supply item. */
+ public requiredQuantity?: QuantitativeValue;
+
+ /**
+ * Creates a `HowToSupply` instance.
+ * @constructor
+ * @param name - The name of the supply item.
+ */
+ public constructor(name: string);
+
+ /**
+ * Creates a `HowToSupply` instance.
+ * @constructor
+ * @param name - The name of the supply item.
+ * @param identifier - The identifier of the supply item.
+ * @param description - The description of the supply item.
+ * @param requiredQuantity - The required quantity of the supply item.
+ */
+ public constructor(
+ name: string,
+ identifier?: string,
+ description?: string,
+ requiredQuantity?: QuantitativeValue,
+ );
+
+ /**
+ * Creates a `HowToSupply` instance.
+ * @constructor
+ * @param name - The name of the supply item.
+ * @param args - Remaining supported arguments.
+ */
+ constructor(name: string, ...args: never[]) {
+ this['@type'] = 'HowToSupply';
+ this.name = name;
+ // eslint-disable-next-line prefer-destructuring
+ if (args[0]) this.identifier = args[0];
+ // eslint-disable-next-line prefer-destructuring
+ if (args[1]) this.description = args[1];
+ // eslint-disable-next-line prefer-destructuring
+ if (args[2]) this.requiredQuantity = args[2];
+ }
+}
diff --git a/src/js/Models/schema/HowToTool.ts b/src/js/Models/schema/HowToTool.ts
new file mode 100644
index 000000000..39d50646f
--- /dev/null
+++ b/src/js/Models/schema/HowToTool.ts
@@ -0,0 +1,58 @@
+import QuantitativeValue from './QuantitativeValue';
+
+/**
+ * Represents a tool used in the recipe instructions.
+ * @class
+ */
+export default class HowToTool {
+ /** The name of the tool. */
+ public name: string;
+
+ /** The identifier of the tool. */
+ public identifier?: string;
+
+ /** The description of the tool. */
+ public description?: string;
+
+ /** The required quantity of the tool. */
+ public requiredQuantity?: QuantitativeValue;
+
+ /**
+ * Creates a HowToTool instance.
+ * @constructor
+ * @param name - The name of the tool.
+ */
+ constructor(name: string);
+
+ /**
+ * Creates a HowToTool instance.
+ * @constructor
+ * @param name - The name of the tool.
+ * @param identifier - The identifier of the tool.
+ * @param description - The description of the tool.
+ * @param requiredQuantity - The required quantity of the tool.
+ */
+ constructor(
+ name: string,
+ identifier?: string,
+ description?: string,
+ requiredQuantity?: QuantitativeValue,
+ );
+
+ /**
+ * Creates a HowToTool instance.
+ * @constructor
+ * @param name - The name of the tool.
+ * @param args - Remaining supported arguments.
+ */
+ constructor(name: string, ...args: never[]) {
+ this['@type'] = 'HowToTool';
+ this.name = name;
+ // eslint-disable-next-line prefer-destructuring
+ if (args[0]) this.identifier = args[0];
+ // eslint-disable-next-line prefer-destructuring
+ if (args[1]) this.description = args[1];
+ // eslint-disable-next-line prefer-destructuring
+ if (args[2]) this.requiredQuantity = args[2];
+ }
+}
diff --git a/src/js/Models/schema/NutritionInformation.ts b/src/js/Models/schema/NutritionInformation.ts
new file mode 100644
index 000000000..6e521ea95
--- /dev/null
+++ b/src/js/Models/schema/NutritionInformation.ts
@@ -0,0 +1,95 @@
+/**
+ * Interface representing the properties of the NutritionInformation class.
+ * @interface
+ */
+export interface NutritionInformationProperties {
+ /** The number of calories. */
+ calories?: string;
+
+ /** The number of grams of carbohydrates. */
+ carbohydrateContent?: string;
+
+ /** The number of milligrams of cholesterol. */
+ cholesterolContent?: string;
+
+ /** The number of grams of fat. */
+ fatContent?: string;
+
+ /** The number of grams of fiber. */
+ fiberContent?: string;
+
+ /** The number of grams of protein. */
+ proteinContent?: string;
+
+ /** The number of grams of saturated fat. */
+ saturatedFatContent?: string;
+
+ /** The serving size, in terms of the number of volume or mass. */
+ servingSize?: string;
+
+ /** The number of milligrams of sodium. */
+ sodiumContent?: string;
+
+ /** The number of grams of sugar. */
+ sugarContent?: string;
+
+ /** The number of grams of trans fat. */
+ transFatContent?: string;
+
+ /** The number of grams of unsaturated fat. */
+ unsaturatedFatContent?: string;
+}
+
+/**
+ * Represents nutrition information.
+ * @class
+ */
+export default class NutritionInformation {
+ /** The number of calories. */
+ public calories?: string;
+
+ /** The number of grams of carbohydrates. */
+ public carbohydrateContent?: string;
+
+ /** The number of milligrams of cholesterol. */
+ public cholesterolContent?: string;
+
+ /** The number of grams of fat. */
+ public fatContent?: string;
+
+ /** The number of grams of fiber. */
+ public fiberContent?: string;
+
+ /** The number of grams of protein. */
+ public proteinContent?: string;
+
+ /** The number of grams of saturated fat. */
+ public saturatedFatContent?: string;
+
+ /** The serving size, in terms of the number of volume or mass. */
+ public servingSize?: string;
+
+ /** The number of milligrams of sodium. */
+ public sodiumContent?: string;
+
+ /** The number of grams of sugar. */
+ public sugarContent?: string;
+
+ /** The number of grams of trans fat. */
+ public transFatContent?: string;
+
+ /** The number of grams of unsaturated fat. */
+ public unsaturatedFatContent?: string;
+
+ /**
+ * Creates a NutritionInformation instance.
+ * @constructor
+ * @param properties - An optional object containing the nutrition information properties.
+ */
+ constructor(properties?: NutritionInformationProperties) {
+ this['@type'] = 'NutritionInformation';
+
+ // Set the properties from the provided object, or default to undefined
+ Object.assign(this, properties);
+ }
+}
diff --git a/src/js/Models/schema/QuantitativeValue.ts b/src/js/Models/schema/QuantitativeValue.ts
new file mode 100644
index 000000000..69fdbfae2
--- /dev/null
+++ b/src/js/Models/schema/QuantitativeValue.ts
@@ -0,0 +1,43 @@
+/**
+ * Represents a quantitative value with unit information.
+ * @class
+ */
+export default class QuantitativeValue {
+ /** The numerical value. */
+ public value: number;
+
+ /** The unit of measurement (e.g., "cup", "kilogram"). */
+ public unitText: string;
+
+ /** The unit code (e.g., "CU", "KGM"). */
+ public unitCode?: string;
+
+ /**
+ * Creates a QuantitativeValue instance.
+ * @constructor
+ * @param value - The numerical value.
+ * @param unitText - The unit of measurement (e.g., "cup", "kilogram").
+ */
+ constructor(value: number, unitText: string);
+
+ /**
+ * Creates a QuantitativeValue instance.
+ * @constructor
+ * @param value - The numerical value.
+ * @param unitText - The unit of measurement (e.g., "cup", "kilogram").
+ * @param unitCode - The unit code (e.g., "CU", "KGM").
+ */
+ constructor(value: number, unitText: string, unitCode?: string);
+
+ /**
+ * Creates a QuantitativeValue instance.
+ * @constructor
+ */
+ constructor(value: number, unitText: string, ...args: never[]) {
+ this['@type'] = 'QuantitativeValue';
+ this.value = value;
+ this.unitText = unitText;
+ // eslint-disable-next-line prefer-destructuring
+ if (args[0]) this.unitCode = args[0];
+ }
+}
diff --git a/src/js/Models/schema/Recipe.ts b/src/js/Models/schema/Recipe.ts
new file mode 100644
index 000000000..c470dd9d5
--- /dev/null
+++ b/src/js/Models/schema/Recipe.ts
@@ -0,0 +1,149 @@
+import HowToDirection from './HowToDirection';
+import HowToSection from './HowToSection';
+import HowToSupply from './HowToSupply';
+import HowToTool from './HowToTool';
+import NutritionInformation from './NutritionInformation';
+import { asArray } from '../../helper';
+
+/** Options for creating a recipe */
+interface RecipeOptions {
+ /** The category of the recipe. */
+ recipeCategory?: string;
+ /** The timestamp of the recipe's creation date. */
+ dateCreated?: string;
+ /** The timestamp of the recipe's modification date. */
+ dateModified?: string;
+ /** The description of the recipe. */
+ description?: string;
+ /** The original image Urls of the recipe. */
+ image?: string | string[];
+ /** Urls to the images of the recipe on the Nextcloud instance. */
+ imageUrl?: string | string[];
+ /** The keywords of the recipe. */
+ keywords?: string | string[];
+ /** The total time required for the recipe. */
+ totalTime?: string;
+ /** The time it takes to actually cook the dish, in ISO 8601 duration format. */
+ cookTime?: string;
+ /** The length of time it takes to prepare the items to be used in instructions or a direction, in ISO 8601 duration
+ * format. */
+ prepTime?: string;
+ /** Nutritional information about the recipe. */
+ nutrition?: NutritionInformation;
+ /** The list of ingredients for the recipe. */
+ recipeIngredient?: string | string[];
+ /** The number of servings for the recipe */
+ recipeYield?: number;
+ /** The list of supplies needed for the recipe. */
+ supply?: HowToSupply | HowToSupply[];
+ /** The step-by-step instructions for the recipe. */
+ recipeInstructions?:
+ | HowToSection
+ | HowToDirection
+ | (HowToSection | HowToDirection)[];
+ /** The tools required for the recipe. */
+ tool?: HowToTool | HowToTool[];
+ /** The URL of the recipe. */
+ url?: string | string[];
+}
+
+/**
+ * Represents a Recipe in Schema.org standard.
+ * @class
+ */
+export default class Recipe {
+ /** The unique identifier of the recipe */
+ public identifier: string;
+
+ /** The name/title of the recipe */
+ public name: string;
+
+ /** The category of the recipe. */
+ public recipeCategory?: string;
+
+ /** The original image Urls of the recipe. */
+ public image: string[];
+
+ /** Urls to the images of the recipe on the Nextcloud instance. */
+ public imageUrl: string[];
+
+ /** The keywords of the recipe. */
+ public keywords: string[];
+
+ /** The total time required for the recipe. */
+ public totalTime: string | undefined;
+
+ /** The time it takes to actually cook the dish, in ISO 8601 duration format. */
+ public cookTime: string | undefined;
+
+ /** The length of time it takes to prepare the items to be used in instructions or a direction, in ISO 8601 duration
+ * format. */
+ public prepTime: string | undefined;
+
+ /** The timestamp of the recipe's creation date. */
+ public dateCreated: string | undefined;
+
+ /** The timestamp of the recipe's modification date. */
+ public dateModified: string | undefined;
+
+ /** The description of the recipe. */
+ public description: string | undefined;
+
+ /** Nutritional information about the recipe. */
+ public nutrition: NutritionInformation | undefined;
+
+ /** The list of ingredients for the recipe. */
+ public recipeIngredient: string[];
+
+ /** The number of servings for the recipe */
+ public recipeYield: number | undefined;
+
+ /** The list of supplies needed for the recipe. */
+ public supply: HowToSupply[];
+
+ /** The step-by-step instructions for the recipe. */
+ public recipeInstructions: (HowToSection | HowToDirection)[];
+
+ /** The tools required for the recipe. */
+ public tool: HowToTool[];
+
+ /** The URLs associated with the recipe. In the current setup, should be a single URL, but let's already allow an
+ * array of URLs. */
+ public url: string[];
+
+ constructor(identifier: string, name: string, options: RecipeOptions = {}) {
+ this['@context'] = 'https://schema.org';
+ this['@type'] = 'Recipe';
+ this.identifier = identifier;
+ this.name = name;
+ // if (options) {
+ this.recipeCategory = options.recipeCategory || undefined;
+ this.description = options.description || undefined;
+ this.dateCreated = options.dateCreated || undefined;
+ this.dateModified = options.dateModified || undefined;
+ this.image = options.image ? asArray(options.image) : [];
+ this.imageUrl = options.imageUrl ? asArray(options.imageUrl) : [];
+ this.keywords = options.keywords ? asArray(options.keywords) : [];
+ this.cookTime = options.cookTime || undefined;
+ this.prepTime = options.prepTime || undefined;
+ this.totalTime = options.totalTime || undefined;
+ this.nutrition = options.nutrition || undefined;
+ this.recipeIngredient = options.recipeIngredient
+ ? asArray(options.recipeIngredient)
+ : [];
+ this.recipeYield = options.recipeYield;
+ this.supply = options.supply ? asArray(options.supply) : [];
+ this.recipeInstructions = options.recipeInstructions
+ ? asArray(options.recipeInstructions)
+ : [];
+ this.tool = options.tool ? asArray(options.tool) : [];
+ this.url = options.url ? asArray(options.url) : [];
+ }
+
+ /**
+ * The unique identifier of the recipe object. This is equivalent to `identifier` in schema.org.
+ */
+ get id(): string {
+ return this.identifier;
+ }
+}
diff --git a/src/js/Models/schema/index.ts b/src/js/Models/schema/index.ts
new file mode 100644
index 000000000..2ea652af1
--- /dev/null
+++ b/src/js/Models/schema/index.ts
@@ -0,0 +1,15 @@
+import HowToDirection from './HowToDirection';
+import HowToSection from './HowToSection';
+import HowToSupply from './HowToSupply';
+import HowToTool from './HowToTool';
+import QuantitativeValue from './QuantitativeValue';
+import Recipe from './Recipe';
+
+export {
+ HowToDirection,
+ HowToSection,
+ HowToSupply,
+ HowToTool,
+ QuantitativeValue,
+ Recipe,
+};
diff --git a/src/js/helper.ts b/src/js/helper.ts
index 43267e380..3a96c7c40 100644
--- a/src/js/helper.ts
+++ b/src/js/helper.ts
@@ -116,11 +116,22 @@ function goTo(url: string): Promise {
/**
* Ensures that item is an array. If not, wraps it in an array.
* @template T
+ * @param {T|T[]} value Item to be wrapped in an array if it isn't an array itself.
+ * @returns {T[]}
+ */
+export function asArray(value: T | T[]): T[] {
+ return Array.isArray(value) ? value : [value];
+}
+
+/**
+ * Ensures that item is an array. If not, wraps it in an array. Removes all `null` or `undefined` values.
+ * @template T
* @param {T|T[]} item Item to be wrapped in an array if it isn't an array itself.
* @returns {T[]}
*/
-export function asArray(item: T | T[]): T[] {
- return Array.isArray(item) ? item : [item];
+export function asCleanedArray(item: T | T[]): NonNullable[] {
+ const arr = asArray(item);
+ return arr.filter((i) => !!i).map((i) => i as NonNullable);
}
export default {
From b17cca28430087df01291106bf89874edd27ecd1 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Fri, 19 Jan 2024 16:11:56 +0100
Subject: [PATCH 003/188] test: Allow testing typescript with Jest
Signed-off-by: Sebastian Fey
---
babel.config.js | 3 +++
jest.config.js | 11 ++++-------
package-lock.json | 1 +
package.json | 1 +
4 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/babel.config.js b/babel.config.js
index 51c96ca86..e9142db74 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,3 +1,6 @@
const babelConfig = require('@nextcloud/babel-config');
+// https://jestjs.io/docs/getting-started#using-typescript
+babelConfig.presets.push('@babel/preset-typescript');
+
module.exports = babelConfig;
diff --git a/jest.config.js b/jest.config.js
index 13240421e..f3ff8e0ef 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,15 +1,12 @@
// Jest configuration
module.exports = {
testEnvironment: 'node',
- moduleFileExtensions: ['js', 'vue'],
- modulePaths: [
- '/src/'
- ],
- modulePathIgnorePatterns: [
- '/.github/'
- ],
+ moduleFileExtensions: ['js', 'ts', 'vue'],
+ modulePaths: ['/src/'],
+ modulePathIgnorePatterns: ['/.github/'],
transform: {
'.*\\.js$': '/node_modules/babel-jest',
+ '.*\\.ts$': '/node_modules/babel-jest',
'.*\\.(vue)$': '/node_modules/@vue/vue2-jest',
},
transformIgnorePatterns: ['node_modules/(?!variables/.*)'],
diff --git a/package-lock.json b/package-lock.json
index 636de6fb1..b9d66140f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,6 +33,7 @@
"vuex": "^3.6.2"
},
"devDependencies": {
+ "@babel/preset-typescript": "^7.23.3",
"@nextcloud/babel-config": "^1.0.0",
"@nextcloud/browserslist-config": "^3.0.0",
"@nextcloud/stylelint-config": "^2.3.1",
diff --git a/package.json b/package.json
index 3b02a8547..8fde698a3 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"vuex": "^3.6.2"
},
"devDependencies": {
+ "@babel/preset-typescript": "^7.23.3",
"@nextcloud/babel-config": "^1.0.0",
"@nextcloud/browserslist-config": "^3.0.0",
"@nextcloud/stylelint-config": "^2.3.1",
From bb6816c06fb2fc331d0b46387031a1569e9043fe Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Fri, 19 Jan 2024 16:12:34 +0100
Subject: [PATCH 004/188] test: Add tests for TS schema.org model classes
Signed-off-by: Sebastian Fey
---
.../unit/Models/schema/HowToDirection.test.ts | 77 ++++++++++
.../unit/Models/schema/HowToSection.test.ts | 57 +++++++
.../unit/Models/schema/HowToSupply.test.ts | 49 ++++++
.../unit/Models/schema/HowToTool.test.ts | 44 ++++++
.../schema/NutritionInformation.test.ts | 59 +++++++
.../Models/schema/QuantitativeValue.test.ts | 24 +++
src/tests/unit/Models/schema/Recipe.test.ts | 145 ++++++++++++++++++
7 files changed, 455 insertions(+)
create mode 100644 src/tests/unit/Models/schema/HowToDirection.test.ts
create mode 100644 src/tests/unit/Models/schema/HowToSection.test.ts
create mode 100644 src/tests/unit/Models/schema/HowToSupply.test.ts
create mode 100644 src/tests/unit/Models/schema/HowToTool.test.ts
create mode 100644 src/tests/unit/Models/schema/NutritionInformation.test.ts
create mode 100644 src/tests/unit/Models/schema/QuantitativeValue.test.ts
create mode 100644 src/tests/unit/Models/schema/Recipe.test.ts
diff --git a/src/tests/unit/Models/schema/HowToDirection.test.ts b/src/tests/unit/Models/schema/HowToDirection.test.ts
new file mode 100644
index 000000000..2152eeb3f
--- /dev/null
+++ b/src/tests/unit/Models/schema/HowToDirection.test.ts
@@ -0,0 +1,77 @@
+import HowToDirection from '../../../../js/Models/schema/HowToDirection';
+import HowToSupply from '../../../../js/Models/schema/HowToSupply';
+import HowToTool from '../../../../js/Models/schema/HowToTool';
+
+describe('HowToDirection', () => {
+ test('should set "@type" property to "HowToDirection"', () => {
+ const direction = new HowToDirection('Step 5');
+
+ expect(direction).toHaveProperty('@type', 'HowToDirection');
+ });
+
+ test('should create an instance with only text', () => {
+ const direction = new HowToDirection('Step 1');
+
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 1');
+ expect(direction.position).toBeUndefined();
+ expect(direction.image).toStrictEqual([]);
+ expect(direction.thumbnailUrl).toStrictEqual([]);
+ expect(direction.timeRequired).toBeUndefined();
+ expect(direction.supply).toStrictEqual([]);
+ expect(direction.tool).toStrictEqual([]);
+ });
+
+ test('should create an instance with text and position', () => {
+ const direction = new HowToDirection('Step 2', { position: 2 });
+
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 2');
+ expect(direction.position).toBe(2);
+ expect(direction.image).toStrictEqual([]);
+ expect(direction.thumbnailUrl).toStrictEqual([]);
+ expect(direction.timeRequired).toBeUndefined();
+ expect(direction.supply).toStrictEqual([]);
+ expect(direction.tool).toStrictEqual([]);
+ });
+
+ test('should create an instance with all properties', () => {
+ const image = ['image1.jpg', 'image2.jpg'];
+ const thumbnailUrl = ['thumb1.jpg', 'thumb2.jpg'];
+ const supply: HowToSupply[] = [{ name: 'Ingredient 1' }];
+ const tool: HowToTool[] = [{ name: 'Tool 1' }];
+
+ const direction = new HowToDirection('Step 3', {
+ position: 3,
+ image,
+ thumbnailUrl,
+ timeRequired: '5 minutes',
+ supply,
+ tool,
+ });
+
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 3');
+ expect(direction.position).toBe(3);
+ expect(direction.image).toEqual(image);
+ expect(direction.thumbnailUrl).toEqual(thumbnailUrl);
+ expect(direction.timeRequired).toBe('5 minutes');
+ expect(direction.supply).toEqual(supply);
+ expect(direction.tool).toEqual(tool);
+ });
+
+ test('should create an instance with only text and image string', () => {
+ const image = 'image1.jpg';
+ const thumbnailUrl = 'image1_thumb.jpg';
+ const direction = new HowToDirection('Step 4', { image, thumbnailUrl });
+
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 4');
+ expect(direction.position).toBeUndefined();
+ expect(direction.image).toEqual([image]);
+ expect(direction.thumbnailUrl).toEqual([thumbnailUrl]);
+ expect(direction.timeRequired).toBeUndefined();
+ expect(direction.supply).toStrictEqual([]);
+ expect(direction.tool).toStrictEqual([]);
+ });
+});
diff --git a/src/tests/unit/Models/schema/HowToSection.test.ts b/src/tests/unit/Models/schema/HowToSection.test.ts
new file mode 100644
index 000000000..148c72dbd
--- /dev/null
+++ b/src/tests/unit/Models/schema/HowToSection.test.ts
@@ -0,0 +1,57 @@
+import HowToDirection from '../../../../js/Models/schema/HowToDirection';
+import HowToSection from '../../../../js/Models/schema/HowToSection';
+
+describe('HowToSection', () => {
+ test('should create a HowToSection instance with required properties', () => {
+ const section = new HowToSection('Section 1');
+
+ expect(section).toHaveProperty('@type', 'HowToSection');
+ expect(section.name).toBe('Section 1');
+ });
+
+ test('should set optional properties when provided in options', () => {
+ const options = {
+ description: 'Section description',
+ position: 2,
+ image: 'section-image.jpg',
+ thumbnailUrl: 'thumbnail.jpg',
+ itemListElement: new HowToDirection('Step 1'),
+ };
+
+ const section = new HowToSection('Section 2', options);
+
+ expect(section.description).toBe(options.description);
+ expect(section.position).toBe(options.position);
+ expect(section.image).toEqual([options.image]);
+ expect(section.thumbnailUrl).toEqual([options.thumbnailUrl]);
+ expect(section.itemListElement).toEqual([options.itemListElement]);
+ });
+
+ test('should handle undefined options', () => {
+ const section = new HowToSection('Section 3', undefined);
+
+ expect(section.description).toBeUndefined();
+ expect(section.position).toBeUndefined();
+ expect(section.image).toEqual([]);
+ expect(section.thumbnailUrl).toEqual([]);
+ expect(section.itemListElement).toEqual([]);
+ });
+
+ test('should handle options with undefined properties', () => {
+ const options = {
+ description: undefined,
+ position: undefined,
+ image: undefined,
+ thumbnailUrl: undefined,
+ itemListElement: undefined,
+ };
+
+ const section = new HowToSection('Section 4', options);
+
+ expect(section.description).toBeUndefined();
+ expect(section.position).toBeUndefined();
+ expect(section.image).toEqual([]);
+ expect(section.thumbnailUrl).toEqual([]);
+ expect(section.itemListElement).toEqual([]);
+ });
+});
diff --git a/src/tests/unit/Models/schema/HowToSupply.test.ts b/src/tests/unit/Models/schema/HowToSupply.test.ts
new file mode 100644
index 000000000..24b634781
--- /dev/null
+++ b/src/tests/unit/Models/schema/HowToSupply.test.ts
@@ -0,0 +1,49 @@
+import HowToSupply from '../../../../js/Models/schema/HowToSupply';
+
+describe('HowToSupply', () => {
+ test('should set the @type property to "HowToSupply"', () => {
+ const howToSupply = new HowToSupply('Ingredient');
+ expect(howToSupply['@type']).toBe('HowToSupply');
+ });
+
+ test('should create an instance of HowToSupply with optional properties undefined', () => {
+ const howToSupply = new HowToSupply('Ingredient');
+ expect(howToSupply).toBeInstanceOf(HowToSupply);
+ expect(howToSupply.name).toBe('Ingredient');
+ expect(howToSupply.identifier).toBeUndefined();
+ expect(howToSupply.description).toBeUndefined();
+ expect(howToSupply.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToSupply with some optional properties defined and some undefined', () => {
+ const howToSupply = new HowToSupply('Ingredient', 'IGD123');
+ expect(howToSupply).toBeInstanceOf(HowToSupply);
+ expect(howToSupply.name).toBe('Ingredient');
+ expect(howToSupply.identifier).toBe('IGD123');
+ expect(howToSupply.description).toBeUndefined();
+ expect(howToSupply.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToSupply with all properties set', () => {
+ const howToSupply = new HowToSupply(
+ 'Flour',
+ 'FLR123',
+ 'High-quality flour',
+ {
+ value: 2,
+ unitText: 'cup',
+ unitCode: undefined,
+ },
+ );
+
+ expect(howToSupply).toBeInstanceOf(HowToSupply);
+ expect(howToSupply.name).toBe('Flour');
+ expect(howToSupply.identifier).toBe('FLR123');
+ expect(howToSupply.description).toBe('High-quality flour');
+ expect(howToSupply.requiredQuantity).toEqual({
+ value: 2,
+ unitText: 'cup',
+ unitCode: undefined,
+ });
+ });
+});
diff --git a/src/tests/unit/Models/schema/HowToTool.test.ts b/src/tests/unit/Models/schema/HowToTool.test.ts
new file mode 100644
index 000000000..94fdf3b92
--- /dev/null
+++ b/src/tests/unit/Models/schema/HowToTool.test.ts
@@ -0,0 +1,44 @@
+import HowToTool from '../../../../js/Models/schema/HowToTool';
+
+describe('HowToTool', () => {
+ test('should set the @type property to "HowToTool"', () => {
+ const howToTool = new HowToTool('ToolA');
+ expect(howToTool['@type']).toBe('HowToTool');
+ });
+
+ test('should create an instance of HowToTool with optional properties undefined', () => {
+ const howToTool = new HowToTool('ToolA');
+ expect(howToTool).toBeInstanceOf(HowToTool);
+ expect(howToTool.name).toBe('ToolA');
+ expect(howToTool.identifier).toBeUndefined();
+ expect(howToTool.description).toBeUndefined();
+ expect(howToTool.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToTool with some optional properties defined and some undefined', () => {
+ const howToTool = new HowToTool('ToolA', 'TA123');
+ expect(howToTool).toBeInstanceOf(HowToTool);
+ expect(howToTool.name).toBe('ToolA');
+ expect(howToTool.identifier).toBe('TA123');
+ expect(howToTool.description).toBeUndefined();
+ expect(howToTool.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToTool with all properties set', () => {
+ const howToTool = new HowToTool('ToolB', 'TB123', 'High-quality tool', {
+ value: 2,
+ unitText: 'pcs',
+ unitCode: undefined,
+ });
+
+ expect(howToTool).toBeInstanceOf(HowToTool);
+ expect(howToTool.name).toBe('ToolB');
+ expect(howToTool.identifier).toBe('TB123');
+ expect(howToTool.description).toBe('High-quality tool');
+ expect(howToTool.requiredQuantity).toEqual({
+ value: 2,
+ unitText: 'pcs',
+ unitCode: undefined,
+ });
+ });
+});
diff --git a/src/tests/unit/Models/schema/NutritionInformation.test.ts b/src/tests/unit/Models/schema/NutritionInformation.test.ts
new file mode 100644
index 000000000..bcf0f449c
--- /dev/null
+++ b/src/tests/unit/Models/schema/NutritionInformation.test.ts
@@ -0,0 +1,59 @@
+import NutritionInformation, {
+ NutritionInformationProperties,
+} from '../../../../js/Models/schema/NutritionInformation';
+
+describe('NutritionInformation', () => {
+ test('should set the @type property to "NutritionInformation"', () => {
+ const properties: NutritionInformationProperties = {};
+
+ const nutritionInfo = new NutritionInformation(properties);
+
+ expect(nutritionInfo['@type']).toBe('NutritionInformation');
+ });
+
+ test('should create an instance of NutritionInformation with specified properties', () => {
+ const properties: NutritionInformationProperties = {
+ calories: '100',
+ carbohydrateContent: '20',
+ proteinContent: '15',
+ servingSize: '1 cup',
+ sodiumContent: '200',
+ };
+
+ const nutritionInfo = new NutritionInformation(properties);
+
+ expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
+ expect(nutritionInfo.calories).toBe(properties.calories);
+ expect(nutritionInfo.carbohydrateContent).toBe(
+ properties.carbohydrateContent,
+ );
+ expect(nutritionInfo.cholesterolContent).toBeUndefined(); // Added test for cholesterolContent
+ expect(nutritionInfo.fatContent).toBeUndefined(); // Added test for fatContent
+ expect(nutritionInfo.fiberContent).toBeUndefined(); // Added test for fiberContent
+ expect(nutritionInfo.proteinContent).toBe(properties.proteinContent);
+ expect(nutritionInfo.saturatedFatContent).toBeUndefined(); // Added test for saturatedFatContent
+ expect(nutritionInfo.servingSize).toBe(properties.servingSize);
+ expect(nutritionInfo.sodiumContent).toBe(properties.sodiumContent);
+ expect(nutritionInfo.sugarContent).toBeUndefined(); // Added test for sugarContent
+ expect(nutritionInfo.transFatContent).toBeUndefined(); // Added test for transFatContent
+ expect(nutritionInfo.unsaturatedFatContent).toBeUndefined(); // Added test for unsaturatedFatContent
+ });
+
+ test('should create an instance of NutritionInformation with all properties set to undefined', () => {
+ const nutritionInfo = new NutritionInformation();
+
+ expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
+ expect(nutritionInfo.calories).toBeUndefined();
+ expect(nutritionInfo.carbohydrateContent).toBeUndefined();
+ expect(nutritionInfo.cholesterolContent).toBeUndefined();
+ expect(nutritionInfo.fatContent).toBeUndefined();
+ expect(nutritionInfo.fiberContent).toBeUndefined();
+ expect(nutritionInfo.proteinContent).toBeUndefined();
+ expect(nutritionInfo.saturatedFatContent).toBeUndefined();
+ expect(nutritionInfo.servingSize).toBeUndefined();
+ expect(nutritionInfo.sodiumContent).toBeUndefined();
+ expect(nutritionInfo.sugarContent).toBeUndefined();
+ expect(nutritionInfo.transFatContent).toBeUndefined();
+ expect(nutritionInfo.unsaturatedFatContent).toBeUndefined();
+ });
+});
diff --git a/src/tests/unit/Models/schema/QuantitativeValue.test.ts b/src/tests/unit/Models/schema/QuantitativeValue.test.ts
new file mode 100644
index 000000000..b9a719add
--- /dev/null
+++ b/src/tests/unit/Models/schema/QuantitativeValue.test.ts
@@ -0,0 +1,24 @@
+import QuantitativeValue from '../../../../js/Models/schema/QuantitativeValue';
+
+describe('QuantitativeValue', () => {
+ test('should set the @type property to "QuantitativeValue"', () => {
+ const quantitativeValue = new QuantitativeValue(15, 'meter');
+ expect(quantitativeValue['@type']).toBe('QuantitativeValue');
+ });
+
+ test('should create an instance of QuantitativeValue with unitCode undefined', () => {
+ const quantitativeValue = new QuantitativeValue(10, 'cup');
+ expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
+ expect(quantitativeValue.value).toBe(10);
+ expect(quantitativeValue.unitText).toBe('cup');
+ expect(quantitativeValue.unitCode).toBeUndefined();
+ });
+
+ test('should create an instance of QuantitativeValue with specified unitCode', () => {
+ const quantitativeValue = new QuantitativeValue(5, 'kilogram', 'KGM');
+ expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
+ expect(quantitativeValue.value).toBe(5);
+ expect(quantitativeValue.unitText).toBe('kilogram');
+ expect(quantitativeValue.unitCode).toBe('KGM');
+ });
+});
diff --git a/src/tests/unit/Models/schema/Recipe.test.ts b/src/tests/unit/Models/schema/Recipe.test.ts
new file mode 100644
index 000000000..61b785f66
--- /dev/null
+++ b/src/tests/unit/Models/schema/Recipe.test.ts
@@ -0,0 +1,145 @@
+import Recipe from '../../../../js/Models/schema/Recipe';
+import HowToDirection from '../../../../js/Models/schema/HowToDirection';
+// import HowToSection from '../../../../js/Models/schema/HowToSection';
+import HowToSupply from '../../../../js/Models/schema/HowToSupply';
+import HowToTool from '../../../../js/Models/schema/HowToTool';
+import NutritionInformation from '../../../../js/Models/schema/NutritionInformation';
+
+describe('Recipe', () => {
+ const recipeId = '123';
+ const recipeName = 'Test Recipe';
+
+ test('should create a Recipe instance with required properties', () => {
+ const recipe = new Recipe(recipeId, recipeName);
+
+ expect(recipe).toHaveProperty('@type', 'Recipe');
+ expect(recipe.identifier).toBe(recipeId);
+ expect(recipe.name).toBe(recipeName);
+ expect(recipe.image).toStrictEqual([]);
+ expect(recipe.imageUrl).toStrictEqual([]);
+ expect(recipe.keywords).toStrictEqual([]);
+ expect(recipe.recipeIngredient).toStrictEqual([]);
+ expect(recipe.supply).toStrictEqual([]);
+ expect(recipe.recipeInstructions).toStrictEqual([]);
+ expect(recipe.tool).toStrictEqual([]);
+ expect(recipe.url).toStrictEqual([]);
+ });
+
+ test('should set optional properties when provided in options', () => {
+ const options = {
+ recipeCategory: 'Dinner',
+ dateCreated: '2022-01-01',
+ dateModified: '2022-01-02',
+ description: 'A delicious recipe',
+ image: 'recipe-image.jpg',
+ imageUrl: 'recipe-thumbnail.jpg',
+ keywords: 'delicious, easy',
+ totalTime: 'PT1H',
+ cookTime: 'PT30M',
+ prepTime: 'PT30M',
+ nutrition: new NutritionInformation({
+ calories: '100',
+ carbohydrateContent: '20',
+ proteinContent: '15',
+ servingSize: '1 cup',
+ sodiumContent: '200',
+ }),
+ recipeIngredient: '1 cup flour',
+ recipeYield: 4,
+ supply: new HowToSupply('Flour', '1 cup'),
+ recipeInstructions: new HowToDirection('Mix the ingredients'),
+ tool: new HowToTool('Mixing Bowl'),
+ url: 'https://example.com/recipe',
+ };
+
+ const recipe = new Recipe(recipeId, recipeName, options);
+
+ expect(recipe.recipeCategory).toBe(options.recipeCategory);
+ expect(recipe.dateCreated).toBe(options.dateCreated);
+ expect(recipe.dateModified).toBe(options.dateModified);
+ expect(recipe.description).toBe(options.description);
+ expect(recipe.image).toEqual([options.image]);
+ expect(recipe.imageUrl).toEqual([options.imageUrl]);
+ expect(recipe.keywords).toEqual([options.keywords]);
+ expect(recipe.totalTime).toBe(options.totalTime);
+ expect(recipe.cookTime).toBe(options.cookTime);
+ expect(recipe.prepTime).toBe(options.prepTime);
+ expect(recipe.nutrition).toEqual(options.nutrition);
+ expect(recipe.recipeIngredient).toEqual([options.recipeIngredient]);
+ expect(recipe.recipeYield).toBe(options.recipeYield);
+ expect(recipe.supply).toEqual([options.supply]);
+ expect(recipe.recipeInstructions).toEqual([options.recipeInstructions]);
+ expect(recipe.tool).toEqual([options.tool]);
+ expect(recipe.url).toEqual([options.url]);
+ });
+
+ test('should handle undefined options', () => {
+ const recipe = new Recipe(recipeId, recipeName, undefined);
+
+ expect(recipe.recipeCategory).toBeUndefined();
+ expect(recipe.dateCreated).toBeUndefined();
+ expect(recipe.dateModified).toBeUndefined();
+ expect(recipe.description).toBeUndefined();
+ expect(recipe.image).toStrictEqual([]);
+ expect(recipe.imageUrl).toStrictEqual([]);
+ expect(recipe.keywords).toStrictEqual([]);
+ expect(recipe.totalTime).toBeUndefined();
+ expect(recipe.cookTime).toBeUndefined();
+ expect(recipe.prepTime).toBeUndefined();
+ expect(recipe.nutrition).toBeUndefined();
+ expect(recipe.recipeIngredient).toStrictEqual([]);
+ expect(recipe.recipeYield).toBeUndefined();
+ expect(recipe.supply).toStrictEqual([]);
+ expect(recipe.recipeInstructions).toStrictEqual([]);
+ expect(recipe.tool).toStrictEqual([]);
+ expect(recipe.url).toStrictEqual([]);
+ });
+
+ test('should handle options with undefined properties', () => {
+ const options = {
+ recipeCategory: undefined,
+ dateCreated: undefined,
+ dateModified: undefined,
+ description: undefined,
+ image: undefined,
+ imageUrl: undefined,
+ keywords: undefined,
+ totalTime: undefined,
+ cookTime: undefined,
+ prepTime: undefined,
+ nutrition: undefined,
+ recipeIngredient: undefined,
+ recipeYield: undefined,
+ supply: undefined,
+ recipeInstructions: undefined,
+ tool: undefined,
+ url: undefined,
+ };
+
+ const recipe = new Recipe(recipeId, recipeName, options);
+
+ expect(recipe.recipeCategory).toBeUndefined();
+ expect(recipe.dateCreated).toBeUndefined();
+ expect(recipe.dateModified).toBeUndefined();
+ expect(recipe.description).toBeUndefined();
+ expect(recipe.image).toStrictEqual([]);
+ expect(recipe.imageUrl).toStrictEqual([]);
+ expect(recipe.keywords).toStrictEqual([]);
+ expect(recipe.totalTime).toBeUndefined();
+ expect(recipe.cookTime).toBeUndefined();
+ expect(recipe.prepTime).toBeUndefined();
+ expect(recipe.nutrition).toBeUndefined();
+ expect(recipe.recipeIngredient).toStrictEqual([]);
+ expect(recipe.recipeYield).toBeUndefined();
+ expect(recipe.supply).toStrictEqual([]);
+ expect(recipe.recipeInstructions).toStrictEqual([]);
+ expect(recipe.tool).toStrictEqual([]);
+ expect(recipe.url).toStrictEqual([]);
+ });
+
+ test('should return same value for id and identifier', () => {
+ const recipe = new Recipe(recipeId, recipeName, undefined);
+
+ expect(recipe.identifier).toBe(recipe.id);
+ });
+});
From d0ec007f3ba9001f06bac674e50f896ce591f918 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sat, 20 Jan 2024 14:13:21 +0100
Subject: [PATCH 005/188] test: Add cookbook aliases to Jest config
Signed-off-by: Sebastian Fey
---
jest.config.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/jest.config.js b/jest.config.js
index f3ff8e0ef..e2b0ab37c 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,6 +2,10 @@
module.exports = {
testEnvironment: 'node',
moduleFileExtensions: ['js', 'ts', 'vue'],
+ moduleNameMapper: {
+ '^cookbook/(.*)$': '/src/$1',
+ '^icons/(.*)$': '/node_modules/vue-material-design-icons/$1',
+ },
modulePaths: ['/src/'],
modulePathIgnorePatterns: ['/.github/'],
transform: {
From c66380fe7467695e3f746cef01b645532d5cfaf8 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sat, 20 Jan 2024 14:38:52 +0100
Subject: [PATCH 006/188] feat: Add utility methods for mapping objects to
integer, string, or string/string[]
Signed-off-by: Sebastian Fey
---
src/js/utils/jsonMapper.ts | 128 +++++++++++++++++++++++++++++++++++++
1 file changed, 128 insertions(+)
create mode 100644 src/js/utils/jsonMapper.ts
diff --git a/src/js/utils/jsonMapper.ts b/src/js/utils/jsonMapper.ts
new file mode 100644
index 000000000..954c605cc
--- /dev/null
+++ b/src/js/utils/jsonMapper.ts
@@ -0,0 +1,128 @@
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
+
+/**
+ * Tries to map `value` to an integer.
+ * @param value The value to be mapped.
+ * @param targetName The name of the target property. Only used for error message.
+ * @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown.
+ * @throws JsonMappingException Thrown if `value` cannot be mapped to an integer number.
+ * @returns Either the value as an integer if mapping was successful or null/undefined if the value was null/undefined
+ * and allowNullOrUndefined is true.
+ */
+export function mapInteger(
+ value: unknown,
+ targetName: string = '',
+ allowNullOrUndefined: boolean = false,
+): number | null | undefined {
+ if (value === undefined || value === null) {
+ // Return null or undefined immediately
+ if (allowNullOrUndefined) return value;
+ // Throw
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected integer number but received "${value}".`,
+ );
+ }
+
+ // Only numbers and strings can be mapped to an integer. Early return.
+ if (typeof value !== 'number' && typeof value !== 'string') {
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected integer number but received "${typeof value}".`,
+ );
+ }
+
+ // `value` is a number, but is it an integer?
+ if (typeof value === 'number') {
+ if (Number.isInteger(value)) {
+ return value;
+ }
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected integer number but received non-integer "${value}".`,
+ );
+ }
+
+ // `value` is a string, can it be parsed to an integer?
+
+ const parsedValue: number = parseInt(value, 10);
+ if (Number.isNaN(parsedValue)) {
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected integer number but received non-parsable string "${value}".`,
+ );
+ }
+ return parsedValue;
+}
+
+/**
+ * Tries to map `value` to a string or an array of strings.
+ * @param value The value to be mapped.
+ * @param targetName The name of the target property. Only used for error message.
+ * @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown.
+ * @throws JsonMappingException Thrown if `value` cannot be mapped to a string or an array of strings.
+ * @returns Either the value as a string or an array of strings if mapping was successful or null/undefined if the
+ * value was null/undefined.
+ */
+export function mapStringOrStringArray(
+ value: unknown,
+ targetName: string = '',
+ allowNullOrUndefined: boolean = false,
+): string | string[] | null | undefined {
+ if (value === undefined || value === null) {
+ // Return null or undefined immediately
+ if (allowNullOrUndefined) return value;
+ // Throw
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected string or string array but received "${value}".`,
+ );
+ }
+
+ // Only strings and string arrays can be mapped. Early return.
+ if (typeof value !== 'string' && !Array.isArray(value)) {
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected string or array but received "${typeof value}".`,
+ );
+ }
+
+ // `value` is an array but is it an array of strings?
+ if (Array.isArray(value)) {
+ if (value.every((i) => typeof i === 'string')) return value;
+
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected string or string array received array with non-string elements.`,
+ );
+ }
+
+ // `value` is a string, return.
+ return value;
+}
+
+/**
+ * Tries to map `value` to a string.
+ * @param value The value to be mapped.
+ * @param targetName The name of the target property. Only used for error message.
+ * @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown.
+ * @throws JsonMappingException Thrown if `value` cannot be mapped to a string.
+ * @returns Either the value as a string if mapping was successful or null/undefined if the value was null/undefined.
+ */
+export function mapString(
+ value: unknown,
+ targetName: string = '',
+ allowNullOrUndefined: boolean = false,
+): string | null | undefined {
+ if (value === undefined || value === null) {
+ // Return null or undefined immediately
+ if (allowNullOrUndefined) return value;
+ // Throw
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected string but received "${value}".`,
+ );
+ }
+
+ // Only strings can be mapped. Early return.
+ if (typeof value !== 'string') {
+ throw new JsonMappingException(
+ `Error mapping ${targetName}. Expected string but received "${typeof value}".`,
+ );
+ }
+
+ // `value` is a string, return.
+ return value;
+}
From 26a427afea36a803a41904817702590fca115c9c Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sat, 20 Jan 2024 14:39:14 +0100
Subject: [PATCH 007/188] test: Add tests for object-mapping utility methods
Signed-off-by: Sebastian Fey
---
src/tests/unit/utils/jsonMapper.test.ts | 159 ++++++++++++++++++++++++
1 file changed, 159 insertions(+)
create mode 100644 src/tests/unit/utils/jsonMapper.test.ts
diff --git a/src/tests/unit/utils/jsonMapper.test.ts b/src/tests/unit/utils/jsonMapper.test.ts
new file mode 100644
index 000000000..5ef79d3ad
--- /dev/null
+++ b/src/tests/unit/utils/jsonMapper.test.ts
@@ -0,0 +1,159 @@
+import {
+ mapInteger,
+ mapString,
+ mapStringOrStringArray,
+} from 'cookbook/js/utils/jsonMapper';
+
+// mapInteger tests
+describe('mapInteger', () => {
+ it('should throw for null value', () => {
+ expect(() => mapInteger(null, 'property')).toThrow(
+ 'Error mapping property. Expected integer number but received "null".',
+ );
+ });
+
+ it('should return null for null value if null and undefined are allowed', () => {
+ const result = mapInteger(null, '', true);
+ expect(result).toBeNull();
+ });
+
+ it('should throw for undefined value', () => {
+ expect(() => mapInteger(undefined, 'property', false)).toThrow(
+ 'Error mapping property. Expected integer number but received "undefined".',
+ );
+ });
+
+ it('should return undefined for undefined value if null and undefined are allowed', () => {
+ const result = mapInteger(undefined, '', true);
+ expect(result).toBeUndefined();
+ });
+
+ it('should map integer number to itself', () => {
+ const result = mapInteger(42);
+ expect(result).toBe(42);
+ });
+
+ it('should map numeric string to an integer', () => {
+ const result = mapInteger('123');
+ expect(result).toBe(123);
+ });
+
+ it('should throw an error for non-integer number', () => {
+ expect(() => mapInteger(42.5, 'property')).toThrow(
+ 'Error mapping property. Expected integer number but received non-integer "42.5".',
+ );
+ });
+
+ it('should throw an error for non-numeric string', () => {
+ expect(() => mapInteger('abc', 'property')).toThrow(
+ 'Error mapping property. Expected integer number but received non-parsable string "abc".',
+ );
+ });
+
+ it('should throw an error for non-numeric and non-string value', () => {
+ const invalidValue = { prop: 'invalid' };
+ expect(() => mapInteger(invalidValue, 'property')).toThrow(
+ 'Error mapping property. Expected integer number but received "object".',
+ );
+ });
+});
+
+// mapStringOrStringArray() tests
+describe('mapStringOrStringArray', () => {
+ it('should throw for null value', () => {
+ expect(() => mapStringOrStringArray(null, 'property')).toThrow(
+ 'Error mapping property. Expected string or string array but received "null".',
+ );
+ });
+
+ it('should return null for null value if null and undefined are allowed', () => {
+ const result = mapStringOrStringArray(null, '', true);
+ expect(result).toBeNull();
+ });
+
+ it('should throw for undefined value', () => {
+ expect(() =>
+ mapStringOrStringArray(undefined, 'property', false),
+ ).toThrow(
+ 'Error mapping property. Expected string or string array but received "undefined".',
+ );
+ });
+
+ it('should return undefined for undefined value if null and undefined are allowed', () => {
+ const result = mapStringOrStringArray(undefined, '', true);
+ expect(result).toBeUndefined();
+ });
+
+ it('should map string to itself', () => {
+ const result = mapStringOrStringArray('test');
+ expect(result).toBe('test');
+ });
+
+ it('should map array of strings to itself', () => {
+ const result = mapStringOrStringArray(['test1', 'test2']);
+ expect(result).toEqual(['test1', 'test2']);
+ });
+
+ it('should throw an error for non-string and non-array value', () => {
+ const invalidValue = 42;
+ const invalidValue2 = { prop: '42' };
+
+ expect(() => mapStringOrStringArray(invalidValue, 'property')).toThrow(
+ 'Error mapping property. Expected string or array but received "number".',
+ );
+
+ expect(() => mapStringOrStringArray(invalidValue2, 'property')).toThrow(
+ 'Error mapping property. Expected string or array but received "object".',
+ );
+ });
+
+ it('should throw an error for array with non-string elements', () => {
+ const invalidValue = ['test', 42];
+ expect(() => mapStringOrStringArray(invalidValue, 'property')).toThrow(
+ 'Error mapping property. Expected string or string array received array with non-string elements.',
+ );
+ });
+});
+
+// mapString tests
+describe('mapString', () => {
+ it('should throw for null value', () => {
+ expect(() => mapString(null, 'property')).toThrow(
+ 'Error mapping property. Expected string but received "null".',
+ );
+ });
+
+ it('should return null for null value if null and undefined are allowed', () => {
+ const result = mapString(null, '', true);
+ expect(result).toBeNull();
+ });
+
+ it('should throw for undefined value', () => {
+ expect(() => mapString(undefined, 'property')).toThrow(
+ 'Error mapping property. Expected string but received "undefined".',
+ );
+ });
+
+ it('should return undefined for undefined value if null and undefined are allowed', () => {
+ const result = mapString(undefined, '', true);
+ expect(result).toBeUndefined();
+ });
+
+ it('should map string to itself', () => {
+ const result = mapString('test');
+ expect(result).toBe('test');
+ });
+
+ it('should throw an error for non-string value', () => {
+ const invalidValueNumber = 42;
+ const invalidValueObject = { value: '42' };
+
+ expect(() => mapString(invalidValueNumber, 'property')).toThrow(
+ 'Error mapping property. Expected string but received "number".',
+ );
+
+ expect(() => mapString(invalidValueObject, 'property')).toThrow(
+ 'Error mapping property. Expected string but received "object".',
+ );
+ });
+});
From d15f3b38ed4a86c2a7e313987f0bfe1c5c657fc7 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:41:47 +0100
Subject: [PATCH 008/188] feat: Add `fromJSON` method mapping JSON to a
NutritionInformation object.
Signed-off-by: Sebastian Fey
---
src/js/Exceptions/JsonMappingException.ts | 4 ++
src/js/Models/schema/NutritionInformation.ts | 60 ++++++++++++++++++++
2 files changed, 64 insertions(+)
create mode 100644 src/js/Exceptions/JsonMappingException.ts
diff --git a/src/js/Exceptions/JsonMappingException.ts b/src/js/Exceptions/JsonMappingException.ts
new file mode 100644
index 000000000..8b9c3fd73
--- /dev/null
+++ b/src/js/Exceptions/JsonMappingException.ts
@@ -0,0 +1,4 @@
+/**
+ * Thrown when an error is encountered while mapping a JSON string to an object.
+ */
+export default class JsonMappingException extends Error {}
diff --git a/src/js/Models/schema/NutritionInformation.ts b/src/js/Models/schema/NutritionInformation.ts
index 6e521ea95..c8715033e 100644
--- a/src/js/Models/schema/NutritionInformation.ts
+++ b/src/js/Models/schema/NutritionInformation.ts
@@ -1,3 +1,5 @@
+import JsonMappingException from '../../Exceptions/JsonMappingException';
+
/**
* Interface representing the properties of the NutritionInformation class.
* @interface
@@ -92,4 +94,62 @@ export default class NutritionInformation {
// Set the properties from the provided object, or default to undefined
Object.assign(this, properties);
}
+
+ /**
+ * Create a `NutritionInformation` instance from a JSON string.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {NutritionInformation} - The created NutritionInformation instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): NutritionInformation {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "NutritionInformation". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const validateStringProperty = (propertyName: string) => {
+ if (
+ jsonObj[propertyName] !== undefined &&
+ jsonObj[propertyName] !== null &&
+ typeof jsonObj[propertyName] !== 'string'
+ ) {
+ throw new JsonMappingException(
+ `Invalid property value: "${propertyName}" must be a string`,
+ );
+ }
+ };
+
+ validateStringProperty('calories');
+ validateStringProperty('carbohydrateContent');
+ validateStringProperty('cholesterolContent');
+ validateStringProperty('fatContent');
+ validateStringProperty('fiberContent');
+ validateStringProperty('proteinContent');
+ validateStringProperty('saturatedFatContent');
+ validateStringProperty('servingSize');
+ validateStringProperty('sodiumContent');
+ validateStringProperty('sugarContent');
+ validateStringProperty('transFatContent');
+ validateStringProperty('unsaturatedFatContent');
+
+ return new NutritionInformation({
+ calories: jsonObj.calories,
+ carbohydrateContent: jsonObj.carbohydrateContent,
+ cholesterolContent: jsonObj.cholesterolContent,
+ fatContent: jsonObj.fatContent,
+ fiberContent: jsonObj.fiberContent,
+ proteinContent: jsonObj.proteinContent,
+ saturatedFatContent: jsonObj.saturatedFatContent,
+ servingSize: jsonObj.servingSize,
+ sodiumContent: jsonObj.sodiumContent,
+ sugarContent: jsonObj.sugarContent,
+ transFatContent: jsonObj.transFatContent,
+ unsaturatedFatContent: jsonObj.unsaturatedFatContent,
+ });
+ }
}
From f46fe6b7cc95f75244d50469c62f2994c2f73f8d Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:43:09 +0100
Subject: [PATCH 009/188] feat: Add `fromJSON` method mapping JSON to a
`QuantitativeValue` object.
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/QuantitativeValue.ts | 39 +++++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/src/js/Models/schema/QuantitativeValue.ts b/src/js/Models/schema/QuantitativeValue.ts
index 69fdbfae2..d45088ae8 100644
--- a/src/js/Models/schema/QuantitativeValue.ts
+++ b/src/js/Models/schema/QuantitativeValue.ts
@@ -1,3 +1,6 @@
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
+import { mapInteger, mapString } from 'cookbook/js/utils/jsonMapper';
+
/**
* Represents a quantitative value with unit information.
* @class
@@ -40,4 +43,40 @@ export default class QuantitativeValue {
// eslint-disable-next-line prefer-destructuring
if (args[0]) this.unitCode = args[0];
}
+
+ /**
+ * Create a `QuantitativeValue` instance from a JSON string.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {QuantitativeValue} - The created QuantitativeValue instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): QuantitativeValue {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "QuantitativeValue". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const value = mapInteger(
+ jsonObj.value,
+ "QuantitativeValue 'value'",
+ ) as NonNullable;
+
+ const unitText = mapString(
+ jsonObj.unitText,
+ "QuantitativeValue 'value'",
+ ) as NonNullable;
+
+ const unitCode = mapString(
+ jsonObj.unitCode,
+ "QuantitativeValue 'value'",
+ true,
+ );
+
+ return new QuantitativeValue(value, unitText, unitCode || undefined);
+ }
}
From 4dbe1c0db5a77ca171086b65975a08c7994ef1b6 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:44:12 +0100
Subject: [PATCH 010/188] feat: Add `fromJSON` method mapping JSON to a
`HowToSupply` and `HowToTool` object.
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/HowToSupply.ts | 48 +++++++++++++++++++++++++++++
src/js/Models/schema/HowToTool.ts | 48 +++++++++++++++++++++++++++++
2 files changed, 96 insertions(+)
diff --git a/src/js/Models/schema/HowToSupply.ts b/src/js/Models/schema/HowToSupply.ts
index b40b6997d..2fcc9eeb5 100644
--- a/src/js/Models/schema/HowToSupply.ts
+++ b/src/js/Models/schema/HowToSupply.ts
@@ -1,3 +1,5 @@
+import { mapString } from 'cookbook/js/utils/jsonMapper';
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
import QuantitativeValue from './QuantitativeValue';
/**
@@ -55,4 +57,50 @@ export default class HowToSupply {
// eslint-disable-next-line prefer-destructuring
if (args[2]) this.requiredQuantity = args[2];
}
+
+ /**
+ * Create a `HowToSupply` instance from a JSON string.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {HowToSupply} - The created HowToSupply instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): HowToSupply {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "HowToSupply". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const name = mapString(
+ jsonObj.name,
+ "HowToSupply 'name'",
+ ) as NonNullable;
+
+ const identifier = mapString(
+ jsonObj.identifier,
+ "HowToSupply 'identifier'",
+ true,
+ );
+
+ const description = mapString(
+ jsonObj.description,
+ "HowToSupply 'description'",
+ true,
+ );
+
+ const requiredQuantity = jsonObj.requiredQuantity
+ ? QuantitativeValue.fromJSON(jsonObj.requiredQuantity)
+ : undefined;
+
+ return new HowToSupply(
+ name,
+ identifier || undefined,
+ description || undefined,
+ requiredQuantity,
+ );
+ }
}
diff --git a/src/js/Models/schema/HowToTool.ts b/src/js/Models/schema/HowToTool.ts
index 39d50646f..78529331e 100644
--- a/src/js/Models/schema/HowToTool.ts
+++ b/src/js/Models/schema/HowToTool.ts
@@ -1,4 +1,6 @@
+import { mapString } from 'cookbook/js/utils/jsonMapper';
import QuantitativeValue from './QuantitativeValue';
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
/**
* Represents a tool used in the recipe instructions.
@@ -55,4 +57,50 @@ export default class HowToTool {
// eslint-disable-next-line prefer-destructuring
if (args[2]) this.requiredQuantity = args[2];
}
+
+ /**
+ * Create a `HowToTool` instance from a JSON string.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {HowToTool} - The created HowToTool instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): HowToTool {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "HowToTool". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const name = mapString(
+ jsonObj.name,
+ "HowToTool 'name'",
+ ) as NonNullable;
+
+ const identifier = mapString(
+ jsonObj.identifier,
+ "HowToTool 'identifier'",
+ true,
+ );
+
+ const description = mapString(
+ jsonObj.description,
+ "HowToTool 'description'",
+ true,
+ );
+
+ const requiredQuantity = jsonObj.requiredQuantity
+ ? QuantitativeValue.fromJSON(jsonObj.requiredQuantity)
+ : undefined;
+
+ return new HowToTool(
+ name,
+ identifier || undefined,
+ description || undefined,
+ requiredQuantity,
+ );
+ }
}
From 8d6095ab28b84119a31526fcfe72135044d3fe76 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:45:15 +0100
Subject: [PATCH 011/188] feat: Add `fromJSON` method mapping JSON to a
`HowToSection` and `HowToDirection` object.
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/HowToDirection.ts | 82 ++++++++++++++++++++++++++
src/js/Models/schema/HowToSection.ts | 69 ++++++++++++++++++++++
2 files changed, 151 insertions(+)
diff --git a/src/js/Models/schema/HowToDirection.ts b/src/js/Models/schema/HowToDirection.ts
index 96bd2f459..3253e9b3c 100644
--- a/src/js/Models/schema/HowToDirection.ts
+++ b/src/js/Models/schema/HowToDirection.ts
@@ -1,3 +1,9 @@
+import {
+ mapInteger,
+ mapString,
+ mapStringOrStringArray,
+} from 'cookbook/js/utils/jsonMapper';
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
import HowToSupply from './HowToSupply';
import HowToTool from './HowToTool';
import { asCleanedArray } from '../../helper';
@@ -68,4 +74,80 @@ export default class HowToDirection {
this.supply = asCleanedArray(options.supply);
this.tool = asCleanedArray(options.tool);
}
+
+ /**
+ * Create a `HowToDirection` instance from a JSON string or object.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {HowToDirection} - The created HowToDirection instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): HowToDirection {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "HowToDirection". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const text = mapString(
+ jsonObj.text,
+ "HowToDirection 'text'",
+ ) as NonNullable;
+
+ const position = mapInteger(
+ jsonObj.position,
+ "HowToDirection 'position'",
+ true,
+ );
+
+ const image = mapStringOrStringArray(
+ jsonObj.image,
+ "HowToDirection 'image'",
+ true,
+ );
+
+ const thumbnailUrl = mapStringOrStringArray(
+ jsonObj.thumbnailUrl,
+ "HowToDirection 'thumbnailUrl'",
+ true,
+ );
+
+ const timeRequired = mapString(
+ jsonObj.timeRequired,
+ "HowToDirection 'timeRequired'",
+ true,
+ );
+
+ // supply
+ let supply: HowToSupply | HowToSupply[] = [];
+ if (jsonObj.supply) {
+ if (Array.isArray(jsonObj.supply)) {
+ supply = jsonObj.supply.map((s) => HowToSupply.fromJSON(s));
+ } else {
+ supply = HowToSupply.fromJSON(jsonObj.supply);
+ }
+ }
+
+ // tool
+ let tool: HowToTool | HowToTool[] = [];
+ if (jsonObj.tool) {
+ if (Array.isArray(jsonObj.tool)) {
+ tool = jsonObj.tool.map((t) => HowToTool.fromJSON(t));
+ } else {
+ tool = HowToTool.fromJSON(jsonObj.tool);
+ }
+ }
+
+ return new HowToDirection(text, {
+ position: position || undefined,
+ image: image || [],
+ thumbnailUrl: thumbnailUrl || [],
+ timeRequired: timeRequired || undefined,
+ supply,
+ tool,
+ });
+ }
}
diff --git a/src/js/Models/schema/HowToSection.ts b/src/js/Models/schema/HowToSection.ts
index c8fdf76cf..4e28c9e1e 100644
--- a/src/js/Models/schema/HowToSection.ts
+++ b/src/js/Models/schema/HowToSection.ts
@@ -1,3 +1,9 @@
+import {
+ mapInteger,
+ mapString,
+ mapStringOrStringArray,
+} from 'cookbook/js/utils/jsonMapper';
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
import HowToDirection from './HowToDirection';
import { asArray, asCleanedArray } from '../../helper';
@@ -60,4 +66,67 @@ export default class HowToSection {
this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
this.itemListElement = asCleanedArray(options.itemListElement);
}
+
+ /**
+ * Create a `HowToSection` instance from a JSON string or object.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {HowToSection} - The created HowToSection instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): HowToSection {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "HowToSection". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const name = mapString(
+ jsonObj.name,
+ "HowToSection 'name'",
+ ) as NonNullable;
+
+ const description = mapString(
+ jsonObj.description,
+ "HowToSection 'description'",
+ true,
+ );
+
+ const position = mapInteger(
+ jsonObj.position,
+ "HowToSection 'position'",
+ true,
+ );
+
+ const image = mapStringOrStringArray(
+ jsonObj.image,
+ "HowToSection 'image'",
+ true,
+ );
+
+ const thumbnailUrl = mapStringOrStringArray(
+ jsonObj.thumbnailUrl,
+ "HowToSection 'thumbnailUrl'",
+ true,
+ );
+
+ // itemListElement
+ let itemListElement: HowToDirection[] = [];
+ if (jsonObj.itemListElement) {
+ itemListElement = asArray(jsonObj.itemListElement).map((item) =>
+ HowToDirection.fromJSON(item),
+ );
+ }
+
+ return new HowToSection(name, {
+ description: description || undefined,
+ position: position || undefined,
+ image: image || [],
+ thumbnailUrl: thumbnailUrl || [],
+ itemListElement: itemListElement || [],
+ });
+ }
}
From 94300a69ad291d37d61d5aad726dabda42837aee Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:46:27 +0100
Subject: [PATCH 012/188] feat: Add `fromJSON` method mapping JSON to a
`Recipe` and object.
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/Recipe.ts | 128 +++++++++++++++++++++++++++++++++
1 file changed, 128 insertions(+)
diff --git a/src/js/Models/schema/Recipe.ts b/src/js/Models/schema/Recipe.ts
index c470dd9d5..fde5322f3 100644
--- a/src/js/Models/schema/Recipe.ts
+++ b/src/js/Models/schema/Recipe.ts
@@ -1,3 +1,9 @@
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
+import {
+ mapInteger,
+ mapString,
+ mapStringOrStringArray,
+} from 'cookbook/js/utils/jsonMapper';
import HowToDirection from './HowToDirection';
import HowToSection from './HowToSection';
import HowToSupply from './HowToSupply';
@@ -146,4 +152,126 @@ export default class Recipe {
get id(): string {
return this.identifier;
}
+
+ /**
+ * Create a `Recipe` instance from a JSON string or object.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {Recipe} - The created Recipe instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): Recipe {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "Recipe". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ // Required
+ const identifier = mapString(
+ jsonObj.identifier,
+ "Recipe 'identifier'",
+ ) as NonNullable;
+ const name = mapString(
+ jsonObj.name,
+ "Recipe 'name'",
+ ) as NonNullable;
+
+ // Optional
+ const recipeCategory = mapString(
+ jsonObj.recipeCategory,
+ "Recipe 'recipeCategory'",
+ true,
+ );
+ const description = mapString(
+ jsonObj.description,
+ "Recipe 'description'",
+ true,
+ );
+ const dateCreated = mapString(
+ jsonObj.dateCreated,
+ "Recipe 'dateCreated'",
+ true,
+ );
+ const dateModified = mapString(
+ jsonObj.dateModified,
+ "Recipe 'dateModified'",
+ true,
+ );
+ const image = mapStringOrStringArray(
+ jsonObj.image,
+ "Recipe 'image'",
+ true,
+ );
+ const imageUrl = mapStringOrStringArray(
+ jsonObj.imageUrl,
+ "Recipe 'imageUrl'",
+ true,
+ );
+ const keywords = mapStringOrStringArray(
+ jsonObj.keywords,
+ "Recipe 'keywords'",
+ true,
+ );
+ const cookTime = mapString(jsonObj.cookTime, "Recipe 'cookTime'", true);
+ const prepTime = mapString(jsonObj.prepTime, "Recipe 'prepTime'", true);
+ const totalTime = mapString(
+ jsonObj.totalTime,
+ "Recipe 'totalTime'",
+ true,
+ );
+ const nutrition = jsonObj.nutrition
+ ? NutritionInformation.fromJSON(jsonObj.nutrition)
+ : undefined;
+ const recipeIngredient = mapStringOrStringArray(
+ jsonObj.recipeIngredient,
+ "Recipe 'recipeIngredient'",
+ true,
+ );
+ const recipeYield = mapInteger(
+ jsonObj.recipeYield,
+ "Recipe 'recipeYield'",
+ true,
+ );
+ const supply = jsonObj.supply
+ ? asArray(jsonObj.supply).map((s) => HowToSupply.fromJSON(s))
+ : [];
+ const recipeInstructions = jsonObj.recipeInstructions
+ ? asArray(jsonObj.recipeInstructions).map((i) => {
+ if (i['@type'] === 'HowToSection') {
+ return HowToSection.fromJSON(i);
+ } else {
+ return HowToDirection.fromJSON(i);
+ }
+ })
+ : [];
+ const tool = jsonObj.tool
+ ? asArray(jsonObj.tool).map((t) => HowToTool.fromJSON(t))
+ : [];
+ const url = mapStringOrStringArray(jsonObj.url, "Recipe 'url'", true);
+
+ // Create and return the Recipe instance
+ return new Recipe(identifier, name, {
+ recipeCategory: recipeCategory || undefined,
+ description: description || undefined,
+ dateCreated: dateCreated || undefined,
+ dateModified: dateModified || undefined,
+ image: image || undefined,
+ imageUrl: imageUrl || undefined,
+ keywords: keywords || undefined,
+ cookTime: cookTime || undefined,
+ prepTime: prepTime || undefined,
+ totalTime: totalTime || undefined,
+ nutrition,
+ recipeIngredient: recipeIngredient || [],
+ recipeYield: recipeYield || undefined,
+ supply,
+ recipeInstructions,
+ tool,
+ url: url || [],
+ });
+ }
}
From 5710085792be5c1548c097374838393b6d2946ad Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:48:13 +0100
Subject: [PATCH 013/188] test(js): Add tests for `fromJSON` method of
`NutritionInformation` and `QuantitativeValue`.
Signed-off-by: Sebastian Fey
---
.../schema/NutritionInformation.test.ts | 173 +++++++++++++-----
.../Models/schema/QuantitativeValue.test.ts | 97 ++++++++--
2 files changed, 207 insertions(+), 63 deletions(-)
diff --git a/src/tests/unit/Models/schema/NutritionInformation.test.ts b/src/tests/unit/Models/schema/NutritionInformation.test.ts
index bcf0f449c..582196f30 100644
--- a/src/tests/unit/Models/schema/NutritionInformation.test.ts
+++ b/src/tests/unit/Models/schema/NutritionInformation.test.ts
@@ -3,57 +3,136 @@ import NutritionInformation, {
} from '../../../../js/Models/schema/NutritionInformation';
describe('NutritionInformation', () => {
- test('should set the @type property to "NutritionInformation"', () => {
- const properties: NutritionInformationProperties = {};
+ describe('constructor', () => {
+ test('should set the @type property to "NutritionInformation"', () => {
+ const properties: NutritionInformationProperties = {};
- const nutritionInfo = new NutritionInformation(properties);
+ const nutritionInfo = new NutritionInformation(properties);
- expect(nutritionInfo['@type']).toBe('NutritionInformation');
- });
+ expect(nutritionInfo['@type']).toBe('NutritionInformation');
+ });
+
+ test('should create an instance of NutritionInformation with specified properties', () => {
+ const properties: NutritionInformationProperties = {
+ calories: '100',
+ carbohydrateContent: '20',
+ proteinContent: '15',
+ servingSize: '1 cup',
+ sodiumContent: '200',
+ };
+
+ const nutritionInfo = new NutritionInformation(properties);
+
+ expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
+ expect(nutritionInfo.calories).toBe(properties.calories);
+ expect(nutritionInfo.carbohydrateContent).toBe(
+ properties.carbohydrateContent,
+ );
+ expect(nutritionInfo.cholesterolContent).toBeUndefined(); // Added test for cholesterolContent
+ expect(nutritionInfo.fatContent).toBeUndefined(); // Added test for fatContent
+ expect(nutritionInfo.fiberContent).toBeUndefined(); // Added test for fiberContent
+ expect(nutritionInfo.proteinContent).toBe(
+ properties.proteinContent,
+ );
+ expect(nutritionInfo.saturatedFatContent).toBeUndefined(); // Added test for saturatedFatContent
+ expect(nutritionInfo.servingSize).toBe(properties.servingSize);
+ expect(nutritionInfo.sodiumContent).toBe(properties.sodiumContent);
+ expect(nutritionInfo.sugarContent).toBeUndefined(); // Added test for sugarContent
+ expect(nutritionInfo.transFatContent).toBeUndefined(); // Added test for transFatContent
+ expect(nutritionInfo.unsaturatedFatContent).toBeUndefined(); // Added test for unsaturatedFatContent
+ });
- test('should create an instance of NutritionInformation with specified properties', () => {
- const properties: NutritionInformationProperties = {
- calories: '100',
- carbohydrateContent: '20',
- proteinContent: '15',
- servingSize: '1 cup',
- sodiumContent: '200',
- };
-
- const nutritionInfo = new NutritionInformation(properties);
-
- expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
- expect(nutritionInfo.calories).toBe(properties.calories);
- expect(nutritionInfo.carbohydrateContent).toBe(
- properties.carbohydrateContent,
- );
- expect(nutritionInfo.cholesterolContent).toBeUndefined(); // Added test for cholesterolContent
- expect(nutritionInfo.fatContent).toBeUndefined(); // Added test for fatContent
- expect(nutritionInfo.fiberContent).toBeUndefined(); // Added test for fiberContent
- expect(nutritionInfo.proteinContent).toBe(properties.proteinContent);
- expect(nutritionInfo.saturatedFatContent).toBeUndefined(); // Added test for saturatedFatContent
- expect(nutritionInfo.servingSize).toBe(properties.servingSize);
- expect(nutritionInfo.sodiumContent).toBe(properties.sodiumContent);
- expect(nutritionInfo.sugarContent).toBeUndefined(); // Added test for sugarContent
- expect(nutritionInfo.transFatContent).toBeUndefined(); // Added test for transFatContent
- expect(nutritionInfo.unsaturatedFatContent).toBeUndefined(); // Added test for unsaturatedFatContent
+ test('should create an instance of NutritionInformation with all properties set to undefined', () => {
+ const nutritionInfo = new NutritionInformation();
+
+ expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
+ expect(nutritionInfo.calories).toBeUndefined();
+ expect(nutritionInfo.carbohydrateContent).toBeUndefined();
+ expect(nutritionInfo.cholesterolContent).toBeUndefined();
+ expect(nutritionInfo.fatContent).toBeUndefined();
+ expect(nutritionInfo.fiberContent).toBeUndefined();
+ expect(nutritionInfo.proteinContent).toBeUndefined();
+ expect(nutritionInfo.saturatedFatContent).toBeUndefined();
+ expect(nutritionInfo.servingSize).toBeUndefined();
+ expect(nutritionInfo.sodiumContent).toBeUndefined();
+ expect(nutritionInfo.sugarContent).toBeUndefined();
+ expect(nutritionInfo.transFatContent).toBeUndefined();
+ expect(nutritionInfo.unsaturatedFatContent).toBeUndefined();
+ });
});
- test('should create an instance of NutritionInformation with all properties set to undefined', () => {
- const nutritionInfo = new NutritionInformation();
-
- expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
- expect(nutritionInfo.calories).toBeUndefined();
- expect(nutritionInfo.carbohydrateContent).toBeUndefined();
- expect(nutritionInfo.cholesterolContent).toBeUndefined();
- expect(nutritionInfo.fatContent).toBeUndefined();
- expect(nutritionInfo.fiberContent).toBeUndefined();
- expect(nutritionInfo.proteinContent).toBeUndefined();
- expect(nutritionInfo.saturatedFatContent).toBeUndefined();
- expect(nutritionInfo.servingSize).toBeUndefined();
- expect(nutritionInfo.sodiumContent).toBeUndefined();
- expect(nutritionInfo.sugarContent).toBeUndefined();
- expect(nutritionInfo.transFatContent).toBeUndefined();
- expect(nutritionInfo.unsaturatedFatContent).toBeUndefined();
+ describe('fromJSON', () => {
+ it('should create a NutritionInformation instance from valid JSON', () => {
+ const validJSON =
+ '{"calories": "100", "carbohydrateContent": "20g", "cholesterolContent": "10mg", "fatContent": "5g", "fiberContent": "3g", "proteinContent": "8g", "saturatedFatContent": "2g", "servingSize": "1 cup", "sodiumContent": "300mg", "sugarContent": "5g", "transFatContent": "0g", "unsaturatedFatContent": "3g"}';
+
+ const nutritionInfo = NutritionInformation.fromJSON(validJSON);
+
+ expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
+ expect(nutritionInfo.calories).toEqual('100');
+ expect(nutritionInfo.carbohydrateContent).toEqual('20g');
+ expect(nutritionInfo.cholesterolContent).toEqual('10mg');
+ expect(nutritionInfo.fatContent).toEqual('5g');
+ expect(nutritionInfo.fiberContent).toEqual('3g');
+ expect(nutritionInfo.proteinContent).toEqual('8g');
+ expect(nutritionInfo.saturatedFatContent).toEqual('2g');
+ expect(nutritionInfo.servingSize).toEqual('1 cup');
+ expect(nutritionInfo.sodiumContent).toEqual('300mg');
+ expect(nutritionInfo.sugarContent).toEqual('5g');
+ expect(nutritionInfo.transFatContent).toEqual('0g');
+ expect(nutritionInfo.unsaturatedFatContent).toEqual('3g');
+ });
+
+ it('should create a NutritionInformation instance with missing properties from JSON', () => {
+ const validJSON =
+ '{"calories": "100", "fatContent": "5g", "proteinContent": "8g", "saturatedFatContent": "2g"}';
+
+ const nutritionInfo = NutritionInformation.fromJSON(validJSON);
+
+ expect(nutritionInfo).toBeInstanceOf(NutritionInformation);
+ expect(nutritionInfo.calories).toEqual('100');
+ expect(nutritionInfo.fatContent).toEqual('5g');
+ expect(nutritionInfo.proteinContent).toEqual('8g');
+ expect(nutritionInfo.saturatedFatContent).toEqual('2g');
+ // Other properties should be undefined
+ expect(nutritionInfo.carbohydrateContent).toBeUndefined();
+ expect(nutritionInfo.cholesterolContent).toBeUndefined();
+ expect(nutritionInfo.fiberContent).toBeUndefined();
+ expect(nutritionInfo.servingSize).toBeUndefined();
+ expect(nutritionInfo.sodiumContent).toBeUndefined();
+ expect(nutritionInfo.sugarContent).toBeUndefined();
+ expect(nutritionInfo.transFatContent).toBeUndefined();
+ expect(nutritionInfo.unsaturatedFatContent).toBeUndefined();
+ });
+
+ it('should throw an error for invalid JSON with non-string (number) property values', () => {
+ const invalidJSON =
+ '{"calories": 100, "fatContent": "5g", "proteinContent": "8g", "saturatedFatContent": "2g"}';
+
+ expect(() =>
+ NutritionInformation.fromJSON(invalidJSON),
+ ).toThrowError(
+ 'Invalid property value: "calories" must be a string',
+ );
+ });
+
+ it('should throw an error for invalid JSON with non-string (object) property values', () => {
+ const invalidJSON =
+ '{"calories": "100", "fatContent": {"value": "5g"}, "proteinContent": "8g", "saturatedFatContent": "2g"}';
+
+ expect(() =>
+ NutritionInformation.fromJSON(invalidJSON),
+ ).toThrowError(
+ 'Invalid property value: "fatContent" must be a string',
+ );
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => NutritionInformation.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "NutritionInformation". Received invalid JSON: "Invalid JSON string"',
+ );
+ });
});
});
diff --git a/src/tests/unit/Models/schema/QuantitativeValue.test.ts b/src/tests/unit/Models/schema/QuantitativeValue.test.ts
index b9a719add..84021379b 100644
--- a/src/tests/unit/Models/schema/QuantitativeValue.test.ts
+++ b/src/tests/unit/Models/schema/QuantitativeValue.test.ts
@@ -1,24 +1,89 @@
import QuantitativeValue from '../../../../js/Models/schema/QuantitativeValue';
describe('QuantitativeValue', () => {
- test('should set the @type property to "QuantitativeValue"', () => {
- const quantitativeValue = new QuantitativeValue(15, 'meter');
- expect(quantitativeValue['@type']).toBe('QuantitativeValue');
- });
+ describe('constructor', () => {
+ test('should set the @type property to "QuantitativeValue"', () => {
+ const quantitativeValue = new QuantitativeValue(15, 'meter');
+ expect(quantitativeValue['@type']).toBe('QuantitativeValue');
+ });
+
+ test('should create an instance of QuantitativeValue with unitCode undefined', () => {
+ const quantitativeValue = new QuantitativeValue(10, 'cup');
+ expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
+ expect(quantitativeValue.value).toBe(10);
+ expect(quantitativeValue.unitText).toBe('cup');
+ expect(quantitativeValue.unitCode).toBeUndefined();
+ });
- test('should create an instance of QuantitativeValue with unitCode undefined', () => {
- const quantitativeValue = new QuantitativeValue(10, 'cup');
- expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
- expect(quantitativeValue.value).toBe(10);
- expect(quantitativeValue.unitText).toBe('cup');
- expect(quantitativeValue.unitCode).toBeUndefined();
+ test('should create an instance of QuantitativeValue with specified unitCode', () => {
+ const quantitativeValue = new QuantitativeValue(
+ 5,
+ 'kilogram',
+ 'KGM',
+ );
+ expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
+ expect(quantitativeValue.value).toBe(5);
+ expect(quantitativeValue.unitText).toBe('kilogram');
+ expect(quantitativeValue.unitCode).toBe('KGM');
+ });
});
- test('should create an instance of QuantitativeValue with specified unitCode', () => {
- const quantitativeValue = new QuantitativeValue(5, 'kilogram', 'KGM');
- expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
- expect(quantitativeValue.value).toBe(5);
- expect(quantitativeValue.unitText).toBe('kilogram');
- expect(quantitativeValue.unitCode).toBe('KGM');
+ describe('fromJSON', () => {
+ it('should create a QuantitativeValue instance from a valid JSON string', () => {
+ const jsonString =
+ '{"value": 250, "unitText": "grams", "unitCode": "G"}';
+ const quantitativeValue = QuantitativeValue.fromJSON(jsonString);
+ expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
+ expect(quantitativeValue.value).toBe(250);
+ expect(quantitativeValue.unitText).toBe('grams');
+ expect(quantitativeValue.unitCode).toBe('G');
+ });
+
+ it('should create a QuantitativeValue instance from a valid JSON object', () => {
+ const jsonObject = { value: 2, unitText: 'cups', unitCode: 'CUP' };
+ const quantitativeValue = QuantitativeValue.fromJSON(jsonObject);
+ expect(quantitativeValue).toBeInstanceOf(QuantitativeValue);
+ expect(quantitativeValue.value).toBe(2);
+ expect(quantitativeValue.unitText).toBe('cups');
+ expect(quantitativeValue.unitCode).toBe('CUP');
+ });
+
+ it('should throw an error for invalid JSON string', () => {
+ const invalidJsonString =
+ '{"value": "invalid", "unitText": "grams", "unitCode": "G"}';
+ expect(() => QuantitativeValue.fromJSON(invalidJsonString)).toThrow(
+ 'Error mapping QuantitativeValue \'value\'. Expected integer number but received non-parsable string "invalid".',
+ );
+ });
+
+ it('should throw an error for missing "value" property', () => {
+ const jsonString = '{"unitText": "grams", "unitCode": "G"}';
+ expect(() => QuantitativeValue.fromJSON(jsonString)).toThrow(
+ 'Error mapping QuantitativeValue \'value\'. Expected integer number but received "undefined".',
+ );
+ });
+
+ it('should throw an error for missing "unitText" property', () => {
+ const jsonString = '{"value": 250, "unitCode": "G"}';
+ expect(() => QuantitativeValue.fromJSON(jsonString)).toThrow(
+ 'Error mapping QuantitativeValue \'value\'. Expected string but received "undefined".',
+ );
+ });
+
+ it('should throw an error for invalid "unitCode" property type', () => {
+ const jsonString =
+ '{"value": 250, "unitText": "grams", "unitCode": 123}';
+ expect(() => QuantitativeValue.fromJSON(jsonString)).toThrow(
+ 'Error mapping QuantitativeValue \'value\'. Expected string but received "number".',
+ );
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => QuantitativeValue.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "QuantitativeValue". Received invalid JSON: "Invalid JSON string"',
+ );
+ });
});
});
From 6723100aa1a0aadd9578fb1e85327f56dbb50a1f Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:48:38 +0100
Subject: [PATCH 014/188] test(js): Add tests for `fromJSON` method of
`HowToSupply` and `HowToTool`.
Signed-off-by: Sebastian Fey
---
.../unit/Models/schema/HowToSupply.test.ts | 152 +++++++++++++-----
.../unit/Models/schema/HowToTool.test.ts | 151 +++++++++++++----
2 files changed, 232 insertions(+), 71 deletions(-)
diff --git a/src/tests/unit/Models/schema/HowToSupply.test.ts b/src/tests/unit/Models/schema/HowToSupply.test.ts
index 24b634781..1d7994bb2 100644
--- a/src/tests/unit/Models/schema/HowToSupply.test.ts
+++ b/src/tests/unit/Models/schema/HowToSupply.test.ts
@@ -1,49 +1,127 @@
-import HowToSupply from '../../../../js/Models/schema/HowToSupply';
+import HowToSupply from 'cookbook/js/Models/schema/HowToSupply';
describe('HowToSupply', () => {
- test('should set the @type property to "HowToSupply"', () => {
- const howToSupply = new HowToSupply('Ingredient');
- expect(howToSupply['@type']).toBe('HowToSupply');
- });
+ describe('constructor', () => {
+ test('should set the @type property to "HowToSupply"', () => {
+ const howToSupply = new HowToSupply('Ingredient');
+ expect(howToSupply['@type']).toBe('HowToSupply');
+ });
- test('should create an instance of HowToSupply with optional properties undefined', () => {
- const howToSupply = new HowToSupply('Ingredient');
- expect(howToSupply).toBeInstanceOf(HowToSupply);
- expect(howToSupply.name).toBe('Ingredient');
- expect(howToSupply.identifier).toBeUndefined();
- expect(howToSupply.description).toBeUndefined();
- expect(howToSupply.requiredQuantity).toBeUndefined();
- });
+ test('should create an instance of HowToSupply with optional properties undefined', () => {
+ const howToSupply = new HowToSupply('Ingredient');
+ expect(howToSupply).toBeInstanceOf(HowToSupply);
+ expect(howToSupply.name).toBe('Ingredient');
+ expect(howToSupply.identifier).toBeUndefined();
+ expect(howToSupply.description).toBeUndefined();
+ expect(howToSupply.requiredQuantity).toBeUndefined();
+ });
- test('should create an instance of HowToSupply with some optional properties defined and some undefined', () => {
- const howToSupply = new HowToSupply('Ingredient', 'IGD123');
- expect(howToSupply).toBeInstanceOf(HowToSupply);
- expect(howToSupply.name).toBe('Ingredient');
- expect(howToSupply.identifier).toBe('IGD123');
- expect(howToSupply.description).toBeUndefined();
- expect(howToSupply.requiredQuantity).toBeUndefined();
- });
+ test('should create an instance of HowToSupply with some optional properties defined and some undefined', () => {
+ const howToSupply = new HowToSupply('Ingredient', 'IGD123');
+ expect(howToSupply).toBeInstanceOf(HowToSupply);
+ expect(howToSupply.name).toBe('Ingredient');
+ expect(howToSupply.identifier).toBe('IGD123');
+ expect(howToSupply.description).toBeUndefined();
+ expect(howToSupply.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToSupply with all properties set', () => {
+ const howToSupply = new HowToSupply(
+ 'Flour',
+ 'FLR123',
+ 'High-quality flour',
+ {
+ value: 2,
+ unitText: 'cup',
+ unitCode: undefined,
+ },
+ );
- test('should create an instance of HowToSupply with all properties set', () => {
- const howToSupply = new HowToSupply(
- 'Flour',
- 'FLR123',
- 'High-quality flour',
- {
+ expect(howToSupply).toBeInstanceOf(HowToSupply);
+ expect(howToSupply.name).toBe('Flour');
+ expect(howToSupply.identifier).toBe('FLR123');
+ expect(howToSupply.description).toBe('High-quality flour');
+ expect(howToSupply.requiredQuantity).toEqual({
value: 2,
unitText: 'cup',
unitCode: undefined,
+ });
+ });
+ });
+
+ describe('parseJSON', () => {
+ const createValidJSON = () => ({
+ name: 'Flour',
+ identifier: 'FLR123',
+ description: 'High-quality flour',
+ requiredQuantity: {
+ value: 1,
+ unitText: 'cup',
},
- );
-
- expect(howToSupply).toBeInstanceOf(HowToSupply);
- expect(howToSupply.name).toBe('Flour');
- expect(howToSupply.identifier).toBe('FLR123');
- expect(howToSupply.description).toBe('High-quality flour');
- expect(howToSupply.requiredQuantity).toEqual({
- value: 2,
- unitText: 'cup',
- unitCode: undefined,
+ });
+
+ it('should create an instance from valid JSON', () => {
+ const validJSON = createValidJSON();
+ const tool = HowToSupply.fromJSON(validJSON);
+ expect(tool).toBeInstanceOf(HowToSupply);
+ expect(tool.name).toBe(validJSON.name);
+ expect(tool.identifier).toBe(validJSON.identifier);
+ expect(tool.description).toBe(validJSON.description);
+ expect(tool.requiredQuantity).toBeDefined();
+ // Add more specific checks for QuantitativeValue if needed
+ });
+
+ it('should create an instance from valid JSON string', () => {
+ const validJSON = createValidJSON();
+ const tool = HowToSupply.fromJSON(JSON.stringify(validJSON));
+ expect(tool).toBeInstanceOf(HowToSupply);
+ expect(tool.name).toBe(validJSON.name);
+ expect(tool.identifier).toBe(validJSON.identifier);
+ expect(tool.description).toBe(validJSON.description);
+ expect(tool.requiredQuantity).toBeDefined();
+ // Add more specific checks for QuantitativeValue if needed
+ });
+
+ it('should handle missing optional properties', () => {
+ const validJSON = { name: 'Knife' };
+ const tool = HowToSupply.fromJSON(validJSON);
+ expect(tool).toBeInstanceOf(HowToSupply);
+ expect(tool.name).toBe(validJSON.name);
+ expect(tool.identifier).toBeUndefined();
+ expect(tool.description).toBeUndefined();
+ expect(tool.requiredQuantity).toBeUndefined();
+ });
+
+ it('should throw an error for invalid JSON', () => {
+ const invalidJSON = { name: 123 }; // 'name' should be a string
+ expect(() => HowToSupply.fromJSON(invalidJSON)).toThrow();
+ });
+
+ it('should throw an error for invalid JSON string', () => {
+ const invalidJSONString = '{"name": 123}'; // 'name' should be a string
+ expect(() => HowToSupply.fromJSON(invalidJSONString)).toThrow();
+ });
+
+ it('should throw an error for invalid JSON with missing name property', () => {
+ const invalidJSON = { prop: 123 }; // 'name' is missing
+ expect(() => HowToSupply.fromJSON(invalidJSON)).toThrow(
+ 'Error mapping HowToSupply \'name\'. Expected string but received "undefined".',
+ );
+ });
+
+ it('should throw an error for invalid JSON string with missing name property', () => {
+ const invalidJSONString = '{"prop": 123}'; // 'name' is missing
+ expect(() => HowToSupply.fromJSON(invalidJSONString)).toThrow(
+ 'Error mapping HowToSupply \'name\'. Expected string but received "undefined".',
+ );
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => HowToSupply.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "HowToSupply". Received invalid JSON: "Invalid JSON string"',
+ );
});
});
});
diff --git a/src/tests/unit/Models/schema/HowToTool.test.ts b/src/tests/unit/Models/schema/HowToTool.test.ts
index 94fdf3b92..a9e797d6d 100644
--- a/src/tests/unit/Models/schema/HowToTool.test.ts
+++ b/src/tests/unit/Models/schema/HowToTool.test.ts
@@ -1,44 +1,127 @@
import HowToTool from '../../../../js/Models/schema/HowToTool';
describe('HowToTool', () => {
- test('should set the @type property to "HowToTool"', () => {
- const howToTool = new HowToTool('ToolA');
- expect(howToTool['@type']).toBe('HowToTool');
- });
+ describe('constructor', () => {
+ test('should set the @type property to "HowToTool"', () => {
+ const howToTool = new HowToTool('ToolA');
+ expect(howToTool['@type']).toBe('HowToTool');
+ });
- test('should create an instance of HowToTool with optional properties undefined', () => {
- const howToTool = new HowToTool('ToolA');
- expect(howToTool).toBeInstanceOf(HowToTool);
- expect(howToTool.name).toBe('ToolA');
- expect(howToTool.identifier).toBeUndefined();
- expect(howToTool.description).toBeUndefined();
- expect(howToTool.requiredQuantity).toBeUndefined();
- });
+ test('should create an instance of HowToTool with optional properties undefined', () => {
+ const howToTool = new HowToTool('ToolA');
+ expect(howToTool).toBeInstanceOf(HowToTool);
+ expect(howToTool.name).toBe('ToolA');
+ expect(howToTool.identifier).toBeUndefined();
+ expect(howToTool.description).toBeUndefined();
+ expect(howToTool.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToTool with some optional properties defined and some undefined', () => {
+ const howToTool = new HowToTool('ToolA', 'TA123');
+ expect(howToTool).toBeInstanceOf(HowToTool);
+ expect(howToTool.name).toBe('ToolA');
+ expect(howToTool.identifier).toBe('TA123');
+ expect(howToTool.description).toBeUndefined();
+ expect(howToTool.requiredQuantity).toBeUndefined();
+ });
+
+ test('should create an instance of HowToTool with all properties set', () => {
+ const howToTool = new HowToTool(
+ 'ToolB',
+ 'TB123',
+ 'High-quality tool',
+ {
+ value: 2,
+ unitText: 'pcs',
+ unitCode: undefined,
+ },
+ );
- test('should create an instance of HowToTool with some optional properties defined and some undefined', () => {
- const howToTool = new HowToTool('ToolA', 'TA123');
- expect(howToTool).toBeInstanceOf(HowToTool);
- expect(howToTool.name).toBe('ToolA');
- expect(howToTool.identifier).toBe('TA123');
- expect(howToTool.description).toBeUndefined();
- expect(howToTool.requiredQuantity).toBeUndefined();
+ expect(howToTool).toBeInstanceOf(HowToTool);
+ expect(howToTool.name).toBe('ToolB');
+ expect(howToTool.identifier).toBe('TB123');
+ expect(howToTool.description).toBe('High-quality tool');
+ expect(howToTool.requiredQuantity).toEqual({
+ value: 2,
+ unitText: 'pcs',
+ unitCode: undefined,
+ });
+ });
});
- test('should create an instance of HowToTool with all properties set', () => {
- const howToTool = new HowToTool('ToolB', 'TB123', 'High-quality tool', {
- value: 2,
- unitText: 'pcs',
- unitCode: undefined,
- });
-
- expect(howToTool).toBeInstanceOf(HowToTool);
- expect(howToTool.name).toBe('ToolB');
- expect(howToTool.identifier).toBe('TB123');
- expect(howToTool.description).toBe('High-quality tool');
- expect(howToTool.requiredQuantity).toEqual({
- value: 2,
- unitText: 'pcs',
- unitCode: undefined,
+ describe('parseJSON', () => {
+ const createValidJSON = () => ({
+ name: 'Knife',
+ identifier: 'tool123',
+ description: 'A sharp cutting tool',
+ requiredQuantity: {
+ value: 1,
+ unitText: 'unit',
+ },
+ });
+
+ it('should create an instance from valid JSON', () => {
+ const validJSON = createValidJSON();
+ const tool = HowToTool.fromJSON(validJSON);
+ expect(tool).toBeInstanceOf(HowToTool);
+ expect(tool.name).toBe(validJSON.name);
+ expect(tool.identifier).toBe(validJSON.identifier);
+ expect(tool.description).toBe(validJSON.description);
+ expect(tool.requiredQuantity).toBeDefined();
+ // Add more specific checks for QuantitativeValue if needed
+ });
+
+ it('should create an instance from valid JSON string', () => {
+ const validJSON = createValidJSON();
+ const tool = HowToTool.fromJSON(JSON.stringify(validJSON));
+ expect(tool).toBeInstanceOf(HowToTool);
+ expect(tool.name).toBe(validJSON.name);
+ expect(tool.identifier).toBe(validJSON.identifier);
+ expect(tool.description).toBe(validJSON.description);
+ expect(tool.requiredQuantity).toBeDefined();
+ // Add more specific checks for QuantitativeValue if needed
+ });
+
+ it('should handle missing optional properties', () => {
+ const validJSON = { name: 'Knife' };
+ const tool = HowToTool.fromJSON(validJSON);
+ expect(tool).toBeInstanceOf(HowToTool);
+ expect(tool.name).toBe(validJSON.name);
+ expect(tool.identifier).toBeUndefined();
+ expect(tool.description).toBeUndefined();
+ expect(tool.requiredQuantity).toBeUndefined();
+ });
+
+ it('should throw an error for invalid JSON', () => {
+ const invalidJSON = { name: 123 }; // 'name' should be a string
+ expect(() => HowToTool.fromJSON(invalidJSON)).toThrow();
+ });
+
+ it('should throw an error for invalid JSON string', () => {
+ const invalidJSONString = '{"name": 123}'; // 'name' should be a string
+ expect(() => HowToTool.fromJSON(invalidJSONString)).toThrow();
+ });
+
+ it('should throw an error for invalid JSON with missing name property', () => {
+ const invalidJSON = { prop: 123 }; // 'name' is missing
+ expect(() => HowToTool.fromJSON(invalidJSON)).toThrow(
+ 'Error mapping HowToTool \'name\'. Expected string but received "undefined".',
+ );
+ });
+
+ it('should throw an error for invalid JSON string with missing name property', () => {
+ const invalidJSONString = '{"prop": 123}'; // 'name' is missing
+ expect(() => HowToTool.fromJSON(invalidJSONString)).toThrow(
+ 'Error mapping HowToTool \'name\'. Expected string but received "undefined".',
+ );
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => HowToTool.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "HowToTool". Received invalid JSON: "Invalid JSON string"',
+ );
});
});
});
From 21605b24b7afd7c76c73a0472918bf847acee881 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:48:57 +0100
Subject: [PATCH 015/188] test(js): Add tests for `fromJSON` method of
`HowToDirection` and `HowToSection`.
Signed-off-by: Sebastian Fey
---
.../unit/Models/schema/HowToDirection.test.ts | 297 ++++++++++++++----
.../unit/Models/schema/HowToSection.test.ts | 219 ++++++++++---
2 files changed, 409 insertions(+), 107 deletions(-)
diff --git a/src/tests/unit/Models/schema/HowToDirection.test.ts b/src/tests/unit/Models/schema/HowToDirection.test.ts
index 2152eeb3f..a7baa66ba 100644
--- a/src/tests/unit/Models/schema/HowToDirection.test.ts
+++ b/src/tests/unit/Models/schema/HowToDirection.test.ts
@@ -3,75 +3,248 @@ import HowToSupply from '../../../../js/Models/schema/HowToSupply';
import HowToTool from '../../../../js/Models/schema/HowToTool';
describe('HowToDirection', () => {
- test('should set "@type" property to "HowToDirection"', () => {
- const direction = new HowToDirection('Step 5');
+ describe('constructor', () => {
+ test('should set "@type" property to "HowToDirection"', () => {
+ const direction = new HowToDirection('Step 5');
- expect(direction).toHaveProperty('@type', 'HowToDirection');
- });
+ expect(direction).toHaveProperty('@type', 'HowToDirection');
+ });
- test('should create an instance with only text', () => {
- const direction = new HowToDirection('Step 1');
-
- expect(direction).toBeInstanceOf(HowToDirection);
- expect(direction.text).toBe('Step 1');
- expect(direction.position).toBeUndefined();
- expect(direction.image).toStrictEqual([]);
- expect(direction.thumbnailUrl).toStrictEqual([]);
- expect(direction.timeRequired).toBeUndefined();
- expect(direction.supply).toStrictEqual([]);
- expect(direction.tool).toStrictEqual([]);
- });
+ test('should create an instance with only text', () => {
+ const direction = new HowToDirection('Step 1');
- test('should create an instance with text and position', () => {
- const direction = new HowToDirection('Step 2', { position: 2 });
-
- expect(direction).toBeInstanceOf(HowToDirection);
- expect(direction.text).toBe('Step 2');
- expect(direction.position).toBe(2);
- expect(direction.image).toStrictEqual([]);
- expect(direction.thumbnailUrl).toStrictEqual([]);
- expect(direction.timeRequired).toBeUndefined();
- expect(direction.supply).toStrictEqual([]);
- expect(direction.tool).toStrictEqual([]);
- });
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 1');
+ expect(direction.position).toBeUndefined();
+ expect(direction.image).toStrictEqual([]);
+ expect(direction.thumbnailUrl).toStrictEqual([]);
+ expect(direction.timeRequired).toBeUndefined();
+ expect(direction.supply).toStrictEqual([]);
+ expect(direction.tool).toStrictEqual([]);
+ });
+
+ test('should create an instance with text and position', () => {
+ const direction = new HowToDirection('Step 2', { position: 2 });
+
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 2');
+ expect(direction.position).toBe(2);
+ expect(direction.image).toStrictEqual([]);
+ expect(direction.thumbnailUrl).toStrictEqual([]);
+ expect(direction.timeRequired).toBeUndefined();
+ expect(direction.supply).toStrictEqual([]);
+ expect(direction.tool).toStrictEqual([]);
+ });
+
+ test('should create an instance with all properties', () => {
+ const image = ['image1.jpg', 'image2.jpg'];
+ const thumbnailUrl = ['thumb1.jpg', 'thumb2.jpg'];
+ const supply: HowToSupply[] = [{ name: 'Ingredient 1' }];
+ const tool: HowToTool[] = [{ name: 'Tool 1' }];
+
+ const direction = new HowToDirection('Step 3', {
+ position: 3,
+ image,
+ thumbnailUrl,
+ timeRequired: '5 minutes',
+ supply,
+ tool,
+ });
- test('should create an instance with all properties', () => {
- const image = ['image1.jpg', 'image2.jpg'];
- const thumbnailUrl = ['thumb1.jpg', 'thumb2.jpg'];
- const supply: HowToSupply[] = [{ name: 'Ingredient 1' }];
- const tool: HowToTool[] = [{ name: 'Tool 1' }];
-
- const direction = new HowToDirection('Step 3', {
- position: 3,
- image,
- thumbnailUrl,
- timeRequired: '5 minutes',
- supply,
- tool,
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 3');
+ expect(direction.position).toBe(3);
+ expect(direction.image).toEqual(image);
+ expect(direction.thumbnailUrl).toEqual(thumbnailUrl);
+ expect(direction.timeRequired).toBe('5 minutes');
+ expect(direction.supply).toEqual(supply);
+ expect(direction.tool).toEqual(tool);
});
- expect(direction).toBeInstanceOf(HowToDirection);
- expect(direction.text).toBe('Step 3');
- expect(direction.position).toBe(3);
- expect(direction.image).toEqual(image);
- expect(direction.thumbnailUrl).toEqual(thumbnailUrl);
- expect(direction.timeRequired).toBe('5 minutes');
- expect(direction.supply).toEqual(supply);
- expect(direction.tool).toEqual(tool);
+ test('should create an instance with only text and image string', () => {
+ const image = 'image1.jpg';
+ const thumbnailUrl = 'image1_thumb.jpg';
+ const direction = new HowToDirection('Step 4', {
+ image,
+ thumbnailUrl,
+ });
+
+ expect(direction).toBeInstanceOf(HowToDirection);
+ expect(direction.text).toBe('Step 4');
+ expect(direction.position).toBeUndefined();
+ expect(direction.image).toEqual([image]);
+ expect(direction.thumbnailUrl).toEqual([thumbnailUrl]);
+ expect(direction.timeRequired).toBeUndefined();
+ expect(direction.supply).toStrictEqual([]);
+ expect(direction.tool).toStrictEqual([]);
+ });
});
- test('should create an instance with only text and image string', () => {
- const image = 'image1.jpg';
- const thumbnailUrl = 'image1_thumb.jpg';
- const direction = new HowToDirection('Step 4', { image, thumbnailUrl });
-
- expect(direction).toBeInstanceOf(HowToDirection);
- expect(direction.text).toBe('Step 4');
- expect(direction.position).toBeUndefined();
- expect(direction.image).toEqual([image]);
- expect(direction.thumbnailUrl).toEqual([thumbnailUrl]);
- expect(direction.timeRequired).toBeUndefined();
- expect(direction.supply).toStrictEqual([]);
- expect(direction.tool).toStrictEqual([]);
+ // fromJSON tests
+ describe('fromJSON', () => {
+ test('should create a HowToDirection instance from valid JSON', () => {
+ const json = {
+ text: 'Mix the ingredients',
+ position: 1,
+ image: ['image1.jpg', 'image2.jpg'],
+ thumbnailUrl: ['thumbnail1.jpg', 'thumbnail2.jpg'],
+ timeRequired: '10 minutes',
+ supply: [
+ {
+ name: 'Flour',
+ requiredQuantity: {
+ value: 200,
+ unitText: 'grams',
+ },
+ },
+ {
+ name: 'Water',
+ requiredQuantity: {
+ value: 150,
+ unitText: 'milliliters',
+ },
+ },
+ ],
+ tool: [
+ {
+ name: 'Mixing Bowl',
+ },
+ {
+ name: 'Spoon',
+ },
+ ],
+ };
+
+ const result = HowToDirection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToDirection);
+ expect(result.text).toEqual('Mix the ingredients');
+ expect(result.position).toEqual(1);
+ expect(result.image).toEqual(['image1.jpg', 'image2.jpg']);
+ expect(result.thumbnailUrl).toEqual([
+ 'thumbnail1.jpg',
+ 'thumbnail2.jpg',
+ ]);
+ expect(result.timeRequired).toEqual('10 minutes');
+
+ // Validate supply property
+ expect(result.supply).toBeInstanceOf(Array);
+ expect(result.supply[0]).toBeInstanceOf(HowToSupply);
+ expect(result.supply[0].name).toEqual('Flour');
+ expect(result.supply[0].requiredQuantity?.value).toEqual(200);
+ expect(result.supply[0].requiredQuantity?.unitText).toEqual(
+ 'grams',
+ );
+
+ // Validate tool property
+ expect(result.tool).toBeInstanceOf(Array);
+ expect(result.tool[0]).toBeInstanceOf(HowToTool);
+ expect(result.tool[0].name).toEqual('Mixing Bowl');
+ });
+
+ test('should create a HowToDirection instance from valid JSON with single tool and supply', () => {
+ const json = {
+ text: 'Mix the ingredients',
+ position: 1,
+ image: ['image1.jpg', 'image2.jpg'],
+ thumbnailUrl: ['thumbnail1.jpg', 'thumbnail2.jpg'],
+ timeRequired: '10 minutes',
+ supply: {
+ name: 'Flour',
+ requiredQuantity: {
+ value: 200,
+ unitText: 'grams',
+ },
+ },
+ tool: {
+ name: 'Mixing Bowl',
+ },
+ };
+
+ const result = HowToDirection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToDirection);
+ expect(result.text).toEqual('Mix the ingredients');
+ expect(result.position).toEqual(1);
+ expect(result.image).toEqual(['image1.jpg', 'image2.jpg']);
+ expect(result.thumbnailUrl).toEqual([
+ 'thumbnail1.jpg',
+ 'thumbnail2.jpg',
+ ]);
+ expect(result.timeRequired).toEqual('10 minutes');
+
+ // Validate supply property
+ expect(result.supply).toBeInstanceOf(Array);
+ expect(result.supply[0]).toBeInstanceOf(HowToSupply);
+ expect(result.supply[0].name).toEqual('Flour');
+ expect(result.supply[0].requiredQuantity?.value).toEqual(200);
+ expect(result.supply[0].requiredQuantity?.unitText).toEqual(
+ 'grams',
+ );
+
+ // Validate tool property
+ expect(result.tool).toBeInstanceOf(Array);
+ expect(result.tool[0]).toBeInstanceOf(HowToTool);
+ expect(result.tool[0].name).toEqual('Mixing Bowl');
+ });
+
+ test('should handle missing optional properties', () => {
+ const json = {
+ text: 'Bake the cake',
+ };
+
+ const result = HowToDirection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToDirection);
+ expect(result.text).toEqual('Bake the cake');
+ expect(result.position).toBeUndefined();
+ expect(result.image).toEqual([]);
+ expect(result.thumbnailUrl).toEqual([]);
+ expect(result.timeRequired).toBeUndefined();
+ expect(result.supply).toEqual([]);
+ expect(result.tool).toEqual([]);
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => HowToDirection.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "HowToDirection". Received invalid JSON: "Invalid JSON string"',
+ );
+ });
+
+ test('should throw an error for missing required properties', () => {
+ const json = {
+ position: 1,
+ // Missing required 'text' property
+ };
+
+ expect(() => HowToDirection.fromJSON(json)).toThrowError(
+ 'Error mapping HowToDirection \'text\'. Expected string but received "undefined".',
+ );
+ });
+
+ test('should handle null or undefined values for optional properties', () => {
+ const json = {
+ text: 'Chop the vegetables',
+ position: null,
+ image: null,
+ thumbnailUrl: undefined,
+ timeRequired: undefined,
+ supply: null,
+ tool: undefined,
+ };
+
+ const result = HowToDirection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToDirection);
+ expect(result.text).toEqual('Chop the vegetables');
+ expect(result.position).toBeUndefined();
+ expect(result.image).toEqual([]);
+ expect(result.thumbnailUrl).toEqual([]);
+ expect(result.timeRequired).toBeUndefined();
+ expect(result.supply).toEqual([]);
+ expect(result.tool).toEqual([]);
+ });
});
});
diff --git a/src/tests/unit/Models/schema/HowToSection.test.ts b/src/tests/unit/Models/schema/HowToSection.test.ts
index 148c72dbd..9fcfffff0 100644
--- a/src/tests/unit/Models/schema/HowToSection.test.ts
+++ b/src/tests/unit/Models/schema/HowToSection.test.ts
@@ -2,56 +2,185 @@ import HowToDirection from '../../../../js/Models/schema/HowToDirection';
import HowToSection from '../../../../js/Models/schema/HowToSection';
describe('HowToSection', () => {
- test('should create a HowToSection instance with required properties', () => {
- const section = new HowToSection('Section 1');
+ // constructor tests
+ describe('constructor', () => {
+ test('should create a HowToSection instance with required properties', () => {
+ const section = new HowToSection('Section 1');
- expect(section).toHaveProperty('@type', 'HowToSection');
- expect(section.name).toBe('Section 1');
- });
+ expect(section).toHaveProperty('@type', 'HowToSection');
+ expect(section.name).toBe('Section 1');
+ });
- test('should set optional properties when provided in options', () => {
- const options = {
- description: 'Section description',
- position: 2,
- image: 'section-image.jpg',
- thumbnailUrl: 'thumbnail.jpg',
- itemListElement: new HowToDirection('Step 1'),
- };
-
- const section = new HowToSection('Section 2', options);
-
- expect(section.description).toBe(options.description);
- expect(section.position).toBe(options.position);
- expect(section.image).toEqual([options.image]);
- expect(section.thumbnailUrl).toEqual([options.thumbnailUrl]);
- expect(section.itemListElement).toEqual([options.itemListElement]);
- });
+ test('should set optional properties when provided in options', () => {
+ const options = {
+ description: 'Section description',
+ position: 2,
+ image: 'section-image.jpg',
+ thumbnailUrl: 'thumbnail.jpg',
+ itemListElement: new HowToDirection('Step 1'),
+ };
+
+ const section = new HowToSection('Section 2', options);
+
+ expect(section.description).toBe(options.description);
+ expect(section.position).toBe(options.position);
+ expect(section.image).toEqual([options.image]);
+ expect(section.thumbnailUrl).toEqual([options.thumbnailUrl]);
+ expect(section.itemListElement).toEqual([options.itemListElement]);
+ });
+
+ test('should handle undefined options', () => {
+ const section = new HowToSection('Section 3', undefined);
+
+ expect(section.description).toBeUndefined();
+ expect(section.position).toBeUndefined();
+ expect(section.image).toEqual([]);
+ expect(section.thumbnailUrl).toEqual([]);
+ expect(section.itemListElement).toEqual([]);
+ });
- test('should handle undefined options', () => {
- const section = new HowToSection('Section 3', undefined);
+ test('should handle options with undefined properties', () => {
+ const options = {
+ description: undefined,
+ position: undefined,
+ image: undefined,
+ thumbnailUrl: undefined,
+ itemListElement: undefined,
+ };
- expect(section.description).toBeUndefined();
- expect(section.position).toBeUndefined();
- expect(section.image).toEqual([]);
- expect(section.thumbnailUrl).toEqual([]);
- expect(section.itemListElement).toEqual([]);
+ const section = new HowToSection('Section 4', options);
+
+ expect(section.description).toBeUndefined();
+ expect(section.position).toBeUndefined();
+ expect(section.image).toEqual([]);
+ expect(section.thumbnailUrl).toEqual([]);
+ expect(section.itemListElement).toEqual([]);
+ });
});
- test('should handle options with undefined properties', () => {
- const options = {
- description: undefined,
- position: undefined,
- image: undefined,
- thumbnailUrl: undefined,
- itemListElement: undefined,
- };
-
- const section = new HowToSection('Section 4', options);
-
- expect(section.description).toBeUndefined();
- expect(section.position).toBeUndefined();
- expect(section.image).toEqual([]);
- expect(section.thumbnailUrl).toEqual([]);
- expect(section.itemListElement).toEqual([]);
+ // fromJSON tests
+ describe('fromJSON', () => {
+ test('should create a HowToSection instance from valid JSON', () => {
+ const json = {
+ name: 'Mixing',
+ description: 'Mixing ingredients',
+ position: 2,
+ image: 'section_image.jpg',
+ thumbnailUrl: 'section_thumbnail.jpg',
+ itemListElement: [
+ {
+ text: 'Mix the flour and water',
+ position: 1,
+ image: ['direction_image.jpg'],
+ thumbnailUrl: ['direction_thumbnail.jpg'],
+ },
+ {
+ text: 'Stir the mixture',
+ position: 2,
+ image: 'stir_image.jpg',
+ thumbnailUrl: 'stir_thumbnail.jpg',
+ },
+ ],
+ };
+
+ const result = HowToSection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToSection);
+ expect(result.name).toEqual('Mixing');
+ expect(result.description).toEqual('Mixing ingredients');
+ expect(result.position).toEqual(2);
+ expect(result.image).toEqual(['section_image.jpg']);
+ expect(result.thumbnailUrl).toEqual(['section_thumbnail.jpg']);
+
+ // Validate itemListElement property
+ expect(result.itemListElement).toBeInstanceOf(Array);
+ expect(result.itemListElement[0]).toBeInstanceOf(HowToDirection);
+ expect(result.itemListElement[0].text).toEqual(
+ 'Mix the flour and water',
+ );
+ expect(result.itemListElement[0].position).toEqual(1);
+ expect(result.itemListElement[0].image).toEqual([
+ 'direction_image.jpg',
+ ]);
+ expect(result.itemListElement[0].thumbnailUrl).toEqual([
+ 'direction_thumbnail.jpg',
+ ]);
+
+ expect(result.itemListElement[1]).toBeInstanceOf(HowToDirection);
+ expect(result.itemListElement[1].text).toEqual('Stir the mixture');
+ expect(result.itemListElement[1].position).toEqual(2);
+ expect(result.itemListElement[1].image).toEqual(['stir_image.jpg']);
+ expect(result.itemListElement[1].thumbnailUrl).toEqual([
+ 'stir_thumbnail.jpg',
+ ]);
+ });
+
+ test('should handle missing optional properties', () => {
+ const json = {
+ name: 'Baking',
+ itemListElement: [
+ {
+ text: 'Preheat the oven',
+ position: 1,
+ },
+ ],
+ };
+
+ const result = HowToSection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToSection);
+ expect(result.name).toEqual('Baking');
+ expect(result.description).toBeUndefined();
+ expect(result.position).toBeUndefined();
+ expect(result.image).toEqual([]);
+ expect(result.thumbnailUrl).toEqual([]);
+
+ // Validate itemListElement property
+ expect(result.itemListElement).toBeInstanceOf(Array);
+ expect(result.itemListElement[0]).toBeInstanceOf(HowToDirection);
+ expect(result.itemListElement[0].text).toEqual('Preheat the oven');
+ expect(result.itemListElement[0].position).toEqual(1);
+ expect(result.itemListElement[0].image).toEqual([]);
+ expect(result.itemListElement[0].thumbnailUrl).toEqual([]);
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => HowToSection.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "HowToSection". Received invalid JSON: "Invalid JSON string"',
+ );
+ });
+
+ test('should throw an error for missing required properties', () => {
+ const json = {
+ // Missing required 'name' property
+ };
+
+ expect(() => HowToSection.fromJSON(json)).toThrow(
+ 'Error mapping HowToSection \'name\'. Expected string but received "undefined".',
+ );
+ });
+
+ test('should handle null or undefined values for optional properties', () => {
+ const json = {
+ name: 'Chopping',
+ description: null,
+ position: undefined,
+ image: null,
+ thumbnailUrl: undefined,
+ itemListElement: null,
+ };
+
+ const result = HowToSection.fromJSON(json);
+
+ expect(result).toBeInstanceOf(HowToSection);
+ expect(result.name).toEqual('Chopping');
+ expect(result.description).toBeUndefined();
+ expect(result.position).toBeUndefined();
+ expect(result.image).toEqual([]);
+ expect(result.thumbnailUrl).toEqual([]);
+ expect(result.itemListElement).toEqual([]);
+ });
});
});
From 3ff6ab5d3795a3b65e59e1b45ad5c29c38c91d07 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 11:49:10 +0100
Subject: [PATCH 016/188] test(js): Add tests for `fromJSON` method of
`Recipe`.
Signed-off-by: Sebastian Fey
---
src/tests/unit/Models/schema/Recipe.test.ts | 495 ++++++++++++++------
1 file changed, 360 insertions(+), 135 deletions(-)
diff --git a/src/tests/unit/Models/schema/Recipe.test.ts b/src/tests/unit/Models/schema/Recipe.test.ts
index 61b785f66..1dffadeec 100644
--- a/src/tests/unit/Models/schema/Recipe.test.ts
+++ b/src/tests/unit/Models/schema/Recipe.test.ts
@@ -1,145 +1,370 @@
-import Recipe from '../../../../js/Models/schema/Recipe';
-import HowToDirection from '../../../../js/Models/schema/HowToDirection';
-// import HowToSection from '../../../../js/Models/schema/HowToSection';
-import HowToSupply from '../../../../js/Models/schema/HowToSupply';
-import HowToTool from '../../../../js/Models/schema/HowToTool';
-import NutritionInformation from '../../../../js/Models/schema/NutritionInformation';
+import HowToDirection from 'cookbook/js/Models/schema/HowToDirection';
+import HowToSection from 'cookbook/js/Models/schema/HowToSection';
+import HowToSupply from 'cookbook/js/Models/schema/HowToSupply';
+import HowToTool from 'cookbook/js/Models/schema/HowToTool';
+import NutritionInformation from 'cookbook/js/Models/schema/NutritionInformation';
+import Recipe from 'cookbook/js/Models/schema/Recipe';
describe('Recipe', () => {
- const recipeId = '123';
- const recipeName = 'Test Recipe';
-
- test('should create a Recipe instance with required properties', () => {
- const recipe = new Recipe(recipeId, recipeName);
-
- expect(recipe).toHaveProperty('@type', 'Recipe');
- expect(recipe.identifier).toBe(recipeId);
- expect(recipe.name).toBe(recipeName);
- expect(recipe.image).toStrictEqual([]);
- expect(recipe.imageUrl).toStrictEqual([]);
- expect(recipe.keywords).toStrictEqual([]);
- expect(recipe.recipeIngredient).toStrictEqual([]);
- expect(recipe.supply).toStrictEqual([]);
- expect(recipe.recipeInstructions).toStrictEqual([]);
- expect(recipe.tool).toStrictEqual([]);
- expect(recipe.url).toStrictEqual([]);
- });
+ // constructor tests
+ describe('constructor', () => {
+ const recipeId = '123';
+ const recipeName = 'Test Recipe';
- test('should set optional properties when provided in options', () => {
- const options = {
- recipeCategory: 'Dinner',
- dateCreated: '2022-01-01',
- dateModified: '2022-01-02',
- description: 'A delicious recipe',
- image: 'recipe-image.jpg',
- imageUrl: 'recipe-thumbnail.jpg',
- keywords: 'delicious, easy',
- totalTime: 'PT1H',
- cookTime: 'PT30M',
- prepTime: 'PT30M',
- nutrition: new NutritionInformation({
- calories: '100',
- carbohydrateContent: '20',
- proteinContent: '15',
- servingSize: '1 cup',
- sodiumContent: '200',
- }),
- recipeIngredient: '1 cup flour',
- recipeYield: 4,
- supply: new HowToSupply('Flour', '1 cup'),
- recipeInstructions: new HowToDirection('Mix the ingredients'),
- tool: new HowToTool('Mixing Bowl'),
- url: 'https://example.com/recipe',
- };
-
- const recipe = new Recipe(recipeId, recipeName, options);
-
- expect(recipe.recipeCategory).toBe(options.recipeCategory);
- expect(recipe.dateCreated).toBe(options.dateCreated);
- expect(recipe.dateModified).toBe(options.dateModified);
- expect(recipe.description).toBe(options.description);
- expect(recipe.image).toEqual([options.image]);
- expect(recipe.imageUrl).toEqual([options.imageUrl]);
- expect(recipe.keywords).toEqual([options.keywords]);
- expect(recipe.totalTime).toBe(options.totalTime);
- expect(recipe.cookTime).toBe(options.cookTime);
- expect(recipe.prepTime).toBe(options.prepTime);
- expect(recipe.nutrition).toEqual(options.nutrition);
- expect(recipe.recipeIngredient).toEqual([options.recipeIngredient]);
- expect(recipe.recipeYield).toBe(options.recipeYield);
- expect(recipe.supply).toEqual([options.supply]);
- expect(recipe.recipeInstructions).toEqual([options.recipeInstructions]);
- expect(recipe.tool).toEqual([options.tool]);
- expect(recipe.url).toEqual([options.url]);
- });
+ test('should create a Recipe instance with required properties', () => {
+ const recipe = new Recipe(recipeId, recipeName);
- test('should handle undefined options', () => {
- const recipe = new Recipe(recipeId, recipeName, undefined);
-
- expect(recipe.recipeCategory).toBeUndefined();
- expect(recipe.dateCreated).toBeUndefined();
- expect(recipe.dateModified).toBeUndefined();
- expect(recipe.description).toBeUndefined();
- expect(recipe.image).toStrictEqual([]);
- expect(recipe.imageUrl).toStrictEqual([]);
- expect(recipe.keywords).toStrictEqual([]);
- expect(recipe.totalTime).toBeUndefined();
- expect(recipe.cookTime).toBeUndefined();
- expect(recipe.prepTime).toBeUndefined();
- expect(recipe.nutrition).toBeUndefined();
- expect(recipe.recipeIngredient).toStrictEqual([]);
- expect(recipe.recipeYield).toBeUndefined();
- expect(recipe.supply).toStrictEqual([]);
- expect(recipe.recipeInstructions).toStrictEqual([]);
- expect(recipe.tool).toStrictEqual([]);
- expect(recipe.url).toStrictEqual([]);
- });
+ expect(recipe).toHaveProperty('@type', 'Recipe');
+ expect(recipe.identifier).toBe(recipeId);
+ expect(recipe.name).toBe(recipeName);
+ expect(recipe.image).toStrictEqual([]);
+ expect(recipe.imageUrl).toStrictEqual([]);
+ expect(recipe.keywords).toStrictEqual([]);
+ expect(recipe.recipeIngredient).toStrictEqual([]);
+ expect(recipe.supply).toStrictEqual([]);
+ expect(recipe.recipeInstructions).toStrictEqual([]);
+ expect(recipe.tool).toStrictEqual([]);
+ expect(recipe.url).toStrictEqual([]);
+ });
+
+ test('should set optional properties when provided in options', () => {
+ const options = {
+ recipeCategory: 'Dinner',
+ dateCreated: '2022-01-01',
+ dateModified: '2022-01-02',
+ description: 'A delicious recipe',
+ image: 'recipe-image.jpg',
+ imageUrl: 'recipe-thumbnail.jpg',
+ keywords: 'delicious, easy',
+ totalTime: 'PT1H',
+ cookTime: 'PT30M',
+ prepTime: 'PT30M',
+ nutrition: new NutritionInformation({
+ calories: '100',
+ carbohydrateContent: '20',
+ proteinContent: '15',
+ servingSize: '1 cup',
+ sodiumContent: '200',
+ }),
+ recipeIngredient: '1 cup flour',
+ recipeYield: 4,
+ supply: new HowToSupply('Flour', '1 cup'),
+ recipeInstructions: new HowToDirection('Mix the ingredients'),
+ tool: new HowToTool('Mixing Bowl'),
+ url: 'https://example.com/recipe',
+ };
+
+ const recipe = new Recipe(recipeId, recipeName, options);
+
+ expect(recipe.recipeCategory).toBe(options.recipeCategory);
+ expect(recipe.dateCreated).toBe(options.dateCreated);
+ expect(recipe.dateModified).toBe(options.dateModified);
+ expect(recipe.description).toBe(options.description);
+ expect(recipe.image).toEqual([options.image]);
+ expect(recipe.imageUrl).toEqual([options.imageUrl]);
+ expect(recipe.keywords).toEqual([options.keywords]);
+ expect(recipe.totalTime).toBe(options.totalTime);
+ expect(recipe.cookTime).toBe(options.cookTime);
+ expect(recipe.prepTime).toBe(options.prepTime);
+ expect(recipe.nutrition).toEqual(options.nutrition);
+ expect(recipe.recipeIngredient).toEqual([options.recipeIngredient]);
+ expect(recipe.recipeYield).toBe(options.recipeYield);
+ expect(recipe.supply).toEqual([options.supply]);
+ expect(recipe.recipeInstructions).toEqual([
+ options.recipeInstructions,
+ ]);
+ expect(recipe.tool).toEqual([options.tool]);
+ expect(recipe.url).toEqual([options.url]);
+ });
+
+ test('should handle undefined options', () => {
+ const recipe = new Recipe(recipeId, recipeName, undefined);
- test('should handle options with undefined properties', () => {
- const options = {
- recipeCategory: undefined,
- dateCreated: undefined,
- dateModified: undefined,
- description: undefined,
- image: undefined,
- imageUrl: undefined,
- keywords: undefined,
- totalTime: undefined,
- cookTime: undefined,
- prepTime: undefined,
- nutrition: undefined,
- recipeIngredient: undefined,
- recipeYield: undefined,
- supply: undefined,
- recipeInstructions: undefined,
- tool: undefined,
- url: undefined,
- };
-
- const recipe = new Recipe(recipeId, recipeName, options);
-
- expect(recipe.recipeCategory).toBeUndefined();
- expect(recipe.dateCreated).toBeUndefined();
- expect(recipe.dateModified).toBeUndefined();
- expect(recipe.description).toBeUndefined();
- expect(recipe.image).toStrictEqual([]);
- expect(recipe.imageUrl).toStrictEqual([]);
- expect(recipe.keywords).toStrictEqual([]);
- expect(recipe.totalTime).toBeUndefined();
- expect(recipe.cookTime).toBeUndefined();
- expect(recipe.prepTime).toBeUndefined();
- expect(recipe.nutrition).toBeUndefined();
- expect(recipe.recipeIngredient).toStrictEqual([]);
- expect(recipe.recipeYield).toBeUndefined();
- expect(recipe.supply).toStrictEqual([]);
- expect(recipe.recipeInstructions).toStrictEqual([]);
- expect(recipe.tool).toStrictEqual([]);
- expect(recipe.url).toStrictEqual([]);
+ expect(recipe.recipeCategory).toBeUndefined();
+ expect(recipe.dateCreated).toBeUndefined();
+ expect(recipe.dateModified).toBeUndefined();
+ expect(recipe.description).toBeUndefined();
+ expect(recipe.image).toStrictEqual([]);
+ expect(recipe.imageUrl).toStrictEqual([]);
+ expect(recipe.keywords).toStrictEqual([]);
+ expect(recipe.totalTime).toBeUndefined();
+ expect(recipe.cookTime).toBeUndefined();
+ expect(recipe.prepTime).toBeUndefined();
+ expect(recipe.nutrition).toBeUndefined();
+ expect(recipe.recipeIngredient).toStrictEqual([]);
+ expect(recipe.recipeYield).toBeUndefined();
+ expect(recipe.supply).toStrictEqual([]);
+ expect(recipe.recipeInstructions).toStrictEqual([]);
+ expect(recipe.tool).toStrictEqual([]);
+ expect(recipe.url).toStrictEqual([]);
+ });
+
+ test('should handle options with undefined properties', () => {
+ const options = {
+ recipeCategory: undefined,
+ dateCreated: undefined,
+ dateModified: undefined,
+ description: undefined,
+ image: undefined,
+ imageUrl: undefined,
+ keywords: undefined,
+ totalTime: undefined,
+ cookTime: undefined,
+ prepTime: undefined,
+ nutrition: undefined,
+ recipeIngredient: undefined,
+ recipeYield: undefined,
+ supply: undefined,
+ recipeInstructions: undefined,
+ tool: undefined,
+ url: undefined,
+ };
+
+ const recipe = new Recipe(recipeId, recipeName, options);
+
+ expect(recipe.recipeCategory).toBeUndefined();
+ expect(recipe.dateCreated).toBeUndefined();
+ expect(recipe.dateModified).toBeUndefined();
+ expect(recipe.description).toBeUndefined();
+ expect(recipe.image).toStrictEqual([]);
+ expect(recipe.imageUrl).toStrictEqual([]);
+ expect(recipe.keywords).toStrictEqual([]);
+ expect(recipe.totalTime).toBeUndefined();
+ expect(recipe.cookTime).toBeUndefined();
+ expect(recipe.prepTime).toBeUndefined();
+ expect(recipe.nutrition).toBeUndefined();
+ expect(recipe.recipeIngredient).toStrictEqual([]);
+ expect(recipe.recipeYield).toBeUndefined();
+ expect(recipe.supply).toStrictEqual([]);
+ expect(recipe.recipeInstructions).toStrictEqual([]);
+ expect(recipe.tool).toStrictEqual([]);
+ expect(recipe.url).toStrictEqual([]);
+ });
+
+ test('should return same value for id and identifier', () => {
+ const recipe = new Recipe(recipeId, recipeName, undefined);
+
+ expect(recipe.identifier).toBe(recipe.id);
+ });
});
- test('should return same value for id and identifier', () => {
- const recipe = new Recipe(recipeId, recipeName, undefined);
+ // fromJSON() tests
+ describe('fromJSON', () => {
+ test('should create a Recipe instance from valid JSON with minimal properties', () => {
+ const minimalJson = {
+ identifier: 'recipeMinimal',
+ name: 'Minimal Recipe',
+ };
+
+ const recipe = Recipe.fromJSON(minimalJson);
+
+ // Assertions
+ expect(recipe.identifier).toBe('recipeMinimal');
+ expect(recipe.name).toBe('Minimal Recipe');
+ });
+
+ test('should create a Recipe instance with default values for missing/undefined/null properties', () => {
+ const jsonWithDefaults = {
+ identifier: 'recipeWithDefaults',
+ name: 'Recipe With Defaults',
+ description: null,
+ dateCreated: undefined,
+ dateModified: null,
+ image: undefined,
+ imageUrl: null,
+ keywords: null,
+ cookTime: null,
+ prepTime: undefined,
+ totalTime: null,
+ nutrition: null,
+ recipeIngredient: undefined,
+ recipeYield: null,
+ supply: null,
+ recipeInstructions: null,
+ tool: undefined,
+ url: null,
+ };
+
+ const recipe = Recipe.fromJSON(jsonWithDefaults);
+
+ // Assertions
+ expect(recipe.identifier).toBe('recipeWithDefaults');
+ expect(recipe.name).toBe('Recipe With Defaults');
+ expect(recipe.description).toBeUndefined();
+ expect(recipe.dateCreated).toBeUndefined();
+ expect(recipe.dateModified).toBeUndefined();
+ expect(recipe.image).toEqual([]);
+ expect(recipe.imageUrl).toEqual([]);
+ expect(recipe.keywords).toEqual([]);
+ expect(recipe.cookTime).toBeUndefined();
+ expect(recipe.prepTime).toBeUndefined();
+ expect(recipe.totalTime).toBeUndefined();
+ expect(recipe.nutrition).toBeUndefined();
+ expect(recipe.recipeIngredient).toEqual([]);
+ expect(recipe.recipeYield).toBeUndefined();
+ expect(recipe.supply).toEqual([]);
+ expect(recipe.recipeInstructions).toEqual([]);
+ expect(recipe.tool).toEqual([]);
+ expect(recipe.url).toEqual([]);
+ });
+
+ test('should handle variations of valid JSON with null/undefined properties', () => {
+ const jsonWithVariations = {
+ identifier: 'recipeVariations',
+ name: 'Recipe Variations',
+ description: 'Some description',
+ dateCreated: null,
+ dateModified: '2022-02-15T10:00:00Z',
+ image: ['image1.jpg', 'image2.jpg'],
+ imageUrl: undefined,
+ keywords: null,
+ cookTime: 'PT45M',
+ prepTime: null,
+ totalTime: undefined,
+ nutrition: {
+ calories: null,
+ fatContent: '10g',
+ },
+ recipeIngredient: 'Ingredient',
+ recipeYield: null,
+ supply: { name: 'Supply1' },
+ recipeInstructions: [
+ {
+ '@type': 'HowToDirection',
+ text: 'Step 1: Do something',
+ },
+ {
+ '@type': 'HowToSection',
+ name: 'Section 1',
+ itemListElement: null,
+ },
+ ],
+ tool: undefined,
+ url: 'https://example.com/recipeVariations',
+ };
+
+ const recipe = Recipe.fromJSON(jsonWithVariations);
+
+ // Assertions
+ expect(recipe.identifier).toBe('recipeVariations');
+ expect(recipe.name).toBe('Recipe Variations');
+ expect(recipe.description).toBe('Some description');
+ expect(recipe.dateCreated).toBeUndefined();
+ expect(recipe.dateModified).toBe('2022-02-15T10:00:00Z');
+ expect(recipe.image).toEqual(['image1.jpg', 'image2.jpg']);
+ expect(recipe.imageUrl).toEqual([]);
+ expect(recipe.keywords).toEqual([]);
+ expect(recipe.cookTime).toBe('PT45M');
+ expect(recipe.prepTime).toBeUndefined();
+ expect(recipe.totalTime).toBeUndefined();
+ expect(recipe.nutrition).toBeInstanceOf(NutritionInformation);
+ expect(recipe.recipeIngredient).toEqual(['Ingredient']);
+ expect(recipe.recipeYield).toBeUndefined();
+ expect(recipe.supply[0].name).toBe('Supply1');
+ expect(recipe.recipeInstructions).toHaveLength(2);
+ expect(recipe.recipeInstructions[0]).toBeInstanceOf(HowToDirection);
+ expect(recipe.recipeInstructions[1]).toBeInstanceOf(HowToSection);
+ expect(recipe.tool).toEqual([]);
+ expect(recipe.url).toStrictEqual([
+ 'https://example.com/recipeVariations',
+ ]);
+ });
+
+ test('should throw an error for invalid JSON', () => {
+ const invalidJson = 'Invalid JSON string';
+
+ expect(() => Recipe.fromJSON(invalidJson)).toThrow(
+ 'Error mapping to "Recipe". Received invalid JSON: "Invalid JSON string"',
+ );
+ });
+
+ test('should handle variations of valid JSON with arrays for properties supporting array values', () => {
+ const jsonWithArrays = {
+ identifier: 'recipeArrays',
+ name: 'Recipe with arrays',
+ image: ['image1.jpg', 'image2.jpg'], // Array value
+ imageUrl: ['image3.jpg', 'image4.jpg'], // Array value
+ keywords: ['keyword1', 'keyword2'], // Array value
+ recipeIngredient: ['1 cup flour', '1 kg butter'], // Array value
+ recipeInstructions: [
+ { text: 'Step 1: Do something' },
+ { text: 'Step 2: So something else' },
+ ], // Array value
+ supply: [{ name: 'Supply1' }, { name: 'Supply2' }], // Array value
+ tool: [{ name: 'Tool1' }, { name: 'Tool2' }], // Array value
+ url: [
+ 'https://example.com/recipe',
+ 'https://example.com/recipe2',
+ ], // Array value
+ };
+
+ const recipe = Recipe.fromJSON(jsonWithArrays);
+
+ // Assertions
+ expect(recipe.identifier).toBe('recipeArrays');
+ expect(recipe.name).toBe('Recipe with arrays');
+ expect(recipe.image).toEqual(['image1.jpg', 'image2.jpg']);
+ expect(recipe.imageUrl).toEqual(['image3.jpg', 'image4.jpg']);
+ expect(recipe.keywords).toEqual(['keyword1', 'keyword2']);
+ expect(recipe.recipeIngredient).toEqual([
+ '1 cup flour',
+ '1 kg butter',
+ ]);
+ expect(recipe.recipeInstructions).toBeInstanceOf(
+ Array,
+ );
+ expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ 'Step 1: Do something',
+ );
+ expect((recipe.recipeInstructions[1] as HowToDirection).text).toBe(
+ 'Step 2: So something else',
+ );
+ expect(recipe.supply).toBeInstanceOf(Array);
+ expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1');
+ expect((recipe.supply[1] as HowToSupply).name).toBe('Supply2');
+ expect(recipe.tool).toBeInstanceOf(Array);
+ expect((recipe.tool[0] as HowToTool).name).toBe('Tool1');
+ expect((recipe.tool[1] as HowToTool).name).toBe('Tool2');
+ expect(recipe.url).toEqual([
+ 'https://example.com/recipe',
+ 'https://example.com/recipe2',
+ ]); // Converted to array
+ });
+
+ test('should handle variations of valid JSON with arrays for properties supporting single values', () => {
+ const jsonWithArrays = {
+ identifier: 'recipeArrays',
+ name: 'Recipe with arrays',
+ image: 'image1.jpg', // Single value
+ imageUrl: 'image3.jpg', // Single value
+ keywords: 'keyword1', // Single value
+ recipeIngredient: '1 cup flour', // Single value
+ recipeInstructions: { text: 'Step 1: Do something' }, // Single value
+ supply: { name: 'Supply1' }, // Single value
+ tool: { name: 'Tool1' }, // Single value
+ url: 'https://example.com/recipe', // Single value
+ };
+
+ const recipe = Recipe.fromJSON(jsonWithArrays);
- expect(recipe.identifier).toBe(recipe.id);
+ // Assertions
+ expect(recipe.identifier).toBe('recipeArrays');
+ expect(recipe.name).toBe('Recipe with arrays');
+ expect(recipe.image).toEqual(['image1.jpg']); // Converted to array
+ expect(recipe.imageUrl).toEqual(['image3.jpg']); // Converted to array
+ expect(recipe.keywords).toEqual(['keyword1']); // Converted to array
+ expect(recipe.recipeIngredient).toEqual(['1 cup flour']); // Converted to array
+ expect(recipe.recipeInstructions).toBeInstanceOf(
+ Array,
+ ); // Converted to array
+ expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ 'Step 1: Do something',
+ );
+ expect(recipe.supply).toBeInstanceOf(Array); // Converted to array
+ expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1');
+ expect(recipe.tool).toBeInstanceOf(Array); // Converted to array
+ expect((recipe.tool[0] as HowToTool).name).toBe('Tool1');
+ expect(recipe.url).toEqual(['https://example.com/recipe']); // Converted to array
+ });
});
});
From 3706bc13becc245cd14eed759bb8fb790f19cca6 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 15:11:40 +0100
Subject: [PATCH 017/188] test(js): Add tests for `fromJSON` method of
`Recipe`: Test mapping simple strings for `tool`, `recipeInstructions`, and
`supply` properties
Signed-off-by: Sebastian Fey
---
src/tests/unit/Models/schema/Recipe.test.ts | 56 ++++++++++++++++++++-
1 file changed, 54 insertions(+), 2 deletions(-)
diff --git a/src/tests/unit/Models/schema/Recipe.test.ts b/src/tests/unit/Models/schema/Recipe.test.ts
index 1dffadeec..1a8a438df 100644
--- a/src/tests/unit/Models/schema/Recipe.test.ts
+++ b/src/tests/unit/Models/schema/Recipe.test.ts
@@ -333,7 +333,7 @@ describe('Recipe', () => {
test('should handle variations of valid JSON with arrays for properties supporting single values', () => {
const jsonWithArrays = {
- identifier: 'recipeArrays',
+ identifier: 'recipeSingleValues',
name: 'Recipe with arrays',
image: 'image1.jpg', // Single value
imageUrl: 'image3.jpg', // Single value
@@ -348,7 +348,7 @@ describe('Recipe', () => {
const recipe = Recipe.fromJSON(jsonWithArrays);
// Assertions
- expect(recipe.identifier).toBe('recipeArrays');
+ expect(recipe.identifier).toBe('recipeSingleValues');
expect(recipe.name).toBe('Recipe with arrays');
expect(recipe.image).toEqual(['image1.jpg']); // Converted to array
expect(recipe.imageUrl).toEqual(['image3.jpg']); // Converted to array
@@ -366,5 +366,57 @@ describe('Recipe', () => {
expect((recipe.tool[0] as HowToTool).name).toBe('Tool1');
expect(recipe.url).toEqual(['https://example.com/recipe']); // Converted to array
});
+
+ test('should handle variations of valid JSON with simple strings in arrays', () => {
+ const jsonWithArrays = {
+ identifier: 'recipeArrays',
+ name: 'Recipe with arrays',
+ recipeInstructions: ['Step 1: Do something'], // Array value
+ supply: ['Supply1'], // Array value
+ tool: ['Tool1'], // Array value
+ };
+
+ const recipe = Recipe.fromJSON(jsonWithArrays);
+
+ // Assertions
+ expect(recipe.identifier).toBe('recipeArrays');
+ expect(recipe.name).toBe('Recipe with arrays');
+ expect(recipe.recipeInstructions).toBeInstanceOf(
+ Array,
+ ); // Converted to HowToDirection[]
+ expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ 'Step 1: Do something',
+ );
+ expect(recipe.supply).toBeInstanceOf(Array); // Converted to HowToSupply[]
+ expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1');
+ expect(recipe.tool).toBeInstanceOf(Array); // Converted to HowToTool[]
+ expect((recipe.tool[0] as HowToTool).name).toBe('Tool1');
+ });
+
+ test('should handle variations of valid JSON with simple strings', () => {
+ const jsonWithArrays = {
+ identifier: 'recipeSingleValues',
+ name: 'Recipe with arrays',
+ recipeInstructions: 'Step 1: Do something', // Single value
+ supply: 'Supply1', // Single value
+ tool: 'Tool1', // Single value
+ };
+
+ const recipe = Recipe.fromJSON(jsonWithArrays);
+
+ // Assertions
+ expect(recipe.identifier).toBe('recipeSingleValues');
+ expect(recipe.name).toBe('Recipe with arrays');
+ expect(recipe.recipeInstructions).toBeInstanceOf(
+ Array,
+ ); // Converted to array
+ expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ 'Step 1: Do something',
+ );
+ expect(recipe.supply).toBeInstanceOf(Array); // Converted to array
+ expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1');
+ expect(recipe.tool).toBeInstanceOf(Array); // Converted to array
+ expect((recipe.tool[0] as HowToTool).name).toBe('Tool1');
+ });
});
});
From 37bcaedbbc32f8ead7e57125641ef45fd4aaa977 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 15:12:19 +0100
Subject: [PATCH 018/188] feat: Add support for mapping simple strings for
`tool`, `recipeInstructions`, and `supply` properties to `fromJSON` method of
`Recipe`.
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/Recipe.ts | 48 ++++++++++++++++++++++++++++------
1 file changed, 40 insertions(+), 8 deletions(-)
diff --git a/src/js/Models/schema/Recipe.ts b/src/js/Models/schema/Recipe.ts
index fde5322f3..5a888b806 100644
--- a/src/js/Models/schema/Recipe.ts
+++ b/src/js/Models/schema/Recipe.ts
@@ -54,7 +54,12 @@ interface RecipeOptions {
}
/**
- * Represents a Recipe in Schema.org standard.
+ * Represents a Recipe in `schema.org` standard. Does not support simple string values (or arrays of strings) for `tool`,
+ * `supply`, `nutritionInformation`, and `recipeInstructions`. This simplifies usage in the frontend, since those types
+ * do not have to be checked if they are string.
+ *
+ * When parsed from JSON the subclasses (`HowToStep`, etc.) should support the `string` values as defined in the
+ * `schema.org` standard. These are then mapped to the internally supported classes for above reasons.
* @class
*/
export default class Recipe {
@@ -237,19 +242,46 @@ export default class Recipe {
true,
);
const supply = jsonObj.supply
- ? asArray(jsonObj.supply).map((s) => HowToSupply.fromJSON(s))
+ ? asArray(jsonObj.supply).map((suppl) => {
+ try {
+ return HowToSupply.fromJSON(suppl);
+ } catch (ex) {
+ if (typeof suppl === 'string') {
+ // Did not receive a valid HowToSupply, treat as simple string.
+ return new HowToSupply(suppl as string);
+ }
+ throw ex;
+ }
+ })
: [];
const recipeInstructions = jsonObj.recipeInstructions
- ? asArray(jsonObj.recipeInstructions).map((i) => {
- if (i['@type'] === 'HowToSection') {
- return HowToSection.fromJSON(i);
- } else {
- return HowToDirection.fromJSON(i);
+ ? asArray(jsonObj.recipeInstructions).map((instruction) => {
+ try {
+ if (instruction['@type'] === 'HowToSection') {
+ return HowToSection.fromJSON(instruction);
+ }
+ return HowToDirection.fromJSON(instruction);
+ } catch (ex) {
+ if (typeof instruction === 'string') {
+ // Did not receive a valid HowToDirection or HowToSection object, treat as simple string.
+ return new HowToDirection(instruction as string);
+ }
+ throw ex;
}
})
: [];
const tool = jsonObj.tool
- ? asArray(jsonObj.tool).map((t) => HowToTool.fromJSON(t))
+ ? asArray(jsonObj.tool).map((t) => {
+ try {
+ return HowToTool.fromJSON(t);
+ } catch (ex) {
+ if (typeof t === 'string') {
+ // Did not receive a valid HowToTool, treat as simple string.
+ return new HowToTool(t as string);
+ }
+ throw ex;
+ }
+ })
: [];
const url = mapStringOrStringArray(jsonObj.url, "Recipe 'url'", true);
From a8861a13f9a07e60c73d724f5a479948714ade54 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Sun, 21 Jan 2024 15:28:18 +0100
Subject: [PATCH 019/188] fix: Migrate `api-interface.js` to Typescript, fix
incorrect @nextcloud/axios usage.
Fix wrong import order in `HowToTool`
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/HowToTool.ts | 2 +-
src/js/api-interface.js | 150 ------------------------------
src/js/api-interface.ts | 149 +++++++++++++++++++++++++++++
3 files changed, 150 insertions(+), 151 deletions(-)
delete mode 100644 src/js/api-interface.js
create mode 100644 src/js/api-interface.ts
diff --git a/src/js/Models/schema/HowToTool.ts b/src/js/Models/schema/HowToTool.ts
index 78529331e..5af8fdb31 100644
--- a/src/js/Models/schema/HowToTool.ts
+++ b/src/js/Models/schema/HowToTool.ts
@@ -1,6 +1,6 @@
import { mapString } from 'cookbook/js/utils/jsonMapper';
-import QuantitativeValue from './QuantitativeValue';
import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
+import QuantitativeValue from './QuantitativeValue';
/**
* Represents a tool used in the recipe instructions.
diff --git a/src/js/api-interface.js b/src/js/api-interface.js
deleted file mode 100644
index 9150bd61f..000000000
--- a/src/js/api-interface.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import Vue from 'vue';
-import axios from '@nextcloud/axios';
-
-import { generateUrl } from '@nextcloud/router';
-
-const instance = axios.create();
-
-const baseUrl = `${generateUrl('apps/cookbook')}/webapp`;
-
-// Add a debug log for every request
-instance.interceptors.request.use((config) => {
- Vue.$log.debug(
- `[axios] Making "${config.method}" request to "${config.url}"`,
- config,
- );
- const contentType = config.headers['Content-Type'];
- if (
- contentType &&
- !['application/json', 'text/json'].includes(contentType)
- ) {
- Vue.$log.warn(
- `[axios] Request to "${config.url}" is using Content-Type "${contentType}", not JSON`,
- );
- }
- return config;
-});
-
-instance.interceptors.response.use(
- (response) => {
- Vue.$log.debug('[axios] Received response', response);
- return response;
- },
- (error) => {
- Vue.$log.warn('[axios] Received error', error);
- return Promise.reject(error);
- },
-);
-
-axios.defaults.headers.common.Accept = 'application/json';
-
-function createNewRecipe(recipe) {
- return instance.post(`${baseUrl}/recipes`, recipe);
-}
-
-function getRecipe(id) {
- return instance.get(`${baseUrl}/recipes/${id}`);
-}
-
-function getAllRecipes() {
- return instance.get(`${baseUrl}/recipes`);
-}
-
-function getAllRecipesOfCategory(categoryName) {
- return instance.get(`${baseUrl}/category/${categoryName}`);
-}
-
-function getAllRecipesWithTag(tags) {
- return instance.get(`${baseUrl}/tags/${tags}`);
-}
-
-function searchRecipes(search) {
- return instance.get(`${baseUrl}/search/${search}`);
-}
-
-function updateRecipe(id, recipe) {
- return instance.put(`${baseUrl}/recipes/${id}`, recipe);
-}
-
-function deleteRecipe(id) {
- return instance.delete(`${baseUrl}/recipes/${id}`);
-}
-
-function importRecipe(url) {
- return instance.post(`${baseUrl}/import`, `url=${url}`);
-}
-
-function getAllCategories() {
- return instance.get(`${baseUrl}/categories`);
-}
-
-function updateCategoryName(oldName, newName) {
- return instance.put(`${baseUrl}/category/${encodeURIComponent(oldName)}`, {
- name: newName,
- });
-}
-
-function getAllKeywords() {
- return instance.get(`${baseUrl}/keywords`);
-}
-
-function getConfig() {
- return instance.get(`${baseUrl}/config`);
-}
-
-function updatePrintImageSetting(enabled) {
- return instance.post(`${baseUrl}/config`, { print_image: enabled ? 1 : 0 });
-}
-
-function updateUpdateInterval(newInterval) {
- return instance.post(`${baseUrl}/config`, { update_interval: newInterval });
-}
-
-function updateRecipeDirectory(newDir) {
- return instance.post(`${baseUrl}/config`, { folder: newDir });
-}
-
-function updateVisibleInfoBlocks(visibleInfoBlocks) {
- return instance.post(`${baseUrl}/config`, { visibleInfoBlocks });
-}
-
-function reindex() {
- return instance.post(`${baseUrl}/reindex`);
-}
-
-export default {
- recipes: {
- create: createNewRecipe,
- getAll: getAllRecipes,
- get: getRecipe,
- allInCategory: getAllRecipesOfCategory,
- allWithTag: getAllRecipesWithTag,
- search: searchRecipes,
- update: updateRecipe,
- delete: deleteRecipe,
- import: importRecipe,
- reindex,
- },
- categories: {
- getAll: getAllCategories,
- update: updateCategoryName,
- },
- keywords: {
- getAll: getAllKeywords,
- },
- config: {
- get: getConfig,
- directory: {
- update: updateRecipeDirectory,
- },
- printImage: {
- update: updatePrintImageSetting,
- },
- updateInterval: {
- update: updateUpdateInterval,
- },
- visibleInfoBlocks: {
- update: updateVisibleInfoBlocks,
- },
- },
-};
diff --git a/src/js/api-interface.ts b/src/js/api-interface.ts
new file mode 100644
index 000000000..6c302a781
--- /dev/null
+++ b/src/js/api-interface.ts
@@ -0,0 +1,149 @@
+import Vue from 'vue';
+import axios from '@nextcloud/axios';
+
+import { generateUrl } from '@nextcloud/router';
+
+const baseUrl = `${generateUrl('apps/cookbook')}/webapp`;
+
+// Add a debug log for every request
+axios.interceptors.request.use((config) => {
+ Vue.$log.debug(
+ `[axios] Making "${config.method}" request to "${config.url}"`,
+ config,
+ );
+ const contentType = config.headers['Content-Type'];
+ if (
+ contentType &&
+ typeof contentType === 'string' &&
+ !['application/json', 'text/json'].includes(contentType)
+ ) {
+ Vue.$log.warn(
+ `[axios] Request to "${config.url}" is using Content-Type "${contentType}", not JSON`,
+ );
+ }
+ return config;
+});
+
+axios.interceptors.response.use(
+ (response) => {
+ Vue.$log.debug('[axios] Received response', response);
+ return response;
+ },
+ (error) => {
+ Vue.$log.warn('[axios] Received error', error);
+ return Promise.reject(error);
+ },
+);
+
+axios.defaults.headers.common.Accept = 'application/json';
+
+function createNewRecipe(recipe) {
+ return axios.post(`${baseUrl}/recipes`, recipe);
+}
+
+function getRecipe(id) {
+ return axios.get(`${baseUrl}/recipes/${id}`);
+}
+
+function getAllRecipes() {
+ return axios.get(`${baseUrl}/recipes`);
+}
+
+function getAllRecipesOfCategory(categoryName) {
+ return axios.get(`${baseUrl}/category/${categoryName}`);
+}
+
+function getAllRecipesWithTag(tags) {
+ return axios.get(`${baseUrl}/tags/${tags}`);
+}
+
+function searchRecipes(search) {
+ return axios.get(`${baseUrl}/search/${search}`);
+}
+
+function updateRecipe(id, recipe) {
+ return axios.put(`${baseUrl}/recipes/${id}`, recipe);
+}
+
+function deleteRecipe(id) {
+ return axios.delete(`${baseUrl}/recipes/${id}`);
+}
+
+function importRecipe(url) {
+ return axios.post(`${baseUrl}/import`, `url=${url}`);
+}
+
+function getAllCategories() {
+ return axios.get(`${baseUrl}/categories`);
+}
+
+function updateCategoryName(oldName, newName) {
+ return axios.put(`${baseUrl}/category/${encodeURIComponent(oldName)}`, {
+ name: newName,
+ });
+}
+
+function getAllKeywords() {
+ return axios.get(`${baseUrl}/keywords`);
+}
+
+function getConfig() {
+ return axios.get(`${baseUrl}/config`);
+}
+
+function updatePrintImageSetting(enabled) {
+ return axios.post(`${baseUrl}/config`, { print_image: enabled ? 1 : 0 });
+}
+
+function updateUpdateInterval(newInterval) {
+ return axios.post(`${baseUrl}/config`, { update_interval: newInterval });
+}
+
+function updateRecipeDirectory(newDir) {
+ return axios.post(`${baseUrl}/config`, { folder: newDir });
+}
+
+function updateVisibleInfoBlocks(visibleInfoBlocks) {
+ return axios.post(`${baseUrl}/config`, { visibleInfoBlocks });
+}
+
+function reindex() {
+ return axios.post(`${baseUrl}/reindex`);
+}
+
+export default {
+ recipes: {
+ create: createNewRecipe,
+ getAll: getAllRecipes,
+ get: getRecipe,
+ allInCategory: getAllRecipesOfCategory,
+ allWithTag: getAllRecipesWithTag,
+ search: searchRecipes,
+ update: updateRecipe,
+ delete: deleteRecipe,
+ import: importRecipe,
+ reindex,
+ },
+ categories: {
+ getAll: getAllCategories,
+ update: updateCategoryName,
+ },
+ keywords: {
+ getAll: getAllKeywords,
+ },
+ config: {
+ get: getConfig,
+ directory: {
+ update: updateRecipeDirectory,
+ },
+ printImage: {
+ update: updatePrintImageSetting,
+ },
+ updateInterval: {
+ update: updateUpdateInterval,
+ },
+ visibleInfoBlocks: {
+ update: updateVisibleInfoBlocks,
+ },
+ },
+};
From d183a04cd9feb80c32e5c806e650c38d43c41fbc Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 16:45:00 +0100
Subject: [PATCH 020/188] test: Add test class for `helpers`
Signed-off-by: Sebastian Fey
---
src/tests/unit/helper.test.ts | 45 +++++++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 src/tests/unit/helper.test.ts
diff --git a/src/tests/unit/helper.test.ts b/src/tests/unit/helper.test.ts
new file mode 100644
index 000000000..528f6e658
--- /dev/null
+++ b/src/tests/unit/helper.test.ts
@@ -0,0 +1,45 @@
+import { asArray, asCleanedArray } from 'cookbook/js/helper';
+
+// asArray() tests
+describe('asArray', () => {
+ it('should return array unchanged', () => {
+ const arr = ['val', null, 1, undefined];
+ expect(asArray(arr)).toStrictEqual(arr);
+ });
+ it('should return single value as array', () => {
+ const val = 'val';
+ expect(asArray(val)).toStrictEqual([val]);
+ });
+ it('should return single undefined value as array', () => {
+ const val = undefined;
+ expect(asArray(val)).toStrictEqual([val]);
+ });
+ it('should return single null value as array', () => {
+ const val = undefined;
+ expect(asArray(val)).toStrictEqual([val]);
+ });
+});
+
+// asCleanedArray() tests
+describe('asCleanedArray', () => {
+ it('should return array with values unchanged', () => {
+ const arr = ['val', { test: 'name', id: 4 }, 1, true];
+ expect(asCleanedArray(arr)).toStrictEqual(arr);
+ });
+ it('should remove null and undefined from array', () => {
+ const arr = ['val', null, 1, undefined];
+ expect(asCleanedArray(arr)).toStrictEqual(['val', 1]);
+ });
+ it('should return single value as array', () => {
+ const val = 'val';
+ expect(asCleanedArray(val)).toStrictEqual([val]);
+ });
+ it('should return single undefined value as empty array', () => {
+ const val = undefined;
+ expect(asCleanedArray(val)).toStrictEqual([]);
+ });
+ it('should return single null value as empty array', () => {
+ const val = undefined;
+ expect(asCleanedArray(val)).toStrictEqual([]);
+ });
+});
From f737a5428f2ea6f48d85bd3633c93aa43ff3f442 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 16:48:16 +0100
Subject: [PATCH 021/188] feat: Add `HowToTip` class
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/HowToTip.ts | 117 +++++++++++++++++++++++++++++++
1 file changed, 117 insertions(+)
create mode 100644 src/js/Models/schema/HowToTip.ts
diff --git a/src/js/Models/schema/HowToTip.ts b/src/js/Models/schema/HowToTip.ts
new file mode 100644
index 000000000..6505e7b39
--- /dev/null
+++ b/src/js/Models/schema/HowToTip.ts
@@ -0,0 +1,117 @@
+import {
+ mapInteger,
+ mapString,
+ mapStringOrStringArray,
+} from 'cookbook/js/utils/jsonMapper';
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
+import { asCleanedArray } from '../../helper';
+
+/**
+ * Interface representing the options for constructing a `HowToTip` instance.
+ * @interface
+ */
+interface HowToTipOptions {
+ /** The position of the tip in the sequence. */
+ position?: number;
+
+ /** The images associated with the tip. */
+ image?: string | string[];
+
+ /** The thumbnail URLs for the images. */
+ thumbnailUrl?: string | string[];
+
+ /** The time required for the tip. */
+ timeRequired?: string;
+}
+
+/**
+ * Represents a tip in the recipe instructions.
+ * @class
+ */
+export default class HowToTip {
+ /** The text content of the tip. */
+ public text: string;
+
+ /** The position of the tip in the sequence. */
+ public position?: number;
+
+ /** The images associated with the tip. */
+ public image: string[];
+
+ /** The thumbnail URLs for the images. */
+ public thumbnailUrl: string[];
+
+ /** The time required for the tip. */
+ public timeRequired?: string;
+
+ /**
+ * Creates a `HowToTip` instance.
+ * @constructor
+ * @param {string} text - The text content of the tip.
+ * @param {HowToTipOptions} options - An options object containing additional properties.
+ */
+ public constructor(text: string, options?: HowToTipOptions) {
+ this['@type'] = 'HowToTip';
+ this.text = text;
+ if (options) {
+ this.position = options.position;
+ this.image = asCleanedArray(options.image);
+ this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
+ this.timeRequired = options.timeRequired;
+ }
+ }
+
+ /**
+ * Create a `HowToTip` instance from a JSON string or object.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {HowToTip} - The created HowToTip instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): HowToTip {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "HowToTip". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const text = mapString(
+ jsonObj.text,
+ "HowToTip 'text'",
+ ) as NonNullable;
+
+ const position = mapInteger(
+ jsonObj.position,
+ "HowToTip 'position'",
+ true,
+ );
+
+ const image = mapStringOrStringArray(
+ jsonObj.image,
+ "HowToTip 'image'",
+ true,
+ );
+
+ const thumbnailUrl = mapStringOrStringArray(
+ jsonObj.thumbnailUrl,
+ "HowToTip 'thumbnailUrl'",
+ true,
+ );
+
+ const timeRequired = mapString(
+ jsonObj.timeRequired,
+ "HowToTip 'timeRequired'",
+ true,
+ );
+
+ return new HowToTip(text, {
+ position: position || undefined,
+ image: image || [],
+ thumbnailUrl: thumbnailUrl || [],
+ timeRequired: timeRequired || undefined,
+ });
+ }
+}
From 62cdd1ac5c9b56be8e9aa11e95fc2fb677e3bddb Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 17:00:25 +0100
Subject: [PATCH 022/188] feat: Add option to split comma-separated string to
array in `jsonMapper` `mapStringOrStringArray()`
Signed-off-by: Sebastian Fey
---
src/js/utils/jsonMapper.ts | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/src/js/utils/jsonMapper.ts b/src/js/utils/jsonMapper.ts
index 954c605cc..63adbbac9 100644
--- a/src/js/utils/jsonMapper.ts
+++ b/src/js/utils/jsonMapper.ts
@@ -56,6 +56,8 @@ export function mapInteger(
* @param value The value to be mapped.
* @param targetName The name of the target property. Only used for error message.
* @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown.
+ * @param treatStringAsCommaSeparatedList If true and a single string is passed, the string is treated as a
+ * comma-separated list and mapped to an array.
* @throws JsonMappingException Thrown if `value` cannot be mapped to a string or an array of strings.
* @returns Either the value as a string or an array of strings if mapping was successful or null/undefined if the
* value was null/undefined.
@@ -64,6 +66,7 @@ export function mapStringOrStringArray(
value: unknown,
targetName: string = '',
allowNullOrUndefined: boolean = false,
+ treatStringAsCommaSeparatedList = false,
): string | string[] | null | undefined {
if (value === undefined || value === null) {
// Return null or undefined immediately
@@ -91,7 +94,16 @@ export function mapStringOrStringArray(
}
// `value` is a string, return.
- return value;
+ if (!treatStringAsCommaSeparatedList) return value;
+
+ return (
+ value
+ .split(',')
+ .map((str) => str.trim())
+ // Remove any empty strings
+ // If empty string, split will create an array of a single empty string
+ .filter((str) => str !== '')
+ );
}
/**
From f58df4bf2ff9ddce969e1748491ef34bfdda9fce Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 17:01:29 +0100
Subject: [PATCH 023/188] feat: Use `Recipe` in store
Signed-off-by: Sebastian Fey
---
src/store/index.ts | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/src/store/index.ts b/src/store/index.ts
index e900c1e26..66cac6ff5 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -91,16 +91,12 @@ const store = new Vuex.Store({
setPage(state, { p }) {
state.page = p;
},
- setRecipe(state, { r }) {
- const rec = JSON.parse(JSON.stringify(r));
- if (rec === null) {
+ setRecipe(state, { r }: { r?: Recipe }) {
+ if (!r) {
state.recipe = null;
return;
}
- if ('nutrition' in rec && rec.nutrition instanceof Array) {
- rec.nutrition = {};
- }
- state.recipe = rec;
+ state.recipe = r;
// Setting recipe also means that loading/reloading the recipe has finished
state.loadingRecipe = 0;
@@ -187,7 +183,7 @@ const store = new Vuex.Store({
setPage(c, { page }) {
c.commit('setPage', { p: page });
},
- setRecipe(c, { recipe }) {
+ setRecipe(c, { recipe }: { recipe: Recipe }) {
c.commit('setRecipe', { r: recipe });
},
setRecipeFilters(c, filters) {
From 9e963ea9ee7d3788ebacbaa3f015005e1998a213 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 17:01:57 +0100
Subject: [PATCH 024/188] feat: Multiple improvements in schema.org model
classes
Signed-off-by: Sebastian Fey
---
src/js/Models/schema/HowToDirection.ts | 21 +-
src/js/Models/schema/HowToSection.ts | 34 +++-
src/js/Models/schema/HowToStep.ts | 195 +++++++++++++++++++
src/js/Models/schema/NutritionInformation.ts | 25 +++
src/js/Models/schema/Recipe.ts | 46 +++--
5 files changed, 286 insertions(+), 35 deletions(-)
create mode 100644 src/js/Models/schema/HowToStep.ts
diff --git a/src/js/Models/schema/HowToDirection.ts b/src/js/Models/schema/HowToDirection.ts
index 3253e9b3c..4379169be 100644
--- a/src/js/Models/schema/HowToDirection.ts
+++ b/src/js/Models/schema/HowToDirection.ts
@@ -64,15 +64,22 @@ export default class HowToDirection {
* @param {string} text - The text content of the direction.
* @param {HowToDirectionOptions} options - An options object containing additional properties.
*/
- public constructor(text: string, options: HowToDirectionOptions = {}) {
+ public constructor(text: string, options?: HowToDirectionOptions) {
this['@type'] = 'HowToDirection';
this.text = text;
- this.position = options.position;
- this.image = asCleanedArray(options.image);
- this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
- this.timeRequired = options.timeRequired;
- this.supply = asCleanedArray(options.supply);
- this.tool = asCleanedArray(options.tool);
+ if (options) {
+ this.position = options.position;
+ this.image = asCleanedArray(options.image);
+ this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
+ this.timeRequired = options.timeRequired;
+ this.supply = asCleanedArray(options.supply);
+ this.tool = asCleanedArray(options.tool);
+ } else {
+ this.image = [];
+ this.thumbnailUrl = [];
+ this.supply = [];
+ this.tool = [];
+ }
}
/**
diff --git a/src/js/Models/schema/HowToSection.ts b/src/js/Models/schema/HowToSection.ts
index 4e28c9e1e..3a13218ee 100644
--- a/src/js/Models/schema/HowToSection.ts
+++ b/src/js/Models/schema/HowToSection.ts
@@ -21,6 +21,9 @@ interface HowToSectionOptions {
/** The images associated with the section. */
image?: string | string[];
+ /** The time required for the direction. */
+ timeRequired?: string;
+
/** The thumbnail URLs for the images defined in `image`. */
thumbnailUrl?: string | string[];
@@ -43,13 +46,16 @@ export default class HowToSection {
public description?: string;
/** The images associated with the section. */
- public image: string[];
+ public image: string[] = [];
+
+ /** The time required for the section. */
+ public timeRequired?: string;
/** The thumbnail URLs for the images defined in `image`. */
- public thumbnailUrl: string[];
+ public thumbnailUrl: string[] = [];
/** The list of directions within the section. */
- public itemListElement: HowToDirection[];
+ public itemListElement: HowToDirection[] = [];
/**
* Creates a HowToSection instance.
@@ -57,14 +63,17 @@ export default class HowToSection {
* @param {string} name - The name of the section.
* @param {HowToSectionOptions} options - An options object containing additional properties.
*/
- public constructor(name: string, options: HowToSectionOptions = {}) {
+ public constructor(name: string, options?: HowToSectionOptions) {
this['@type'] = 'HowToSection';
this.name = name;
- this.description = options.description;
- this.position = options.position;
- this.image = asCleanedArray(options.image);
- this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
- this.itemListElement = asCleanedArray(options.itemListElement);
+ if (options) {
+ this.description = options.description;
+ this.position = options.position;
+ this.image = asCleanedArray(options.image);
+ this.timeRequired = options.timeRequired;
+ this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
+ this.itemListElement = asCleanedArray(options.itemListElement);
+ }
}
/**
@@ -107,6 +116,12 @@ export default class HowToSection {
true,
);
+ const timeRequired = mapString(
+ jsonObj.timeRequired,
+ "HowToSection 'timeRequired'",
+ true,
+ );
+
const thumbnailUrl = mapStringOrStringArray(
jsonObj.thumbnailUrl,
"HowToSection 'thumbnailUrl'",
@@ -125,6 +140,7 @@ export default class HowToSection {
description: description || undefined,
position: position || undefined,
image: image || [],
+ timeRequired: timeRequired || undefined,
thumbnailUrl: thumbnailUrl || [],
itemListElement: itemListElement || [],
});
diff --git a/src/js/Models/schema/HowToStep.ts b/src/js/Models/schema/HowToStep.ts
new file mode 100644
index 000000000..b1609880b
--- /dev/null
+++ b/src/js/Models/schema/HowToStep.ts
@@ -0,0 +1,195 @@
+import {
+ mapInteger,
+ mapString,
+ mapStringOrStringArray,
+} from 'cookbook/js/utils/jsonMapper';
+import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException';
+import HowToDirection from 'cookbook/js/Models/schema/HowToDirection';
+import HowToTip from 'cookbook/js/Models/schema/HowToTip';
+import { asArray, asCleanedArray } from '../../helper';
+
+/**
+ * Interface representing the options for constructing a `HowToStep` instance.
+ * @interface
+ */
+interface HowToStepOptions {
+ /** The position of the step in the sequence. */
+ position?: number;
+
+ /** The images associated with the step. */
+ image?: string | string[];
+
+ /** The thumbnail URLs for the images. */
+ thumbnailUrl?: string | string[];
+
+ /** The time required for the step. */
+ timeRequired?: string;
+}
+
+/**
+ * Represents a step in the recipe instructions.
+ * @class
+ */
+export default class HowToStep {
+ /** The text content of the step. Required if `itemListElement` is not set. */
+ public _text?: string;
+
+ /** The position of the step in the sequence. */
+ public position?: number;
+
+ /** The images associated with the step. */
+ public image: string[];
+
+ /** A list of substeps. This may include directions or tips. Required if `text` is not set. */
+ public _itemListElement: (HowToDirection | HowToTip)[];
+
+ /** The thumbnail URLs for the images. */
+ public thumbnailUrl: string[];
+
+ /** The time required for the step. */
+ public timeRequired?: string;
+
+ private validationMsg =
+ 'HowToStep requires either `text` or `itemListElement` to be set';
+
+ /**
+ * Creates a `HowToStep` instance.
+ * @constructor
+ * @param {string} text - The text content of the step.
+ * @param {HowToStepOptions} options - An options object containing additional properties.
+ */
+ public constructor(
+ text: string,
+ itemListElements: (HowToDirection | HowToTip)[],
+ options?: HowToStepOptions,
+ ) {
+ if (!text && !itemListElements) {
+ throw Error(this.validationMsg);
+ }
+
+ this['@type'] = 'HowToStep';
+ this._text = text;
+ if (options) {
+ this._itemListElement = itemListElements;
+ this.position = options.position;
+ this.image = asCleanedArray(options.image);
+ this.thumbnailUrl = asCleanedArray(options.thumbnailUrl);
+ this.timeRequired = options.timeRequired;
+ }
+ }
+
+ /** A list of substeps. This may include directions or tips. Required if `text` is not set. */
+ public get itemListElement(): (HowToDirection | HowToTip)[] {
+ return this._itemListElement;
+ }
+
+ /** A list of substeps. This may include directions or tips. Required if `text` is not set. */
+ public set itemListElement(
+ value:
+ | HowToDirection
+ | HowToTip
+ | (HowToDirection | HowToTip)[]
+ | undefined,
+ ) {
+ if (!this.text && !value) {
+ throw Error(this.validationMsg);
+ }
+ this._itemListElement = value ? asArray(value) : [];
+ }
+
+ /** The text content of the step. Required if `itemListElement` is not set. */
+ public get text(): string | undefined {
+ return this._text;
+ }
+
+ /** The text content of the step. Required if `itemListElement` is not set. */
+ public set text(value: string | undefined) {
+ if (!this._itemListElement && !value) {
+ throw Error(this.validationMsg);
+ }
+ this._text = value;
+ }
+
+ /**
+ * Create a `HowToStep` instance from a JSON string or object.
+ * @param {string | object} json - The JSON string or object.
+ * @returns {HowToStep} - The created HowToStep instance.
+ * @throws {Error} If the input JSON is invalid or missing required properties.
+ */
+ static fromJSON(json: string | object): HowToStep {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let jsonObj: any;
+ try {
+ jsonObj = typeof json === 'string' ? JSON.parse(json) : json;
+ } catch {
+ throw new JsonMappingException(
+ `Error mapping to "HowToStep". Received invalid JSON: "${json}"`,
+ );
+ }
+
+ const text = mapString(
+ jsonObj.text,
+ "HowToStep 'text'",
+ ) as NonNullable;
+
+ const itemListElements = this.mapDirectionOrTipArray(
+ jsonObj.itemListElement,
+ );
+
+ const position = mapInteger(
+ jsonObj.position,
+ "HowToStep 'position'",
+ true,
+ );
+
+ const image = mapStringOrStringArray(
+ jsonObj.image,
+ "HowToStep 'image'",
+ true,
+ );
+
+ const thumbnailUrl = mapStringOrStringArray(
+ jsonObj.thumbnailUrl,
+ "HowToStep 'thumbnailUrl'",
+ true,
+ );
+
+ const timeRequired = mapString(
+ jsonObj.timeRequired,
+ "HowToStep 'timeRequired'",
+ true,
+ );
+
+ return new HowToStep(text, itemListElements, {
+ position: position || undefined,
+ image: image || [],
+ thumbnailUrl: thumbnailUrl || [],
+ timeRequired: timeRequired || undefined,
+ });
+ }
+
+ /**
+ * Tries to map `json` to a string or an array of strings.
+ * @param json The value to be mapped.
+ * @returns The value as an array of `HowToDirection` and `HowToTip` items.
+ */
+ private static mapDirectionOrTipArray(
+ json: unknown,
+ ): (HowToDirection | HowToTip)[] {
+ const jsonArray = json ? asArray(json) : [];
+ const mappedArray = jsonArray.map((item) => {
+ if (typeof item === 'string') {
+ return new HowToDirection(item);
+ }
+ try {
+ return HowToDirection.fromJSON(item);
+ } catch {}
+ try {
+ return HowToTip.fromJSON(item);
+ } catch {}
+ return null;
+ });
+
+ return mappedArray.filter((itm) => !!itm).map((itm) => itm!);
+ }
+}
diff --git a/src/js/Models/schema/NutritionInformation.ts b/src/js/Models/schema/NutritionInformation.ts
index c8715033e..44bc3c19c 100644
--- a/src/js/Models/schema/NutritionInformation.ts
+++ b/src/js/Models/schema/NutritionInformation.ts
@@ -95,6 +95,31 @@ export default class NutritionInformation {
Object.assign(this, properties);
}
+ /**
+ * Checks if any nutrition value in this object is a non-empty string.
+ * @returns {boolean} - `true` if there is a nutrition value defined. `false` otherwise.
+ */
+ public isUndefined(): boolean {
+ return !(
+ // Does any of these have a value?
+ (
+ (this.calories && this.calories !== '') ||
+ (this.carbohydrateContent && this.carbohydrateContent !== '') ||
+ (this.cholesterolContent && this.cholesterolContent !== '') ||
+ (this.fatContent && this.fatContent !== '') ||
+ (this.fiberContent && this.fiberContent !== '') ||
+ (this.proteinContent && this.proteinContent !== '') ||
+ (this.saturatedFatContent && this.saturatedFatContent !== '') ||
+ (this.servingSize && this.servingSize !== '') ||
+ (this.sodiumContent && this.sodiumContent !== '') ||
+ (this.sugarContent && this.sugarContent !== '') ||
+ (this.transFatContent && this.transFatContent !== '') ||
+ (this.unsaturatedFatContent &&
+ this.unsaturatedFatContent !== '')
+ )
+ );
+ }
+
/**
* Create a `NutritionInformation` instance from a JSON string.
* @param {string | object} json - The JSON string or object.
diff --git a/src/js/Models/schema/Recipe.ts b/src/js/Models/schema/Recipe.ts
index 5a888b806..5c8ed4eb4 100644
--- a/src/js/Models/schema/Recipe.ts
+++ b/src/js/Models/schema/Recipe.ts
@@ -4,7 +4,7 @@ import {
mapString,
mapStringOrStringArray,
} from 'cookbook/js/utils/jsonMapper';
-import HowToDirection from './HowToDirection';
+import HowToStep from 'cookbook/js/Models/schema/HowToStep';
import HowToSection from './HowToSection';
import HowToSupply from './HowToSupply';
import HowToTool from './HowToTool';
@@ -45,8 +45,8 @@ interface RecipeOptions {
/** The step-by-step instructions for the recipe. */
recipeInstructions?:
| HowToSection
- | HowToDirection
- | (HowToSection | HowToDirection)[];
+ | HowToStep
+ | (HowToSection | HowToStep)[];
/** The tools required for the recipe. */
tool?: HowToTool | HowToTool[];
/** The URL of the recipe. */
@@ -113,7 +113,7 @@ export default class Recipe {
public supply: HowToSupply[];
/** The step-by-step instructions for the recipe. */
- public recipeInstructions: (HowToSection | HowToDirection)[];
+ public recipeInstructions: (HowToSection | HowToStep)[];
/** The tools required for the recipe. */
public tool: HowToTool[];
@@ -176,10 +176,13 @@ export default class Recipe {
}
// Required
+
+ // The cookbook API returns the `identifier` property under the name `id`
const identifier = mapString(
jsonObj.identifier,
"Recipe 'identifier'",
) as NonNullable;
+
const name = mapString(
jsonObj.name,
"Recipe 'name'",
@@ -216,10 +219,12 @@ export default class Recipe {
"Recipe 'imageUrl'",
true,
);
+ // The cookbook API returns the `keywords` property as a comma-separated list instead of an array
const keywords = mapStringOrStringArray(
jsonObj.keywords,
"Recipe 'keywords'",
true,
+ true,
);
const cookTime = mapString(jsonObj.cookTime, "Recipe 'cookTime'", true);
const prepTime = mapString(jsonObj.prepTime, "Recipe 'prepTime'", true);
@@ -241,35 +246,38 @@ export default class Recipe {
"Recipe 'recipeYield'",
true,
);
- const supply = jsonObj.supply
- ? asArray(jsonObj.supply).map((suppl) => {
+ // Supported values for recipe instruction are: string, HowToSection, HowToStep or an array of those
+ const recipeInstructions = jsonObj.recipeInstructions
+ ? asArray(jsonObj.recipeInstructions).map((instruction) => {
try {
- return HowToSupply.fromJSON(suppl);
+ if (instruction['@type'] === 'HowToSection') {
+ return HowToSection.fromJSON(instruction);
+ }
+ return HowToStep.fromJSON(instruction);
} catch (ex) {
- if (typeof suppl === 'string') {
- // Did not receive a valid HowToSupply, treat as simple string.
- return new HowToSupply(suppl as string);
+ if (typeof instruction === 'string') {
+ // Did not receive a valid HowToStep or HowToSection object, treat as simple string.
+ return new HowToStep(instruction, []);
}
throw ex;
}
})
: [];
- const recipeInstructions = jsonObj.recipeInstructions
- ? asArray(jsonObj.recipeInstructions).map((instruction) => {
+ // Supported values for supply are: string, HowToTool, or an array of those
+ const supply = jsonObj.supply
+ ? asArray(jsonObj.supply).map((suppl) => {
try {
- if (instruction['@type'] === 'HowToSection') {
- return HowToSection.fromJSON(instruction);
- }
- return HowToDirection.fromJSON(instruction);
+ return HowToSupply.fromJSON(suppl);
} catch (ex) {
- if (typeof instruction === 'string') {
- // Did not receive a valid HowToDirection or HowToSection object, treat as simple string.
- return new HowToDirection(instruction as string);
+ if (typeof suppl === 'string') {
+ // Did not receive a valid HowToSupply, treat as simple string.
+ return new HowToSupply(suppl as string);
}
throw ex;
}
})
: [];
+ // Supported values for tools are: string, HowToTool, or an array of those
const tool = jsonObj.tool
? asArray(jsonObj.tool).map((t) => {
try {
From fd00646959834d37a8451521d20eaeb92bfb8d50 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 17:02:36 +0100
Subject: [PATCH 025/188] test: Update for schema.org model classes
Signed-off-by: Sebastian Fey
---
.../unit/Models/schema/HowToSection.test.ts | 13 +++++++++---
.../schema/NutritionInformation.test.ts | 7 +++++++
src/tests/unit/Models/schema/Recipe.test.ts | 21 ++++++++++---------
3 files changed, 28 insertions(+), 13 deletions(-)
diff --git a/src/tests/unit/Models/schema/HowToSection.test.ts b/src/tests/unit/Models/schema/HowToSection.test.ts
index 9fcfffff0..e21b758e3 100644
--- a/src/tests/unit/Models/schema/HowToSection.test.ts
+++ b/src/tests/unit/Models/schema/HowToSection.test.ts
@@ -16,6 +16,7 @@ describe('HowToSection', () => {
description: 'Section description',
position: 2,
image: 'section-image.jpg',
+ timeRequired: '5 minutes',
thumbnailUrl: 'thumbnail.jpg',
itemListElement: new HowToDirection('Step 1'),
};
@@ -25,6 +26,7 @@ describe('HowToSection', () => {
expect(section.description).toBe(options.description);
expect(section.position).toBe(options.position);
expect(section.image).toEqual([options.image]);
+ expect(section.timeRequired).toEqual(options.timeRequired);
expect(section.thumbnailUrl).toEqual([options.thumbnailUrl]);
expect(section.itemListElement).toEqual([options.itemListElement]);
});
@@ -35,6 +37,7 @@ describe('HowToSection', () => {
expect(section.description).toBeUndefined();
expect(section.position).toBeUndefined();
expect(section.image).toEqual([]);
+ expect(section.timeRequired).toBeUndefined();
expect(section.thumbnailUrl).toEqual([]);
expect(section.itemListElement).toEqual([]);
});
@@ -44,6 +47,7 @@ describe('HowToSection', () => {
description: undefined,
position: undefined,
image: undefined,
+ timeRequired: undefined,
thumbnailUrl: undefined,
itemListElement: undefined,
};
@@ -53,6 +57,7 @@ describe('HowToSection', () => {
expect(section.description).toBeUndefined();
expect(section.position).toBeUndefined();
expect(section.image).toEqual([]);
+ expect(section.timeRequired).toBeUndefined();
expect(section.thumbnailUrl).toEqual([]);
expect(section.itemListElement).toEqual([]);
});
@@ -66,6 +71,7 @@ describe('HowToSection', () => {
description: 'Mixing ingredients',
position: 2,
image: 'section_image.jpg',
+ timeRequired: '5 minutes',
thumbnailUrl: 'section_thumbnail.jpg',
itemListElement: [
{
@@ -90,6 +96,7 @@ describe('HowToSection', () => {
expect(result.description).toEqual('Mixing ingredients');
expect(result.position).toEqual(2);
expect(result.image).toEqual(['section_image.jpg']);
+ expect(result.timeRequired).toEqual('5 minutes');
expect(result.thumbnailUrl).toEqual(['section_thumbnail.jpg']);
// Validate itemListElement property
@@ -110,9 +117,6 @@ describe('HowToSection', () => {
expect(result.itemListElement[1].text).toEqual('Stir the mixture');
expect(result.itemListElement[1].position).toEqual(2);
expect(result.itemListElement[1].image).toEqual(['stir_image.jpg']);
- expect(result.itemListElement[1].thumbnailUrl).toEqual([
- 'stir_thumbnail.jpg',
- ]);
});
test('should handle missing optional properties', () => {
@@ -133,6 +137,7 @@ describe('HowToSection', () => {
expect(result.description).toBeUndefined();
expect(result.position).toBeUndefined();
expect(result.image).toEqual([]);
+ expect(result.timeRequired).toBeUndefined();
expect(result.thumbnailUrl).toEqual([]);
// Validate itemListElement property
@@ -169,6 +174,7 @@ describe('HowToSection', () => {
position: undefined,
image: null,
thumbnailUrl: undefined,
+ timeRequired: undefined,
itemListElement: null,
};
@@ -179,6 +185,7 @@ describe('HowToSection', () => {
expect(result.description).toBeUndefined();
expect(result.position).toBeUndefined();
expect(result.image).toEqual([]);
+ expect(result.timeRequired).toBeUndefined();
expect(result.thumbnailUrl).toEqual([]);
expect(result.itemListElement).toEqual([]);
});
diff --git a/src/tests/unit/Models/schema/NutritionInformation.test.ts b/src/tests/unit/Models/schema/NutritionInformation.test.ts
index 582196f30..3849289d9 100644
--- a/src/tests/unit/Models/schema/NutritionInformation.test.ts
+++ b/src/tests/unit/Models/schema/NutritionInformation.test.ts
@@ -135,4 +135,11 @@ describe('NutritionInformation', () => {
);
});
});
+
+ describe('isUndefined', () => {
+ it('return true if no value is defined', () => {
+ const nutritionInfo = new NutritionInformation();
+ expect(nutritionInfo.isUndefined()).toBe(true);
+ });
+ });
});
diff --git a/src/tests/unit/Models/schema/Recipe.test.ts b/src/tests/unit/Models/schema/Recipe.test.ts
index 1a8a438df..18fc60244 100644
--- a/src/tests/unit/Models/schema/Recipe.test.ts
+++ b/src/tests/unit/Models/schema/Recipe.test.ts
@@ -1,5 +1,6 @@
import HowToDirection from 'cookbook/js/Models/schema/HowToDirection';
import HowToSection from 'cookbook/js/Models/schema/HowToSection';
+import HowToStep from 'cookbook/js/Models/schema/HowToStep';
import HowToSupply from 'cookbook/js/Models/schema/HowToSupply';
import HowToTool from 'cookbook/js/Models/schema/HowToTool';
import NutritionInformation from 'cookbook/js/Models/schema/NutritionInformation';
@@ -49,7 +50,7 @@ describe('Recipe', () => {
recipeIngredient: '1 cup flour',
recipeYield: 4,
supply: new HowToSupply('Flour', '1 cup'),
- recipeInstructions: new HowToDirection('Mix the ingredients'),
+ recipeInstructions: new HowToStep('Mix the ingredients', []),
tool: new HowToTool('Mixing Bowl'),
url: 'https://example.com/recipe',
};
@@ -230,7 +231,7 @@ describe('Recipe', () => {
supply: { name: 'Supply1' },
recipeInstructions: [
{
- '@type': 'HowToDirection',
+ '@type': 'HowToStep',
text: 'Step 1: Do something',
},
{
@@ -262,7 +263,7 @@ describe('Recipe', () => {
expect(recipe.recipeYield).toBeUndefined();
expect(recipe.supply[0].name).toBe('Supply1');
expect(recipe.recipeInstructions).toHaveLength(2);
- expect(recipe.recipeInstructions[0]).toBeInstanceOf(HowToDirection);
+ expect(recipe.recipeInstructions[0]).toBeInstanceOf(HowToStep);
expect(recipe.recipeInstructions[1]).toBeInstanceOf(HowToSection);
expect(recipe.tool).toEqual([]);
expect(recipe.url).toStrictEqual([
@@ -287,8 +288,8 @@ describe('Recipe', () => {
keywords: ['keyword1', 'keyword2'], // Array value
recipeIngredient: ['1 cup flour', '1 kg butter'], // Array value
recipeInstructions: [
- { text: 'Step 1: Do something' },
- { text: 'Step 2: So something else' },
+ { '@type': 'HowToStep', text: 'Step 1: Do something' },
+ { '@type': 'HowToStep', text: 'Step 2: So something else' },
], // Array value
supply: [{ name: 'Supply1' }, { name: 'Supply2' }], // Array value
tool: [{ name: 'Tool1' }, { name: 'Tool2' }], // Array value
@@ -313,10 +314,10 @@ describe('Recipe', () => {
expect(recipe.recipeInstructions).toBeInstanceOf(
Array,
);
- expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ expect((recipe.recipeInstructions[0] as HowToStep).text).toBe(
'Step 1: Do something',
);
- expect((recipe.recipeInstructions[1] as HowToDirection).text).toBe(
+ expect((recipe.recipeInstructions[1] as HowToStep).text).toBe(
'Step 2: So something else',
);
expect(recipe.supply).toBeInstanceOf(Array);
@@ -357,7 +358,7 @@ describe('Recipe', () => {
expect(recipe.recipeInstructions).toBeInstanceOf(
Array,
); // Converted to array
- expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ expect((recipe.recipeInstructions[0] as HowToStep).text).toBe(
'Step 1: Do something',
);
expect(recipe.supply).toBeInstanceOf(Array); // Converted to array
@@ -384,7 +385,7 @@ describe('Recipe', () => {
expect(recipe.recipeInstructions).toBeInstanceOf(
Array,
); // Converted to HowToDirection[]
- expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ expect((recipe.recipeInstructions[0] as HowToStep).text).toBe(
'Step 1: Do something',
);
expect(recipe.supply).toBeInstanceOf(Array); // Converted to HowToSupply[]
@@ -410,7 +411,7 @@ describe('Recipe', () => {
expect(recipe.recipeInstructions).toBeInstanceOf(
Array,
); // Converted to array
- expect((recipe.recipeInstructions[0] as HowToDirection).text).toBe(
+ expect((recipe.recipeInstructions[0] as HowToStep).text).toBe(
'Step 1: Do something',
);
expect(recipe.supply).toBeInstanceOf(Array); // Converted to array
From c3c28b6826d9c6395a8587e7d75e351a5de0c83a Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 17:04:52 +0100
Subject: [PATCH 026/188] fix: Multiple updates to `RecipeView` to consider
usage of new model classes
Signed-off-by: Sebastian Fey
---
src/components/RecipeView/RecipeView.vue | 22 +++++++++++++---------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/src/components/RecipeView/RecipeView.vue b/src/components/RecipeView/RecipeView.vue
index 8b34bc30b..67ffbb8a5 100644
--- a/src/components/RecipeView/RecipeView.vue
+++ b/src/components/RecipeView/RecipeView.vue
@@ -58,13 +58,13 @@
:markdown="parsedDescription"
class="markdown-description"
/>
-
+
{{ t('cookbook', 'Source') }}: {{ $store.state.recipe.url }}{{ $store.state.recipe.url?.[0] }}
@@ -449,7 +449,7 @@ const recipe = computed(() => {
if (store.state.recipe.recipeInstructions) {
tmpRecipe.instructions = Object.values(
store.state.recipe.recipeInstructions,
- ).map((i) => helpers.escapeHTML(i));
+ ).map((i) => helpers.escapeHTML(i.text));
}
if (store.state.recipe.keywords) {
@@ -513,6 +513,10 @@ const recipe = computed(() => {
if (store.state.recipe.nutrition) {
if (store.state.recipe.nutrition instanceof Array) {
tmpRecipe.nutrition = {};
+ } else if (
+ store.state.recipe.nutrition['@type'] === 'NutritionInformation'
+ ) {
+ tmpRecipe.nutrition = store.state.recipe.nutrition;
} else {
tmpRecipe.nutrition = store.state.recipe.nutrition;
}
@@ -553,10 +557,10 @@ const visibleInfoBlocks = computed(
const showNutritionData = computed(
() =>
+ visibleInfoBlocks.value['nutrition-information'] &&
recipe.value.nutrition &&
- !(recipe.value.nutrition instanceof Array) &&
- Object.keys(recipe.value.nutrition).length > 1 &&
- visibleInfoBlocks.value['nutrition-information'],
+ recipe.value.nutrition['@type'] === 'NutritionInformation' &&
+ !recipe.value.nutrition.isUndefined,
);
const scaledIngredients = computed(() =>
@@ -611,14 +615,14 @@ const setup = async () => {
}
try {
- const response = await api.recipes.get(route.params.id);
- const tmpRecipe = response.data;
+ const tmpRecipe = await api.recipes.get(route.params.id);
+
// Store recipe data in vuex
store.dispatch('setRecipe', { recipe: tmpRecipe });
// Always set the active page last!
store.dispatch('setPage', { page: 'recipe' });
- } catch {
+ } catch (ex) {
if (store.state.loadingRecipe) {
// Reset loading recipe
store.dispatch('setLoadingRecipe', { recipe: 0 });
From 6ebeba549a7c738fa285263e81ff864440f79b85 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Wed, 31 Jan 2024 17:06:11 +0100
Subject: [PATCH 027/188] feat: Add mapper for API responses.
Move `api-interface` to subdirectory
Signed-off-by: Sebastian Fey
---
src/components/AppIndex.vue | 2 +-
src/components/AppNavi.vue | 2 +-
.../FormComponents/EditInputGroup.vue | 47 ++++++++++--
src/components/Modals/SettingsDialog.vue | 2 +-
src/components/RecipeEdit.vue | 74 +++++--------------
src/components/RecipeView/RecipeView.vue | 2 +-
src/components/SearchResults.vue | 2 +-
src/js/Api/Mappers/RecipeMappers.ts | 31 ++++++++
src/js/title-rename.ts | 15 ++--
src/js/{ => utils}/api-interface.ts | 16 +++-
src/store/index.ts | 3 +-
11 files changed, 115 insertions(+), 81 deletions(-)
create mode 100644 src/js/Api/Mappers/RecipeMappers.ts
rename src/js/{ => utils}/api-interface.ts (85%)
diff --git a/src/components/AppIndex.vue b/src/components/AppIndex.vue
index fe5170820..93a40ab4a 100644
--- a/src/components/AppIndex.vue
+++ b/src/components/AppIndex.vue
@@ -3,7 +3,7 @@
+
+
From 8a9a80079db9b56dc2c5147aa6930e56aeabebf8 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Thu, 8 Feb 2024 08:50:46 +0100
Subject: [PATCH 033/188] feat: Add `RecipeInstructionsDirection` vue component
Signed-off-by: Sebastian Fey
---
.../RecipeInstructionsDirection.vue | 95 +++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
new file mode 100644
index 000000000..b3fdb4634
--- /dev/null
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+ {{ direction.text }}
+
+
+
+
+
+
From c172d3890ded5686a6edd79d7f2014d2975fd136 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Thu, 8 Feb 2024 08:51:14 +0100
Subject: [PATCH 034/188] feat: Add `RecipeInstructionsStep` vue component
Signed-off-by: Sebastian Fey
---
.../Instructions/RecipeInstructionsStep.vue | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 src/components/RecipeView/Instructions/RecipeInstructionsStep.vue
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue b/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue
new file mode 100644
index 000000000..178fd4a63
--- /dev/null
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue
@@ -0,0 +1,156 @@
+
+
+
+ {{ step.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 86eaac413f6df21fd02aab676c0706ddf2d6db73 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Thu, 8 Feb 2024 08:51:49 +0100
Subject: [PATCH 035/188] feat: Add `RecipeInstructionsSection` vue component
Signed-off-by: Sebastian Fey
---
.../RecipeInstructionsSection.vue | 112 ++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
new file mode 100644
index 000000000..0eccc0f77
--- /dev/null
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
From 89247c2fccbcd27ea36053b498d218f737be1caa Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Thu, 8 Feb 2024 09:19:17 +0100
Subject: [PATCH 036/188] feat: Add `RecipeInstructions` vue component
Signed-off-by: Sebastian Fey
---
.../Instructions/RecipeInstructions.vue | 86 +++++++++++++++++++
1 file changed, 86 insertions(+)
create mode 100644 src/components/RecipeView/Instructions/RecipeInstructions.vue
diff --git a/src/components/RecipeView/Instructions/RecipeInstructions.vue b/src/components/RecipeView/Instructions/RecipeInstructions.vue
new file mode 100644
index 000000000..07d06c871
--- /dev/null
+++ b/src/components/RecipeView/Instructions/RecipeInstructions.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
From 55301081243c96ae625377fdb7729702ad22c1b1 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Thu, 8 Feb 2024 09:55:34 +0100
Subject: [PATCH 037/188] fix: Alignment, padding, and margin issues
Signed-off-by: Sebastian Fey
---
.../Instructions/RecipeInstructions.vue | 2 +-
.../RecipeInstructionsDirection.vue | 18 +++++---
.../RecipeInstructionsSection.vue | 37 ++++++++++------
.../Instructions/RecipeInstructionsStep.vue | 43 +++++++++++--------
.../Instructions/RecipeInstructionsTip.vue | 5 ++-
5 files changed, 65 insertions(+), 40 deletions(-)
diff --git a/src/components/RecipeView/Instructions/RecipeInstructions.vue b/src/components/RecipeView/Instructions/RecipeInstructions.vue
index 07d06c871..370e6fcf5 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructions.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructions.vue
@@ -2,9 +2,9 @@
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
index b3fdb4634..2d4fa9acd 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
@@ -5,19 +5,23 @@
:class="{ done: isDone }"
@click="toggleDone"
>
-
-
-
-
-
-
{{ direction.text }}
+
+
+
+
+
+
+
+
{{ direction.text }}
+
+
-
+
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
index 2d4fa9acd..0d757e5e6 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
@@ -63,7 +63,6 @@ li.instructions-direction {
padding-left: calc(36px + 1rem);
margin-bottom: 2rem;
clear: both;
- counter-increment: item;
cursor: pointer;
white-space: pre-line;
@@ -71,15 +70,14 @@ li.instructions-direction {
position: absolute;
top: 0;
left: 0;
- width: 36px;
- height: 36px;
+ width: 30px;
+ height: 30px;
border: 1px solid var(--color-border-dark);
border-radius: 50%;
background-color: var(--color-background-dark);
background-position: center;
background-repeat: no-repeat;
- content: counters(item);
- line-height: 36px;
+ line-height: 30px;
outline: none;
text-align: center;
}
@@ -96,4 +94,20 @@ li.instructions-direction {
content: '✔';
}
}
+
+/* If there is a list and a text, numbers are shown for the substeps - add padding. */
+.instructions-step__text ~ .step-children {
+ .instructions-direction {
+ padding-left: calc(36px + 1rem);
+ }
+}
+
+/** For top level directions outside a section, show top-level count */
+ol.instructions > li.instructions-direction {
+ //counter-increment: sectionIndex;
+
+ ::before {
+ //content: counter(sectionIndex);
+ }
+}
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
index eaed45193..4db8e79fd 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
@@ -111,15 +111,6 @@ li.instructions-section-root {
}
ol {
- counter-reset: item;
list-style-type: none;
}
-
-ol > li {
- counter-increment: item;
-}
-
-ol > li::before {
- content: counters(item, '.');
-}
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue b/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue
index 9024df32a..8530a7ac2 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsStep.vue
@@ -17,6 +17,7 @@
v-if="
step.itemListElement && step.itemListElement.length > 0
"
+ class="step-children"
>
props.step,
+ (step) => {
+ console.log(step);
+ },
+);
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
index 761e4afd1..41c0a2243 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
@@ -13,7 +13,7 @@
- {{ direction.text }}
+ {{ normalizedText }}
@@ -21,7 +21,11 @@
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
index 41c0a2243..57558d87e 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsDirection.vue
@@ -13,7 +13,10 @@
- {{ normalizedText }}
+
diff --git a/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue b/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
index 230232f8b..7eeca5ffc 100644
--- a/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
+++ b/src/components/RecipeView/Instructions/RecipeInstructionsSection.vue
@@ -4,7 +4,12 @@
- {{ normalizedDescription }}
+
+
+
@@ -109,6 +114,10 @@ function childComponentProps(item, index) {
diff --git a/src/components/RecipeView/RecipeTool.vue b/src/components/RecipeView/RecipeTool.vue
index 167c23beb..0c85a780b 100644
--- a/src/components/RecipeView/RecipeTool.vue
+++ b/src/components/RecipeView/RecipeTool.vue
@@ -1,16 +1,71 @@
-
-
+
+ ,
+
diff --git a/src/components/RecipeView/RecipeView.vue b/src/components/RecipeView/RecipeView.vue
index fc5b4fb43..4b22bf510 100644
--- a/src/components/RecipeView/RecipeView.vue
+++ b/src/components/RecipeView/RecipeView.vue
@@ -191,13 +191,20 @@
-
-
-
- {{ t('cookbook', 'Servings') }}:
-
-
- {{ recipeYield }}
-
-
-
-
-
-
-
-
+
+ {{ t('cookbook', 'Servings') }}:
+
+
@@ -381,6 +359,7 @@ import RecipeKeyword from '../RecipeKeyword.vue';
import RecipeNutritionInfoItem from './RecipeNutritionInfoItem.vue';
import RecipeTimer from './RecipeTimer.vue';
import RecipeTool from './RecipeTool.vue';
+import RecipeYield from './RecipeYield.vue';
const route = useRoute();
const router = useRouter();
@@ -635,10 +614,6 @@ const setup = async () => {
recipeYield.value = store.state.recipe.recipeYield;
};
-const changeRecipeYield = (increase = true) => {
- recipeYield.value = +recipeYield.value + (increase ? 1 : -1);
-};
-
function showCopySuccess(item) {
showSuccess(t('cookbook', '{item} copied to clipboard', { item }));
}
@@ -686,10 +661,6 @@ const copyIngredientsToClipboard = () => {
}
};
-const restoreOriginalRecipeYield = () => {
- recipeYield.value = store.state.recipe.recipeYield;
-};
-
// ===================
// Watchers
// ===================
@@ -731,15 +702,6 @@ watch(
},
);
-watch(
- () => recipeYield.value,
- () => {
- if (recipeYield.value < 0) {
- restoreOriginalRecipeYield();
- }
- },
-);
-
// ===================
// Vue lifecycle
// ===================
@@ -789,7 +751,7 @@ export default {
padding: 3rem 0;
}
-.print-only {
+:deep(.print-only) {
display: none;
}
@@ -811,11 +773,11 @@ export default {
content: '';
}
- .print-hidden {
+ :deep(.print-hidden) {
display: none !important;
}
- .print-only {
+ :deep(.print-only) {
display: initial !important;
}
}
@@ -1119,20 +1081,6 @@ main {
.ingredient-parsing-error span.icon-error {
display: inline-block;
}
-
-.recipeYieldInput {
- width: 75px;
-
- /* Chrome, Safari, Edge */
- &::-webkit-inner-spin-button,
- &::-webkit-outer-spin-button {
- margin: 0;
- -webkit-appearance: none;
- }
-
- /* Firefox */
- -moz-appearance: textfield;
-}
diff --git a/src/js/helper.ts b/src/js/helper.ts
index 3a96c7c40..84b806ad3 100644
--- a/src/js/helper.ts
+++ b/src/js/helper.ts
@@ -11,6 +11,34 @@ function clamp(val: number, min: number, max: number): number {
return Math.min(max, Math.max(min, val));
}
+/**
+ * Adjusts a numeric value by a specified step size.
+ * If the original value is a float, it will be rounded to the nearest integer.
+ * The adjusted value will always be greater than 0.
+ *
+ * @param {number} value - The original numeric value.
+ * @param {number} step - The step size by which to adjust the value.
+ * @returns {number} - The adjusted value.
+ */
+export function adjustToInteger(value, step) {
+ // Add the step
+ const modifiedValue = value + step;
+
+ // Round the value to the nearest integer
+ let adjustedValue =
+ step > 0 ? Math.floor(modifiedValue) : Math.floor(modifiedValue);
+
+ // Ensure the adjusted value is at least 1
+ adjustedValue = Math.max(adjustedValue, 1);
+
+ // If the original value is between 0 and 1 and the step is negative, adjust accordingly
+ if (value > 0 && value < 1 && step < 0) {
+ adjustedValue = Math.min(value, adjustedValue);
+ }
+
+ return adjustedValue;
+}
+
// Check if two routes point to the same component but have different content
function shouldReloadContent(url1: string, url2: string): boolean {
if (url1 === url2) {
@@ -135,6 +163,7 @@ export function asCleanedArray
(item: T | T[]): NonNullable[] {
}
export default {
+ adjustToInteger,
asArray,
clamp,
shouldReloadContent,
From 9de47df0a9871172b64057f7cbddc82d7278e721 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Mon, 12 Feb 2024 08:38:35 +0100
Subject: [PATCH 052/188] test: Add tests for adjustToInteger method
Signed-off-by: Sebastian Fey
---
src/tests/unit/helper.test.ts | 36 ++++++++++++++++++++++++++++++++++-
1 file changed, 35 insertions(+), 1 deletion(-)
diff --git a/src/tests/unit/helper.test.ts b/src/tests/unit/helper.test.ts
index 528f6e658..c870a5629 100644
--- a/src/tests/unit/helper.test.ts
+++ b/src/tests/unit/helper.test.ts
@@ -1,4 +1,38 @@
-import { asArray, asCleanedArray } from 'cookbook/js/helper';
+import { adjustToInteger, asArray, asCleanedArray } from 'cookbook/js/helper';
+
+// adjustToIntegerToInteger() tests
+describe('adjustToInteger function', () => {
+ it('should increase the value by 1 when step is 1', () => {
+ expect(adjustToInteger(1, 1)).toBe(2);
+ expect(adjustToInteger(1.5, 1)).toBe(2);
+ });
+
+ it('should increase the value by 2 when step is 2', () => {
+ expect(adjustToInteger(1, 2)).toBe(3);
+ expect(adjustToInteger(1.5, 2)).toBe(3);
+ });
+
+ it('should decrease the value by 1 when step is -1', () => {
+ expect(adjustToInteger(2, -1)).toBe(1);
+ expect(adjustToInteger(2.5, -1)).toBe(2);
+ expect(adjustToInteger(1, -1)).toBe(1);
+ expect(adjustToInteger(0.5, -1)).toBe(0.5);
+ });
+
+ it('should decrease the value by 2 when step is -2', () => {
+ expect(adjustToInteger(3, -2)).toBe(1);
+ expect(adjustToInteger(3.5, -2)).toBe(2);
+ expect(adjustToInteger(1.5, -2)).toBe(1);
+ expect(adjustToInteger(0.5, -2)).toBe(0.5);
+ });
+
+ it('should handle values between 0 and 1 correctly with negative step', () => {
+ expect(adjustToInteger(0.5, -1)).toBe(0.5);
+ expect(adjustToInteger(0.5, -2)).toBe(0.5);
+ expect(adjustToInteger(0.3, -1)).toBe(0.3);
+ expect(adjustToInteger(0.3, -2)).toBe(0.3);
+ });
+});
// asArray() tests
describe('asArray', () => {
From 1110a85d29c809c7f58316524878a044501b52e0 Mon Sep 17 00:00:00 2001
From: Sebastian Fey
Date: Mon, 12 Feb 2024 08:39:55 +0100
Subject: [PATCH 053/188] fix: Several spacings and headline sizes in
`RecipeView`
Signed-off-by: Sebastian Fey
---
src/components/RecipeView/RecipeView.vue | 44 +++++++++++++++++++-----
1 file changed, 35 insertions(+), 9 deletions(-)
diff --git a/src/components/RecipeView/RecipeView.vue b/src/components/RecipeView/RecipeView.vue
index e32665142..534d434bf 100644
--- a/src/components/RecipeView/RecipeView.vue
+++ b/src/components/RecipeView/RecipeView.vue
@@ -18,6 +18,7 @@
@@ -132,18 +148,6 @@
class="section-title"
>
{{ t('cookbook', 'Ingredients') }}
-
-
-
-
-
div {
- margin: 1rem 0.75rem;
-}
-
-.times .time {
- position: relative;
- flex-grow: 1;
- border: 1px solid var(--color-border-dark);
- border-radius: 3px;
- margin: 1rem 2rem;
- font-size: 1.2rem;
- text-align: center;
-}
-
-.times .time button {
- position: absolute;
- top: 0;
- left: 0;
- width: 36px;
- height: 36px;
- transform: translate(-50%, -50%);
-}
-
-.times .time h4 {
- padding: 0.5rem;
- border-bottom: 1px solid var(--color-border-dark);
- background-color: var(--color-background-dark);
- font-weight: bold;
-}
-
-.times .time p {
- padding: 0.5rem;
-}
-
section {
margin-bottom: 1rem;
}
diff --git a/src/components/RecipeView/RecipeYield.vue b/src/components/RecipeView/RecipeYield.vue
index 29d1e4c83..955153cee 100644
--- a/src/components/RecipeView/RecipeYield.vue
+++ b/src/components/RecipeView/RecipeYield.vue
@@ -20,8 +20,8 @@