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 @@ + + + + + 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 @@ + + + + + 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 @@ {{ section.name }} -
{{ 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', 'Tools') }}

-

    Date: Tue, 13 Feb 2024 15:02:13 +0100 Subject: [PATCH 060/188] feat: Update styles of timer and recipe yield in recipe view Signed-off-by: Sebastian Fey --- src/assets/css/main.scss | 40 +++++- src/components/AppMain.vue | 2 +- src/components/RecipeView/RecipeImages.vue | 10 +- src/components/RecipeView/RecipeTimer.vue | 78 +++++----- src/components/RecipeView/RecipeView.vue | 158 ++++++++------------- src/components/RecipeView/RecipeYield.vue | 14 +- 6 files changed, 154 insertions(+), 148 deletions(-) diff --git a/src/assets/css/main.scss b/src/assets/css/main.scss index 79a76c7af..ba34f579f 100644 --- a/src/assets/css/main.scss +++ b/src/assets/css/main.scss @@ -29,6 +29,7 @@ .flex-col { flex-direction: column; } + .flex-row { flex-direction: row; } @@ -91,7 +92,7 @@ } } -.self-md-stretch { +.self-stretch { align-self: stretch; } @@ -103,6 +104,10 @@ /** justify */ +.justify-center { + justify-content: center; +} + .justify-end { justify-content: end; } @@ -127,10 +132,23 @@ margin-right: 1rem; } +.mt-2 { + margin-top: 0.5rem; +} + .mt-4 { margin-top: 1rem; } +.mt-6 { + margin-top: 1.5rem; +} + +.mx-0 { + margin-right: 0; + margin-left: 0; +} + .pl-3 { padding-left: 0.75rem; } @@ -159,6 +177,24 @@ } } +.gap-y-lg-8 { + @media (min-width: 1024px) { + row-gap: 2rem; + } +} + +.gap-lg-16 { + @media (min-width: 1024px) { + gap: 4rem; + } +} + +.gap-x-lg-16 { + @media (min-width: 1024px) { + column-gap: 4rem; + } +} + /** Height */ .h-0 { @@ -171,6 +207,7 @@ { width: 8rem; } + .w-md-32 { @media (min-width: 768px) { @@ -182,6 +219,7 @@ { width: 16rem; } + .w-md-64 { @media (min-width: 768px) { diff --git a/src/components/AppMain.vue b/src/components/AppMain.vue index c60f3175e..cdd9f4ef2 100644 --- a/src/components/AppMain.vue +++ b/src/components/AppMain.vue @@ -78,7 +78,7 @@ export default { diff --git a/src/components/RecipeView/RecipeView.vue b/src/components/RecipeView/RecipeView.vue index 63e98431e..c321846a8 100644 --- a/src/components/RecipeView/RecipeView.vue +++ b/src/components/RecipeView/RecipeView.vue @@ -77,13 +77,17 @@ >{{ $store.state.recipe.url?.[0] }}

    + +
    - {{ t('cookbook', 'Servings') }}: - +

    + {{ t('cookbook', 'Servings') }} +

    -
    -
    - - - +
    + + + +
    @@ -132,18 +148,6 @@ class="section-title" > {{ t('cookbook', 'Ingredients') }} - - -